diff --git a/Assets/Tests/Editor/CliPlayModeRunInBackgroundControllerTests.cs b/Assets/Tests/Editor/CliPlayModeRunInBackgroundControllerTests.cs new file mode 100644 index 000000000..a60397462 --- /dev/null +++ b/Assets/Tests/Editor/CliPlayModeRunInBackgroundControllerTests.cs @@ -0,0 +1,176 @@ +#nullable enable +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Pure unit tests for CLI PlayMode runInBackground override state transitions. + /// + public sealed class CliPlayModeRunInBackgroundControllerTests + { + [Test] + public void OnCliPlayStarting_WhenInactive_SavesOriginalAndRequestsTrue() + { + // Verifies CLI Play start records the pre-Play value and forces runInBackground on. + InMemoryCliPlayModeRunInBackgroundStore store = new InMemoryCliPlayModeRunInBackgroundStore(); + CliPlayModeRunInBackgroundController controller = new CliPlayModeRunInBackgroundController(store); + + bool desired = controller.OnCliPlayStarting(currentRunInBackground: false); + + Assert.That(desired, Is.True); + Assert.That(store.IsActive, Is.True); + Assert.That(store.OriginalRunInBackground, Is.False); + } + + [Test] + public void OnCliPlayStarting_WhenAlreadyActive_DoesNotOverwriteOriginal() + { + // Verifies a second CLI Play while the override is active keeps the first original value. + InMemoryCliPlayModeRunInBackgroundStore store = new InMemoryCliPlayModeRunInBackgroundStore(); + CliPlayModeRunInBackgroundController controller = new CliPlayModeRunInBackgroundController(store); + controller.OnCliPlayStarting(currentRunInBackground: false); + + bool desired = controller.OnCliPlayStarting(currentRunInBackground: true); + + Assert.That(desired, Is.True); + Assert.That(store.IsActive, Is.True); + Assert.That(store.OriginalRunInBackground, Is.False); + } + + [Test] + public void PeekOriginalIfActive_WhenActive_ReturnsOriginalWithoutClearing() + { + // Verifies early exit restore can re-apply the original without dropping ownership yet. + InMemoryCliPlayModeRunInBackgroundStore store = new InMemoryCliPlayModeRunInBackgroundStore(); + CliPlayModeRunInBackgroundController controller = new CliPlayModeRunInBackgroundController(store); + controller.OnCliPlayStarting(currentRunInBackground: false); + + bool? peeked = controller.PeekOriginalIfActive(); + + Assert.That(peeked, Is.False); + Assert.That(store.IsActive, Is.True); + Assert.That(store.OriginalRunInBackground, Is.False); + } + + [Test] + public void CommitRestoreAfterPlayModeExit_WhenActive_RestoresOriginalAndClears() + { + // Verifies stable EditMode exit (CLI Stop or manual Stop) restores and clears ownership. + InMemoryCliPlayModeRunInBackgroundStore store = new InMemoryCliPlayModeRunInBackgroundStore(); + CliPlayModeRunInBackgroundController controller = new CliPlayModeRunInBackgroundController(store); + controller.OnCliPlayStarting(currentRunInBackground: false); + + bool? restored = controller.CommitRestoreAfterPlayModeExit(); + + Assert.That(restored, Is.False); + Assert.That(store.IsActive, Is.False); + } + + [Test] + public void CommitRestoreAfterPlayModeExit_WhenInactive_ReturnsNull() + { + // Verifies manual Play sessions that never went through CLI Play leave runInBackground alone. + InMemoryCliPlayModeRunInBackgroundStore store = new InMemoryCliPlayModeRunInBackgroundStore(); + CliPlayModeRunInBackgroundController controller = new CliPlayModeRunInBackgroundController(store); + + bool? restored = controller.CommitRestoreAfterPlayModeExit(); + + Assert.That(restored, Is.Null); + Assert.That(store.IsActive, Is.False); + } + + [Test] + public void OnEditorStartup_WhenActiveAndStillPlaying_ReappliesTrue() + { + // Verifies domain reload during CLI Play re-applies runInBackground without clearing the original. + InMemoryCliPlayModeRunInBackgroundStore store = new InMemoryCliPlayModeRunInBackgroundStore(); + CliPlayModeRunInBackgroundController controller = new CliPlayModeRunInBackgroundController(store); + controller.OnCliPlayStarting(currentRunInBackground: false); + + bool? desired = controller.OnEditorStartup(isPlaying: true); + + Assert.That(desired, Is.True); + Assert.That(store.IsActive, Is.True); + Assert.That(store.OriginalRunInBackground, Is.False); + } + + [Test] + public void OnEditorStartup_WhenActiveButNotPlaying_RestoresOriginalAndClears() + { + // Verifies domain reload after Play exit still restores when ownership was not committed yet. + InMemoryCliPlayModeRunInBackgroundStore store = new InMemoryCliPlayModeRunInBackgroundStore(); + store.Activate(originalRunInBackground: false); + CliPlayModeRunInBackgroundController controller = new CliPlayModeRunInBackgroundController(store); + + bool? desired = controller.OnEditorStartup(isPlaying: false); + + Assert.That(desired, Is.False); + Assert.That(store.IsActive, Is.False); + } + + [Test] + public void OnEditorStartup_WhenInactive_ReturnsNull() + { + // Verifies startup is a no-op when CLI never enabled a PlayMode override. + InMemoryCliPlayModeRunInBackgroundStore store = new InMemoryCliPlayModeRunInBackgroundStore(); + CliPlayModeRunInBackgroundController controller = new CliPlayModeRunInBackgroundController(store); + + bool? desired = controller.OnEditorStartup(isPlaying: true); + + Assert.That(desired, Is.Null); + } + + [Test] + public void CommitRestoreAfterPlayModeExit_WhenOriginalWasTrue_RestoresTrue() + { + // Verifies projects that already had runInBackground enabled stay enabled after CLI Play ends. + InMemoryCliPlayModeRunInBackgroundStore store = new InMemoryCliPlayModeRunInBackgroundStore(); + CliPlayModeRunInBackgroundController controller = new CliPlayModeRunInBackgroundController(store); + controller.OnCliPlayStarting(currentRunInBackground: true); + + bool? restored = controller.CommitRestoreAfterPlayModeExit(); + + Assert.That(restored, Is.True); + Assert.That(store.IsActive, Is.False); + } + + [Test] + public void ExitSequence_PeekThenCommit_SurvivesTransitionOverwriteModel() + { + // Verifies early peek restore + later commit matches the ExitingPlayMode → EnteredEditMode path. + InMemoryCliPlayModeRunInBackgroundStore store = new InMemoryCliPlayModeRunInBackgroundStore(); + CliPlayModeRunInBackgroundController controller = new CliPlayModeRunInBackgroundController(store); + controller.OnCliPlayStarting(currentRunInBackground: false); + + bool? peeked = controller.PeekOriginalIfActive(); + bool? committed = controller.CommitRestoreAfterPlayModeExit(); + + Assert.That(peeked, Is.False); + Assert.That(committed, Is.False); + Assert.That(store.IsActive, Is.False); + Assert.That(controller.PeekOriginalIfActive(), Is.Null); + Assert.That(controller.CommitRestoreAfterPlayModeExit(), Is.Null); + } + + private sealed class InMemoryCliPlayModeRunInBackgroundStore : ICliPlayModeRunInBackgroundStore + { + public bool IsActive { get; private set; } + + public bool OriginalRunInBackground { get; private set; } + + public void Activate(bool originalRunInBackground) + { + OriginalRunInBackground = originalRunInBackground; + IsActive = true; + } + + public void Clear() + { + IsActive = false; + OriginalRunInBackground = false; + } + } + } +} diff --git a/Assets/Tests/Editor/CliPlayModeRunInBackgroundControllerTests.cs.meta b/Assets/Tests/Editor/CliPlayModeRunInBackgroundControllerTests.cs.meta new file mode 100644 index 000000000..c1c9e91e5 --- /dev/null +++ b/Assets/Tests/Editor/CliPlayModeRunInBackgroundControllerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 385bd0005d7bc4684b6a3fc00c554972 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PressHoldUntilEdgeLogicTests.cs b/Assets/Tests/Editor/PressHoldUntilEdgeLogicTests.cs new file mode 100644 index 000000000..048162ee0 --- /dev/null +++ b/Assets/Tests/Editor/PressHoldUntilEdgeLogicTests.cs @@ -0,0 +1,113 @@ +#nullable enable +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Pure unit tests for Press hold-until-edge wait decisions. + /// + public sealed class PressHoldUntilEdgeLogicTests + { + [Test] + public void IsBaseWaitSatisfied_WhenFramesAndDurationMet_ReturnsTrue() + { + // Verifies the normal Press wait can finish once min frames and duration both pass. + bool satisfied = PressHoldUntilEdgeLogic.IsBaseWaitSatisfied( + observedFrames: 2, + minimumObservationFrames: 2, + elapsedSeconds: 0f, + durationSeconds: 0f); + + Assert.That(satisfied, Is.True); + } + + [Test] + public void IsBaseWaitSatisfied_WhenFramesShort_ReturnsFalse() + { + // Verifies duration alone is not enough before the minimum observation frames elapse. + bool satisfied = PressHoldUntilEdgeLogic.IsBaseWaitSatisfied( + observedFrames: 1, + minimumObservationFrames: 2, + elapsedSeconds: 1f, + durationSeconds: 0f); + + Assert.That(satisfied, Is.False); + } + + [Test] + public void ShouldExtendHoldForEdge_WhenEdgeAlreadyObserved_ReturnsFalse() + { + // Verifies release proceeds immediately once the gameplay press edge was seen. + bool shouldExtend = PressHoldUntilEdgeLogic.ShouldExtendHoldForEdge( + pressEdgeObserved: true, + baseWaitSatisfied: true, + elapsedMilliseconds: 10, + timeoutMilliseconds: 5000); + + Assert.That(shouldExtend, Is.False); + } + + [Test] + public void ShouldExtendHoldForEdge_WhenEdgeMissingAndUnderTimeout_ReturnsTrue() + { + // Verifies release is delayed until the edge is observed (within the existing timeout budget). + bool shouldExtend = PressHoldUntilEdgeLogic.ShouldExtendHoldForEdge( + pressEdgeObserved: false, + baseWaitSatisfied: true, + elapsedMilliseconds: 100, + timeoutMilliseconds: 5000); + + Assert.That(shouldExtend, Is.True); + } + + [Test] + public void ShouldExtendHoldForEdge_WhenTimeoutExceeded_ReturnsFalse() + { + // Verifies the hold does not block forever when the edge never becomes visible. + bool shouldExtend = PressHoldUntilEdgeLogic.ShouldExtendHoldForEdge( + pressEdgeObserved: false, + baseWaitSatisfied: true, + elapsedMilliseconds: 5000, + timeoutMilliseconds: 5000); + + Assert.That(shouldExtend, Is.False); + } + + [Test] + public void ShouldExtendHoldForEdge_WhenBaseWaitNotSatisfied_ReturnsFalse() + { + // Verifies edge extension only starts after duration + min frames already completed. + bool shouldExtend = PressHoldUntilEdgeLogic.ShouldExtendHoldForEdge( + pressEdgeObserved: false, + baseWaitSatisfied: false, + elapsedMilliseconds: 0, + timeoutMilliseconds: 5000); + + Assert.That(shouldExtend, Is.False); + } + + [Test] + public void CountExtendedFrames_WhenBeyondBase_ReturnsDelta() + { + // Verifies response can report how many frames release was delayed for edge observation. + int extended = PressHoldUntilEdgeLogic.CountExtendedFrames( + observedFrames: 5, + baseSatisfiedFrameCount: 2); + + Assert.That(extended, Is.EqualTo(3)); + } + + [Test] + public void CountExtendedFrames_WhenNotBeyondBase_ReturnsZero() + { + // Verifies no extension is reported when release happened at the normal wait end. + int extended = PressHoldUntilEdgeLogic.CountExtendedFrames( + observedFrames: 2, + baseSatisfiedFrameCount: 2); + + Assert.That(extended, Is.EqualTo(0)); + } + } +} diff --git a/Assets/Tests/Editor/PressHoldUntilEdgeLogicTests.cs.meta b/Assets/Tests/Editor/PressHoldUntilEdgeLogicTests.cs.meta new file mode 100644 index 000000000..ba1bb4fe8 --- /dev/null +++ b/Assets/Tests/Editor/PressHoldUntilEdgeLogicTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9096cc25d829c425b867a72b28f83b2c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs index 9a7db23b9..b1a561982 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs @@ -184,6 +184,23 @@ public static async Task WaitForObservationFrames(Ca } public static async Task WaitForPressLifetime(float duration, CancellationToken ct) + { + PressLifetimeWaitResult result = await WaitForPressLifetime( + duration, + isPressEdgeObserved: null, + ct).ConfigureAwait(false); + return result.Outcome; + } + + /// + /// Waits for duration + min frames, then optionally holds release until a press edge is observed. + /// Why: when wasPressedThisFrame never becomes true in the normal window, delaying release + /// gives gameplay more frames without reinjecting down/up (which would double-fire actions). + /// + public static async Task WaitForPressLifetime( + float duration, + Func? isPressEdgeObserved, + CancellationToken ct) { await SwitchToMainThreadIfNeeded(ct); @@ -192,14 +209,43 @@ public static async Task WaitForPressLifetime(float float startTime = Time.realtimeSinceStartup; float elapsed = 0f; int observedFrames = 0; + int baseSatisfiedFrameCount = -1; int timeoutMilliseconds = GetPressLifetimeTimeoutMilliseconds(duration); System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew(); - while (observedFrames < minimumObservationFrames || elapsed < duration) + while (true) { + bool baseWaitSatisfied = PressHoldUntilEdgeLogic.IsBaseWaitSatisfied( + observedFrames, + minimumObservationFrames, + elapsed, + duration); + if (baseWaitSatisfied && baseSatisfiedFrameCount < 0) + { + baseSatisfiedFrameCount = observedFrames; + } + + bool pressEdgeObserved = isPressEdgeObserved != null && isPressEdgeObserved(); + bool shouldExtendForEdge = isPressEdgeObserved != null && + PressHoldUntilEdgeLogic.ShouldExtendHoldForEdge( + pressEdgeObserved, + baseWaitSatisfied, + stopwatch.ElapsedMilliseconds, + timeoutMilliseconds); + + if (baseWaitSatisfied && !shouldExtendForEdge) + { + int extendedFrames = baseSatisfiedFrameCount < 0 + ? 0 + : PressHoldUntilEdgeLogic.CountExtendedFrames(observedFrames, baseSatisfiedFrameCount); + return new PressLifetimeWaitResult( + InputSimulationWaitOutcome.Completed, + extendedFrames); + } + if (IsPaused()) { - return InputSimulationWaitOutcome.Paused; + return new PressLifetimeWaitResult(InputSimulationWaitOutcome.Paused, 0); } InputSimulationWaitOutcome frameOutcome = await WaitOneRuntimeFrameOrTimeout( @@ -208,22 +254,49 @@ public static async Task WaitForPressLifetime(float ct).ConfigureAwait(false); if (frameOutcome == InputSimulationWaitOutcome.TimedOut) { - return InputSimulationWaitOutcome.TimedOut; + // Why: timeout during edge-extension still completes Press successfully with + // PressEdgeObserved=false — same contract as before, not a hard failure. + if (baseWaitSatisfied) + { + int extendedFrames = baseSatisfiedFrameCount < 0 + ? 0 + : PressHoldUntilEdgeLogic.CountExtendedFrames(observedFrames, baseSatisfiedFrameCount); + return new PressLifetimeWaitResult( + InputSimulationWaitOutcome.Completed, + extendedFrames); + } + + return new PressLifetimeWaitResult(InputSimulationWaitOutcome.TimedOut, 0); } await SwitchToMainThreadIfNeeded(ct); if (IsPaused()) { - return InputSimulationWaitOutcome.Paused; + return new PressLifetimeWaitResult(InputSimulationWaitOutcome.Paused, 0); } observedFrames = Time.frameCount - startFrameCount; elapsed = Time.realtimeSinceStartup - startTime; } + } - return InputSimulationWaitOutcome.Completed; + /// + /// Outcome of a Press duration wait, including how many frames release was delayed for edge observation. + /// + internal readonly struct PressLifetimeWaitResult + { + public PressLifetimeWaitResult(InputSimulationWaitOutcome outcome, int extendedObservationFrames) + { + Outcome = outcome; + ExtendedObservationFrames = extendedObservationFrames; + } + + public InputSimulationWaitOutcome Outcome { get; } + + public int ExtendedObservationFrames { get; } } + public static void RunExplicitUpdate(InputUpdateType targetUpdateType) { InputSettings? settings = InputSystem.settings; diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/PressHoldUntilEdgeLogic.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/PressHoldUntilEdgeLogic.cs new file mode 100644 index 000000000..098659547 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/PressHoldUntilEdgeLogic.cs @@ -0,0 +1,59 @@ +#nullable enable + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Pure wait-loop decisions for Press holds that may extend until wasPressedThisFrame is observed. + /// Why: releasing as soon as duration/min frames elapse can drop the down state before gameplay + /// polls it; extending release is safe because down/up counts stay the same (no reinjection). + /// + internal static class PressHoldUntilEdgeLogic + { + /// + /// Returns whether the duration + minimum observation frame requirements are already satisfied. + /// + public static bool IsBaseWaitSatisfied( + int observedFrames, + int minimumObservationFrames, + float elapsedSeconds, + float durationSeconds) + { + return observedFrames >= minimumObservationFrames && elapsedSeconds >= durationSeconds; + } + + /// + /// Returns whether the hold should continue after the base wait, waiting for a press edge. + /// + public static bool ShouldExtendHoldForEdge( + bool pressEdgeObserved, + bool baseWaitSatisfied, + long elapsedMilliseconds, + int timeoutMilliseconds) + { + if (pressEdgeObserved) + { + return false; + } + + if (!baseWaitSatisfied) + { + return false; + } + + return elapsedMilliseconds < timeoutMilliseconds; + } + + /// + /// Returns how many observation frames were spent only after the base wait completed. + /// + public static int CountExtendedFrames(int observedFrames, int baseSatisfiedFrameCount) + { + if (observedFrames <= baseSatisfiedFrameCount) + { + return 0; + } + + return observedFrames - baseSatisfiedFrameCount; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/PressHoldUntilEdgeLogic.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/PressHoldUntilEdgeLogic.cs.meta new file mode 100644 index 000000000..35edc4dba --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/PressHoldUntilEdgeLogic.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 84df81205d7fe4e64b5915317177cc72 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundController.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundController.cs new file mode 100644 index 000000000..077deea67 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundController.cs @@ -0,0 +1,103 @@ +#nullable enable +using System.Diagnostics; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Stores whether a CLI-started PlayMode session owns a temporary Application.runInBackground override. + /// + internal interface ICliPlayModeRunInBackgroundStore + { + bool IsActive { get; } + + bool OriginalRunInBackground { get; } + + void Activate(bool originalRunInBackground); + + void Clear(); + } + + /// + /// Pure state machine for keeping runInBackground true during CLI-initiated PlayMode. + /// Why: Editor throttles the player loop when unfocused unless runInBackground is true, which + /// breaks time-dependent E2E between CLI commands. Manual Play must not be affected. + /// + internal sealed class CliPlayModeRunInBackgroundController + { + private readonly ICliPlayModeRunInBackgroundStore _store; + + public CliPlayModeRunInBackgroundController(ICliPlayModeRunInBackgroundStore store) + { + Debug.Assert(store != null, "store must not be null"); + _store = store!; + } + + /// + /// Records the pre-Play value and requests runInBackground=true for a CLI Play start. + /// Returns the value that Application.runInBackground should be set to. + /// + public bool OnCliPlayStarting(bool currentRunInBackground) + { + if (!_store.IsActive) + { + _store.Activate(currentRunInBackground); + } + + return true; + } + + /// + /// Returns the original value to re-apply while exiting PlayMode without clearing ownership. + /// Why: Unity may domain-reload or overwrite Application.runInBackground during the exit + /// transition; SessionState must stay active until EditMode is stable. + /// + public bool? PeekOriginalIfActive() + { + if (!_store.IsActive) + { + return null; + } + + return _store.OriginalRunInBackground; + } + + /// + /// Restores the original runInBackground value when EditMode is stable after Play ends. + /// Returns null when this controller did not own an override. + /// + public bool? CommitRestoreAfterPlayModeExit() + { + if (!_store.IsActive) + { + return null; + } + + bool originalRunInBackground = _store.OriginalRunInBackground; + _store.Clear(); + return originalRunInBackground; + } + + /// + /// Recovers override state after domain reload. + /// Why: SessionState survives domain reload but Application.runInBackground may not stay true, + /// and an orphaned active flag after Play has already ended must not stick forever. + /// Returns the value to apply, or null when no write is needed. + /// + public bool? OnEditorStartup(bool isPlaying) + { + if (!_store.IsActive) + { + return null; + } + + if (isPlaying) + { + return true; + } + + bool originalRunInBackground = _store.OriginalRunInBackground; + _store.Clear(); + return originalRunInBackground; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundController.cs.meta b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundController.cs.meta new file mode 100644 index 000000000..306fa4584 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d308430e33774a3fb1ba01b3f96a910 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundService.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundService.cs new file mode 100644 index 000000000..adb101b79 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundService.cs @@ -0,0 +1,85 @@ +#nullable enable +using UnityEditor; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Applies CLI PlayMode runInBackground overrides through Unity Application/Editor hooks. + /// + internal sealed class CliPlayModeRunInBackgroundService + { + private readonly CliPlayModeRunInBackgroundController _controller; + private bool _isPlayModeCallbackRegistered; + + public CliPlayModeRunInBackgroundService(CliPlayModeRunInBackgroundController controller) + { + System.Diagnostics.Debug.Assert(controller != null, "controller must not be null"); + _controller = controller!; + } + + /// + /// Subscribes to PlayMode exit and re-applies or cleans up state after domain reload. + /// + public void InitializeForEditorStartup() + { + RegisterPlayModeCallback(); + + bool? desiredRunInBackground = _controller.OnEditorStartup(EditorApplication.isPlaying); + if (desiredRunInBackground.HasValue) + { + Application.runInBackground = desiredRunInBackground.Value; + } + } + + /// + /// Enables runInBackground for a CLI-started PlayMode transition from EditMode. + /// + public void EnableForCliPlayStart() + { + bool desiredRunInBackground = _controller.OnCliPlayStarting(Application.runInBackground); + Application.runInBackground = desiredRunInBackground; + } + + private void RegisterPlayModeCallback() + { + if (_isPlayModeCallbackRegistered) + { + return; + } + + // Why: domain reload re-runs startup; unsubscribe first to avoid duplicate handlers. + EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + _isPlayModeCallbackRegistered = true; + } + + private void OnPlayModeStateChanged(PlayModeStateChange state) + { + // Why: ExitingPlayMode covers CLI Stop and the toolbar Stop button, but Unity may + // overwrite runInBackground during the transition or domain-reload afterward. + // Peek+apply early, then commit clear on EnteredEditMode (or OnEditorStartup if reload). + if (state == PlayModeStateChange.ExitingPlayMode) + { + bool? originalRunInBackground = _controller.PeekOriginalIfActive(); + if (originalRunInBackground.HasValue) + { + Application.runInBackground = originalRunInBackground.Value; + } + + return; + } + + if (state != PlayModeStateChange.EnteredEditMode) + { + return; + } + + bool? restoredRunInBackground = _controller.CommitRestoreAfterPlayModeExit(); + if (restoredRunInBackground.HasValue) + { + Application.runInBackground = restoredRunInBackground.Value; + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundService.cs.meta b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundService.cs.meta new file mode 100644 index 000000000..1f97a6532 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 80c6f9bc20de44cc082845a7a8e74271 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundSessionStore.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundSessionStore.cs new file mode 100644 index 000000000..cd16cd59d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundSessionStore.cs @@ -0,0 +1,33 @@ +#nullable enable +using UnityEditor; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Persists CLI PlayMode runInBackground override state in SessionState so domain reload cannot lose it. + /// + internal sealed class CliPlayModeRunInBackgroundSessionStore : ICliPlayModeRunInBackgroundStore + { + // Why: domain reload clears static fields; SessionState is the Editor-local store that survives it. + private const string ActiveKey = + "io.github.hatayama.uloopmcp.cliPlayModeRunInBackground.active"; + private const string OriginalKey = + "io.github.hatayama.uloopmcp.cliPlayModeRunInBackground.original"; + + public bool IsActive => SessionState.GetBool(ActiveKey, false); + + public bool OriginalRunInBackground => SessionState.GetBool(OriginalKey, false); + + public void Activate(bool originalRunInBackground) + { + SessionState.SetBool(OriginalKey, originalRunInBackground); + SessionState.SetBool(ActiveKey, true); + } + + public void Clear() + { + SessionState.SetBool(ActiveKey, false); + SessionState.SetBool(OriginalKey, false); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundSessionStore.cs.meta b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundSessionStore.cs.meta new file mode 100644 index 000000000..e549867b9 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/CliPlayModeRunInBackgroundSessionStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 24dcfade385f04eadbc0d55d23ef80d4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeServices.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeServices.cs index 502ecaab4..d0bd42960 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeServices.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeServices.cs @@ -8,9 +8,14 @@ internal sealed class ControlPlayModeServicesRegistry internal ControlPlayModeCompilationFailureService CompilationFailureService { get; } = new(); internal ControlPlayModeCompilationFailureGate CompilationFailureGate { get; } = new(); + internal CliPlayModeRunInBackgroundService RunInBackgroundService { get; } = + new CliPlayModeRunInBackgroundService( + new CliPlayModeRunInBackgroundController(new CliPlayModeRunInBackgroundSessionStore())); + internal void InitializeForEditorStartup() { CompilationFailureService.InitializeForEditorStartup(); + RunInBackgroundService.InitializeForEditorStartup(); } } @@ -27,6 +32,9 @@ internal static class ControlPlayModeServices internal static IControlPlayModeCompilationFailureGate CompilationFailureGate => RegistryValue.CompilationFailureGate; + internal static CliPlayModeRunInBackgroundService RunInBackgroundService => + RegistryValue.RunInBackgroundService; + internal static void InitializeForEditorStartup() { RegistryValue.InitializeForEditorStartup(); diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs index 1a8596f38..fec9689a8 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs @@ -114,6 +114,8 @@ private ControlPlayModeActionResult ExecutePlayModeStart(bool wasPaused, bool wa } if (!EditorApplication.isPlaying) { + // Why: only CLI-started Play owns the override; manual Editor Play must keep project defaults. + ControlPlayModeServices.RunInBackgroundService.EnableForCliPlayStart(); EditorApplication.isPlaying = true; } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputActionExecutor.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputActionExecutor.cs index 14be89f6b..773804631 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputActionExecutor.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputActionExecutor.cs @@ -45,6 +45,7 @@ internal static async Task ExecutePress( KeyboardKeyState.RegisterTransientKey(key); bool pressWasApplied = false; bool pressEdgeObserved = false; + int pressHoldExtendedFrames = 0; InputSimulationWaitOutcome waitOutcome = InputSimulationWaitOutcome.Completed; // The edge must be probed inside gameplay input updates: editor-tick polling can @@ -62,8 +63,13 @@ internal static async Task ExecutePress( if (waitOutcome == InputSimulationWaitOutcome.Completed) { pressWasApplied = true; - waitOutcome = await InputSystemUpdateHelper.WaitForPressLifetime(duration, ct) - .ConfigureAwait(false); + InputSystemUpdateHelper.PressLifetimeWaitResult pressWaitResult = + await InputSystemUpdateHelper.WaitForPressLifetime( + duration, + () => pressEdgeObserved, + ct).ConfigureAwait(false); + waitOutcome = pressWaitResult.Outcome; + pressHoldExtendedFrames = pressWaitResult.ExtendedObservationFrames; } } finally @@ -123,16 +129,30 @@ await KeyboardInputMainThreadCleanup.ReleaseKeyStateIfPossible( } string durationText = duration > 0f ? $" for {InputSimulationDurationFormatter.FormatSeconds(duration)}s" : ""; - string edgeText = pressEdgeObserved - ? "" - : " (press edge was not observed via wasPressedThisFrame; gameplay polling may have missed it, so retry or verify with a focused log)"; + string edgeText; + if (pressEdgeObserved && pressHoldExtendedFrames > 0) + { + edgeText = + $" (release delayed {pressHoldExtendedFrames} frame(s) until wasPressedThisFrame was observed)"; + } + else if (pressEdgeObserved) + { + edgeText = ""; + } + else + { + edgeText = + " (press edge was not observed via wasPressedThisFrame; gameplay polling may have missed it, so retry or verify with a focused log)"; + } + return new SimulateKeyboardResponse { Success = true, Message = $"Pressed '{keyName}'{durationText}{edgeText}", Action = UnityCliLoopKeyboardAction.Press.ToString(), KeyName = keyName, - PressEdgeObserved = pressEdgeObserved + PressEdgeObserved = pressEdgeObserved, + PressHoldExtendedFrames = pressHoldExtendedFrames > 0 ? pressHoldExtendedFrames : null }; } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs index dc26f95cb..4b5166ed8 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs @@ -21,6 +21,12 @@ public class SimulateKeyboardResponse : UnityCliLoopToolResponse public List? PausePointHits { get; set; } public bool? PressEdgeObserved { get; set; } + /// + /// Extra observation frames spent holding the key after the normal duration window + /// while waiting for wasPressedThisFrame. Null when release was not delayed. + /// + public int? PressHoldExtendedFrames { get; set; } + public SimulateKeyboardResponse() { }