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..4398f5fcff 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,108 @@ 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);
+ }
+
+ ///
+ /// 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)
@@ -247,6 +355,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..035e9e15e2
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/DeferredPlayerLatchSynchronizer.cs
@@ -0,0 +1,162 @@
+#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;
+ private static bool playModeExitHooked;
+
+ ///
+ /// 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);
+ }
+
+ HookPlayModeExitIfNeeded();
+
+ if (registeredCallback != null)
+ {
+ return true;
+ }
+
+ registeredCallback = OnAfterUpdate;
+ InputSystem.onAfterUpdate += registeredCallback;
+ 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();
+ }
+
+ 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();
+ }
+
+ 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.
+ ClearPendingAndUnsubscribe();
+
+ Keyboard keyboard = Keyboard.current;
+ if (keyboard == null)
+ {
+ return;
+ }
+
+ 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);
+ }
+ }
+ }
+
+ 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..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);
@@ -391,6 +396,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 +418,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: