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()
{
}