From 9292c3a645f12a94a174ab34939ec7555e720459 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 20 Aug 2026 21:26:12 +0900 Subject: [PATCH 1/3] fix: sync stale player-view key latches after simulate-keyboard releases ReleaseAll while paused can skip the immediate ForceSync gate because it reads the editor view. A one-shot onAfterUpdate callback now ForceSyncs on the next Dynamic/Fixed/Manual update so resume does not keep isPressed true while ReadValue is already 0. Co-authored-by: Cursor --- .../skills/uloop-simulate-keyboard/SKILL.md | 1 + .../skills/uloop-simulate-keyboard/SKILL.md | 1 + .../DeferredPlayerLatchSyncDecisionTests.cs | 84 ++++++++++++++ ...ferredPlayerLatchSyncDecisionTests.cs.meta | 11 ++ ...SimulateKeyboardReleaseDeviceStateTests.cs | 70 +++++++++++- .../Tests/PlayMode/SimulateKeyboardTests.cs | 1 + .../DeferredLatchSyncTickDecision.cs | 23 ++++ .../DeferredLatchSyncTickDecision.cs.meta | 11 ++ .../DeferredPlayerLatchSyncDecision.cs | 27 +++++ .../DeferredPlayerLatchSyncDecision.cs.meta | 11 ++ .../DeferredPlayerLatchSynchronizer.cs | 105 ++++++++++++++++++ .../DeferredPlayerLatchSynchronizer.cs.meta | 11 ++ .../KeyboardInputActionExecutor.cs | 8 +- .../KeyboardInputMainThreadCleanup.cs | 8 +- .../ReleaseAllKeysImmediateResult.cs | 8 +- ...SimulateKeyboardReleaseMessageFormatter.cs | 19 ++++ .../SimulateKeyboardResponse.cs | 11 ++ .../SimulateKeyboardUseCase.cs | 8 +- .../SimulateKeyboard/Skill/SKILL.md | 1 + 19 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 Assets/Tests/PlayMode/DeferredPlayerLatchSyncDecisionTests.cs create mode 100644 Assets/Tests/PlayMode/DeferredPlayerLatchSyncDecisionTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredLatchSyncTickDecision.cs create mode 100644 Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredLatchSyncTickDecision.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSyncDecision.cs create mode 100644 Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSyncDecision.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs create mode 100644 Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs.meta diff --git a/.agents/skills/uloop-simulate-keyboard/SKILL.md b/.agents/skills/uloop-simulate-keyboard/SKILL.md index fc861bf855..ab1316709f 100644 --- a/.agents/skills/uloop-simulate-keyboard/SKILL.md +++ b/.agents/skills/uloop-simulate-keyboard/SKILL.md @@ -96,6 +96,7 @@ Returns JSON with: - `KeyStateReadUpdateType` (string, nullable): Set for `ReleaseAll`. The `InputState.currentUpdateType` at that device readback (`Editor`, `Dynamic`, `Fixed`, ...). Omitted when empty - `KeyStateTrackedHeld` (boolean, nullable): Whether Unity CLI Loop's own key-hold tracker considered the key held. Set on KeyDown "already held" / KeyUp "not currently held" rejections, and on successful `KeyUp` - `KeyStateDeviceIsPressed` (boolean, nullable): Whether `keyboard[key].isPressed` reported pressed at that same moment. Set on the same responses as `KeyStateTrackedHeld` +- `DeferredLatchSyncScheduled` (boolean): Set `true` on successful `ReleaseAll` / `KeyUp` when a one-shot player-update latch sync was scheduled. Omitted when false. The sync runs on the next Dynamic/Fixed/Manual Input System update (not Editor); while PlayMode is paused it therefore waits until resume. Gameplay polling during that same input update may still see a stale press; polling from `Update` after that input update should see the key up - `InterruptedByPausePoint` / `PausePointId` / `PausePointHitCount` / `PausePointHits`: Pause-point interruption info (all nullable except the boolean). `PausePointHits` lists every marker hit during this input in hit order; `PausePointId` only names the latest one. See the Pause Point Inspection section above - `PressEdgeObserved` (boolean, nullable): For `Press` and `KeyDown`, whether the press edge (`wasPressedThisFrame`) was visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge — verify with a focused log instead of trusting `Success` alone. `null` only for `KeyUp` and timed-out responses; pause-point interruptions still report the observed value. When a single-shot pause point is armed, do not blindly retry on `false`: the input may still have registered late, so check `pause-point-status` for a hit first — a blind retry can consume a re-enabled marker or double-fire the scenario - `PressEdgeConsumedByUpdateType` / `PressEdgeAnyDynamicUpdateObserved` / `PressEdgeKeyAlreadyPressedBeforeQueue` (nullable): Diagnostics populated only when `PressEdgeObserved` is `false` (all `null` when the edge was observed). `Message` carries the same diagnosis as text. Read them before retrying: diff --git a/.claude/skills/uloop-simulate-keyboard/SKILL.md b/.claude/skills/uloop-simulate-keyboard/SKILL.md index fc861bf855..ab1316709f 100644 --- a/.claude/skills/uloop-simulate-keyboard/SKILL.md +++ b/.claude/skills/uloop-simulate-keyboard/SKILL.md @@ -96,6 +96,7 @@ Returns JSON with: - `KeyStateReadUpdateType` (string, nullable): Set for `ReleaseAll`. The `InputState.currentUpdateType` at that device readback (`Editor`, `Dynamic`, `Fixed`, ...). Omitted when empty - `KeyStateTrackedHeld` (boolean, nullable): Whether Unity CLI Loop's own key-hold tracker considered the key held. Set on KeyDown "already held" / KeyUp "not currently held" rejections, and on successful `KeyUp` - `KeyStateDeviceIsPressed` (boolean, nullable): Whether `keyboard[key].isPressed` reported pressed at that same moment. Set on the same responses as `KeyStateTrackedHeld` +- `DeferredLatchSyncScheduled` (boolean): Set `true` on successful `ReleaseAll` / `KeyUp` when a one-shot player-update latch sync was scheduled. Omitted when false. The sync runs on the next Dynamic/Fixed/Manual Input System update (not Editor); while PlayMode is paused it therefore waits until resume. Gameplay polling during that same input update may still see a stale press; polling from `Update` after that input update should see the key up - `InterruptedByPausePoint` / `PausePointId` / `PausePointHitCount` / `PausePointHits`: Pause-point interruption info (all nullable except the boolean). `PausePointHits` lists every marker hit during this input in hit order; `PausePointId` only names the latest one. See the Pause Point Inspection section above - `PressEdgeObserved` (boolean, nullable): For `Press` and `KeyDown`, whether the press edge (`wasPressedThisFrame`) was visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge — verify with a focused log instead of trusting `Success` alone. `null` only for `KeyUp` and timed-out responses; pause-point interruptions still report the observed value. When a single-shot pause point is armed, do not blindly retry on `false`: the input may still have registered late, so check `pause-point-status` for a hit first — a blind retry can consume a re-enabled marker or double-fire the scenario - `PressEdgeConsumedByUpdateType` / `PressEdgeAnyDynamicUpdateObserved` / `PressEdgeKeyAlreadyPressedBeforeQueue` (nullable): Diagnostics populated only when `PressEdgeObserved` is `false` (all `null` when the edge was observed). `Message` carries the same diagnosis as text. Read them before retrying: diff --git a/Assets/Tests/PlayMode/DeferredPlayerLatchSyncDecisionTests.cs b/Assets/Tests/PlayMode/DeferredPlayerLatchSyncDecisionTests.cs new file mode 100644 index 0000000000..b218480ecd --- /dev/null +++ b/Assets/Tests/PlayMode/DeferredPlayerLatchSyncDecisionTests.cs @@ -0,0 +1,84 @@ +#if ULOOP_HAS_INPUT_SYSTEM +#nullable enable +using NUnit.Framework; +using UnityEngine.InputSystem.LowLevel; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.PlayMode +{ + /// + /// Verifies deferred latch-sync fire and one-shot consume rules independently of Input System callbacks. + /// + public sealed class DeferredPlayerLatchSyncDecisionTests + { + /// + /// Verifies a Dynamic player update both runs the sync and consumes the one-shot registration. + /// + [Test] + public void Decide_WhenDynamic_RunsSyncAndUnsubscribes() + { + DeferredLatchSyncTickDecision decision = + DeferredPlayerLatchSyncDecision.Decide(InputUpdateType.Dynamic); + + Assert.That(decision.ShouldSync, Is.True); + Assert.That(decision.ShouldUnsubscribe, Is.True); + } + + /// + /// Verifies a Fixed player update both runs the sync and consumes the one-shot registration. + /// + [Test] + public void Decide_WhenFixed_RunsSyncAndUnsubscribes() + { + DeferredLatchSyncTickDecision decision = + DeferredPlayerLatchSyncDecision.Decide(InputUpdateType.Fixed); + + Assert.That(decision.ShouldSync, Is.True); + Assert.That(decision.ShouldUnsubscribe, Is.True); + } + + /// + /// Verifies a Manual player update both runs the sync and consumes the one-shot registration. + /// + [Test] + public void Decide_WhenManual_RunsSyncAndUnsubscribes() + { + DeferredLatchSyncTickDecision decision = + DeferredPlayerLatchSyncDecision.Decide(InputUpdateType.Manual); + + Assert.That(decision.ShouldSync, Is.True); + Assert.That(decision.ShouldUnsubscribe, Is.True); + } + + /// + /// Verifies an Editor update neither syncs nor consumes the registration, so a paused + /// PlayMode session still syncs on the first player update after resume. + /// + [Test] + public void Decide_WhenEditor_DoesNotSyncAndDoesNotUnsubscribe() + { + DeferredLatchSyncTickDecision decision = + DeferredPlayerLatchSyncDecision.Decide(InputUpdateType.Editor); + + Assert.That(decision.ShouldSync, Is.False); + Assert.That(decision.ShouldUnsubscribe, Is.False); + } + + /// + /// Verifies the combined Default mask does not count as a player update. Why not HasFlag: + /// Default includes Editor, and treating it as fireable would consume the one-shot during + /// editor-only pause updates. + /// + [Test] + public void Decide_WhenDefaultMask_DoesNotSyncAndDoesNotUnsubscribe() + { + DeferredLatchSyncTickDecision decision = + DeferredPlayerLatchSyncDecision.Decide(InputUpdateType.Default); + + Assert.That(decision.ShouldSync, Is.False); + Assert.That(decision.ShouldUnsubscribe, Is.False); + } + } +} +#endif diff --git a/Assets/Tests/PlayMode/DeferredPlayerLatchSyncDecisionTests.cs.meta b/Assets/Tests/PlayMode/DeferredPlayerLatchSyncDecisionTests.cs.meta new file mode 100644 index 0000000000..936bc5c959 --- /dev/null +++ b/Assets/Tests/PlayMode/DeferredPlayerLatchSyncDecisionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c26f1c3c0b14848ae908673fb2605d91 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/PlayMode/SimulateKeyboardReleaseDeviceStateTests.cs b/Assets/Tests/PlayMode/SimulateKeyboardReleaseDeviceStateTests.cs index c109fd3a22..a67d0a667f 100644 --- a/Assets/Tests/PlayMode/SimulateKeyboardReleaseDeviceStateTests.cs +++ b/Assets/Tests/PlayMode/SimulateKeyboardReleaseDeviceStateTests.cs @@ -35,6 +35,7 @@ public override void Setup() public override void TearDown() { + DeferredPlayerLatchSynchronizer.ResetForTests(); KeyboardKeyState.ReleaseAllKeys(); SimulateKeyboardOverlayState.Clear(); InputVisualizationCanvas[] canvases = @@ -106,7 +107,11 @@ public IEnumerator ReleaseAll_AfterHeldKey_WiresReleasedKeyStatesOntoResponse() Assert.That(dState!.DeviceIsPressedAfterRelease, Is.EqualTo(keyboard[Key.D].isPressed)); Assert.That(lastResponse.KeyStateReadUpdateType, Is.Not.Empty); Assert.That(lastResponse.KeyStateReadUpdateType, Is.EqualTo(InputState.currentUpdateType.ToString())); - Assert.That(lastResponse.Message, Is.EqualTo("Released 1 key(s): D")); + Assert.That(lastResponse.DeferredLatchSyncScheduled, Is.True); + Assert.That( + lastResponse.Message, + Is.EqualTo( + "Released 1 key(s): D A deferred latch sync will run on the next player input update.")); } /// @@ -125,6 +130,7 @@ public IEnumerator ReleaseAll_WhenNothingHeld_ReturnsEmptyReleasedKeysAndStates( Assert.IsTrue(lastResponse.Success); Assert.That(lastResponse.ReleasedKeys, Is.Empty); Assert.That(lastResponse.ReleasedKeyStates, Is.Empty); + Assert.That(lastResponse.DeferredLatchSyncScheduled, Is.False); } /// @@ -152,6 +158,39 @@ public IEnumerator KeyUp_WhenHeld_ReportsTrackedAndDeviceKeyState() Assert.That(lastResponse.KeyStateTrackedHeld, Is.EqualTo(KeyboardKeyState.IsKeyHeld(Key.W))); Assert.That(lastResponse.KeyStateDeviceIsPressed, Is.Not.Null); Assert.That(lastResponse.KeyStateDeviceIsPressed, Is.EqualTo(keyboard[Key.W].isPressed)); + Assert.That(lastResponse.DeferredLatchSyncScheduled, Is.True); + } + + /// + /// Verifies KeyDown then ReleaseAll leaves the device unpressed after player update frames, + /// including the deferred latch-sync path. + /// + [UnityTest] + public IEnumerator ReleaseAll_AfterHeldKey_DeviceIsUnpressedAfterPlayerUpdateFrames() + { + yield return null; + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.KeyDown.ToString(), + ["key"] = "D" + }); + Assert.IsTrue(lastResponse.Success); + + yield return RunTool(new JObject + { + ["action"] = UnityCliLoopKeyboardAction.ReleaseAll.ToString() + }); + Assert.IsTrue(lastResponse.Success); + Assert.That(lastResponse.DeferredLatchSyncScheduled, Is.True); + + for (int frame = 0; frame < 5; frame++) + { + InputSystem.Update(); + yield return null; + } + + Assert.That(keyboard[Key.D].isPressed, Is.False); } private IEnumerator RunTool(JObject parameters) @@ -247,6 +286,35 @@ public void AppendStillPressedNote_WhenNoKeyStillPressed_ReturnsMessageUnchanged Assert.That(message, Is.EqualTo("Released 2 key(s): D, W")); } + + /// + /// Verifies the deferred latch-sync note matches the exact English sentence callers will see. + /// + [Test] + public void AppendDeferredLatchSyncNote_WhenScheduled_AppendsExactLiteral() + { + string message = SimulateKeyboardReleaseMessageFormatter.AppendDeferredLatchSyncNote( + "Released 1 key(s): D", + true); + + Assert.That( + message, + Is.EqualTo( + "Released 1 key(s): D A deferred latch sync will run on the next player input update.")); + } + + /// + /// Verifies the deferred latch-sync note is omitted when nothing was scheduled. + /// + [Test] + public void AppendDeferredLatchSyncNote_WhenNotScheduled_ReturnsMessageUnchanged() + { + string message = SimulateKeyboardReleaseMessageFormatter.AppendDeferredLatchSyncNote( + "Released all keys (none were held).", + false); + + Assert.That(message, Is.EqualTo("Released all keys (none were held).")); + } } /// diff --git a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs index a63ba4fd9c..e35576381b 100644 --- a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs +++ b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs @@ -67,6 +67,7 @@ public override void TearDown() InputSettings settings = RequireInputSettings(); settings.updateMode = originalUpdateMode; Time.timeScale = originalTimeScale; + DeferredPlayerLatchSynchronizer.ResetForTests(); KeyboardKeyState.ReleaseAllKeys(); SimulateKeyboardOverlayState.Clear(); InputVisualizationCanvas[] canvases = diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredLatchSyncTickDecision.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredLatchSyncTickDecision.cs new file mode 100644 index 0000000000..40f5bf3d56 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredLatchSyncTickDecision.cs @@ -0,0 +1,23 @@ +#if ULOOP_HAS_INPUT_SYSTEM +#nullable enable + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Outcome of one deferred latch-sync callback tick: whether to run the player-view + /// sync, and whether to consume the one-shot registration. + /// + internal readonly struct DeferredLatchSyncTickDecision + { + internal DeferredLatchSyncTickDecision(bool shouldSync, bool shouldUnsubscribe) + { + ShouldSync = shouldSync; + ShouldUnsubscribe = shouldUnsubscribe; + } + + internal bool ShouldSync { get; } + + internal bool ShouldUnsubscribe { get; } + } +} +#endif diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredLatchSyncTickDecision.cs.meta b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredLatchSyncTickDecision.cs.meta new file mode 100644 index 0000000000..a9b45aa62e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredLatchSyncTickDecision.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e44429044e71b42598c4032b5bac7c73 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSyncDecision.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSyncDecision.cs new file mode 100644 index 0000000000..49348acea8 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSyncDecision.cs @@ -0,0 +1,27 @@ +#if ULOOP_HAS_INPUT_SYSTEM +#nullable enable +using UnityEngine.InputSystem.LowLevel; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Pure fire and one-shot consume rules for deferred player-view key latch sync. + /// + internal static class DeferredPlayerLatchSyncDecision + { + /// + /// Decides whether this Input System update should sync stale player latches and + /// then drop the callback. Why Editor is kept: pause only runs Editor updates, so + /// consuming the one-shot there would skip the first player update after resume. + /// Why not HasFlag: Default includes Editor and would look like a player update. + /// + internal static DeferredLatchSyncTickDecision Decide(InputUpdateType currentUpdateType) + { + bool isPlayerUpdate = currentUpdateType == InputUpdateType.Dynamic + || currentUpdateType == InputUpdateType.Fixed + || currentUpdateType == InputUpdateType.Manual; + return new DeferredLatchSyncTickDecision(isPlayerUpdate, isPlayerUpdate); + } + } +} +#endif diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSyncDecision.cs.meta b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSyncDecision.cs.meta new file mode 100644 index 0000000000..d82b9baece --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSyncDecision.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8e8cdf19abb344176941520619062328 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs new file mode 100644 index 0000000000..b677d3353c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs @@ -0,0 +1,105 @@ +#if ULOOP_HAS_INPUT_SYSTEM +#nullable enable +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.InputSystem.LowLevel; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Schedules a one-shot player-update callback that ForceSyncs stale press latches after + /// ReleaseAll/KeyUp. Why deferred: ReleaseAll often runs in an Editor update (especially + /// while paused), so the immediate ForceSync gate can read the editor view and skip. + /// + internal static class DeferredPlayerLatchSynchronizer + { + private static readonly HashSet PendingKeys = new HashSet(); + private static Action? registeredCallback; + + /// + /// Merges keys into the pending set and registers onAfterUpdate when playing. + /// Returns true when a callback is (or stays) registered. Why onAfterUpdate not + /// onBeforeUpdate: ForceSync calls RunExplicitUpdate, and nesting InputSystem.Update + /// from onBeforeUpdate of an in-flight player update is reentrant. + /// + internal static bool Schedule(IReadOnlyCollection keys) + { + if (!EditorApplication.isPlaying) + { + return false; + } + + if (keys == null || keys.Count == 0) + { + return registeredCallback != null; + } + + foreach (Key key in keys) + { + PendingKeys.Add(key); + } + + if (registeredCallback != null) + { + return true; + } + + registeredCallback = OnAfterUpdate; + InputSystem.onAfterUpdate += registeredCallback; + return true; + } + + internal static void ResetForTests() + { + Unsubscribe(); + PendingKeys.Clear(); + } + + private static void OnAfterUpdate() + { + DeferredLatchSyncTickDecision decision = + DeferredPlayerLatchSyncDecision.Decide(InputState.currentUpdateType); + if (!decision.ShouldSync) + { + Debug.Assert(!decision.ShouldUnsubscribe, "Editor ticks must keep the one-shot registration"); + return; + } + + List keysToSync = new List(PendingKeys); + // Why unsubscribe before ForceSync: ForceSync runs nested InputSystem.Update, which + // would re-enter this handler and recurse if the one-shot were still registered. + Unsubscribe(); + PendingKeys.Clear(); + + Keyboard keyboard = Keyboard.current; + if (keyboard == null) + { + return; + } + + for (int index = 0; index < keysToSync.Count; index++) + { + Key key = keysToSync[index]; + if (keyboard[key].isPressed) + { + KeyboardInputMainThreadCleanup.ForceSyncButtonPressLatch(keyboard, key); + } + } + } + + private static void Unsubscribe() + { + if (registeredCallback == null) + { + return; + } + + InputSystem.onAfterUpdate -= registeredCallback; + registeredCallback = null; + } + } +} +#endif diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs.meta b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs.meta new file mode 100644 index 0000000000..d40e36e781 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 26bd52edd9cb84be487563922c4122f6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputActionExecutor.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputActionExecutor.cs index 26a36ed237..30ded43d3b 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputActionExecutor.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputActionExecutor.cs @@ -391,6 +391,7 @@ await KeyboardInputMainThreadCleanup.ReleaseKeyStateIfPossible( await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); KeyboardKeyState.SetKeyUp(key); SimulateKeyboardOverlayState.RemoveHeldKey(keyName); + bool deferredLatchSyncScheduled = DeferredPlayerLatchSynchronizer.Schedule(new Key[] { key }); InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct) .ConfigureAwait(false); @@ -412,11 +413,14 @@ await KeyboardInputMainThreadCleanup.ReleaseKeyStateIfPossible( return new SimulateKeyboardResponse { Success = true, - Message = $"Key '{keyName}' released", + Message = SimulateKeyboardReleaseMessageFormatter.AppendDeferredLatchSyncNote( + $"Key '{keyName}' released", + deferredLatchSyncScheduled), Action = UnityCliLoopKeyboardAction.KeyUp.ToString(), KeyName = keyName, KeyStateTrackedHeld = KeyboardKeyState.IsKeyHeld(key), - KeyStateDeviceIsPressed = keyboard[key].isPressed + KeyStateDeviceIsPressed = keyboard[key].isPressed, + DeferredLatchSyncScheduled = deferredLatchSyncScheduled }; } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputMainThreadCleanup.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputMainThreadCleanup.cs index 458a436e05..f142d5241c 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputMainThreadCleanup.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputMainThreadCleanup.cs @@ -201,7 +201,7 @@ internal static void ReleaseKeyStateImmediatelyAfterPauseInterruption(Keyboard k /// Writing zero into an already-zero state buffer is a no-op for that latch sync, so after /// Editor pause/resume isPressed can stay true forever while ReadValue is 0. /// - private static void ForceSyncButtonPressLatch(Keyboard keyboard, Key key) + internal static void ForceSyncButtonPressLatch(Keyboard keyboard, Key key) { Debug.Assert(CanInjectKeyboardState(keyboard), "press latch sync requires an injectable keyboard"); if (!CanInjectKeyboardState(keyboard)) @@ -287,7 +287,11 @@ private static ReleaseAllKeysImmediateResult ReadReleasedKeyStates( releasedKeyStates = new List(); } - return new ReleaseAllKeysImmediateResult(releasedNames, releasedKeyStates, keyStateReadUpdateType); + return new ReleaseAllKeysImmediateResult( + releasedNames, + releasedKeyStates, + keyStateReadUpdateType, + sortedKeys); } // Why a pure mapper: PlayMode readback is false on a healthy device, so a hardcoded diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/ReleaseAllKeysImmediateResult.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/ReleaseAllKeysImmediateResult.cs index 2b356a5600..1588b04ca1 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/ReleaseAllKeysImmediateResult.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/ReleaseAllKeysImmediateResult.cs @@ -1,6 +1,8 @@ +#if ULOOP_HAS_INPUT_SYSTEM #nullable enable using System.Collections.Generic; +using UnityEngine.InputSystem; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { @@ -12,15 +14,19 @@ internal sealed class ReleaseAllKeysImmediateResult internal ReleaseAllKeysImmediateResult( IReadOnlyList releasedKeys, IReadOnlyList releasedKeyStates, - string keyStateReadUpdateType) + string keyStateReadUpdateType, + IReadOnlyList releasedInputKeys) { ReleasedKeys = releasedKeys; ReleasedKeyStates = releasedKeyStates; KeyStateReadUpdateType = keyStateReadUpdateType; + ReleasedInputKeys = releasedInputKeys; } internal IReadOnlyList ReleasedKeys { get; } internal IReadOnlyList ReleasedKeyStates { get; } internal string KeyStateReadUpdateType { get; } + internal IReadOnlyList ReleasedInputKeys { get; } } } +#endif diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardReleaseMessageFormatter.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardReleaseMessageFormatter.cs index 9681dae8aa..bb31050573 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardReleaseMessageFormatter.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardReleaseMessageFormatter.cs @@ -15,6 +15,9 @@ internal static class SimulateKeyboardReleaseMessageFormatter internal const string StillPressedInViewNoteFormat = "{0} key(s) still report pressed in the {1} view; the release may not yet be visible to gameplay polling."; + internal const string DeferredLatchSyncScheduledNote = + "A deferred latch sync will run on the next player input update."; + internal static string FormatStillPressedNote(int stillPressedCount, string updateType) { Debug.Assert(stillPressedCount > 0, "still-pressed note is only for remaining pressed keys"); @@ -58,5 +61,21 @@ private static int CountStillPressed(IReadOnlyList releasedKey return stillPressedCount; } + + internal static string AppendDeferredLatchSyncNote(string message, bool scheduled) + { + if (!scheduled) + { + return message ?? string.Empty; + } + + if (message == null) + { + Debug.Assert(false, "release message must exist when appending the deferred latch-sync note"); + return DeferredLatchSyncScheduledNote; + } + + return message + " " + DeferredLatchSyncScheduledNote; + } } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs index e22d679ea3..bf0289d24e 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs @@ -86,6 +86,12 @@ public class SimulateKeyboardResponse : UnityCliLoopToolResponse /// public string? KeyStateReadUpdateType { get; set; } + /// + /// True when a one-shot player-update latch sync was scheduled after this ReleaseAll or KeyUp. + /// Omitted when false. + /// + public bool DeferredLatchSyncScheduled { get; set; } + public bool ShouldSerializeReleasedKeyStates() { return ReleasedKeyStates != null; @@ -96,6 +102,11 @@ public bool ShouldSerializeKeyStateReadUpdateType() return !string.IsNullOrEmpty(KeyStateReadUpdateType); } + public bool ShouldSerializeDeferredLatchSyncScheduled() + { + return DeferredLatchSyncScheduled; + } + public SimulateKeyboardResponse() { } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs index 3b888e3796..31bce90a2b 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -214,6 +214,11 @@ private static async Task ExecuteReleaseAllAsync( message, releasedKeyStatesList, releaseResult.KeyStateReadUpdateType); + bool deferredLatchSyncScheduled = + DeferredPlayerLatchSynchronizer.Schedule(releaseResult.ReleasedInputKeys); + message = SimulateKeyboardReleaseMessageFormatter.AppendDeferredLatchSyncNote( + message, + deferredLatchSyncScheduled); SimulateKeyboardResponse response = new SimulateKeyboardResponse { @@ -222,7 +227,8 @@ private static async Task ExecuteReleaseAllAsync( Action = UnityCliLoopKeyboardAction.ReleaseAll.ToString(), ReleasedKeys = releasedKeysList, ReleasedKeyStates = releasedKeyStatesList, - KeyStateReadUpdateType = releaseResult.KeyStateReadUpdateType + KeyStateReadUpdateType = releaseResult.KeyStateReadUpdateType, + DeferredLatchSyncScheduled = deferredLatchSyncScheduled }; VibeLogger.LogInfo( diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index fc861bf855..ab1316709f 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -96,6 +96,7 @@ Returns JSON with: - `KeyStateReadUpdateType` (string, nullable): Set for `ReleaseAll`. The `InputState.currentUpdateType` at that device readback (`Editor`, `Dynamic`, `Fixed`, ...). Omitted when empty - `KeyStateTrackedHeld` (boolean, nullable): Whether Unity CLI Loop's own key-hold tracker considered the key held. Set on KeyDown "already held" / KeyUp "not currently held" rejections, and on successful `KeyUp` - `KeyStateDeviceIsPressed` (boolean, nullable): Whether `keyboard[key].isPressed` reported pressed at that same moment. Set on the same responses as `KeyStateTrackedHeld` +- `DeferredLatchSyncScheduled` (boolean): Set `true` on successful `ReleaseAll` / `KeyUp` when a one-shot player-update latch sync was scheduled. Omitted when false. The sync runs on the next Dynamic/Fixed/Manual Input System update (not Editor); while PlayMode is paused it therefore waits until resume. Gameplay polling during that same input update may still see a stale press; polling from `Update` after that input update should see the key up - `InterruptedByPausePoint` / `PausePointId` / `PausePointHitCount` / `PausePointHits`: Pause-point interruption info (all nullable except the boolean). `PausePointHits` lists every marker hit during this input in hit order; `PausePointId` only names the latest one. See the Pause Point Inspection section above - `PressEdgeObserved` (boolean, nullable): For `Press` and `KeyDown`, whether the press edge (`wasPressedThisFrame`) was visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge — verify with a focused log instead of trusting `Success` alone. `null` only for `KeyUp` and timed-out responses; pause-point interruptions still report the observed value. When a single-shot pause point is armed, do not blindly retry on `false`: the input may still have registered late, so check `pause-point-status` for a hit first — a blind retry can consume a re-enabled marker or double-fire the scenario - `PressEdgeConsumedByUpdateType` / `PressEdgeAnyDynamicUpdateObserved` / `PressEdgeKeyAlreadyPressedBeforeQueue` (nullable): Diagnostics populated only when `PressEdgeObserved` is `false` (all `null` when the edge was observed). `Message` carries the same diagnosis as text. Read them before retrying: From 2e56af4a1e910e76d5d9e9332b056dc7b0a08bec Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 20 Aug 2026 21:41:47 +0900 Subject: [PATCH 2/3] fix: drop deferred latch sync when Play Mode exits Enter Play Mode Options can skip domain reload, so a leftover onAfterUpdate subscription would ForceSync on the next session's first player update. Co-authored-by: Cursor --- .../DeferredPlayerLatchSynchronizer.cs | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs index b677d3353c..251dacd0ef 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs @@ -18,6 +18,7 @@ internal static class DeferredPlayerLatchSynchronizer { private static readonly HashSet PendingKeys = new HashSet(); private static Action? registeredCallback; + private static bool playModeExitHooked; /// /// Merges keys into the pending set and registers onAfterUpdate when playing. @@ -42,6 +43,8 @@ internal static bool Schedule(IReadOnlyCollection keys) PendingKeys.Add(key); } + HookPlayModeExitIfNeeded(); + if (registeredCallback != null) { return true; @@ -53,6 +56,34 @@ internal static bool Schedule(IReadOnlyCollection keys) } internal static void ResetForTests() + { + ClearPendingAndUnsubscribe(); + } + + private static void HookPlayModeExitIfNeeded() + { + if (playModeExitHooked) + { + return; + } + + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + playModeExitHooked = true; + } + + private static void OnPlayModeStateChanged(PlayModeStateChange change) + { + if (change != PlayModeStateChange.ExitingPlayMode) + { + return; + } + + // Why: Enter Play Mode Options can skip domain reload, so a static onAfterUpdate + // subscription would survive Stop and ForceSync on the next session's first player update. + ClearPendingAndUnsubscribe(); + } + + private static void ClearPendingAndUnsubscribe() { Unsubscribe(); PendingKeys.Clear(); @@ -71,8 +102,7 @@ private static void OnAfterUpdate() List keysToSync = new List(PendingKeys); // Why unsubscribe before ForceSync: ForceSync runs nested InputSystem.Update, which // would re-enter this handler and recurse if the one-shot were still registered. - Unsubscribe(); - PendingKeys.Clear(); + ClearPendingAndUnsubscribe(); Keyboard keyboard = Keyboard.current; if (keyboard == null) From cff72c2b386661564c26b95f09372cf4ad7cc739 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 20 Aug 2026 22:05:09 +0900 Subject: [PATCH 3/3] fix: skip deferred latch sync for tracked KeyDown re-presses A later KeyDown after Schedule is a live hold, not a leftover player latch. ForceSync-ing it from isPressed alone released the device while KeyboardKeyState still tracked the key. Skip tracked holds in the callback and drop the key from the pending set on successful KeyDown. Co-authored-by: Cursor --- ...SimulateKeyboardReleaseDeviceStateTests.cs | 69 +++++++++++++++++++ .../DeferredPlayerLatchSynchronizer.cs | 27 ++++++++ .../KeyboardInputActionExecutor.cs | 5 ++ 3 files changed, 101 insertions(+) diff --git a/Assets/Tests/PlayMode/SimulateKeyboardReleaseDeviceStateTests.cs b/Assets/Tests/PlayMode/SimulateKeyboardReleaseDeviceStateTests.cs index a67d0a667f..4398f5fcff 100644 --- a/Assets/Tests/PlayMode/SimulateKeyboardReleaseDeviceStateTests.cs +++ b/Assets/Tests/PlayMode/SimulateKeyboardReleaseDeviceStateTests.cs @@ -193,6 +193,75 @@ public IEnumerator ReleaseAll_AfterHeldKey_DeviceIsUnpressedAfterPlayerUpdateFra Assert.That(keyboard[Key.D].isPressed, Is.False); } + /// + /// Verifies KeyDown after ReleaseAll of the same key is not ForceSync-released by the + /// deferred latch callback: a tracked re-press must stay pressed across player updates. + /// + [UnityTest] + public IEnumerator KeyDown_AfterReleaseAll_SameKeyStaysPressedAcrossPlayerUpdates() + { + yield return null; + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.KeyDown.ToString(), + ["key"] = "D" + }); + Assert.IsTrue(lastResponse.Success); + + yield return RunTool(new JObject + { + ["action"] = UnityCliLoopKeyboardAction.ReleaseAll.ToString() + }); + Assert.IsTrue(lastResponse.Success); + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.KeyDown.ToString(), + ["key"] = "D" + }); + Assert.IsTrue(lastResponse.Success); + Assert.That(keyboard[Key.D].isPressed, Is.True); + Assert.That(KeyboardKeyState.IsKeyHeld(Key.D), Is.True); + + // Why re-arm: UnityTest frame pumps between ReleaseAll and the second KeyDown + // already consume the ReleaseAll one-shot. Scheduling again while the tracker + // holds is the stale-shaped callback the IsKeyHeld skip must ignore. + bool scheduled = DeferredPlayerLatchSynchronizer.Schedule(new Key[] { Key.D }); + Assert.That(scheduled, Is.True); + + for (int frame = 0; frame < 5; frame++) + { + InputSystem.Update(); + yield return null; + } + + Assert.That(keyboard[Key.D].isPressed, Is.True); + } + + /// + /// Verifies Schedule plus a player update ForceSyncs a device press that the tracker + /// does not own, so deleting the onAfterUpdate subscription cannot satisfy this test. + /// + [UnityTest] + public IEnumerator Schedule_WhenDevicePressedWithoutTracker_ForceSyncsUnpressedOnNextPlayerUpdate() + { + yield return null; + + InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.D)); + InputSystem.Update(); + Assert.That(keyboard[Key.D].isPressed, Is.True); + Assert.That(KeyboardKeyState.IsKeyHeld(Key.D), Is.False); + + bool scheduled = DeferredPlayerLatchSynchronizer.Schedule(new Key[] { Key.D }); + Assert.That(scheduled, Is.True); + + InputSystem.Update(); + yield return null; + + Assert.That(keyboard[Key.D].isPressed, Is.False); + } + private IEnumerator RunTool(JObject parameters) { Task task = tool.ExecuteAsync(parameters, System.Threading.CancellationToken.None); diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs index 251dacd0ef..035e9e15e2 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs @@ -55,6 +55,23 @@ internal static bool Schedule(IReadOnlyCollection keys) return true; } + /// + /// Drops a key from the pending set after a later successful KeyDown so the deferred + /// callback will not treat that live press as a leftover latch. + /// + internal static void CancelPending(Key key) + { + if (!PendingKeys.Remove(key)) + { + return; + } + + if (PendingKeys.Count == 0) + { + Unsubscribe(); + } + } + internal static void ResetForTests() { ClearPendingAndUnsubscribe(); @@ -113,6 +130,16 @@ private static void OnAfterUpdate() for (int index = 0; index < keysToSync.Count; index++) { Key key = keysToSync[index]; + // Why skip tracked holds: a later KeyDown can legitimately re-press the same + // key after Schedule and before this callback. isPressed alone would treat that + // live press as a stale latch, ForceSync-release it, and leave KeyboardKeyState + // still held. After ReleaseAll/KeyUp the tracker is already cleared, so the + // stale path still syncs. + if (KeyboardKeyState.IsKeyHeld(key)) + { + continue; + } + if (keyboard[key].isPressed) { KeyboardInputMainThreadCleanup.ForceSyncButtonPressLatch(keyboard, key); diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputActionExecutor.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputActionExecutor.cs index 30ded43d3b..4b211ebcca 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputActionExecutor.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputActionExecutor.cs @@ -277,6 +277,10 @@ internal static async Task ExecuteKeyDown( { edgeMissDiagnostics.KeyAlreadyPressedBeforeQueue = keyboard[key].isPressed; KeyboardKeyState.SetKeyState(keyboard, key, true); + // Why here: apply runs onBeforeUpdate of the same player tick that + // fires deferred onAfterUpdate. SetKeyDown has not run yet, so the + // IsKeyHeld skip cannot protect this frame. + DeferredPlayerLatchSynchronizer.CancelPending(key); }, ct).ConfigureAwait(false); if (waitOutcome == InputSimulationWaitOutcome.Completed) @@ -284,6 +288,7 @@ internal static async Task ExecuteKeyDown( keyDownApplied = true; await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); KeyboardKeyState.SetKeyDown(key); + DeferredPlayerLatchSynchronizer.CancelPending(key); SimulateKeyboardOverlayState.AddHeldKey(keyName); waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct) .ConfigureAwait(false);