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: