diff --git a/.agents/skills/uloop-wait-for-pause-point/SKILL.md b/.agents/skills/uloop-wait-for-pause-point/SKILL.md index a0055d4f4..04afaf105 100644 --- a/.agents/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.agents/skills/uloop-wait-for-pause-point/SKILL.md @@ -1,72 +1,72 @@ --- name: uloop-wait-for-pause-point -description: "Pauses Unity's playback and allows you to inspect specific frames. Use this for bug investigation or PlayMode/E2E testing. It's the most convenient yet rigorous method for verifying variable states at specific frames or confirming whether particular code has been executed." +description: "Pauses Unity playback at any source file:line without editing code or recompiling, and returns a snapshot of the locals, parameters, and instance fields at that exact frame. Use this for bug investigation or PlayMode/E2E testing. It's the most convenient yet rigorous method for verifying variable states at specific frames or confirming whether particular code has been executed." --- # uloop wait-for-pause-point ## Quick Check Template -Use this small loop for one representative frame you care about: +Use this small loop for one representative frame you care about. No source edit and no recompile: the pause point is patched into the already-compiled code and can be enabled mid-PlayMode. -Custom asmdef scripts must reference `UnityCLILoop.PausePoints.Runtime` before calling `UloopPausePoint.Pause`. `Assembly-CSharp` scripts can usually use it without a manual reference. +1. Enter PlayMode, then enable a pause point on the line you want to freeze: -1. Put a focused log and marker at the natural transition point. Log only local/intermediate values that will be hard to inspect later: - -```csharp -using UnityEngine; -using io.github.hatayama.UnityCliLoop.Runtime; - -Debug.Log($"state-transition-applied localValue={localValue} reason={reason}"); -UloopPausePoint.Pause("state-transition-applied"); +```bash +uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 30 ``` -1. Compile, enter PlayMode, then enable the marker: +The response returns the derived marker `Id` (`Assets/Scripts/Enemy.cs:42`), the `ResolvedLine` that was actually patched, and the `ResolvedMethod`. When the requested line has no executable statement, the pause point rounds forward to the next executable line — check `ResolvedLine` when precision matters. Use the returned `Id` for every follow-up command. + +2. Trigger the action with a `simulate-*` command. +3. Wait for the hit, even if the trigger command already returned `InterruptedByPausePoint=true`: ```bash -uloop enable-pause-point --id state-transition-applied --timeout-seconds 30 +uloop wait-for-pause-point --id "Assets/Scripts/Enemy.cs:42" --timeout-seconds 30 ``` -1. Trigger the action with a `simulate-*` command. -2. Wait for the marker and read the focused log in one call, even if the trigger command already returned `InterruptedByPausePoint=true`: +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. +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. Clear the marker with `uloop clear-pause-point --id "Assets/Scripts/Enemy.cs:42"` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. Clearing also removes the underlying code patch, so the method runs untouched afterwards. -```bash -uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 -``` +## Reading CapturedVariables -The hit response always embeds the log entries matching the marker id as `MatchingLogs` (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Log embedding is always on; there is no opt-in flag, and a `--include-matching-logs` option no longer exists. An empty `MatchingLogs` array means the fetch succeeded and no matching log exists; if the field is absent, the log fetch itself failed, so fall back to `uloop get-logs --search-text state-transition-applied --max-count 20` while paused. +Every hit response embeds `CapturedVariables`: the method's in-scope locals, its parameters, and the `this` instance fields, captured at the exact moment execution reached the patched line. Values are point-in-time strings, not live references, so they stay valid as evidence even after Unity resumes. -Read `EvidenceSummary` first when it is present. It groups `EditorState`, pause point hit metadata, matching-log counts, truncation status, and warnings so you can tell whether the evidence is a single clean hit or needs closer inspection. `EditorState.CapturedAt` names when the Unity Editor play/pause state was observed, such as `PausePointHit`, `Current`, or `ClearAll`. +- The snapshot is taken **before** the resolved line executes, exactly like an IDE breakpoint on that line. To inspect a value after an assignment, place the pause point on the following line. +- `Scope` is `Local`, `Parameter`, or `InstanceField`. +- `UnityEngine.Object` values additionally carry `UnityObjectKind` (`SceneObject`, `PrefabAsset`, `Asset`, `RuntimeInstance`, or `Destroyed`), `UnityObjectPath`, and `UnityObjectInstanceId`. Use these as handles for the next dig: a `SceneObject` path feeds `get-hierarchy`/`find-game-objects`, an asset path locates the asset, and the InstanceID works with `execute-dynamic-code`. +- `CapturedVariablesTruncated=true` means at least one value was clipped to the length cap or the variable-count cap stopped enumeration; clipped values are still present up to the cap. +- async and coroutine methods work: hoisted locals and the original `this` fields appear under their normal names. +- If the patched method ran off the main thread, values degrade to type names with a `(captured off main thread)` note; the hit itself is still recorded. -Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. +Read `EvidenceSummary` first when it is present. It groups `EditorState`, pause point hit metadata, matching-log counts, truncation status, and warnings so you can tell whether the evidence is a single clean hit or needs closer inspection. `MatchingLogs` (log entries whose text contains the marker id) is still embedded, but source-derived ids rarely appear in log text, so treat `CapturedVariables` as the primary variable evidence. -1. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. -2. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. The clear response's `EditorState` describes Unity Editor play/pause state, not marker state. +Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. ## When To Use - Use this as the standard frame proof for state-changing PlayMode/E2E simulated input, physics, or UI transitions. - Consider a pause point during E2E passes when transition-frame evidence would add confidence, even if durable state, logs, or screenshots can later confirm the final result. - Use this before reaching for `Time.timeScale`, sleeps, repeated polling, or after-the-fact `execute-dynamic-code`; those checks can supplement the paused-frame proof, but they are not substitutes. -- If the value you need is a method local, an intermediate calculation, or a branch reason that `execute-dynamic-code` cannot reach, add a focused `Debug.Log` immediately before the marker and read it with `get-logs` while paused. Do not count the breakpoint check complete until the matching log has been read. -- Treat the pause like a lightweight breakpoint for one important transition: combine nearby debug logs with paused-frame inspection to confirm the variables and component state at that point. +- If the value you need is a method local, an intermediate calculation, or a branch reason that `execute-dynamic-code` cannot reach, put the pause point on that line: `CapturedVariables` records it without touching the source. +- Treat the pause like a lightweight breakpoint for one important transition: the captured snapshot plus paused-frame inspection confirm the variables and component state at that point. - Do not treat `simulate-* Success=true`, generic action logs, sleeps/retries, testing-only counters, or `Time.timeScale` changes as paused-frame proof. - Skip this only for ordinary persistent-state checks when you are not validating simulated input delivery, event ordering, or transition-frame fidelity. ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `Error.Details.Hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `Error.Details.RecommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the marker using the returned `Id` and `TimeoutSeconds`. Then inspect `Error.Details.Status`, `HitCount`, `Generation`, `EnabledAtUtc`, `EditorState`, `ElapsedSinceEnabledMilliseconds`, and `RemainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. On `PAUSE_POINT_WAIT_TIMEOUT`, `Error.Details.MatchingLogs` and `Error.Details.EvidenceSummary` show whether the marker's focused log ever appeared. `ElapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the patched line was not reached while the command waited. Read `Error.Details.Hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `Error.Details.RecommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the pause point using the returned `Id` and `TimeoutSeconds`. Then inspect `Error.Details.Status`, `HitCount`, `Generation`, `EnabledAtUtc`, `EditorState`, `ElapsedSinceEnabledMilliseconds`, and `RemainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. `ElapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. -Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. +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. ## Fast-Progressing Games -When the game advances on its own (a ball keeps bouncing, blocks keep falling), the state you are arranging can move past the marker before the input command and the wait are even issued. Pause the Editor and walk frames explicitly instead: +When the game advances on its own (a ball keeps bouncing, blocks keep falling), the state you are arranging can move past the target line before the input command and the wait are even issued. Pause the Editor and walk frames explicitly instead: ```bash # Freeze the whole player loop while arranging the scenario uloop control-play-mode --action Pause -# ... enable markers, inspect/arrange state with execute-dynamic-code, get-hierarchy, get-logs ... +# ... enable pause points, inspect/arrange state with execute-dynamic-code, get-hierarchy, get-logs ... # Advance exactly one frame and stay paused (the Editor's Next Frame button) uloop control-play-mode --action Step # Resume right before sending the input you are verifying (input simulation needs an unpaused player) @@ -75,19 +75,22 @@ uloop control-play-mode --action Play Do not use `Time.timeScale = 0` for this: projects that read unscaled time keep advancing regardless, and the value silently persists into the next PlayMode session. Editor pause and `Step` freeze the entire player loop independent of `Time.timeScale`. -A pause point hit leaves Unity in this same paused state, so `Step` also works right after a marker hits: inspect the paused frame, then step forward to watch the following frames commit one at a time. +A pause point hit leaves Unity in this same paused state, so `Step` also works right after a hit: inspect the paused frame, then step forward to watch the following frames commit one at a time. -## Marker Placement +## Line Placement - 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, place the marker on the suspicious state branch or immediately after the state mutation you need to freeze. -- To avoid Domain Reload loss or tool Busy states, enable markers after Play Mode is running, and prefer checkpoints reached after the triggering input command can return. -- Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect. Immediate markers can interrupt the input command before the resulting runtime state settles. -- Use separate ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`, instead of reusing one broad marker. - -## Safety - -- Code in a custom asmdef must reference `UnityCLILoop.PausePoints.Runtime` to use `UloopPausePoint.Pause`. -- Do not pass side-effect expressions as the id argument. Use stable string ids. -- This feature does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. -- If `enable-pause-point` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable it again after PlayMode starts. +- 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). +- 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. +- Use separate pause points on distinct lines for strict phases, for example input read, state updated, and result committed, instead of one broad pause point. + +## Requirements & Safety + +- **Debug code optimization is required.** When the Editor's Code Optimization mode is Release, enable is rejected with instructions; switch to Debug via the bug icon in the main toolbar, recompile, then retry. +- **Patches do not survive compiles or domain reloads.** Any script compile or domain reload removes every source pause point together with its marker, leaving the code exactly as compiled. Re-enable after the reload finishes. This is also why an interrupted CLI session never leaves stale patches behind. +- If `enable-pause-point` fails, read the failure `Message` and `RecommendedNextAction`: they name the exact next step, for example waiting for a reload to finish, re-resolving after a recompile, or what to do when the method cannot be patched. +- For scripts under `Packages/`, pass the package-id form of the path — `Packages//...`, exactly as the Unity Project window and console stack traces show it. The physical checkout path of an embedded package does not resolve. +- If enable fails with a "No sequence point found" error even for clearly executable lines, that script's assembly lacks debug sequence points and no line in the file can be patched. Move the pause point to a script in an assembly that carries them, such as a script under `Assets/`. +- Very small methods can be inlined by Mono's JIT into callers, in which case the pause point never hits even though the line executes. If a line demonstrably runs but the pause point stays unhit, move the pause point into the calling method. +- If `enable-pause-point` warns about Domain Reload before PlayMode, the pause point may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable it again after PlayMode starts. diff --git a/.claude/skills/uloop-wait-for-pause-point/SKILL.md b/.claude/skills/uloop-wait-for-pause-point/SKILL.md index a0055d4f4..04afaf105 100644 --- a/.claude/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.claude/skills/uloop-wait-for-pause-point/SKILL.md @@ -1,72 +1,72 @@ --- name: uloop-wait-for-pause-point -description: "Pauses Unity's playback and allows you to inspect specific frames. Use this for bug investigation or PlayMode/E2E testing. It's the most convenient yet rigorous method for verifying variable states at specific frames or confirming whether particular code has been executed." +description: "Pauses Unity playback at any source file:line without editing code or recompiling, and returns a snapshot of the locals, parameters, and instance fields at that exact frame. Use this for bug investigation or PlayMode/E2E testing. It's the most convenient yet rigorous method for verifying variable states at specific frames or confirming whether particular code has been executed." --- # uloop wait-for-pause-point ## Quick Check Template -Use this small loop for one representative frame you care about: +Use this small loop for one representative frame you care about. No source edit and no recompile: the pause point is patched into the already-compiled code and can be enabled mid-PlayMode. -Custom asmdef scripts must reference `UnityCLILoop.PausePoints.Runtime` before calling `UloopPausePoint.Pause`. `Assembly-CSharp` scripts can usually use it without a manual reference. +1. Enter PlayMode, then enable a pause point on the line you want to freeze: -1. Put a focused log and marker at the natural transition point. Log only local/intermediate values that will be hard to inspect later: - -```csharp -using UnityEngine; -using io.github.hatayama.UnityCliLoop.Runtime; - -Debug.Log($"state-transition-applied localValue={localValue} reason={reason}"); -UloopPausePoint.Pause("state-transition-applied"); +```bash +uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 30 ``` -1. Compile, enter PlayMode, then enable the marker: +The response returns the derived marker `Id` (`Assets/Scripts/Enemy.cs:42`), the `ResolvedLine` that was actually patched, and the `ResolvedMethod`. When the requested line has no executable statement, the pause point rounds forward to the next executable line — check `ResolvedLine` when precision matters. Use the returned `Id` for every follow-up command. + +2. Trigger the action with a `simulate-*` command. +3. Wait for the hit, even if the trigger command already returned `InterruptedByPausePoint=true`: ```bash -uloop enable-pause-point --id state-transition-applied --timeout-seconds 30 +uloop wait-for-pause-point --id "Assets/Scripts/Enemy.cs:42" --timeout-seconds 30 ``` -1. Trigger the action with a `simulate-*` command. -2. Wait for the marker and read the focused log in one call, even if the trigger command already returned `InterruptedByPausePoint=true`: +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. +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. Clear the marker with `uloop clear-pause-point --id "Assets/Scripts/Enemy.cs:42"` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. Clearing also removes the underlying code patch, so the method runs untouched afterwards. -```bash -uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 -``` +## Reading CapturedVariables -The hit response always embeds the log entries matching the marker id as `MatchingLogs` (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Log embedding is always on; there is no opt-in flag, and a `--include-matching-logs` option no longer exists. An empty `MatchingLogs` array means the fetch succeeded and no matching log exists; if the field is absent, the log fetch itself failed, so fall back to `uloop get-logs --search-text state-transition-applied --max-count 20` while paused. +Every hit response embeds `CapturedVariables`: the method's in-scope locals, its parameters, and the `this` instance fields, captured at the exact moment execution reached the patched line. Values are point-in-time strings, not live references, so they stay valid as evidence even after Unity resumes. -Read `EvidenceSummary` first when it is present. It groups `EditorState`, pause point hit metadata, matching-log counts, truncation status, and warnings so you can tell whether the evidence is a single clean hit or needs closer inspection. `EditorState.CapturedAt` names when the Unity Editor play/pause state was observed, such as `PausePointHit`, `Current`, or `ClearAll`. +- The snapshot is taken **before** the resolved line executes, exactly like an IDE breakpoint on that line. To inspect a value after an assignment, place the pause point on the following line. +- `Scope` is `Local`, `Parameter`, or `InstanceField`. +- `UnityEngine.Object` values additionally carry `UnityObjectKind` (`SceneObject`, `PrefabAsset`, `Asset`, `RuntimeInstance`, or `Destroyed`), `UnityObjectPath`, and `UnityObjectInstanceId`. Use these as handles for the next dig: a `SceneObject` path feeds `get-hierarchy`/`find-game-objects`, an asset path locates the asset, and the InstanceID works with `execute-dynamic-code`. +- `CapturedVariablesTruncated=true` means at least one value was clipped to the length cap or the variable-count cap stopped enumeration; clipped values are still present up to the cap. +- async and coroutine methods work: hoisted locals and the original `this` fields appear under their normal names. +- If the patched method ran off the main thread, values degrade to type names with a `(captured off main thread)` note; the hit itself is still recorded. -Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. +Read `EvidenceSummary` first when it is present. It groups `EditorState`, pause point hit metadata, matching-log counts, truncation status, and warnings so you can tell whether the evidence is a single clean hit or needs closer inspection. `MatchingLogs` (log entries whose text contains the marker id) is still embedded, but source-derived ids rarely appear in log text, so treat `CapturedVariables` as the primary variable evidence. -1. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. -2. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. The clear response's `EditorState` describes Unity Editor play/pause state, not marker state. +Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. ## When To Use - Use this as the standard frame proof for state-changing PlayMode/E2E simulated input, physics, or UI transitions. - Consider a pause point during E2E passes when transition-frame evidence would add confidence, even if durable state, logs, or screenshots can later confirm the final result. - Use this before reaching for `Time.timeScale`, sleeps, repeated polling, or after-the-fact `execute-dynamic-code`; those checks can supplement the paused-frame proof, but they are not substitutes. -- If the value you need is a method local, an intermediate calculation, or a branch reason that `execute-dynamic-code` cannot reach, add a focused `Debug.Log` immediately before the marker and read it with `get-logs` while paused. Do not count the breakpoint check complete until the matching log has been read. -- Treat the pause like a lightweight breakpoint for one important transition: combine nearby debug logs with paused-frame inspection to confirm the variables and component state at that point. +- If the value you need is a method local, an intermediate calculation, or a branch reason that `execute-dynamic-code` cannot reach, put the pause point on that line: `CapturedVariables` records it without touching the source. +- Treat the pause like a lightweight breakpoint for one important transition: the captured snapshot plus paused-frame inspection confirm the variables and component state at that point. - Do not treat `simulate-* Success=true`, generic action logs, sleeps/retries, testing-only counters, or `Time.timeScale` changes as paused-frame proof. - Skip this only for ordinary persistent-state checks when you are not validating simulated input delivery, event ordering, or transition-frame fidelity. ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `Error.Details.Hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `Error.Details.RecommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the marker using the returned `Id` and `TimeoutSeconds`. Then inspect `Error.Details.Status`, `HitCount`, `Generation`, `EnabledAtUtc`, `EditorState`, `ElapsedSinceEnabledMilliseconds`, and `RemainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. On `PAUSE_POINT_WAIT_TIMEOUT`, `Error.Details.MatchingLogs` and `Error.Details.EvidenceSummary` show whether the marker's focused log ever appeared. `ElapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the patched line was not reached while the command waited. Read `Error.Details.Hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `Error.Details.RecommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the pause point using the returned `Id` and `TimeoutSeconds`. Then inspect `Error.Details.Status`, `HitCount`, `Generation`, `EnabledAtUtc`, `EditorState`, `ElapsedSinceEnabledMilliseconds`, and `RemainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. `ElapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. -Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. +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. ## Fast-Progressing Games -When the game advances on its own (a ball keeps bouncing, blocks keep falling), the state you are arranging can move past the marker before the input command and the wait are even issued. Pause the Editor and walk frames explicitly instead: +When the game advances on its own (a ball keeps bouncing, blocks keep falling), the state you are arranging can move past the target line before the input command and the wait are even issued. Pause the Editor and walk frames explicitly instead: ```bash # Freeze the whole player loop while arranging the scenario uloop control-play-mode --action Pause -# ... enable markers, inspect/arrange state with execute-dynamic-code, get-hierarchy, get-logs ... +# ... enable pause points, inspect/arrange state with execute-dynamic-code, get-hierarchy, get-logs ... # Advance exactly one frame and stay paused (the Editor's Next Frame button) uloop control-play-mode --action Step # Resume right before sending the input you are verifying (input simulation needs an unpaused player) @@ -75,19 +75,22 @@ uloop control-play-mode --action Play Do not use `Time.timeScale = 0` for this: projects that read unscaled time keep advancing regardless, and the value silently persists into the next PlayMode session. Editor pause and `Step` freeze the entire player loop independent of `Time.timeScale`. -A pause point hit leaves Unity in this same paused state, so `Step` also works right after a marker hits: inspect the paused frame, then step forward to watch the following frames commit one at a time. +A pause point hit leaves Unity in this same paused state, so `Step` also works right after a hit: inspect the paused frame, then step forward to watch the following frames commit one at a time. -## Marker Placement +## Line Placement - 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, place the marker on the suspicious state branch or immediately after the state mutation you need to freeze. -- To avoid Domain Reload loss or tool Busy states, enable markers after Play Mode is running, and prefer checkpoints reached after the triggering input command can return. -- Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect. Immediate markers can interrupt the input command before the resulting runtime state settles. -- Use separate ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`, instead of reusing one broad marker. - -## Safety - -- Code in a custom asmdef must reference `UnityCLILoop.PausePoints.Runtime` to use `UloopPausePoint.Pause`. -- Do not pass side-effect expressions as the id argument. Use stable string ids. -- This feature does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. -- If `enable-pause-point` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable it again after PlayMode starts. +- 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). +- 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. +- Use separate pause points on distinct lines for strict phases, for example input read, state updated, and result committed, instead of one broad pause point. + +## Requirements & Safety + +- **Debug code optimization is required.** When the Editor's Code Optimization mode is Release, enable is rejected with instructions; switch to Debug via the bug icon in the main toolbar, recompile, then retry. +- **Patches do not survive compiles or domain reloads.** Any script compile or domain reload removes every source pause point together with its marker, leaving the code exactly as compiled. Re-enable after the reload finishes. This is also why an interrupted CLI session never leaves stale patches behind. +- If `enable-pause-point` fails, read the failure `Message` and `RecommendedNextAction`: they name the exact next step, for example waiting for a reload to finish, re-resolving after a recompile, or what to do when the method cannot be patched. +- For scripts under `Packages/`, pass the package-id form of the path — `Packages//...`, exactly as the Unity Project window and console stack traces show it. The physical checkout path of an embedded package does not resolve. +- If enable fails with a "No sequence point found" error even for clearly executable lines, that script's assembly lacks debug sequence points and no line in the file can be patched. Move the pause point to a script in an assembly that carries them, such as a script under `Assets/`. +- Very small methods can be inlined by Mono's JIT into callers, in which case the pause point never hits even though the line executes. If a line demonstrably runs but the pause point stays unhit, move the pause point into the calling method. +- If `enable-pause-point` warns about Domain Reload before PlayMode, the pause point may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable it again after PlayMode starts. diff --git a/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs b/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs index 1726f8360..83a1eec10 100644 --- a/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs +++ b/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs @@ -85,6 +85,23 @@ public void CreateHeartbeatResponse_WhenSerialized_ProducesFrozenJson() "{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"alive\":true},\"uloop\":{\"phase\":\"heartbeat\",\"mainThreadStallSeconds\":12.5}}")); } + [Test] + public void CreateErrorResponse_ForBusyException_IncludesSecondsSinceLastMainThreadTick() + { + // Verifies busy error responses carry secondsSinceLastMainThreadTick so clients can + // distinguish a live main thread from a frozen Editor while BUSY. + UnityCliLoopToolBusyException busyException = new( + "running-tool", "requested-tool", isPlaying: true, isPaused: true); + + string response = JsonRpcResponseFactory.CreateErrorResponse(1, busyException); + + JObject json = JObject.Parse(response); + JToken dataToken = json["error"]!["data"]!; + Assert.That(dataToken["type"]!.Value(), Is.EqualTo(JsonRpcErrorTypes.ServerBusy)); + Assert.That(dataToken["secondsSinceLastMainThreadTick"], Is.Not.Null); + Assert.That(dataToken["secondsSinceLastMainThreadTick"]!.Value(), Is.GreaterThanOrEqualTo(0)); + } + [Test] public async Task ProcessRequest_WhenProtocolVersionIsTooOld_ProducesFrozenMismatchJson() { diff --git a/Assets/Tests/Editor/SkillInstallLayoutTests.cs b/Assets/Tests/Editor/SkillInstallLayoutTests.cs index 4dac816fc..26c1a8784 100644 --- a/Assets/Tests/Editor/SkillInstallLayoutTests.cs +++ b/Assets/Tests/Editor/SkillInstallLayoutTests.cs @@ -164,7 +164,7 @@ public void GetToolDescriptionsByToolName_WhenSkillHasDescription_MapsDescriptio IReadOnlyDictionary descriptions = SkillInstallLayout.GetToolDescriptionsByToolName(_projectRoot); Assert.That(descriptions["compile"], Is.EqualTo("Compile the Unity project and report errors/warnings. Use after C# edits.")); - Assert.That(descriptions[UnityCliLoopConstants.COMMAND_NAME_WAIT_FOR_PAUSE_POINT], Does.StartWith("Pauses Unity's playback")); + Assert.That(descriptions[UnityCliLoopConstants.COMMAND_NAME_WAIT_FOR_PAUSE_POINT], Does.StartWith("Pauses Unity playback")); } // Tests that duplicate skill names use the earlier source root across each precedence boundary. diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index a0055d4f4..04afaf105 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -1,72 +1,72 @@ --- name: uloop-wait-for-pause-point -description: "Pauses Unity's playback and allows you to inspect specific frames. Use this for bug investigation or PlayMode/E2E testing. It's the most convenient yet rigorous method for verifying variable states at specific frames or confirming whether particular code has been executed." +description: "Pauses Unity playback at any source file:line without editing code or recompiling, and returns a snapshot of the locals, parameters, and instance fields at that exact frame. Use this for bug investigation or PlayMode/E2E testing. It's the most convenient yet rigorous method for verifying variable states at specific frames or confirming whether particular code has been executed." --- # uloop wait-for-pause-point ## Quick Check Template -Use this small loop for one representative frame you care about: +Use this small loop for one representative frame you care about. No source edit and no recompile: the pause point is patched into the already-compiled code and can be enabled mid-PlayMode. -Custom asmdef scripts must reference `UnityCLILoop.PausePoints.Runtime` before calling `UloopPausePoint.Pause`. `Assembly-CSharp` scripts can usually use it without a manual reference. +1. Enter PlayMode, then enable a pause point on the line you want to freeze: -1. Put a focused log and marker at the natural transition point. Log only local/intermediate values that will be hard to inspect later: - -```csharp -using UnityEngine; -using io.github.hatayama.UnityCliLoop.Runtime; - -Debug.Log($"state-transition-applied localValue={localValue} reason={reason}"); -UloopPausePoint.Pause("state-transition-applied"); +```bash +uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 30 ``` -1. Compile, enter PlayMode, then enable the marker: +The response returns the derived marker `Id` (`Assets/Scripts/Enemy.cs:42`), the `ResolvedLine` that was actually patched, and the `ResolvedMethod`. When the requested line has no executable statement, the pause point rounds forward to the next executable line — check `ResolvedLine` when precision matters. Use the returned `Id` for every follow-up command. + +2. Trigger the action with a `simulate-*` command. +3. Wait for the hit, even if the trigger command already returned `InterruptedByPausePoint=true`: ```bash -uloop enable-pause-point --id state-transition-applied --timeout-seconds 30 +uloop wait-for-pause-point --id "Assets/Scripts/Enemy.cs:42" --timeout-seconds 30 ``` -1. Trigger the action with a `simulate-*` command. -2. Wait for the marker and read the focused log in one call, even if the trigger command already returned `InterruptedByPausePoint=true`: +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. +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. Clear the marker with `uloop clear-pause-point --id "Assets/Scripts/Enemy.cs:42"` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. Clearing also removes the underlying code patch, so the method runs untouched afterwards. -```bash -uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 -``` +## Reading CapturedVariables -The hit response always embeds the log entries matching the marker id as `MatchingLogs` (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Log embedding is always on; there is no opt-in flag, and a `--include-matching-logs` option no longer exists. An empty `MatchingLogs` array means the fetch succeeded and no matching log exists; if the field is absent, the log fetch itself failed, so fall back to `uloop get-logs --search-text state-transition-applied --max-count 20` while paused. +Every hit response embeds `CapturedVariables`: the method's in-scope locals, its parameters, and the `this` instance fields, captured at the exact moment execution reached the patched line. Values are point-in-time strings, not live references, so they stay valid as evidence even after Unity resumes. -Read `EvidenceSummary` first when it is present. It groups `EditorState`, pause point hit metadata, matching-log counts, truncation status, and warnings so you can tell whether the evidence is a single clean hit or needs closer inspection. `EditorState.CapturedAt` names when the Unity Editor play/pause state was observed, such as `PausePointHit`, `Current`, or `ClearAll`. +- The snapshot is taken **before** the resolved line executes, exactly like an IDE breakpoint on that line. To inspect a value after an assignment, place the pause point on the following line. +- `Scope` is `Local`, `Parameter`, or `InstanceField`. +- `UnityEngine.Object` values additionally carry `UnityObjectKind` (`SceneObject`, `PrefabAsset`, `Asset`, `RuntimeInstance`, or `Destroyed`), `UnityObjectPath`, and `UnityObjectInstanceId`. Use these as handles for the next dig: a `SceneObject` path feeds `get-hierarchy`/`find-game-objects`, an asset path locates the asset, and the InstanceID works with `execute-dynamic-code`. +- `CapturedVariablesTruncated=true` means at least one value was clipped to the length cap or the variable-count cap stopped enumeration; clipped values are still present up to the cap. +- async and coroutine methods work: hoisted locals and the original `this` fields appear under their normal names. +- If the patched method ran off the main thread, values degrade to type names with a `(captured off main thread)` note; the hit itself is still recorded. -Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. +Read `EvidenceSummary` first when it is present. It groups `EditorState`, pause point hit metadata, matching-log counts, truncation status, and warnings so you can tell whether the evidence is a single clean hit or needs closer inspection. `MatchingLogs` (log entries whose text contains the marker id) is still embedded, but source-derived ids rarely appear in log text, so treat `CapturedVariables` as the primary variable evidence. -1. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. -2. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. The clear response's `EditorState` describes Unity Editor play/pause state, not marker state. +Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. ## When To Use - Use this as the standard frame proof for state-changing PlayMode/E2E simulated input, physics, or UI transitions. - Consider a pause point during E2E passes when transition-frame evidence would add confidence, even if durable state, logs, or screenshots can later confirm the final result. - Use this before reaching for `Time.timeScale`, sleeps, repeated polling, or after-the-fact `execute-dynamic-code`; those checks can supplement the paused-frame proof, but they are not substitutes. -- If the value you need is a method local, an intermediate calculation, or a branch reason that `execute-dynamic-code` cannot reach, add a focused `Debug.Log` immediately before the marker and read it with `get-logs` while paused. Do not count the breakpoint check complete until the matching log has been read. -- Treat the pause like a lightweight breakpoint for one important transition: combine nearby debug logs with paused-frame inspection to confirm the variables and component state at that point. +- If the value you need is a method local, an intermediate calculation, or a branch reason that `execute-dynamic-code` cannot reach, put the pause point on that line: `CapturedVariables` records it without touching the source. +- Treat the pause like a lightweight breakpoint for one important transition: the captured snapshot plus paused-frame inspection confirm the variables and component state at that point. - Do not treat `simulate-* Success=true`, generic action logs, sleeps/retries, testing-only counters, or `Time.timeScale` changes as paused-frame proof. - Skip this only for ordinary persistent-state checks when you are not validating simulated input delivery, event ordering, or transition-frame fidelity. ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `Error.Details.Hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `Error.Details.RecommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the marker using the returned `Id` and `TimeoutSeconds`. Then inspect `Error.Details.Status`, `HitCount`, `Generation`, `EnabledAtUtc`, `EditorState`, `ElapsedSinceEnabledMilliseconds`, and `RemainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. On `PAUSE_POINT_WAIT_TIMEOUT`, `Error.Details.MatchingLogs` and `Error.Details.EvidenceSummary` show whether the marker's focused log ever appeared. `ElapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the patched line was not reached while the command waited. Read `Error.Details.Hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `Error.Details.RecommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the pause point using the returned `Id` and `TimeoutSeconds`. Then inspect `Error.Details.Status`, `HitCount`, `Generation`, `EnabledAtUtc`, `EditorState`, `ElapsedSinceEnabledMilliseconds`, and `RemainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. `ElapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. -Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. +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. ## Fast-Progressing Games -When the game advances on its own (a ball keeps bouncing, blocks keep falling), the state you are arranging can move past the marker before the input command and the wait are even issued. Pause the Editor and walk frames explicitly instead: +When the game advances on its own (a ball keeps bouncing, blocks keep falling), the state you are arranging can move past the target line before the input command and the wait are even issued. Pause the Editor and walk frames explicitly instead: ```bash # Freeze the whole player loop while arranging the scenario uloop control-play-mode --action Pause -# ... enable markers, inspect/arrange state with execute-dynamic-code, get-hierarchy, get-logs ... +# ... enable pause points, inspect/arrange state with execute-dynamic-code, get-hierarchy, get-logs ... # Advance exactly one frame and stay paused (the Editor's Next Frame button) uloop control-play-mode --action Step # Resume right before sending the input you are verifying (input simulation needs an unpaused player) @@ -75,19 +75,22 @@ uloop control-play-mode --action Play Do not use `Time.timeScale = 0` for this: projects that read unscaled time keep advancing regardless, and the value silently persists into the next PlayMode session. Editor pause and `Step` freeze the entire player loop independent of `Time.timeScale`. -A pause point hit leaves Unity in this same paused state, so `Step` also works right after a marker hits: inspect the paused frame, then step forward to watch the following frames commit one at a time. +A pause point hit leaves Unity in this same paused state, so `Step` also works right after a hit: inspect the paused frame, then step forward to watch the following frames commit one at a time. -## Marker Placement +## Line Placement - 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, place the marker on the suspicious state branch or immediately after the state mutation you need to freeze. -- To avoid Domain Reload loss or tool Busy states, enable markers after Play Mode is running, and prefer checkpoints reached after the triggering input command can return. -- Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect. Immediate markers can interrupt the input command before the resulting runtime state settles. -- Use separate ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`, instead of reusing one broad marker. - -## Safety - -- Code in a custom asmdef must reference `UnityCLILoop.PausePoints.Runtime` to use `UloopPausePoint.Pause`. -- Do not pass side-effect expressions as the id argument. Use stable string ids. -- This feature does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. -- If `enable-pause-point` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable it again after PlayMode starts. +- 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). +- 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. +- Use separate pause points on distinct lines for strict phases, for example input read, state updated, and result committed, instead of one broad pause point. + +## Requirements & Safety + +- **Debug code optimization is required.** When the Editor's Code Optimization mode is Release, enable is rejected with instructions; switch to Debug via the bug icon in the main toolbar, recompile, then retry. +- **Patches do not survive compiles or domain reloads.** Any script compile or domain reload removes every source pause point together with its marker, leaving the code exactly as compiled. Re-enable after the reload finishes. This is also why an interrupted CLI session never leaves stale patches behind. +- If `enable-pause-point` fails, read the failure `Message` and `RecommendedNextAction`: they name the exact next step, for example waiting for a reload to finish, re-resolving after a recompile, or what to do when the method cannot be patched. +- For scripts under `Packages/`, pass the package-id form of the path — `Packages//...`, exactly as the Unity Project window and console stack traces show it. The physical checkout path of an embedded package does not resolve. +- If enable fails with a "No sequence point found" error even for clearly executable lines, that script's assembly lacks debug sequence points and no line in the file can be patched. Move the pause point to a script in an assembly that carries them, such as a script under `Assets/`. +- Very small methods can be inlined by Mono's JIT into callers, in which case the pause point never hits even though the line executes. If a line demonstrably runs but the pause point stays unhit, move the pause point into the calling method. +- If `enable-pause-point` warns about Domain Reload before PlayMode, the pause point may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable it again after PlayMode starts. diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileTool.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileTool.cs index 5dec9dc14..e994284c7 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileTool.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileTool.cs @@ -25,7 +25,7 @@ protected override async Task ExecuteAsync(CompileSchema parame UnityCliLoopCompileSessionLifecycleFacade.Service, UnityCliLoopCompileResultSessionRepositoryFacade.Repository, UnityCliLoopPendingCompileSessionRepositoryFacade.Repository); - return await useCase.CompileAsync(parameters, ct); + return await useCase.CompileAsync(parameters, ct).ConfigureAwait(false); } } } diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeTool.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeTool.cs index 3a8f4b876..4e6b0e10c 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeTool.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeTool.cs @@ -16,7 +16,7 @@ public class ControlPlayModeTool : UnityCliLoopTool ExecuteAsync(ControlPlayModeSchema parameters, CancellationToken ct) { ControlPlayModeUseCase useCase = new(); - return await useCase.ExecuteAsync(parameters, ct); + return await useCase.ExecuteAsync(parameters, ct).ConfigureAwait(false); } } } diff --git a/Packages/src/Editor/FirstPartyTools/FindGameObjects/FindGameObjectsTool.cs b/Packages/src/Editor/FirstPartyTools/FindGameObjects/FindGameObjectsTool.cs index 1b56c9cad..5fe750c95 100644 --- a/Packages/src/Editor/FirstPartyTools/FindGameObjects/FindGameObjectsTool.cs +++ b/Packages/src/Editor/FirstPartyTools/FindGameObjects/FindGameObjectsTool.cs @@ -16,7 +16,7 @@ public class FindGameObjectsTool : UnityCliLoopTool ExecuteAsync(FindGameObjectsSchema parameters, CancellationToken ct) { FindGameObjectsUseCase useCase = new(new GameObjectFinderService(), new ComponentSerializer()); - return await useCase.ExecuteAsync(parameters, ct); + return await useCase.ExecuteAsync(parameters, ct).ConfigureAwait(false); } } } diff --git a/Packages/src/Editor/FirstPartyTools/GetHierarchy/GetHierarchyTool.cs b/Packages/src/Editor/FirstPartyTools/GetHierarchy/GetHierarchyTool.cs index d9be472c4..5900fb192 100644 --- a/Packages/src/Editor/FirstPartyTools/GetHierarchy/GetHierarchyTool.cs +++ b/Packages/src/Editor/FirstPartyTools/GetHierarchy/GetHierarchyTool.cs @@ -16,7 +16,7 @@ public class GetHierarchyTool : UnityCliLoopTool ExecuteAsync(GetHierarchySchema parameters, CancellationToken ct) { GetHierarchyUseCase useCase = new(new HierarchyService(), new HierarchySerializer()); - return await useCase.ExecuteAsync(parameters, ct); + return await useCase.ExecuteAsync(parameters, ct).ConfigureAwait(false); } } } diff --git a/Packages/src/Editor/FirstPartyTools/GetLogs/GetLogsTool.cs b/Packages/src/Editor/FirstPartyTools/GetLogs/GetLogsTool.cs index 1152e0554..7156e2d43 100644 --- a/Packages/src/Editor/FirstPartyTools/GetLogs/GetLogsTool.cs +++ b/Packages/src/Editor/FirstPartyTools/GetLogs/GetLogsTool.cs @@ -16,7 +16,7 @@ public class GetLogsTool : UnityCliLoopTool protected override async Task ExecuteAsync(GetLogsSchema parameters, CancellationToken ct) { GetLogsUseCase useCase = new(new LogRetrievalService(), new LogFilteringService()); - return await useCase.ExecuteAsync(parameters, ct); + return await useCase.ExecuteAsync(parameters, ct).ConfigureAwait(false); } } } diff --git a/Packages/src/Editor/FirstPartyTools/ReplayInput/ReplayInputTool.cs b/Packages/src/Editor/FirstPartyTools/ReplayInput/ReplayInputTool.cs index 3b90ef2a2..6c2e59d19 100644 --- a/Packages/src/Editor/FirstPartyTools/ReplayInput/ReplayInputTool.cs +++ b/Packages/src/Editor/FirstPartyTools/ReplayInput/ReplayInputTool.cs @@ -16,7 +16,7 @@ public class ReplayInputTool : UnityCliLoopTool ExecuteAsync(ReplayInputSchema parameters, CancellationToken ct) { ReplayInputUseCase useCase = new(); - return await useCase.ReplayInputAsync(parameters, ct); + return await useCase.ReplayInputAsync(parameters, ct).ConfigureAwait(false); } } } diff --git a/Packages/src/Editor/FirstPartyTools/RunTests/RunTestsTool.cs b/Packages/src/Editor/FirstPartyTools/RunTests/RunTestsTool.cs index ac20d40e5..2ab8d602c 100644 --- a/Packages/src/Editor/FirstPartyTools/RunTests/RunTestsTool.cs +++ b/Packages/src/Editor/FirstPartyTools/RunTests/RunTestsTool.cs @@ -16,7 +16,7 @@ public class RunTestsTool : UnityCliLoopTool protected override async Task ExecuteAsync(RunTestsSchema parameters, CancellationToken ct) { RunTestsUseCase useCase = new(); - return await useCase.ExecuteAsync(parameters, ct); + return await useCase.ExecuteAsync(parameters, ct).ConfigureAwait(false); } } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs index b1b3d3e2b..887f5a167 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs @@ -16,7 +16,7 @@ public class SimulateKeyboardTool : UnityCliLoopTool ExecuteAsync(SimulateKeyboardSchema parameters, CancellationToken ct) { SimulateKeyboardUseCase useCase = new(); - return await useCase.ExecuteAsync(parameters, ct); + return await useCase.ExecuteAsync(parameters, ct).ConfigureAwait(false); } } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs index 2f8c867ad..ce3b52fb0 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs @@ -16,7 +16,7 @@ public class SimulateMouseInputTool : UnityCliLoopTool ExecuteAsync(SimulateMouseInputSchema parameters, CancellationToken ct) { SimulateMouseInputUseCase useCase = new(); - return await useCase.ExecuteAsync(parameters, ct); + return await useCase.ExecuteAsync(parameters, ct).ConfigureAwait(false); } } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiDragEventExecutor.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiDragEventExecutor.cs index 8accd4f13..ef9760cf7 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiDragEventExecutor.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiDragEventExecutor.cs @@ -81,7 +81,7 @@ private static void UpdatePointerRaycast(PointerEventData pointerData) pointerData.pointerCurrentRaycast = hit ?? new RaycastResult(); } - internal static async Task InterpolateDragPosition( + internal static async Task InterpolateDragPosition( PointerEventData pointerData, GameObject target, Vector2 endPos, @@ -96,10 +96,10 @@ internal static async Task InterpolateDragPosition( if (duration <= 0f) { - bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); - if (!frameReady) + MouseUiFrameWaitOutcome frameOutcome = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); + if (frameOutcome != MouseUiFrameWaitOutcome.Completed) { - return false; + return frameOutcome; } await MainThreadSwitcher.SwitchToMainThread(ct); @@ -110,7 +110,7 @@ internal static async Task InterpolateDragPosition( ExecuteEvents.Execute(target, pointerData, ExecuteEvents.dragHandler); SimulateMouseUiOverlayState.UpdatePosition(MouseUiCoordinateConverter.ScreenToInput(endPos)); - return true; + return MouseUiFrameWaitOutcome.Completed; } float startTime = Time.realtimeSinceStartup; @@ -118,10 +118,10 @@ internal static async Task InterpolateDragPosition( do { - bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); - if (!frameReady) + MouseUiFrameWaitOutcome frameOutcome = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); + if (frameOutcome != MouseUiFrameWaitOutcome.Completed) { - return false; + return frameOutcome; } await MainThreadSwitcher.SwitchToMainThread(ct); @@ -140,7 +140,7 @@ internal static async Task InterpolateDragPosition( } while (t < 1.0f); - return true; + return MouseUiFrameWaitOutcome.Completed; } } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiEditorFrameWaiter.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiEditorFrameWaiter.cs index 6aa39b1ce..c6d4572f0 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiEditorFrameWaiter.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiEditorFrameWaiter.cs @@ -1,30 +1,58 @@ #nullable enable using System.Threading; using System.Threading.Tasks; +using UnityEditor; using io.github.hatayama.UnityCliLoop.Application; using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { + /// + /// Reports whether a mouse UI frame wait finished normally, was cut short by a + /// Pause Point, or exceeded the wall-clock guard. + /// + internal enum MouseUiFrameWaitOutcome + { + Completed = 0, + Paused = 1, + TimedOut = 2 + } + /// /// Waits for an Editor frame and restores execution to the Unity main thread. /// internal static class MouseUiEditorFrameWaiter { - internal static async Task WaitForEditorFrameAndSwitchToMainThreadAsync(CancellationToken ct) + // EditorApplication.update keeps ticking while Play Mode is paused, so a frame wait + // keyed only to update ticks would never report the pause. Duration-based callers + // (MouseUiOverlayAnimator, the LongPress hold loop) measure elapsed time with + // Time.realtimeSinceStartup, which freezes once EditorApplication.isPaused is true, + // so without this check they would spin forever: every individual frame wait keeps + // succeeding while the duration they are accumulating toward never advances. + internal static async Task WaitForEditorFrameAndSwitchToMainThreadAsync(CancellationToken ct) { + if (EditorApplication.isPaused) + { + return MouseUiFrameWaitOutcome.Paused; + } + bool frameReady = await EditorFrameWaiter.WaitFramesOrTimeoutAsync( 1, UnityCliLoopConstants.EDITOR_FRAME_WAIT_TIMEOUT_MS, ct).ConfigureAwait(false); if (!frameReady) { - return false; + return MouseUiFrameWaitOutcome.TimedOut; } await MainThreadSwitcher.SwitchToMainThread(ct); - return true; + if (EditorApplication.isPaused) + { + return MouseUiFrameWaitOutcome.Paused; + } + + return MouseUiFrameWaitOutcome.Completed; } } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiIncrementalDragExecutor.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiIncrementalDragExecutor.cs index ef676a2b7..c30eedcf7 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiIncrementalDragExecutor.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiIncrementalDragExecutor.cs @@ -51,20 +51,34 @@ internal static async Task ExecuteDragStart( { SimulateMouseUiOverlayState.Update( MouseAction.DragStart, inputPos, null, null, Handles.GetMainGameViewSize()); - bool expandCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); - if (!expandCompleted) + MouseUiFrameWaitOutcome noTargetExpandOutcome = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); + if (noTargetExpandOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragStart, inputPos, null, null); } + if (noTargetExpandOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.DragStart, inputPos, null, + "DragStart stopped because Unity paused during Pause Point inspection. No draggable target was found at the position, so no drag was initiated."); + } await MainThreadSwitcher.SwitchToMainThread(ct); - bool dissipateCompleted = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); - if (!dissipateCompleted) + MouseUiFrameWaitOutcome noTargetDissipateOutcome = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); + if (noTargetDissipateOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragStart, inputPos, null, null); } + if (noTargetDissipateOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.DragStart, inputPos, null, + "DragStart stopped because Unity paused during Pause Point inspection. No draggable target was found at the position, so no drag was initiated."); + } await MainThreadSwitcher.SwitchToMainThread(ct); return new SimulateMouseUiResponse @@ -93,12 +107,19 @@ internal static async Task ExecuteDragStart( bool animationCompleted = false; try { - animationCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); - if (!animationCompleted) + MouseUiFrameWaitOutcome expandOutcome = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); + if (expandOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragStart, inputPos, null, targetName); } + if (expandOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.DragStart, inputPos, targetName, + "DragStart was finalized early (pointerUp/drop/endDrag dispatched via cleanup) because Unity paused during Pause Point inspection before the start animation finished. No drag session is active; call DragStart again to retry."); + } await MainThreadSwitcher.SwitchToMainThread(ct); animationCompleted = true; } @@ -165,14 +186,21 @@ internal static async Task ExecuteDragMove( targetName, Handles.GetMainGameViewSize()); // Cancellation leaves drag state intact so the user can continue with DragMove/DragEnd - bool dragCompleted = await MouseUiDragEventExecutor.InterpolateDragPosition( + MouseUiFrameWaitOutcome dragOutcome = await MouseUiDragEventExecutor.InterpolateDragPosition( pointerData, target, screenEnd, parameters.DragSpeed, ct).ConfigureAwait(false); - if (!dragCompleted) + if (dragOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragMove, inputEnd, null, targetName); } + if (dragOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.DragMove, inputEnd, targetName, + "DragMove was interrupted because Unity paused during Pause Point inspection while interpolating. The drag session is still active (not finalized); the pointer may not have reached the requested position. Call DragMove or DragEnd to continue."); + } await MainThreadSwitcher.SwitchToMainThread(ct); SimulateMouseUiOverlayState.AddWaypoint(inputEnd); @@ -235,24 +263,41 @@ internal static async Task ExecuteDragEnd( SimulateMouseUiOverlayState.DragStartPosition, targetName, Handles.GetMainGameViewSize()); + // Any Paused exit inside this try still runs FinalizeDrag + MouseDragState.Clear() + // in the finally below, so every in-try branch reports the drag as finalized early. + const string DragEndInterruptedMessage = + "DragEnd was finalized early (pointerUp/drop/endDrag dispatched via cleanup, drag state cleared) because Unity paused during Pause Point inspection before the drag motion finished. The drag may have stopped short of the target position."; + try { - bool dragCompleted = await MouseUiDragEventExecutor.InterpolateDragPosition( + MouseUiFrameWaitOutcome dragOutcome = await MouseUiDragEventExecutor.InterpolateDragPosition( pointerData, target, screenEnd, parameters.DragSpeed, ct).ConfigureAwait(false); - if (!dragCompleted) + if (dragOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragEnd, inputEnd, null, targetName); } + if (dragOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.DragEnd, inputEnd, targetName, DragEndInterruptedMessage); + } await MainThreadSwitcher.SwitchToMainThread(ct); - bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); - if (!frameReady) + MouseUiFrameWaitOutcome settleOutcome = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); + if (settleOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragEnd, inputEnd, null, targetName); } + if (settleOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.DragEnd, inputEnd, targetName, DragEndInterruptedMessage); + } await MainThreadSwitcher.SwitchToMainThread(ct); } finally @@ -267,12 +312,19 @@ internal static async Task ExecuteDragEnd( SimulateMouseUiOverlayState.Update( MouseAction.DragEnd, inputEnd, null, targetName, Handles.GetMainGameViewSize()); - bool dissipateCompleted = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); - if (!dissipateCompleted) + MouseUiFrameWaitOutcome dissipateOutcome = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); + if (dissipateOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateDragEndResult(parameters, inputEnd, targetName); } + if (dissipateOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.DragEnd, inputEnd, targetName, + "DragEnd was already completed (target position reached, pointerUp/drop/endDrag dispatched, drag state cleared). Unity paused during Pause Point inspection while the overlay animation was still playing; only the animation was interrupted."); + } await MainThreadSwitcher.SwitchToMainThread(ct); return MouseUiSimulationResponseFactory.CreateDragEndResult(parameters, inputEnd, targetName); diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOneShotDragExecutor.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOneShotDragExecutor.cs index 0e697fb3c..35e0891c1 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOneShotDragExecutor.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOneShotDragExecutor.cs @@ -51,20 +51,34 @@ internal static async Task ExecuteDragOneShot( { SimulateMouseUiOverlayState.Update( MouseAction.Drag, inputStart, null, null, Handles.GetMainGameViewSize()); - bool expandCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); - if (!expandCompleted) + MouseUiFrameWaitOutcome noTargetExpandOutcome = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); + if (noTargetExpandOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.Drag, inputStart, inputEnd, null); } + if (noTargetExpandOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.Drag, inputStart, null, + "Drag stopped because Unity paused during Pause Point inspection. No draggable target was found at the start position, so no drag was initiated."); + } await MainThreadSwitcher.SwitchToMainThread(ct); - bool dissipateCompleted = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); - if (!dissipateCompleted) + MouseUiFrameWaitOutcome noTargetDissipateOutcome = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); + if (noTargetDissipateOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.Drag, inputStart, inputEnd, null); } + if (noTargetDissipateOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.Drag, inputStart, null, + "Drag stopped because Unity paused during Pause Point inspection. No draggable target was found at the start position, so no drag was initiated."); + } await MainThreadSwitcher.SwitchToMainThread(ct); return new SimulateMouseUiResponse @@ -90,31 +104,55 @@ internal static async Task ExecuteDragOneShot( SimulateMouseUiOverlayState.Update( MouseAction.Drag, inputStart, inputStart, targetName, Handles.GetMainGameViewSize()); + // Any Paused exit inside this try still runs FinalizeDrag in the finally below + // (pointerUp/drop/endDrag), so every branch reports the drag as finalized early + // rather than merely "not yet dispatched" like Click/LongPress's pre-input pause. + const string DragInterruptedDuringMotionMessage = + "Drag was finalized early (pointerUp/drop/endDrag dispatched via cleanup) because Unity paused during Pause Point inspection before the drag motion finished. The drag may have stopped short of the target position."; + try { - bool expandCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); - if (!expandCompleted) + MouseUiFrameWaitOutcome expandOutcome = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); + if (expandOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.Drag, inputStart, inputEnd, targetName); } + if (expandOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.Drag, inputStart, targetName, DragInterruptedDuringMotionMessage); + } await MainThreadSwitcher.SwitchToMainThread(ct); - bool dragCompleted = await MouseUiDragEventExecutor.InterpolateDragPosition(pointerData, target, screenEnd, parameters.DragSpeed, ct) + MouseUiFrameWaitOutcome dragOutcome = await MouseUiDragEventExecutor.InterpolateDragPosition(pointerData, target, screenEnd, parameters.DragSpeed, ct) .ConfigureAwait(false); - if (!dragCompleted) + if (dragOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.Drag, inputStart, inputEnd, targetName); } + if (dragOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.Drag, inputStart, targetName, DragInterruptedDuringMotionMessage); + } await MainThreadSwitcher.SwitchToMainThread(ct); - bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); - if (!frameReady) + MouseUiFrameWaitOutcome settleOutcome = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); + if (settleOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.Drag, inputStart, inputEnd, targetName); } + if (settleOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.Drag, inputStart, targetName, DragInterruptedDuringMotionMessage); + } await MainThreadSwitcher.SwitchToMainThread(ct); } finally @@ -125,12 +163,19 @@ internal static async Task ExecuteDragOneShot( SimulateMouseUiOverlayState.Update( MouseAction.Drag, inputEnd, inputStart, targetName, Handles.GetMainGameViewSize()); - bool completedDissipate = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); - if (!completedDissipate) + MouseUiFrameWaitOutcome dissipateOutcome = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); + if (dissipateOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateDragResult(parameters, inputStart, inputEnd, targetName); } + if (dissipateOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.Drag, inputStart, targetName, + "Drag was already completed (target position reached, pointerUp/drop/endDrag dispatched). Unity paused during Pause Point inspection while the overlay animation was still playing; only the animation was interrupted."); + } await MainThreadSwitcher.SwitchToMainThread(ct); return MouseUiSimulationResponseFactory.CreateDragResult(parameters, inputStart, inputEnd, targetName); diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOverlayAnimator.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOverlayAnimator.cs index 829f9685e..598a89ff3 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOverlayAnimator.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOverlayAnimator.cs @@ -13,7 +13,7 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class MouseUiOverlayAnimator { - internal static async Task PlayExpandAnimation(CancellationToken ct) + internal static async Task PlayExpandAnimation(CancellationToken ct) { SimulateMouseUiOverlay overlay = OverlayCanvasFactory.VisualizationCanvas.MouseUiOverlay; @@ -26,19 +26,19 @@ internal static async Task PlayExpandAnimation(CancellationToken ct) { float t = elapsed / SimulateMouseUiAnimationConstants.EXPAND_DURATION; overlay.SetCursorScale(Mathf.Lerp(SimulateMouseUiAnimationConstants.EXPAND_START_SCALE, 1f, t)); - bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); - if (!frameReady) + MouseUiFrameWaitOutcome frameOutcome = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); + if (frameOutcome != MouseUiFrameWaitOutcome.Completed) { - return false; + return frameOutcome; } await MainThreadSwitcher.SwitchToMainThread(ct); elapsed = Time.realtimeSinceStartup - startTime; } overlay.SetCursorScale(1f); - return true; + return MouseUiFrameWaitOutcome.Completed; } - internal static async Task PlayDissipateAnimation(CancellationToken ct) + internal static async Task PlayDissipateAnimation(CancellationToken ct) { SimulateMouseUiOverlay overlay = OverlayCanvasFactory.VisualizationCanvas.MouseUiOverlay; @@ -49,10 +49,10 @@ internal static async Task PlayDissipateAnimation(CancellationToken ct) float t = elapsed / SimulateMouseUiAnimationConstants.DISSIPATE_DURATION; overlay.SetCursorScale(Mathf.Lerp(1f, 0f, t)); overlay.SetAlpha(Mathf.Lerp(1f, 0f, t)); - bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); - if (!frameReady) + MouseUiFrameWaitOutcome frameOutcome = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); + if (frameOutcome != MouseUiFrameWaitOutcome.Completed) { - return false; + return frameOutcome; } await MainThreadSwitcher.SwitchToMainThread(ct); elapsed = Time.realtimeSinceStartup - startTime; @@ -60,7 +60,7 @@ internal static async Task PlayDissipateAnimation(CancellationToken ct) overlay!.SetCursorScale(0f); overlay!.SetAlpha(0f); SimulateMouseUiOverlayState.Clear(); - return true; + return MouseUiFrameWaitOutcome.Completed; } } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPressActionExecutor.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPressActionExecutor.cs index ea4e9cc38..1eabaffee 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPressActionExecutor.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiPressActionExecutor.cs @@ -50,23 +50,37 @@ internal static async Task ExecuteClick( MouseAction.Click, inputPos, null, targetName, Handles.GetMainGameViewSize()); - bool expandCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); - if (!expandCompleted) + MouseUiFrameWaitOutcome expandOutcome = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); + if (expandOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.Click, inputPos, null, targetName); } + if (expandOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.Click, inputPos, targetName, + "Click stopped because Unity paused during Pause Point inspection before the click was dispatched. No pointer event was fired."); + } await MainThreadSwitcher.SwitchToMainThread(ct); // Fire click events after expand animation so the user sees where the click lands ExecutePointerClickEvents(resolvedTargets, pointerData); - bool dissipateCompleted = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); - if (!dissipateCompleted) + MouseUiFrameWaitOutcome dissipateOutcome = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); + if (dissipateOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateClickResult(parameters, inputPos, targetName, hitTarget); } + if (dissipateOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.Click, inputPos, targetName, + "Click was already dispatched. Unity paused during Pause Point inspection while the click overlay animation was still playing; only the animation was interrupted."); + } await MainThreadSwitcher.SwitchToMainThread(ct); return MouseUiSimulationResponseFactory.CreateClickResult(parameters, inputPos, targetName, hitTarget); @@ -118,12 +132,19 @@ internal static async Task ExecuteLongPress( MouseAction.LongPress, inputPos, null, targetName, Handles.GetMainGameViewSize()); - bool expandCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); - if (!expandCompleted) + MouseUiFrameWaitOutcome expandOutcome = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); + if (expandOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.LongPress, inputPos, null, targetName); } + if (expandOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.LongPress, inputPos, targetName, + "Long-press stopped because Unity paused during Pause Point inspection before pointerDown was dispatched. No pointer event was fired."); + } await MainThreadSwitcher.SwitchToMainThread(ct); ExecuteLongPressPointerDown(resolvedTargets, pointerData); @@ -136,12 +157,20 @@ internal static async Task ExecuteLongPress( while (elapsed < parameters.Duration) { SimulateMouseUiOverlayState.UpdateLongPressElapsed(elapsed); - bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); - if (!frameReady) + MouseUiFrameWaitOutcome frameOutcome = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); + if (frameOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.LongPress, inputPos, null, targetName); } + if (frameOutcome == MouseUiFrameWaitOutcome.Paused) + { + // Returning here still runs the finally below, which releases pointerUp early. + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.LongPress, inputPos, targetName, + "Long-press pointerDown was already dispatched. Unity paused during Pause Point inspection while holding; pointerUp was released early and the press duration was cut short."); + } await MainThreadSwitcher.SwitchToMainThread(ct); elapsed = Time.realtimeSinceStartup - startTime; } @@ -157,12 +186,19 @@ internal static async Task ExecuteLongPress( } } - bool dissipateCompleted = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); - if (!dissipateCompleted) + MouseUiFrameWaitOutcome dissipateOutcome = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); + if (dissipateOutcome == MouseUiFrameWaitOutcome.TimedOut) { cleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateLongPressResult(parameters, inputPos, targetName, hitTarget); } + if (dissipateOutcome == MouseUiFrameWaitOutcome.Paused) + { + cleanupScheduler.QueueOverlayClear(); + return MouseUiSimulationResponseFactory.CreateInterruptedResult( + MouseAction.LongPress, inputPos, targetName, + "Long-press was already completed (pointerDown and pointerUp both dispatched). Unity paused during Pause Point inspection while the overlay animation was still playing; only the animation was interrupted."); + } await MainThreadSwitcher.SwitchToMainThread(ct); return MouseUiSimulationResponseFactory.CreateLongPressResult(parameters, inputPos, targetName, hitTarget); diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationResponseFactory.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationResponseFactory.cs index 739f75311..6e739c7c0 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationResponseFactory.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationResponseFactory.cs @@ -1,4 +1,5 @@ #nullable enable +using System.Collections.Generic; using UnityEngine; using io.github.hatayama.UnityCliLoop.Runtime; @@ -120,5 +121,78 @@ internal static SimulateMouseUiResponse CreateDragEndResult( PositionY = inputEnd.y }; } + + // Message must state whether the pointer event was already dispatched, since AI + // callers rely on this text to decide whether the click/press actually reached the + // target instead of only its overlay animation being interrupted. + internal static SimulateMouseUiResponse CreateInterruptedResult( + MouseAction action, + Vector2 position, + string? hitGameObjectName, + string message) + { + SimulateMouseUiResponse result = new() + { + Success = true, + Message = message, + Action = action.ToString(), + HitGameObjectName = hitGameObjectName, + PositionX = position.x, + PositionY = position.y, + InterruptedByPausePoint = true + }; + AttachPausePointHit(result); + return result; + } + + private static void AttachPausePointHit(SimulateMouseUiResponse result) + { + if (result == null) + { + Debug.Assert(false, "result must not be null"); + return; + } + + UloopPausePointSnapshot? snapshot = UloopPausePointRegistry.GetLatestHitSnapshot(); + if (snapshot == null) + { + return; + } + + if (!snapshot.IsHit) + { + return; + } + + string? snapshotId = snapshot.Id; + if (string.IsNullOrEmpty(snapshotId)) + { + return; + } + + result.PausePointId = snapshotId; + result.PausePointHitCount = snapshot.HitCount; + result.PausePointHits = CollectPausePointHits(); + } + + // One input can hit several markers in the same frame; the representative + // PausePointId alone forced agents into extra status calls to find the others. + private static List CollectPausePointHits() + { + List hits = new(); + foreach (UloopPausePointSnapshot snapshot in UloopPausePointRegistry.GetHitSnapshots()) + { + if (!snapshot.IsHit || string.IsNullOrEmpty(snapshot.Id)) + { + continue; + } + hits.Add(new UnityCliLoopPausePointHit + { + Id = snapshot.Id, + HitCount = snapshot.HitCount + }); + } + return hits; + } } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiResponse.cs index 3b7594a8f..efbff5159 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiResponse.cs @@ -1,5 +1,7 @@ #nullable enable +using System.Collections.Generic; + using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -17,6 +19,10 @@ public class SimulateMouseUiResponse : UnityCliLoopToolResponse public float PositionY { get; set; } public float? EndPositionX { get; set; } public float? EndPositionY { get; set; } + public bool InterruptedByPausePoint { get; set; } + public string? PausePointId { get; set; } + public int? PausePointHitCount { get; set; } + public List? PausePointHits { get; set; } public SimulateMouseUiResponse() { diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiTool.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiTool.cs index e1b44369a..05467746f 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiTool.cs @@ -16,7 +16,7 @@ public class SimulateMouseUiTool : UnityCliLoopTool ExecuteAsync(SimulateMouseUiSchema parameters, CancellationToken ct) { SimulateMouseUiUseCase useCase = new(); - return await useCase.ExecuteAsync(parameters, ct); + return await useCase.ExecuteAsync(parameters, ct).ConfigureAwait(false); } } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/UnityCLILoop.FirstPartyTools.SimulateMouseUi.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/UnityCLILoop.FirstPartyTools.SimulateMouseUi.Editor.asmdef index 4873b93fe..23bb2f8f5 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/UnityCLILoop.FirstPartyTools.SimulateMouseUi.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/UnityCLILoop.FirstPartyTools.SimulateMouseUi.Editor.asmdef @@ -10,7 +10,8 @@ "GUID:c956a21f824994ef087b6de566690b3d", "GUID:33fef506e6744f2982e8c13b1196f696", "GUID:45d3617c96fa64d3e95383450e67f25a", - "GUID:75469ad4d38634e559750d17036d5f7c" + "GUID:75469ad4d38634e559750d17036d5f7c", + "GUID:527f26a36b5043c2bd4d4036d04cd76d" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs index 24f692b03..b8c4edda0 100644 --- a/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs @@ -78,7 +78,8 @@ internal static string CreateErrorResponse(object id, Exception ex) busyEx.RequestedToolName, busyEx.IsPlaying, busyEx.IsPaused, - exceptionResponse.Explanation ?? ex.Message); + exceptionResponse.Explanation ?? ex.Message, + EditorMainThreadLivenessTracker.SecondsSinceLastMainThreadTick()); } else { diff --git a/Packages/src/Editor/Infrastructure/Api/ServerBusyErrorData.cs b/Packages/src/Editor/Infrastructure/Api/ServerBusyErrorData.cs index bae0e015d..93b9b2fea 100644 --- a/Packages/src/Editor/Infrastructure/Api/ServerBusyErrorData.cs +++ b/Packages/src/Editor/Infrastructure/Api/ServerBusyErrorData.cs @@ -19,18 +19,25 @@ public class ServerBusyErrorData : JsonRpcErrorData [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public bool? isPaused { get; } + // Lets a client distinguish "main thread still ticking, tool genuinely running long" from + // a frozen/deadlocked Editor while BUSY, without needing native stack sampling. + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public double? secondsSinceLastMainThreadTick { get; } + public ServerBusyErrorData( string runningToolName, string requestedToolName, bool? isPlaying, bool? isPaused, - string message) + string message, + double? secondsSinceLastMainThreadTick = null) : base(message) { this.runningToolName = runningToolName; this.requestedToolName = requestedToolName; this.isPlaying = isPlaying; this.isPaused = isPaused; + this.secondsSinceLastMainThreadTick = secondsSinceLastMainThreadTick; } } } diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopTool.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopTool.cs index c3f9e0627..831a7d6e2 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopTool.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopTool.cs @@ -47,6 +47,14 @@ public abstract class UnityCliLoopTool : IUnityCliLoopTool /// /// Execute tool with type-safe Schema parameters. /// + /// + /// Implementations must use ConfigureAwait(false) on every await in this method + /// and everything it calls into. Continuations posted to Unity's SynchronizationContext + /// are not executed while Play Mode is paused (observed: a pause-point hit mid-command + /// left a captured continuation unexecuted while EditorApplication.update kept ticking), + /// so a single captured await anywhere in the chain can hang a tool forever once the + /// Editor pauses mid-command. + /// /// Strongly typed parameters /// Cancellation token for timeout control /// Strongly typed tool execution result diff --git a/Packages/src/Runtime/PausePoints/AssemblyInfo.cs b/Packages/src/Runtime/PausePoints/AssemblyInfo.cs index b97c1c72a..879048109 100644 --- a/Packages/src/Runtime/PausePoints/AssemblyInfo.cs +++ b/Packages/src/Runtime/PausePoints/AssemblyInfo.cs @@ -3,6 +3,7 @@ [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.PausePoint.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.SimulateKeyboard.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.SimulateMouseInput.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.SimulateMouseUi.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Infrastructure")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.PlayMode")] diff --git a/docs/glossary.md b/docs/glossary.md index 98212f539..db3904744 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -99,3 +99,29 @@ Cursor). Each target defines where its skill copies live and which folder layout An Application-layer orchestration class that coordinates one tool execution flow across domain services and infrastructure ports. UseCases contain no UI and no direct Unity editor state access; they are the only layer that sequences multi-step tool work. + +### Pause point + +A registry entry (`UloopPausePointRegistry`) that freezes PlayMode when a specific code path +is reached, then reports execution state through `wait-for-pause-point`/`pause-point-status`. +A pause point is enabled either by a hand-written `UloopPausePoint.Pause(id)` marker call, or +as a source pause point resolved from a `--file`/`--line` location with no source edit. + +### Source pause point + +A pause point enabled by `enable-pause-point --file --line ` instead of a marker +`Id`. `SourcePausePointResolver` maps the file and line to a patch location over the +method's portable PDB, and `SourcePausePointPatcher` injects the capture call at that +instruction via a Harmony transpiler — no source edit or recompile is required. Source +pause points are removed automatically on `clear-pause-point`/`ClearAll` and never survive a +script compile or domain reload. + +### Captured variable + +One local, parameter, or `this` instance field snapshotted at the moment execution reaches +a pause point, represented by `UloopCapturedVariable` internally and exposed as +`CapturedVariables` in tool and status responses. Its `Scope` is `Local`, `Parameter`, or +`InstanceField`; `UnityEngine.Object` values additionally carry `UnityObjectKind`, +`UnityObjectPath`, and `UnityObjectInstanceId`. The snapshot is taken before the resolved +line executes, and `CapturedVariablesTruncated` reports whether the length or count cap +clipped any evidence.