diff --git a/.agents/skills/uloop-control-play-mode/SKILL.md b/.agents/skills/uloop-control-play-mode/SKILL.md index 43e26f877d..383a5388ad 100644 --- a/.agents/skills/uloop-control-play-mode/SKILL.md +++ b/.agents/skills/uloop-control-play-mode/SKILL.md @@ -29,12 +29,14 @@ Returns JSON with the current play mode state: - `IsPaused`: Whether play mode is paused - `Changed`: Whether the requested action changed the current play mode state - `WasAlreadyStopped`: Whether `Stop` was requested while Play Mode was already stopped +- `ResumedFromPause`: Whether `Play` resumed a paused Play Mode session instead of starting a new one - `Message`: Description of the action performed ## Notes - Stop on an already-stopped Editor sets `Changed: false`, `WasAlreadyStopped: true` - `Play` on an Editor that is already playing is a no-op: it sets `Changed: false` and leaves the current session (its accumulated state, spawned objects, progress) untouched instead of restarting it. If you need a clean state for verification, explicitly `Stop` then `Play` rather than relying on `Play` alone to reset anything. +- `Play` while Play Mode is paused resumes the same session: it sets `Changed: true`, `ResumedFromPause: true`, and `Message: "Play mode resumed"` — the session is not restarted. - `Step` advances exactly one frame and leaves PlayMode paused (the Editor's Next Frame button); it is independent of `Time.timeScale` and requires PlayMode to be running - The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. - Before relying on PlayMode behavior as verification evidence, check `uloop get-logs --log-type Error` for pre-existing errors. An error already present when PlayMode starts can otherwise be mistaken for one caused by the action under test. diff --git a/.agents/skills/uloop-pause-point/SKILL.md b/.agents/skills/uloop-pause-point/SKILL.md index f34dad8108..42c3d59853 100644 --- a/.agents/skills/uloop-pause-point/SKILL.md +++ b/.agents/skills/uloop-pause-point/SKILL.md @@ -15,7 +15,7 @@ Use this small loop for one representative frame you care about. No source edit uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 30 --await ``` -Run this in the background: it blocks until the marker is hit or the timeout elapses, and the next step needs to run a `simulate-*` command while it is still waiting. +Run this in the background only because the next step needs to run a `simulate-*` command while this call is still blocking. When nothing has to run in parallel (the game reaches the line on its own), run the same call in the foreground instead: it prints the hit result with `CapturedVariables` directly to stdout when it unblocks. When you must guarantee the marker is armed before sending input, split the steps: run `enable-pause-point` without `--await` in the foreground (its response returning is the arm confirmation), then start `uloop await-pause-point --id ` in the background, then send the input. Do not approximate arm-waiting with a fixed sleep after a backgrounded enable. `--timeout-seconds` on enable starts the marker lifetime clock at enable time and is also the deadline `--await` waits against, so size it to cover both the setup steps before the trigger and the wait itself. @@ -25,9 +25,9 @@ The response returns the derived marker `Id` (`Assets/Scripts/Enemy.cs:42`), the 3. Read the backgrounded call's output for the hit result. 4. Read `CapturedVariables` in the hit response first: the locals, parameters, and `this` instance fields at the paused line are already there (see the next section). Adding a temporary `Debug.Log` just to see a local variable is no longer necessary. (the snapshot is pre-line: taken before the resolved line executes, like an IDE breakpoint) 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. -6. A `single-shot` marker (the default) disarms itself after the hit, so no clear call is required before moving on. Clearing is still what removes the underlying code patch (a disarmed marker leaves the patch installed), so for `continuous`/`trace` markers, or when the method must run fully untouched again, clear it with `uloop clear-pause-point --id "Assets/Scripts/Enemy.cs:42"` (or `--all` to clear every active marker at once, for example when resetting between E2E scenarios) or stop PlayMode. +6. A `single-shot` marker (the default) disarms itself after the hit, so no clear call is required before moving on. Clearing is still what removes the underlying code patch (a disarmed marker leaves the patch installed), so for `continuous`/`trace` markers, or when the method must run fully untouched again, clear it with `uloop clear-pause-point --id "Assets/Scripts/Enemy.cs:42"` (or `--all` to clear every active marker at once, for example when resetting between E2E scenarios) or stop PlayMode. Clearing resumes Play Mode only when the current pause is owned by a pause-point hit — the clear response then carries a `Warning` saying it resumed Play Mode. A manual pause (`control-play-mode --action Pause` or the Editor pause button) is left untouched by clear. -A hit pauses Unity at the next frame boundary — the patched method and the rest of that frame still run to completion. Only `CapturedVariables` is evidence of the values at the patched line; state read after the pause (for example via `execute-dynamic-code`) may already have advanced past it. +A hit pauses Unity at the next frame boundary — the patched method and the rest of that frame still run to completion. Only `CapturedVariables` is evidence of the values at the patched line; state read after the pause (for example via `execute-dynamic-code`) may already have advanced past it. Treat every post-hit live read as a supplement for follow-up digging — the primary evidence for what a value was at the paused line is always `CapturedVariables`. If the game progresses on its own (timers, gravity, spawners), run `control-play-mode` `Pause` before setting up scenario state and resume with `control-play-mode --action Play` only after `enable-pause-point` succeeds — otherwise the scenario can be consumed before your input arrives. See Fast-Progressing Games below. @@ -83,7 +83,7 @@ A watch evaluates only on a changed, paused frame, and a domain reload clears al - The id-only marker records the hit itself and nothing more: `CapturedVariables` is always empty, and no raw capture is stored, so `TryGetCapturedValue`/`GetCapturedNames` return nothing for these hits. When you need variable values at an id-only marker, read the target objects directly with `execute-dynamic-code` while the Editor is paused, or use a file:line marker instead. - For ordinary file:line debugging you do not need `UloopPausePoint.Pause` in source. Prefer CLI enable when the target line can be patched. - Physical Unity message methods (`OnCollisionEnter2D`, `OnTriggerEnter2D`, and similar callbacks) can silently never hit even though the method body demonstrably runs: Unity can resolve a GameObject's message dispatch before the file:line patch is installed, so a GameObject that already existed at enable time may keep calling the pre-patch code. If `await-pause-point`/`pause-point-status` reports `HitCount=0` on a physical callback line, check the response `Warning` for this note, then work around it by recreating the GameObject after enabling, or by embedding `UloopPausePoint.Pause("")` directly in the method body via an id-only marker instead of a file:line one. -- The physics-callback warning matches the target method name only. A regular method that is *called from* a physics callback (for example a helper invoked inside `OnCollisionEnter2D`) gets no warning but can still miss existing GameObjects for the same reason — treat it as the same limitation. +- The same warning also covers one-hop indirect calls: a regular method that is *called from* a physics message method elsewhere in the same compiled assembly (for example a helper invoked inside `OnCollisionEnter2D`) can miss pre-existing GameObjects for the same reason, and enable warns about it too. A call chain deeper than one hop, or a caller in a different assembly, is not detected — when such a helper stays at `HitCount=0` without explanation, treat it as this same limitation. - A method already bound into a delegate or event before `enable-pause-point` may not fire through that delegate: the pre-bound invocation path can bypass the patch. Workarounds: enable the pause point before the delegate is created, recreate the subscribing GameObject, or re-bind the delegate (e.g. via `execute-dynamic-code`) after enabling. ## Catching a Runtime Condition with a Dynamic-Code Trigger @@ -120,6 +120,8 @@ If this command times out, the patched line was not reached while the command wa Use `uloop pause-point-status --id "Assets/Scripts/Enemy.cs:42"` only when you need to confirm the marker is armed or inspect the current hit state. +To locate where control flow stops before an unhit line, bisect with a second pause point on the method's entry (its first executable line). If the entry point hits while the target line stays at `HitCount=0`, an early return or a branch between the two lines is filtering execution — inspect the guard values in the entry hit's `CapturedVariables` instead of retrying the original line. + If none of the above explains `HitCount=0`, suspect JIT inlining: Mono can inline very small target methods into callers, and the pause point then never fires even though the line runs. Move the pause point into the calling method (see Requirements & Safety). The `enable-pause-point --timeout-seconds` countdown freezes while a hit holds the Editor paused: the elapsed pause duration is credited back onto the marker's expiry on resume, so inspecting a paused hit for as long as you need does not erode the remaining timeout budget. The freeze applies only to a pause caused by a pause-point hit; a manual pause without a hit does not stop the countdown. @@ -149,7 +151,7 @@ While the Editor is paused, injecting state by writing fields or transforms dire - Prefer natural runtime points after input has been consumed, such as after a command is accepted, a state value changes, an evaluation step resolves, or a dependent component is updated. - For frame-specific bugs, target the suspicious state branch or the line right after the mutation you need to freeze (the snapshot is taken before the target line runs). - A line that runs unconditionally every frame hits on the very next frame, before the input or event you actually wanted to observe arrives. If you need to catch a specific moment, choose a line that only executes conditionally (inside an `if` guarding the event you care about) so the pause point does not fire prematurely. The opposite applies to `continuous` mode paired with a watch expression: the watch only re-evaluates on a paused frame where the marker's line executes, so a conditional line that stops being reached leaves the watch value frozen (see Watch Expressions) — pick a line reached every frame when you need continuous per-Step updates. -- When every reachable line around the state change you want runs unconditionally every frame, with no existing `if` to hang the pause point on, move the moment you want to observe into a conditional block: `if () { ; Debug.Assert(); }`, then target the pause point at the `Debug.Assert` line. The `if` creates a line that executes only when the event actually happens, so the pause point no longer fires on the very next frame; the `Debug.Assert` states the mutation's postcondition, so the line you pause on is meaningful production code ("this must hold here") rather than an arbitrary probe, and it can stay in the codebase after the investigation ends. +- When every reachable line around the state change you want runs unconditionally every frame, with no existing `if` to hang the pause point on, move the moment you want to observe into a conditional block: `if () { ; Debug.Assert(); }`, then target the pause point at the `Debug.Assert` line. The `if` creates a line that executes only when the event actually happens, so the pause point no longer fires on the very next frame; the `Debug.Assert` states the mutation's postcondition, so the line you pause on is meaningful production code ("this must hold here") rather than an arbitrary probe, and it can stay in the codebase after the investigation ends. Use `UnityEngine.Debug.Assert` for this, not `System.Diagnostics.Debug.Assert`: a failed System.Diagnostics assert never reaches the Unity Console, so `get-logs` cannot observe it. - An empty-body loop such as `while (TryMove(0, 1)) { }` has no statement inside the braces, so a pause point on the line right after the loop hits at the loop's condition re-check, not once the loop has actually finished advancing. If you need the state after the loop completes, target a line that is guaranteed to run exactly once after the loop exits, not the loop line itself. - Enable pause points after PlayMode is running: entering PlayMode with Domain Reload enabled reloads the domain and silently removes every source pause point (see Requirements & Safety). - Targeting the line that directly handles simulated input is safe: when the pause lands mid-command, the `simulate-*` command returns promptly with `InterruptedByPausePoint=true` instead of running to completion, and `simulate-mouse-ui` additionally states in `Message` whether the pointer event was already dispatched before the pause. Prefer a line after the input is consumed when you want the settled result state rather than the input-handling moment. diff --git a/.agents/skills/uloop-simulate-keyboard/SKILL.md b/.agents/skills/uloop-simulate-keyboard/SKILL.md index 1fdebf9261..863f6b80ae 100644 --- a/.agents/skills/uloop-simulate-keyboard/SKILL.md +++ b/.agents/skills/uloop-simulate-keyboard/SKILL.md @@ -38,6 +38,8 @@ uloop simulate-keyboard --action --key [options] | `KeyDown` | KeyDown only (held until KeyUp) | Start continuous movement, hold sprint | | `KeyUp` | KeyUp only (release held key) | Stop movement, release sprint | +There is no separate hold action: to hold a key, use `Press --duration ` (fixed-time hold) or `KeyDown` followed later by `KeyUp` (open-ended hold). + Use `Press` for edge-triggered keyboard code such as `Keyboard.current.spaceKey.wasPressedThisFrame`. `KeyDown` emits one initial press edge, then only keeps the key held. It does not keep `wasPressedThisFrame` true while the key remains held. If a successful `Press` or `KeyDown` leaves `Keyboard.current..isPressed` true but runtime state does not change, do not immediately rewrite the user's runtime code to `isPressed`. First verify that the target component is active during the command, that it polls input in the configured Input System update phase, and that a missed `KeyDown` edge is followed by `KeyUp` before retrying. @@ -84,6 +86,7 @@ Returns JSON with: - `KeyName` (string, nullable): The key that was acted on; may be `null` when the action could not resolve a key - `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 actually visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge (e.g. the press was consumed by an editor-only input update) — retry the input or verify with a focused log instead of trusting `Success` alone. `null` only for `KeyUp` and for timed-out responses; pause-point interruptions still report the observed value. When a single-shot pause point is armed, do not blindly retry on `PressEdgeObserved=false` — the input may still have registered late. Check `pause-point-status` for a hit first, so a retry does not 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). Read them before retrying: `PressEdgeKeyAlreadyPressedBeforeQueue=true` means the key was already held so no press transition could occur — release it with `KeyUp` before pressing again; `PressEdgeConsumedByUpdateType` naming a non-`Dynamic` update type (for example `Editor`) means an editor-side update consumed the edge before gameplay polling could see it; `PressEdgeAnyDynamicUpdateObserved=false` means no gameplay input update ran at all during the press window, so check that PlayMode is running and unpaused rather than retrying blindly. `Message` carries the same diagnosis as text ## Prerequisites diff --git a/.claude/skills/uloop-control-play-mode/SKILL.md b/.claude/skills/uloop-control-play-mode/SKILL.md index 43e26f877d..383a5388ad 100644 --- a/.claude/skills/uloop-control-play-mode/SKILL.md +++ b/.claude/skills/uloop-control-play-mode/SKILL.md @@ -29,12 +29,14 @@ Returns JSON with the current play mode state: - `IsPaused`: Whether play mode is paused - `Changed`: Whether the requested action changed the current play mode state - `WasAlreadyStopped`: Whether `Stop` was requested while Play Mode was already stopped +- `ResumedFromPause`: Whether `Play` resumed a paused Play Mode session instead of starting a new one - `Message`: Description of the action performed ## Notes - Stop on an already-stopped Editor sets `Changed: false`, `WasAlreadyStopped: true` - `Play` on an Editor that is already playing is a no-op: it sets `Changed: false` and leaves the current session (its accumulated state, spawned objects, progress) untouched instead of restarting it. If you need a clean state for verification, explicitly `Stop` then `Play` rather than relying on `Play` alone to reset anything. +- `Play` while Play Mode is paused resumes the same session: it sets `Changed: true`, `ResumedFromPause: true`, and `Message: "Play mode resumed"` — the session is not restarted. - `Step` advances exactly one frame and leaves PlayMode paused (the Editor's Next Frame button); it is independent of `Time.timeScale` and requires PlayMode to be running - The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. - Before relying on PlayMode behavior as verification evidence, check `uloop get-logs --log-type Error` for pre-existing errors. An error already present when PlayMode starts can otherwise be mistaken for one caused by the action under test. diff --git a/.claude/skills/uloop-pause-point/SKILL.md b/.claude/skills/uloop-pause-point/SKILL.md index f34dad8108..42c3d59853 100644 --- a/.claude/skills/uloop-pause-point/SKILL.md +++ b/.claude/skills/uloop-pause-point/SKILL.md @@ -15,7 +15,7 @@ Use this small loop for one representative frame you care about. No source edit uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 30 --await ``` -Run this in the background: it blocks until the marker is hit or the timeout elapses, and the next step needs to run a `simulate-*` command while it is still waiting. +Run this in the background only because the next step needs to run a `simulate-*` command while this call is still blocking. When nothing has to run in parallel (the game reaches the line on its own), run the same call in the foreground instead: it prints the hit result with `CapturedVariables` directly to stdout when it unblocks. When you must guarantee the marker is armed before sending input, split the steps: run `enable-pause-point` without `--await` in the foreground (its response returning is the arm confirmation), then start `uloop await-pause-point --id ` in the background, then send the input. Do not approximate arm-waiting with a fixed sleep after a backgrounded enable. `--timeout-seconds` on enable starts the marker lifetime clock at enable time and is also the deadline `--await` waits against, so size it to cover both the setup steps before the trigger and the wait itself. @@ -25,9 +25,9 @@ The response returns the derived marker `Id` (`Assets/Scripts/Enemy.cs:42`), the 3. Read the backgrounded call's output for the hit result. 4. Read `CapturedVariables` in the hit response first: the locals, parameters, and `this` instance fields at the paused line are already there (see the next section). Adding a temporary `Debug.Log` just to see a local variable is no longer necessary. (the snapshot is pre-line: taken before the resolved line executes, like an IDE breakpoint) 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. -6. A `single-shot` marker (the default) disarms itself after the hit, so no clear call is required before moving on. Clearing is still what removes the underlying code patch (a disarmed marker leaves the patch installed), so for `continuous`/`trace` markers, or when the method must run fully untouched again, clear it with `uloop clear-pause-point --id "Assets/Scripts/Enemy.cs:42"` (or `--all` to clear every active marker at once, for example when resetting between E2E scenarios) or stop PlayMode. +6. A `single-shot` marker (the default) disarms itself after the hit, so no clear call is required before moving on. Clearing is still what removes the underlying code patch (a disarmed marker leaves the patch installed), so for `continuous`/`trace` markers, or when the method must run fully untouched again, clear it with `uloop clear-pause-point --id "Assets/Scripts/Enemy.cs:42"` (or `--all` to clear every active marker at once, for example when resetting between E2E scenarios) or stop PlayMode. Clearing resumes Play Mode only when the current pause is owned by a pause-point hit — the clear response then carries a `Warning` saying it resumed Play Mode. A manual pause (`control-play-mode --action Pause` or the Editor pause button) is left untouched by clear. -A hit pauses Unity at the next frame boundary — the patched method and the rest of that frame still run to completion. Only `CapturedVariables` is evidence of the values at the patched line; state read after the pause (for example via `execute-dynamic-code`) may already have advanced past it. +A hit pauses Unity at the next frame boundary — the patched method and the rest of that frame still run to completion. Only `CapturedVariables` is evidence of the values at the patched line; state read after the pause (for example via `execute-dynamic-code`) may already have advanced past it. Treat every post-hit live read as a supplement for follow-up digging — the primary evidence for what a value was at the paused line is always `CapturedVariables`. If the game progresses on its own (timers, gravity, spawners), run `control-play-mode` `Pause` before setting up scenario state and resume with `control-play-mode --action Play` only after `enable-pause-point` succeeds — otherwise the scenario can be consumed before your input arrives. See Fast-Progressing Games below. @@ -83,7 +83,7 @@ A watch evaluates only on a changed, paused frame, and a domain reload clears al - The id-only marker records the hit itself and nothing more: `CapturedVariables` is always empty, and no raw capture is stored, so `TryGetCapturedValue`/`GetCapturedNames` return nothing for these hits. When you need variable values at an id-only marker, read the target objects directly with `execute-dynamic-code` while the Editor is paused, or use a file:line marker instead. - For ordinary file:line debugging you do not need `UloopPausePoint.Pause` in source. Prefer CLI enable when the target line can be patched. - Physical Unity message methods (`OnCollisionEnter2D`, `OnTriggerEnter2D`, and similar callbacks) can silently never hit even though the method body demonstrably runs: Unity can resolve a GameObject's message dispatch before the file:line patch is installed, so a GameObject that already existed at enable time may keep calling the pre-patch code. If `await-pause-point`/`pause-point-status` reports `HitCount=0` on a physical callback line, check the response `Warning` for this note, then work around it by recreating the GameObject after enabling, or by embedding `UloopPausePoint.Pause("")` directly in the method body via an id-only marker instead of a file:line one. -- The physics-callback warning matches the target method name only. A regular method that is *called from* a physics callback (for example a helper invoked inside `OnCollisionEnter2D`) gets no warning but can still miss existing GameObjects for the same reason — treat it as the same limitation. +- The same warning also covers one-hop indirect calls: a regular method that is *called from* a physics message method elsewhere in the same compiled assembly (for example a helper invoked inside `OnCollisionEnter2D`) can miss pre-existing GameObjects for the same reason, and enable warns about it too. A call chain deeper than one hop, or a caller in a different assembly, is not detected — when such a helper stays at `HitCount=0` without explanation, treat it as this same limitation. - A method already bound into a delegate or event before `enable-pause-point` may not fire through that delegate: the pre-bound invocation path can bypass the patch. Workarounds: enable the pause point before the delegate is created, recreate the subscribing GameObject, or re-bind the delegate (e.g. via `execute-dynamic-code`) after enabling. ## Catching a Runtime Condition with a Dynamic-Code Trigger @@ -120,6 +120,8 @@ If this command times out, the patched line was not reached while the command wa Use `uloop pause-point-status --id "Assets/Scripts/Enemy.cs:42"` only when you need to confirm the marker is armed or inspect the current hit state. +To locate where control flow stops before an unhit line, bisect with a second pause point on the method's entry (its first executable line). If the entry point hits while the target line stays at `HitCount=0`, an early return or a branch between the two lines is filtering execution — inspect the guard values in the entry hit's `CapturedVariables` instead of retrying the original line. + If none of the above explains `HitCount=0`, suspect JIT inlining: Mono can inline very small target methods into callers, and the pause point then never fires even though the line runs. Move the pause point into the calling method (see Requirements & Safety). The `enable-pause-point --timeout-seconds` countdown freezes while a hit holds the Editor paused: the elapsed pause duration is credited back onto the marker's expiry on resume, so inspecting a paused hit for as long as you need does not erode the remaining timeout budget. The freeze applies only to a pause caused by a pause-point hit; a manual pause without a hit does not stop the countdown. @@ -149,7 +151,7 @@ While the Editor is paused, injecting state by writing fields or transforms dire - Prefer natural runtime points after input has been consumed, such as after a command is accepted, a state value changes, an evaluation step resolves, or a dependent component is updated. - For frame-specific bugs, target the suspicious state branch or the line right after the mutation you need to freeze (the snapshot is taken before the target line runs). - A line that runs unconditionally every frame hits on the very next frame, before the input or event you actually wanted to observe arrives. If you need to catch a specific moment, choose a line that only executes conditionally (inside an `if` guarding the event you care about) so the pause point does not fire prematurely. The opposite applies to `continuous` mode paired with a watch expression: the watch only re-evaluates on a paused frame where the marker's line executes, so a conditional line that stops being reached leaves the watch value frozen (see Watch Expressions) — pick a line reached every frame when you need continuous per-Step updates. -- When every reachable line around the state change you want runs unconditionally every frame, with no existing `if` to hang the pause point on, move the moment you want to observe into a conditional block: `if () { ; Debug.Assert(); }`, then target the pause point at the `Debug.Assert` line. The `if` creates a line that executes only when the event actually happens, so the pause point no longer fires on the very next frame; the `Debug.Assert` states the mutation's postcondition, so the line you pause on is meaningful production code ("this must hold here") rather than an arbitrary probe, and it can stay in the codebase after the investigation ends. +- When every reachable line around the state change you want runs unconditionally every frame, with no existing `if` to hang the pause point on, move the moment you want to observe into a conditional block: `if () { ; Debug.Assert(); }`, then target the pause point at the `Debug.Assert` line. The `if` creates a line that executes only when the event actually happens, so the pause point no longer fires on the very next frame; the `Debug.Assert` states the mutation's postcondition, so the line you pause on is meaningful production code ("this must hold here") rather than an arbitrary probe, and it can stay in the codebase after the investigation ends. Use `UnityEngine.Debug.Assert` for this, not `System.Diagnostics.Debug.Assert`: a failed System.Diagnostics assert never reaches the Unity Console, so `get-logs` cannot observe it. - An empty-body loop such as `while (TryMove(0, 1)) { }` has no statement inside the braces, so a pause point on the line right after the loop hits at the loop's condition re-check, not once the loop has actually finished advancing. If you need the state after the loop completes, target a line that is guaranteed to run exactly once after the loop exits, not the loop line itself. - Enable pause points after PlayMode is running: entering PlayMode with Domain Reload enabled reloads the domain and silently removes every source pause point (see Requirements & Safety). - Targeting the line that directly handles simulated input is safe: when the pause lands mid-command, the `simulate-*` command returns promptly with `InterruptedByPausePoint=true` instead of running to completion, and `simulate-mouse-ui` additionally states in `Message` whether the pointer event was already dispatched before the pause. Prefer a line after the input is consumed when you want the settled result state rather than the input-handling moment. diff --git a/.claude/skills/uloop-simulate-keyboard/SKILL.md b/.claude/skills/uloop-simulate-keyboard/SKILL.md index 1fdebf9261..863f6b80ae 100644 --- a/.claude/skills/uloop-simulate-keyboard/SKILL.md +++ b/.claude/skills/uloop-simulate-keyboard/SKILL.md @@ -38,6 +38,8 @@ uloop simulate-keyboard --action --key [options] | `KeyDown` | KeyDown only (held until KeyUp) | Start continuous movement, hold sprint | | `KeyUp` | KeyUp only (release held key) | Stop movement, release sprint | +There is no separate hold action: to hold a key, use `Press --duration ` (fixed-time hold) or `KeyDown` followed later by `KeyUp` (open-ended hold). + Use `Press` for edge-triggered keyboard code such as `Keyboard.current.spaceKey.wasPressedThisFrame`. `KeyDown` emits one initial press edge, then only keeps the key held. It does not keep `wasPressedThisFrame` true while the key remains held. If a successful `Press` or `KeyDown` leaves `Keyboard.current..isPressed` true but runtime state does not change, do not immediately rewrite the user's runtime code to `isPressed`. First verify that the target component is active during the command, that it polls input in the configured Input System update phase, and that a missed `KeyDown` edge is followed by `KeyUp` before retrying. @@ -84,6 +86,7 @@ Returns JSON with: - `KeyName` (string, nullable): The key that was acted on; may be `null` when the action could not resolve a key - `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 actually visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge (e.g. the press was consumed by an editor-only input update) — retry the input or verify with a focused log instead of trusting `Success` alone. `null` only for `KeyUp` and for timed-out responses; pause-point interruptions still report the observed value. When a single-shot pause point is armed, do not blindly retry on `PressEdgeObserved=false` — the input may still have registered late. Check `pause-point-status` for a hit first, so a retry does not 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). Read them before retrying: `PressEdgeKeyAlreadyPressedBeforeQueue=true` means the key was already held so no press transition could occur — release it with `KeyUp` before pressing again; `PressEdgeConsumedByUpdateType` naming a non-`Dynamic` update type (for example `Editor`) means an editor-side update consumed the edge before gameplay polling could see it; `PressEdgeAnyDynamicUpdateObserved=false` means no gameplay input update ran at all during the press window, so check that PlayMode is running and unpaused rather than retrying blindly. `Message` carries the same diagnosis as text ## Prerequisites diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index f34dad8108..42c3d59853 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -15,7 +15,7 @@ Use this small loop for one representative frame you care about. No source edit uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 30 --await ``` -Run this in the background: it blocks until the marker is hit or the timeout elapses, and the next step needs to run a `simulate-*` command while it is still waiting. +Run this in the background only because the next step needs to run a `simulate-*` command while this call is still blocking. When nothing has to run in parallel (the game reaches the line on its own), run the same call in the foreground instead: it prints the hit result with `CapturedVariables` directly to stdout when it unblocks. When you must guarantee the marker is armed before sending input, split the steps: run `enable-pause-point` without `--await` in the foreground (its response returning is the arm confirmation), then start `uloop await-pause-point --id ` in the background, then send the input. Do not approximate arm-waiting with a fixed sleep after a backgrounded enable. `--timeout-seconds` on enable starts the marker lifetime clock at enable time and is also the deadline `--await` waits against, so size it to cover both the setup steps before the trigger and the wait itself. @@ -25,9 +25,9 @@ The response returns the derived marker `Id` (`Assets/Scripts/Enemy.cs:42`), the 3. Read the backgrounded call's output for the hit result. 4. Read `CapturedVariables` in the hit response first: the locals, parameters, and `this` instance fields at the paused line are already there (see the next section). Adding a temporary `Debug.Log` just to see a local variable is no longer necessary. (the snapshot is pre-line: taken before the resolved line executes, like an IDE breakpoint) 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. -6. A `single-shot` marker (the default) disarms itself after the hit, so no clear call is required before moving on. Clearing is still what removes the underlying code patch (a disarmed marker leaves the patch installed), so for `continuous`/`trace` markers, or when the method must run fully untouched again, clear it with `uloop clear-pause-point --id "Assets/Scripts/Enemy.cs:42"` (or `--all` to clear every active marker at once, for example when resetting between E2E scenarios) or stop PlayMode. +6. A `single-shot` marker (the default) disarms itself after the hit, so no clear call is required before moving on. Clearing is still what removes the underlying code patch (a disarmed marker leaves the patch installed), so for `continuous`/`trace` markers, or when the method must run fully untouched again, clear it with `uloop clear-pause-point --id "Assets/Scripts/Enemy.cs:42"` (or `--all` to clear every active marker at once, for example when resetting between E2E scenarios) or stop PlayMode. Clearing resumes Play Mode only when the current pause is owned by a pause-point hit — the clear response then carries a `Warning` saying it resumed Play Mode. A manual pause (`control-play-mode --action Pause` or the Editor pause button) is left untouched by clear. -A hit pauses Unity at the next frame boundary — the patched method and the rest of that frame still run to completion. Only `CapturedVariables` is evidence of the values at the patched line; state read after the pause (for example via `execute-dynamic-code`) may already have advanced past it. +A hit pauses Unity at the next frame boundary — the patched method and the rest of that frame still run to completion. Only `CapturedVariables` is evidence of the values at the patched line; state read after the pause (for example via `execute-dynamic-code`) may already have advanced past it. Treat every post-hit live read as a supplement for follow-up digging — the primary evidence for what a value was at the paused line is always `CapturedVariables`. If the game progresses on its own (timers, gravity, spawners), run `control-play-mode` `Pause` before setting up scenario state and resume with `control-play-mode --action Play` only after `enable-pause-point` succeeds — otherwise the scenario can be consumed before your input arrives. See Fast-Progressing Games below. @@ -83,7 +83,7 @@ A watch evaluates only on a changed, paused frame, and a domain reload clears al - The id-only marker records the hit itself and nothing more: `CapturedVariables` is always empty, and no raw capture is stored, so `TryGetCapturedValue`/`GetCapturedNames` return nothing for these hits. When you need variable values at an id-only marker, read the target objects directly with `execute-dynamic-code` while the Editor is paused, or use a file:line marker instead. - For ordinary file:line debugging you do not need `UloopPausePoint.Pause` in source. Prefer CLI enable when the target line can be patched. - Physical Unity message methods (`OnCollisionEnter2D`, `OnTriggerEnter2D`, and similar callbacks) can silently never hit even though the method body demonstrably runs: Unity can resolve a GameObject's message dispatch before the file:line patch is installed, so a GameObject that already existed at enable time may keep calling the pre-patch code. If `await-pause-point`/`pause-point-status` reports `HitCount=0` on a physical callback line, check the response `Warning` for this note, then work around it by recreating the GameObject after enabling, or by embedding `UloopPausePoint.Pause("")` directly in the method body via an id-only marker instead of a file:line one. -- The physics-callback warning matches the target method name only. A regular method that is *called from* a physics callback (for example a helper invoked inside `OnCollisionEnter2D`) gets no warning but can still miss existing GameObjects for the same reason — treat it as the same limitation. +- The same warning also covers one-hop indirect calls: a regular method that is *called from* a physics message method elsewhere in the same compiled assembly (for example a helper invoked inside `OnCollisionEnter2D`) can miss pre-existing GameObjects for the same reason, and enable warns about it too. A call chain deeper than one hop, or a caller in a different assembly, is not detected — when such a helper stays at `HitCount=0` without explanation, treat it as this same limitation. - A method already bound into a delegate or event before `enable-pause-point` may not fire through that delegate: the pre-bound invocation path can bypass the patch. Workarounds: enable the pause point before the delegate is created, recreate the subscribing GameObject, or re-bind the delegate (e.g. via `execute-dynamic-code`) after enabling. ## Catching a Runtime Condition with a Dynamic-Code Trigger @@ -120,6 +120,8 @@ If this command times out, the patched line was not reached while the command wa Use `uloop pause-point-status --id "Assets/Scripts/Enemy.cs:42"` only when you need to confirm the marker is armed or inspect the current hit state. +To locate where control flow stops before an unhit line, bisect with a second pause point on the method's entry (its first executable line). If the entry point hits while the target line stays at `HitCount=0`, an early return or a branch between the two lines is filtering execution — inspect the guard values in the entry hit's `CapturedVariables` instead of retrying the original line. + If none of the above explains `HitCount=0`, suspect JIT inlining: Mono can inline very small target methods into callers, and the pause point then never fires even though the line runs. Move the pause point into the calling method (see Requirements & Safety). The `enable-pause-point --timeout-seconds` countdown freezes while a hit holds the Editor paused: the elapsed pause duration is credited back onto the marker's expiry on resume, so inspecting a paused hit for as long as you need does not erode the remaining timeout budget. The freeze applies only to a pause caused by a pause-point hit; a manual pause without a hit does not stop the countdown. @@ -149,7 +151,7 @@ While the Editor is paused, injecting state by writing fields or transforms dire - Prefer natural runtime points after input has been consumed, such as after a command is accepted, a state value changes, an evaluation step resolves, or a dependent component is updated. - For frame-specific bugs, target the suspicious state branch or the line right after the mutation you need to freeze (the snapshot is taken before the target line runs). - A line that runs unconditionally every frame hits on the very next frame, before the input or event you actually wanted to observe arrives. If you need to catch a specific moment, choose a line that only executes conditionally (inside an `if` guarding the event you care about) so the pause point does not fire prematurely. The opposite applies to `continuous` mode paired with a watch expression: the watch only re-evaluates on a paused frame where the marker's line executes, so a conditional line that stops being reached leaves the watch value frozen (see Watch Expressions) — pick a line reached every frame when you need continuous per-Step updates. -- When every reachable line around the state change you want runs unconditionally every frame, with no existing `if` to hang the pause point on, move the moment you want to observe into a conditional block: `if () { ; Debug.Assert(); }`, then target the pause point at the `Debug.Assert` line. The `if` creates a line that executes only when the event actually happens, so the pause point no longer fires on the very next frame; the `Debug.Assert` states the mutation's postcondition, so the line you pause on is meaningful production code ("this must hold here") rather than an arbitrary probe, and it can stay in the codebase after the investigation ends. +- When every reachable line around the state change you want runs unconditionally every frame, with no existing `if` to hang the pause point on, move the moment you want to observe into a conditional block: `if () { ; Debug.Assert(); }`, then target the pause point at the `Debug.Assert` line. The `if` creates a line that executes only when the event actually happens, so the pause point no longer fires on the very next frame; the `Debug.Assert` states the mutation's postcondition, so the line you pause on is meaningful production code ("this must hold here") rather than an arbitrary probe, and it can stay in the codebase after the investigation ends. Use `UnityEngine.Debug.Assert` for this, not `System.Diagnostics.Debug.Assert`: a failed System.Diagnostics assert never reaches the Unity Console, so `get-logs` cannot observe it. - An empty-body loop such as `while (TryMove(0, 1)) { }` has no statement inside the braces, so a pause point on the line right after the loop hits at the loop's condition re-check, not once the loop has actually finished advancing. If you need the state after the loop completes, target a line that is guaranteed to run exactly once after the loop exits, not the loop line itself. - Enable pause points after PlayMode is running: entering PlayMode with Domain Reload enabled reloads the domain and silently removes every source pause point (see Requirements & Safety). - Targeting the line that directly handles simulated input is safe: when the pause lands mid-command, the `simulate-*` command returns promptly with `InterruptedByPausePoint=true` instead of running to completion, and `simulate-mouse-ui` additionally states in `Message` whether the pointer event was already dispatched before the pause. Prefer a line after the input is consumed when you want the settled result state rather than the input-handling moment. diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md index 43e26f877d..383a5388ad 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md @@ -29,12 +29,14 @@ Returns JSON with the current play mode state: - `IsPaused`: Whether play mode is paused - `Changed`: Whether the requested action changed the current play mode state - `WasAlreadyStopped`: Whether `Stop` was requested while Play Mode was already stopped +- `ResumedFromPause`: Whether `Play` resumed a paused Play Mode session instead of starting a new one - `Message`: Description of the action performed ## Notes - Stop on an already-stopped Editor sets `Changed: false`, `WasAlreadyStopped: true` - `Play` on an Editor that is already playing is a no-op: it sets `Changed: false` and leaves the current session (its accumulated state, spawned objects, progress) untouched instead of restarting it. If you need a clean state for verification, explicitly `Stop` then `Play` rather than relying on `Play` alone to reset anything. +- `Play` while Play Mode is paused resumes the same session: it sets `Changed: true`, `ResumedFromPause: true`, and `Message: "Play mode resumed"` — the session is not restarted. - `Step` advances exactly one frame and leaves PlayMode paused (the Editor's Next Frame button); it is independent of `Time.timeScale` and requires PlayMode to be running - The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. - Before relying on PlayMode behavior as verification evidence, check `uloop get-logs --log-type Error` for pre-existing errors. An error already present when PlayMode starts can otherwise be mistaken for one caused by the action under test. diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index 1fdebf9261..863f6b80ae 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -38,6 +38,8 @@ uloop simulate-keyboard --action --key [options] | `KeyDown` | KeyDown only (held until KeyUp) | Start continuous movement, hold sprint | | `KeyUp` | KeyUp only (release held key) | Stop movement, release sprint | +There is no separate hold action: to hold a key, use `Press --duration ` (fixed-time hold) or `KeyDown` followed later by `KeyUp` (open-ended hold). + Use `Press` for edge-triggered keyboard code such as `Keyboard.current.spaceKey.wasPressedThisFrame`. `KeyDown` emits one initial press edge, then only keeps the key held. It does not keep `wasPressedThisFrame` true while the key remains held. If a successful `Press` or `KeyDown` leaves `Keyboard.current..isPressed` true but runtime state does not change, do not immediately rewrite the user's runtime code to `isPressed`. First verify that the target component is active during the command, that it polls input in the configured Input System update phase, and that a missed `KeyDown` edge is followed by `KeyUp` before retrying. @@ -84,6 +86,7 @@ Returns JSON with: - `KeyName` (string, nullable): The key that was acted on; may be `null` when the action could not resolve a key - `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 actually visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge (e.g. the press was consumed by an editor-only input update) — retry the input or verify with a focused log instead of trusting `Success` alone. `null` only for `KeyUp` and for timed-out responses; pause-point interruptions still report the observed value. When a single-shot pause point is armed, do not blindly retry on `PressEdgeObserved=false` — the input may still have registered late. Check `pause-point-status` for a hit first, so a retry does not 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). Read them before retrying: `PressEdgeKeyAlreadyPressedBeforeQueue=true` means the key was already held so no press transition could occur — release it with `KeyUp` before pressing again; `PressEdgeConsumedByUpdateType` naming a non-`Dynamic` update type (for example `Editor`) means an editor-side update consumed the edge before gameplay polling could see it; `PressEdgeAnyDynamicUpdateObserved=false` means no gameplay input update ran at all during the press window, so check that PlayMode is running and unpaused rather than retrying blindly. `Message` carries the same diagnosis as text ## Prerequisites