diff --git a/.agents/skills/uloop-control-play-mode/SKILL.md b/.agents/skills/uloop-control-play-mode/SKILL.md index 80cc725ba6..677925847b 100644 --- a/.agents/skills/uloop-control-play-mode/SKILL.md +++ b/.agents/skills/uloop-control-play-mode/SKILL.md @@ -1,12 +1,12 @@ --- name: uloop-control-play-mode toolName: control-play-mode -description: "Control Unity Editor Play Mode. Use to start, stop, or pause Play Mode for runtime behavior checks and frame inspection." +description: "Control Unity Editor Play Mode. Use to start, stop, pause, or advance one paused PlayMode frame for runtime behavior checks and frame inspection." --- # uloop control-play-mode -Control Unity Editor play mode (play/stop/pause). +Control Unity Editor play mode (play/stop/pause/next frame). ## Usage @@ -18,7 +18,7 @@ uloop control-play-mode [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause` | +| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `NextFrame` | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | ## Global Options @@ -41,6 +41,9 @@ uloop control-play-mode --action Stop # Pause play mode uloop control-play-mode --action Pause + +# Advance one frame while paused in play mode +uloop control-play-mode --action NextFrame ``` ## Output @@ -55,5 +58,6 @@ Returns JSON with the current play mode state: - Play action starts the game in the Unity Editor (also resumes from pause) - Stop action exits play mode and returns to edit mode - Pause action pauses the game while remaining in play mode +- NextFrame action advances one frame only when Unity is already paused in PlayMode; otherwise it is ignored - Useful for automated testing workflows -- The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. +- Play, Stop, and Pause wait for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. diff --git a/.agents/skills/uloop-execute-dynamic-code/SKILL.md b/.agents/skills/uloop-execute-dynamic-code/SKILL.md index d29fcdc9c4..3ed61bb1fa 100644 --- a/.agents/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.agents/skills/uloop-execute-dynamic-code/SKILL.md @@ -83,4 +83,4 @@ For detailed code examples, refer to these files: - **PlayMode UI controls**: See [references/playmode-ui-controls.md](references/playmode-ui-controls.md) - InputField, Slider, Toggle, Dropdown, drag & drop simulation, list all UI controls - **PlayMode inspection**: See [references/playmode-inspection.md](references/playmode-inspection.md) - - Scene info, game state via reflection, physics state, raycast checks, GameObject search, position/rotation + - Scene info, game state via reflection, physics state, raycast checks, GameObject search, position/rotation, scheduled Debug.Break inspection diff --git a/.agents/skills/uloop-execute-dynamic-code/references/playmode-inspection.md b/.agents/skills/uloop-execute-dynamic-code/references/playmode-inspection.md index 7cecc3d749..16a8f4eb15 100644 --- a/.agents/skills/uloop-execute-dynamic-code/references/playmode-inspection.md +++ b/.agents/skills/uloop-execute-dynamic-code/references/playmode-inspection.md @@ -117,6 +117,39 @@ Transform t = target.transform; return $"{target.name}: pos={t.position}, rot={t.rotation.eulerAngles}, scale={t.localScale}"; ``` +## Schedule Debug.Break for Later Inspection + +Use this pattern when direct inspection is not enough and you want Unity to pause after a short delay or after the next runtime updates. Start `uloop wait-for-debug-break` in another session before triggering this snippet, or give the delay enough time to start the wait immediately after scheduling it. If Unity pauses before the wait command starts, the wait command will reject the stale pause. + +```csharp +using UnityEditor; +using UnityEngine; + +double startedAt = EditorApplication.timeSinceStartup; +double delaySeconds = 3d; + +EditorApplication.CallbackFunction tick = null; +tick = () => +{ + if (EditorApplication.timeSinceStartup - startedAt < delaySeconds) + { + return; + } + + EditorApplication.update -= tick; + + GameObject player = GameObject.Find("Player"); + Vector3 position = player != null ? player.transform.position : Vector3.zero; + Debug.Log($"[DebugBreak] delayed break after {delaySeconds}s, playerExists={player != null}, playerPosition={position}"); + Debug.Break(); +}; + +EditorApplication.update += tick; +return $"Scheduled Debug.Break in {delaySeconds} seconds."; +``` + +After the wait command observes the pause, inspect the frozen state with `uloop get-logs`, `uloop get-hierarchy`, or another `uloop execute-dynamic-code` call. Resume PlayMode with `uloop control-play-mode --action Play` when inspection is complete. + ## Check Rigidbody State ```csharp diff --git a/.agents/skills/uloop-simulate-keyboard/SKILL.md b/.agents/skills/uloop-simulate-keyboard/SKILL.md index debad2993c..c38685e45f 100644 --- a/.agents/skills/uloop-simulate-keyboard/SKILL.md +++ b/.agents/skills/uloop-simulate-keyboard/SKILL.md @@ -14,7 +14,18 @@ Simulate keyboard input on Unity PlayMode: $ARGUMENTS 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. Execute the appropriate `uloop simulate-keyboard` command 3. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -4. Report what happened +4. If a screenshot cannot prove the gameplay state changed, use the Debug.Break verification workflow below +5. Report what happened + +## Debug.Break Verification Workflow + +Use this when input appears to fire but the result is hard to verify visually, such as pressing Space to jump and needing to confirm the player actually entered the jump state. + +1. Start `uloop wait-for-debug-break` in another terminal/session. +2. Add a temporary delayed `Debug.Break()` marker in the project code path under test, and log the state you need to inspect, such as position, velocity, grounded flags, or animation state. +3. Trigger the keyboard input before the delay expires, for example `uloop simulate-keyboard --action Press --key Space`. +4. When `wait-for-debug-break` returns, inspect the frozen state with `uloop get-logs`, `uloop get-hierarchy`, or another focused uLoop inspection command. +5. Resume PlayMode with `uloop control-play-mode --action Play` after inspection. ## Tool Reference diff --git a/.agents/skills/uloop-simulate-mouse-input/SKILL.md b/.agents/skills/uloop-simulate-mouse-input/SKILL.md index da66d0b1fd..d2a5309997 100644 --- a/.agents/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.agents/skills/uloop-simulate-mouse-input/SKILL.md @@ -15,7 +15,18 @@ Simulate mouse input via Input System in Unity PlayMode: $ARGUMENTS 2. For Click/LongPress: determine the target screen position (use `uloop screenshot` to find coordinates) 3. Execute the appropriate `uloop simulate-mouse-input` command 4. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -5. Report what happened +5. If a screenshot cannot prove the gameplay state changed, use the Debug.Break verification workflow below +6. Report what happened + +## Debug.Break Verification Workflow + +Use this when mouse input appears to fire but the resulting gameplay state is hard to confirm visually, such as a click that should place or destroy an object, a mouse delta that should rotate a camera, or a scroll action that should change a selected slot. + +1. Start `uloop wait-for-debug-break` in another terminal/session. +2. Add a temporary delayed `Debug.Break()` marker in the project code path under test, and log the state you need to inspect, such as target object existence, camera rotation, selected slot, hit result, or component fields. +3. Trigger the mouse input before the delay expires, for example `uloop simulate-mouse-input --action Click --x 400 --y 300 --button Right`. +4. When `wait-for-debug-break` returns, inspect the frozen state with `uloop get-logs`, `uloop get-hierarchy`, or another focused uLoop inspection command. +5. Resume PlayMode with `uloop control-play-mode --action Play` after inspection. ## Tool Reference diff --git a/.agents/skills/uloop-simulate-mouse-ui/SKILL.md b/.agents/skills/uloop-simulate-mouse-ui/SKILL.md index 54e34d6939..364edbb6ea 100644 --- a/.agents/skills/uloop-simulate-mouse-ui/SKILL.md +++ b/.agents/skills/uloop-simulate-mouse-ui/SKILL.md @@ -16,7 +16,18 @@ Simulate mouse interaction on Unity PlayMode UI: $ARGUMENTS 3. Use the `AnnotatedElements` array to find the target element by `Label`, `Name`, or `Path` (A=frontmost, B=next, ...). Use `Interaction` to distinguish click targets from drag/drop/text targets, then use `SimX`/`SimY` directly as `--x`/`--y` coordinates. 4. Execute the appropriate `uloop simulate-mouse-ui` command 5. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering --annotate-elements` -6. Report what happened +6. If a screenshot cannot prove the UI state changed, use the Debug.Break verification workflow below +7. Report what happened + +## Debug.Break Verification Workflow + +Use this when UI pointer events appear to fire but the resulting state is hard to confirm visually, such as a button callback that should update game state, a long-press that should open a menu, or a drag/drop action that should mutate inventory data. + +1. Start `uloop wait-for-debug-break` in another terminal/session. +2. Add a temporary delayed `Debug.Break()` marker in the project code path under test, and log the state you need to inspect, such as selected item, active panel, inventory contents, button callback side effects, or component fields. +3. Trigger the UI mouse action before the delay expires, for example `uloop simulate-mouse-ui --action Click --x 400 --y 300`. +4. When `wait-for-debug-break` returns, inspect the frozen state with `uloop get-logs`, `uloop get-hierarchy`, or another focused uLoop inspection command. +5. Resume PlayMode with `uloop control-play-mode --action Play` after inspection. ## Tool Reference diff --git a/.agents/skills/uloop-wait-for-debug-break/SKILL.md b/.agents/skills/uloop-wait-for-debug-break/SKILL.md new file mode 100644 index 0000000000..9a6d6edb21 --- /dev/null +++ b/.agents/skills/uloop-wait-for-debug-break/SKILL.md @@ -0,0 +1,68 @@ +--- +name: uloop-wait-for-debug-break +description: "Wait until Unity Editor pauses after Debug.Break. Use when log output is too noisy to inspect live, or when input simulation needs a precise pause point for state inspection." +--- + +# uloop wait-for-debug-break + +Wait until Unity Editor becomes paused after a `Debug.Break()` call. + +## Usage + +```bash +uloop wait-for-debug-break +``` + +## Parameters + +None. + +## Global Options + +| Option | Description | +|--------|-------------| +| `--project-path ` | Optional. Use only when the target Unity project is not the current directory. | + +## Workflow + +1. Add a temporary marker before the break point: + +```csharp +UnityEngine.Debug.Log("[DebugBreak] reached target state"); +UnityEngine.Debug.Break(); +``` + +2. Start the wait command before triggering the behavior: + +```bash +uloop wait-for-debug-break +``` + +3. Trigger the action, input simulation, or reproduction path. +4. When the command returns success, inspect Unity Console, Hierarchy, Inspector, or other uLoop state commands while Unity is paused. +5. Resume Unity after inspection: + +```bash +uloop control-play-mode --action Play +``` + +6. Remove the temporary `Debug.Break()` and marker log unless the break is intentionally part of the debugging workflow. + +## Output + +Returns JSON with: + +- `Success`: Whether the pause was observed +- `IsPlaying`: Current Unity PlayMode state +- `IsPaused`: Current Unity paused state +- `ElapsedMilliseconds`: Time spent waiting +- `Message`: Status message + +## Notes + +- Start this command while Unity is not paused. If Unity is already paused, the command fails to avoid confusing an old pause with the target `Debug.Break()`. +- This command polls a lightweight internal PlayMode-state bridge and can be left waiting while other uLoop commands are used. +- This command only observes Unity pause state. If it was started accidentally, stop the CLI process from the runner/session that started it. +- Resume a Debug.Break pause with `uloop control-play-mode --action Play`; existing control-play-mode behavior clears `EditorApplication.isPaused`. +- Remove the temporary `Debug.Break()` call from code to prevent future pauses. +- Use marker logs when several code paths can pause Unity, then inspect the Console after the wait completes. diff --git a/.claude/skills/uloop-control-play-mode/SKILL.md b/.claude/skills/uloop-control-play-mode/SKILL.md index 80cc725ba6..677925847b 100644 --- a/.claude/skills/uloop-control-play-mode/SKILL.md +++ b/.claude/skills/uloop-control-play-mode/SKILL.md @@ -1,12 +1,12 @@ --- name: uloop-control-play-mode toolName: control-play-mode -description: "Control Unity Editor Play Mode. Use to start, stop, or pause Play Mode for runtime behavior checks and frame inspection." +description: "Control Unity Editor Play Mode. Use to start, stop, pause, or advance one paused PlayMode frame for runtime behavior checks and frame inspection." --- # uloop control-play-mode -Control Unity Editor play mode (play/stop/pause). +Control Unity Editor play mode (play/stop/pause/next frame). ## Usage @@ -18,7 +18,7 @@ uloop control-play-mode [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause` | +| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `NextFrame` | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | ## Global Options @@ -41,6 +41,9 @@ uloop control-play-mode --action Stop # Pause play mode uloop control-play-mode --action Pause + +# Advance one frame while paused in play mode +uloop control-play-mode --action NextFrame ``` ## Output @@ -55,5 +58,6 @@ Returns JSON with the current play mode state: - Play action starts the game in the Unity Editor (also resumes from pause) - Stop action exits play mode and returns to edit mode - Pause action pauses the game while remaining in play mode +- NextFrame action advances one frame only when Unity is already paused in PlayMode; otherwise it is ignored - Useful for automated testing workflows -- The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. +- Play, Stop, and Pause wait for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. diff --git a/.claude/skills/uloop-execute-dynamic-code/SKILL.md b/.claude/skills/uloop-execute-dynamic-code/SKILL.md index d29fcdc9c4..3ed61bb1fa 100644 --- a/.claude/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.claude/skills/uloop-execute-dynamic-code/SKILL.md @@ -83,4 +83,4 @@ For detailed code examples, refer to these files: - **PlayMode UI controls**: See [references/playmode-ui-controls.md](references/playmode-ui-controls.md) - InputField, Slider, Toggle, Dropdown, drag & drop simulation, list all UI controls - **PlayMode inspection**: See [references/playmode-inspection.md](references/playmode-inspection.md) - - Scene info, game state via reflection, physics state, raycast checks, GameObject search, position/rotation + - Scene info, game state via reflection, physics state, raycast checks, GameObject search, position/rotation, scheduled Debug.Break inspection diff --git a/.claude/skills/uloop-execute-dynamic-code/references/playmode-inspection.md b/.claude/skills/uloop-execute-dynamic-code/references/playmode-inspection.md index 7cecc3d749..16a8f4eb15 100644 --- a/.claude/skills/uloop-execute-dynamic-code/references/playmode-inspection.md +++ b/.claude/skills/uloop-execute-dynamic-code/references/playmode-inspection.md @@ -117,6 +117,39 @@ Transform t = target.transform; return $"{target.name}: pos={t.position}, rot={t.rotation.eulerAngles}, scale={t.localScale}"; ``` +## Schedule Debug.Break for Later Inspection + +Use this pattern when direct inspection is not enough and you want Unity to pause after a short delay or after the next runtime updates. Start `uloop wait-for-debug-break` in another session before triggering this snippet, or give the delay enough time to start the wait immediately after scheduling it. If Unity pauses before the wait command starts, the wait command will reject the stale pause. + +```csharp +using UnityEditor; +using UnityEngine; + +double startedAt = EditorApplication.timeSinceStartup; +double delaySeconds = 3d; + +EditorApplication.CallbackFunction tick = null; +tick = () => +{ + if (EditorApplication.timeSinceStartup - startedAt < delaySeconds) + { + return; + } + + EditorApplication.update -= tick; + + GameObject player = GameObject.Find("Player"); + Vector3 position = player != null ? player.transform.position : Vector3.zero; + Debug.Log($"[DebugBreak] delayed break after {delaySeconds}s, playerExists={player != null}, playerPosition={position}"); + Debug.Break(); +}; + +EditorApplication.update += tick; +return $"Scheduled Debug.Break in {delaySeconds} seconds."; +``` + +After the wait command observes the pause, inspect the frozen state with `uloop get-logs`, `uloop get-hierarchy`, or another `uloop execute-dynamic-code` call. Resume PlayMode with `uloop control-play-mode --action Play` when inspection is complete. + ## Check Rigidbody State ```csharp diff --git a/.claude/skills/uloop-simulate-keyboard/SKILL.md b/.claude/skills/uloop-simulate-keyboard/SKILL.md index debad2993c..c38685e45f 100644 --- a/.claude/skills/uloop-simulate-keyboard/SKILL.md +++ b/.claude/skills/uloop-simulate-keyboard/SKILL.md @@ -14,7 +14,18 @@ Simulate keyboard input on Unity PlayMode: $ARGUMENTS 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. Execute the appropriate `uloop simulate-keyboard` command 3. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -4. Report what happened +4. If a screenshot cannot prove the gameplay state changed, use the Debug.Break verification workflow below +5. Report what happened + +## Debug.Break Verification Workflow + +Use this when input appears to fire but the result is hard to verify visually, such as pressing Space to jump and needing to confirm the player actually entered the jump state. + +1. Start `uloop wait-for-debug-break` in another terminal/session. +2. Add a temporary delayed `Debug.Break()` marker in the project code path under test, and log the state you need to inspect, such as position, velocity, grounded flags, or animation state. +3. Trigger the keyboard input before the delay expires, for example `uloop simulate-keyboard --action Press --key Space`. +4. When `wait-for-debug-break` returns, inspect the frozen state with `uloop get-logs`, `uloop get-hierarchy`, or another focused uLoop inspection command. +5. Resume PlayMode with `uloop control-play-mode --action Play` after inspection. ## Tool Reference diff --git a/.claude/skills/uloop-simulate-mouse-input/SKILL.md b/.claude/skills/uloop-simulate-mouse-input/SKILL.md index da66d0b1fd..d2a5309997 100644 --- a/.claude/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.claude/skills/uloop-simulate-mouse-input/SKILL.md @@ -15,7 +15,18 @@ Simulate mouse input via Input System in Unity PlayMode: $ARGUMENTS 2. For Click/LongPress: determine the target screen position (use `uloop screenshot` to find coordinates) 3. Execute the appropriate `uloop simulate-mouse-input` command 4. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -5. Report what happened +5. If a screenshot cannot prove the gameplay state changed, use the Debug.Break verification workflow below +6. Report what happened + +## Debug.Break Verification Workflow + +Use this when mouse input appears to fire but the resulting gameplay state is hard to confirm visually, such as a click that should place or destroy an object, a mouse delta that should rotate a camera, or a scroll action that should change a selected slot. + +1. Start `uloop wait-for-debug-break` in another terminal/session. +2. Add a temporary delayed `Debug.Break()` marker in the project code path under test, and log the state you need to inspect, such as target object existence, camera rotation, selected slot, hit result, or component fields. +3. Trigger the mouse input before the delay expires, for example `uloop simulate-mouse-input --action Click --x 400 --y 300 --button Right`. +4. When `wait-for-debug-break` returns, inspect the frozen state with `uloop get-logs`, `uloop get-hierarchy`, or another focused uLoop inspection command. +5. Resume PlayMode with `uloop control-play-mode --action Play` after inspection. ## Tool Reference diff --git a/.claude/skills/uloop-simulate-mouse-ui/SKILL.md b/.claude/skills/uloop-simulate-mouse-ui/SKILL.md index 54e34d6939..364edbb6ea 100644 --- a/.claude/skills/uloop-simulate-mouse-ui/SKILL.md +++ b/.claude/skills/uloop-simulate-mouse-ui/SKILL.md @@ -16,7 +16,18 @@ Simulate mouse interaction on Unity PlayMode UI: $ARGUMENTS 3. Use the `AnnotatedElements` array to find the target element by `Label`, `Name`, or `Path` (A=frontmost, B=next, ...). Use `Interaction` to distinguish click targets from drag/drop/text targets, then use `SimX`/`SimY` directly as `--x`/`--y` coordinates. 4. Execute the appropriate `uloop simulate-mouse-ui` command 5. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering --annotate-elements` -6. Report what happened +6. If a screenshot cannot prove the UI state changed, use the Debug.Break verification workflow below +7. Report what happened + +## Debug.Break Verification Workflow + +Use this when UI pointer events appear to fire but the resulting state is hard to confirm visually, such as a button callback that should update game state, a long-press that should open a menu, or a drag/drop action that should mutate inventory data. + +1. Start `uloop wait-for-debug-break` in another terminal/session. +2. Add a temporary delayed `Debug.Break()` marker in the project code path under test, and log the state you need to inspect, such as selected item, active panel, inventory contents, button callback side effects, or component fields. +3. Trigger the UI mouse action before the delay expires, for example `uloop simulate-mouse-ui --action Click --x 400 --y 300`. +4. When `wait-for-debug-break` returns, inspect the frozen state with `uloop get-logs`, `uloop get-hierarchy`, or another focused uLoop inspection command. +5. Resume PlayMode with `uloop control-play-mode --action Play` after inspection. ## Tool Reference diff --git a/.claude/skills/uloop-wait-for-debug-break/SKILL.md b/.claude/skills/uloop-wait-for-debug-break/SKILL.md new file mode 100644 index 0000000000..9a6d6edb21 --- /dev/null +++ b/.claude/skills/uloop-wait-for-debug-break/SKILL.md @@ -0,0 +1,68 @@ +--- +name: uloop-wait-for-debug-break +description: "Wait until Unity Editor pauses after Debug.Break. Use when log output is too noisy to inspect live, or when input simulation needs a precise pause point for state inspection." +--- + +# uloop wait-for-debug-break + +Wait until Unity Editor becomes paused after a `Debug.Break()` call. + +## Usage + +```bash +uloop wait-for-debug-break +``` + +## Parameters + +None. + +## Global Options + +| Option | Description | +|--------|-------------| +| `--project-path ` | Optional. Use only when the target Unity project is not the current directory. | + +## Workflow + +1. Add a temporary marker before the break point: + +```csharp +UnityEngine.Debug.Log("[DebugBreak] reached target state"); +UnityEngine.Debug.Break(); +``` + +2. Start the wait command before triggering the behavior: + +```bash +uloop wait-for-debug-break +``` + +3. Trigger the action, input simulation, or reproduction path. +4. When the command returns success, inspect Unity Console, Hierarchy, Inspector, or other uLoop state commands while Unity is paused. +5. Resume Unity after inspection: + +```bash +uloop control-play-mode --action Play +``` + +6. Remove the temporary `Debug.Break()` and marker log unless the break is intentionally part of the debugging workflow. + +## Output + +Returns JSON with: + +- `Success`: Whether the pause was observed +- `IsPlaying`: Current Unity PlayMode state +- `IsPaused`: Current Unity paused state +- `ElapsedMilliseconds`: Time spent waiting +- `Message`: Status message + +## Notes + +- Start this command while Unity is not paused. If Unity is already paused, the command fails to avoid confusing an old pause with the target `Debug.Break()`. +- This command polls a lightweight internal PlayMode-state bridge and can be left waiting while other uLoop commands are used. +- This command only observes Unity pause state. If it was started accidentally, stop the CLI process from the runner/session that started it. +- Resume a Debug.Break pause with `uloop control-play-mode --action Play`; existing control-play-mode behavior clears `EditorApplication.isPaused`. +- Remove the temporary `Debug.Break()` call from code to prevent future pauses. +- Use marker logs when several code paths can pause Unity, then inspect the Console after the wait completes. diff --git a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs index 0db1594228..fad24850f3 100644 --- a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs +++ b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs @@ -31,14 +31,14 @@ public async Task InstallGlobalCliAsync_UsesMinimumRequiredCliReleaseTag() } [Test] - public void GetMinimumRequiredCliVersion_RequiresExternalSceneChangeFlagCliRelease() + public void GetMinimumRequiredCliVersion_RequiresDebugBreakWaitCliRelease() { - // Verifies this package release requires the CLI with external Scene change options. + // Verifies this package release requires the CLI with wait-for-debug-break support. CliSetupApplicationService service = new( new FakeCliInstallationDetector(new string[] { null }), new FakeNativeCliInstaller()); - Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.24")); + Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.25")); } [Test] @@ -49,7 +49,7 @@ public void GetMinimumRequiredCliReleaseTag_UsesCliGitHubReleaseTag() new FakeCliInstallationDetector(new string[] { null }), new FakeNativeCliInstaller()); - Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo("cli-v3.0.0-beta.24")); + Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo("cli-v3.0.0-beta.25")); } [Test] diff --git a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs index 79ed756a91..914d37cb7e 100644 --- a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs +++ b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs @@ -34,5 +34,21 @@ public async Task ExecuteAsync_WhenStatusOnly_ReturnsCurrentPlayModeState() Assert.That(response.Message, Is.EqualTo("Play mode status")); } + + [Test] + public async Task ExecuteAsync_WhenNextFrameOutsidePlayMode_IgnoresRequest() + { + // Verifies that frame stepping never starts PlayMode as a side effect. + ControlPlayModeUseCase useCase = new ControlPlayModeUseCase(); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.NextFrame, + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That(response.IsPlaying, Is.False); + Assert.That(response.Message, Is.EqualTo("Next frame ignored")); + } } } diff --git a/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs b/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs index 77e2ddd1a1..6cccef8dde 100644 --- a/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs +++ b/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs @@ -446,6 +446,59 @@ public async Task ProcessRequest_WhenInternalBridgeCommandRuns_SwitchesToMainThr } } + [Test] + public async Task ProcessRequest_WhenToolExecutionSlotIsBusy_AllowsPlayModeStateBridgeCommand() + { + // Verifies Debug.Break state polling bypasses the normal single-flight tool execution slot. + CapturingMainThreadDispatcher dispatcher = new(); + MainThreadSwitcher.RegisterService(dispatcher); + + UnityCliLoopToolRegistrarService previousService = UnityCliLoopToolRegistrar.Service; + ToolSettingsService toolSettingsService = new(new ToolSettingsRepository()); + UnityCliLoopToolRegistrarService service = new( + new EmptyInternalToolNameProvider(), + toolSettingsService, + new UnityCliLoopToolExecutionService()); + UnityCliLoopToolRegistrar.RegisterService(service); + service.RegisterCustomTool(new SingleFlightTestTool()); + + Task toolResponseTask = null; + Task bridgeResponseTask = null; + try + { + toolResponseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync( + BuildToolRequest(SingleFlightTestTool.Name, 1), + CancellationToken.None, + (_, _) => Task.CompletedTask); + + Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1)); + + bridgeResponseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync( + BuildToolRequest(UnityCliLoopConstants.COMMAND_NAME_GET_PLAY_MODE_STATE, 2), + CancellationToken.None, + (_, _) => Task.CompletedTask); + + Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(2)); + Assert.That(bridgeResponseTask.IsCompleted, Is.False); + + dispatcher.RunContinuations(); + string bridgeResponse = await AwaitWithTimeout(bridgeResponseTask, TimeSpan.FromMilliseconds(200)); + JObject parsed = JObject.Parse(bridgeResponse); + + Assert.That(parsed["error"], Is.Null); + Assert.That(parsed["result"]?["IsPaused"], Is.Not.Null); + LogAssert.NoUnexpectedReceived(); + } + finally + { + dispatcher.RunContinuations(); + await DrainTaskIfNeeded(toolResponseTask); + await DrainTaskIfNeeded(bridgeResponseTask); + UnityCliLoopToolRegistrar.RegisterService(previousService); + RestoreEditorMainThreadDispatcher(); + } + } + [Test] public async Task ProcessRequest_WhenMainThreadSwitchIsCanceled_ReleasesExecutionGateWithoutError() { diff --git a/Assets/Tests/Editor/PlayModeStateBridgeCommandTests.cs b/Assets/Tests/Editor/PlayModeStateBridgeCommandTests.cs new file mode 100644 index 0000000000..4b957d66be --- /dev/null +++ b/Assets/Tests/Editor/PlayModeStateBridgeCommandTests.cs @@ -0,0 +1,39 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Tests PlayMode state bridge responses without depending on Editor PlayMode transitions. + /// + [TestFixture] + public sealed class PlayModeStateBridgeCommandTests + { + [Test] + public void BuildResponse_WhenEditorIsPlayingAndPaused_ReturnsPausedMessage() + { + // Verifies Debug.Break polling can distinguish the paused PlayMode state. + GetPlayModeStateResponse response = PlayModeStateBridgeCommand.BuildResponse( + isPlaying: true, + isPaused: true); + + Assert.That(response.IsPlaying, Is.True); + Assert.That(response.IsPaused, Is.True); + Assert.That(response.Message, Does.Contain("paused")); + } + + [Test] + public void BuildResponse_WhenEditorIsNotPlaying_ReturnsUnpausedMessage() + { + // Verifies non-PlayMode idle state is reported as unpaused instead of a debug break. + GetPlayModeStateResponse response = PlayModeStateBridgeCommand.BuildResponse( + isPlaying: false, + isPaused: false); + + Assert.That(response.IsPlaying, Is.False); + Assert.That(response.IsPaused, Is.False); + Assert.That(response.Message, Does.Contain("not playing")); + } + } +} diff --git a/Assets/Tests/Editor/PlayModeStateBridgeCommandTests.cs.meta b/Assets/Tests/Editor/PlayModeStateBridgeCommandTests.cs.meta new file mode 100644 index 0000000000..282c113932 --- /dev/null +++ b/Assets/Tests/Editor/PlayModeStateBridgeCommandTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8078b77b04634104ae150077115bff47 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/CliOnlyTools~/WaitForDebugBreak/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/WaitForDebugBreak/Skill/SKILL.md new file mode 100644 index 0000000000..9a6d6edb21 --- /dev/null +++ b/Packages/src/Editor/CliOnlyTools~/WaitForDebugBreak/Skill/SKILL.md @@ -0,0 +1,68 @@ +--- +name: uloop-wait-for-debug-break +description: "Wait until Unity Editor pauses after Debug.Break. Use when log output is too noisy to inspect live, or when input simulation needs a precise pause point for state inspection." +--- + +# uloop wait-for-debug-break + +Wait until Unity Editor becomes paused after a `Debug.Break()` call. + +## Usage + +```bash +uloop wait-for-debug-break +``` + +## Parameters + +None. + +## Global Options + +| Option | Description | +|--------|-------------| +| `--project-path ` | Optional. Use only when the target Unity project is not the current directory. | + +## Workflow + +1. Add a temporary marker before the break point: + +```csharp +UnityEngine.Debug.Log("[DebugBreak] reached target state"); +UnityEngine.Debug.Break(); +``` + +2. Start the wait command before triggering the behavior: + +```bash +uloop wait-for-debug-break +``` + +3. Trigger the action, input simulation, or reproduction path. +4. When the command returns success, inspect Unity Console, Hierarchy, Inspector, or other uLoop state commands while Unity is paused. +5. Resume Unity after inspection: + +```bash +uloop control-play-mode --action Play +``` + +6. Remove the temporary `Debug.Break()` and marker log unless the break is intentionally part of the debugging workflow. + +## Output + +Returns JSON with: + +- `Success`: Whether the pause was observed +- `IsPlaying`: Current Unity PlayMode state +- `IsPaused`: Current Unity paused state +- `ElapsedMilliseconds`: Time spent waiting +- `Message`: Status message + +## Notes + +- Start this command while Unity is not paused. If Unity is already paused, the command fails to avoid confusing an old pause with the target `Debug.Break()`. +- This command polls a lightweight internal PlayMode-state bridge and can be left waiting while other uLoop commands are used. +- This command only observes Unity pause state. If it was started accidentally, stop the CLI process from the runner/session that started it. +- Resume a Debug.Break pause with `uloop control-play-mode --action Play`; existing control-play-mode behavior clears `EditorApplication.isPaused`. +- Remove the temporary `Debug.Break()` call from code to prevent future pauses. +- Use marker logs when several code paths can pause Unity, then inspect the Console after the wait completes. diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index aa953aac8b..1e063fecbd 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -6,7 +6,7 @@ namespace io.github.hatayama.UnityCliLoop.Domain public static class CliConstants { public const string EXECUTABLE_NAME = "uloop"; - public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.24"; + public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.25"; public const string MINIMUM_REQUIRED_CLI_RELEASE_TAG = CLI_RELEASE_TAG_PREFIX + MINIMUM_REQUIRED_CLI_VERSION; public const string VERSION_FLAG = "--version"; public const string SHORT_VERSION_FLAG = "-v"; diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs index b518f0e2b7..94bcd8a5a7 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs @@ -9,7 +9,8 @@ public enum PlayModeAction { Play = 0, Stop = 1, - Pause = 2 + Pause = 2, + NextFrame = 3 } /// diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs index c473551e69..33bfe69eac 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs @@ -59,6 +59,16 @@ public Task ExecuteAsync(ControlPlayModeSchema paramete message = "Play mode paused"; break; + case PlayModeAction.NextFrame: + if (EditorApplication.isPlaying && EditorApplication.isPaused) + { + EditorApplication.Step(); + message = "Play mode advanced one frame"; + break; + } + message = "Next frame ignored"; + break; + default: message = $"Unknown action: {parameters.Action}"; break; diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md index 80cc725ba6..677925847b 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md @@ -1,12 +1,12 @@ --- name: uloop-control-play-mode toolName: control-play-mode -description: "Control Unity Editor Play Mode. Use to start, stop, or pause Play Mode for runtime behavior checks and frame inspection." +description: "Control Unity Editor Play Mode. Use to start, stop, pause, or advance one paused PlayMode frame for runtime behavior checks and frame inspection." --- # uloop control-play-mode -Control Unity Editor play mode (play/stop/pause). +Control Unity Editor play mode (play/stop/pause/next frame). ## Usage @@ -18,7 +18,7 @@ uloop control-play-mode [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause` | +| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `NextFrame` | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | ## Global Options @@ -41,6 +41,9 @@ uloop control-play-mode --action Stop # Pause play mode uloop control-play-mode --action Pause + +# Advance one frame while paused in play mode +uloop control-play-mode --action NextFrame ``` ## Output @@ -55,5 +58,6 @@ Returns JSON with the current play mode state: - Play action starts the game in the Unity Editor (also resumes from pause) - Stop action exits play mode and returns to edit mode - Pause action pauses the game while remaining in play mode +- NextFrame action advances one frame only when Unity is already paused in PlayMode; otherwise it is ignored - Useful for automated testing workflows -- The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. +- Play, Stop, and Pause wait for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md index d29fcdc9c4..3ed61bb1fa 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md @@ -83,4 +83,4 @@ For detailed code examples, refer to these files: - **PlayMode UI controls**: See [references/playmode-ui-controls.md](references/playmode-ui-controls.md) - InputField, Slider, Toggle, Dropdown, drag & drop simulation, list all UI controls - **PlayMode inspection**: See [references/playmode-inspection.md](references/playmode-inspection.md) - - Scene info, game state via reflection, physics state, raycast checks, GameObject search, position/rotation + - Scene info, game state via reflection, physics state, raycast checks, GameObject search, position/rotation, scheduled Debug.Break inspection diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/references/playmode-inspection.md b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/references/playmode-inspection.md index 7cecc3d749..16a8f4eb15 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/references/playmode-inspection.md +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/references/playmode-inspection.md @@ -117,6 +117,39 @@ Transform t = target.transform; return $"{target.name}: pos={t.position}, rot={t.rotation.eulerAngles}, scale={t.localScale}"; ``` +## Schedule Debug.Break for Later Inspection + +Use this pattern when direct inspection is not enough and you want Unity to pause after a short delay or after the next runtime updates. Start `uloop wait-for-debug-break` in another session before triggering this snippet, or give the delay enough time to start the wait immediately after scheduling it. If Unity pauses before the wait command starts, the wait command will reject the stale pause. + +```csharp +using UnityEditor; +using UnityEngine; + +double startedAt = EditorApplication.timeSinceStartup; +double delaySeconds = 3d; + +EditorApplication.CallbackFunction tick = null; +tick = () => +{ + if (EditorApplication.timeSinceStartup - startedAt < delaySeconds) + { + return; + } + + EditorApplication.update -= tick; + + GameObject player = GameObject.Find("Player"); + Vector3 position = player != null ? player.transform.position : Vector3.zero; + Debug.Log($"[DebugBreak] delayed break after {delaySeconds}s, playerExists={player != null}, playerPosition={position}"); + Debug.Break(); +}; + +EditorApplication.update += tick; +return $"Scheduled Debug.Break in {delaySeconds} seconds."; +``` + +After the wait command observes the pause, inspect the frozen state with `uloop get-logs`, `uloop get-hierarchy`, or another `uloop execute-dynamic-code` call. Resume PlayMode with `uloop control-play-mode --action Play` when inspection is complete. + ## Check Rigidbody State ```csharp diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index debad2993c..c38685e45f 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -14,7 +14,18 @@ Simulate keyboard input on Unity PlayMode: $ARGUMENTS 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. Execute the appropriate `uloop simulate-keyboard` command 3. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -4. Report what happened +4. If a screenshot cannot prove the gameplay state changed, use the Debug.Break verification workflow below +5. Report what happened + +## Debug.Break Verification Workflow + +Use this when input appears to fire but the result is hard to verify visually, such as pressing Space to jump and needing to confirm the player actually entered the jump state. + +1. Start `uloop wait-for-debug-break` in another terminal/session. +2. Add a temporary delayed `Debug.Break()` marker in the project code path under test, and log the state you need to inspect, such as position, velocity, grounded flags, or animation state. +3. Trigger the keyboard input before the delay expires, for example `uloop simulate-keyboard --action Press --key Space`. +4. When `wait-for-debug-break` returns, inspect the frozen state with `uloop get-logs`, `uloop get-hierarchy`, or another focused uLoop inspection command. +5. Resume PlayMode with `uloop control-play-mode --action Play` after inspection. ## Tool Reference diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index da66d0b1fd..d2a5309997 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md @@ -15,7 +15,18 @@ Simulate mouse input via Input System in Unity PlayMode: $ARGUMENTS 2. For Click/LongPress: determine the target screen position (use `uloop screenshot` to find coordinates) 3. Execute the appropriate `uloop simulate-mouse-input` command 4. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -5. Report what happened +5. If a screenshot cannot prove the gameplay state changed, use the Debug.Break verification workflow below +6. Report what happened + +## Debug.Break Verification Workflow + +Use this when mouse input appears to fire but the resulting gameplay state is hard to confirm visually, such as a click that should place or destroy an object, a mouse delta that should rotate a camera, or a scroll action that should change a selected slot. + +1. Start `uloop wait-for-debug-break` in another terminal/session. +2. Add a temporary delayed `Debug.Break()` marker in the project code path under test, and log the state you need to inspect, such as target object existence, camera rotation, selected slot, hit result, or component fields. +3. Trigger the mouse input before the delay expires, for example `uloop simulate-mouse-input --action Click --x 400 --y 300 --button Right`. +4. When `wait-for-debug-break` returns, inspect the frozen state with `uloop get-logs`, `uloop get-hierarchy`, or another focused uLoop inspection command. +5. Resume PlayMode with `uloop control-play-mode --action Play` after inspection. ## Tool Reference diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md index 54e34d6939..364edbb6ea 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md @@ -16,7 +16,18 @@ Simulate mouse interaction on Unity PlayMode UI: $ARGUMENTS 3. Use the `AnnotatedElements` array to find the target element by `Label`, `Name`, or `Path` (A=frontmost, B=next, ...). Use `Interaction` to distinguish click targets from drag/drop/text targets, then use `SimX`/`SimY` directly as `--x`/`--y` coordinates. 4. Execute the appropriate `uloop simulate-mouse-ui` command 5. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering --annotate-elements` -6. Report what happened +6. If a screenshot cannot prove the UI state changed, use the Debug.Break verification workflow below +7. Report what happened + +## Debug.Break Verification Workflow + +Use this when UI pointer events appear to fire but the resulting state is hard to confirm visually, such as a button callback that should update game state, a long-press that should open a menu, or a drag/drop action that should mutate inventory data. + +1. Start `uloop wait-for-debug-break` in another terminal/session. +2. Add a temporary delayed `Debug.Break()` marker in the project code path under test, and log the state you need to inspect, such as selected item, active panel, inventory contents, button callback side effects, or component fields. +3. Trigger the UI mouse action before the delay expires, for example `uloop simulate-mouse-ui --action Click --x 400 --y 300`. +4. When `wait-for-debug-break` returns, inspect the frozen state with `uloop get-logs`, `uloop get-hierarchy`, or another focused uLoop inspection command. +5. Resume PlayMode with `uloop control-play-mode --action Play` after inspection. ## Tool Reference diff --git a/Packages/src/Editor/Infrastructure/Api/GetPlayModeStateResponse.cs b/Packages/src/Editor/Infrastructure/Api/GetPlayModeStateResponse.cs new file mode 100644 index 0000000000..4689f45beb --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/GetPlayModeStateResponse.cs @@ -0,0 +1,14 @@ +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// PlayMode pause state returned by the internal CLI polling bridge command. + /// + public class GetPlayModeStateResponse : UnityCliLoopToolResponse + { + public bool IsPlaying { get; set; } + public bool IsPaused { get; set; } + public string Message { get; set; } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/GetPlayModeStateResponse.cs.meta b/Packages/src/Editor/Infrastructure/Api/GetPlayModeStateResponse.cs.meta new file mode 100644 index 0000000000..7df38f7b5e --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/GetPlayModeStateResponse.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 77c592879d0841e78208eead5cffcda2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs b/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs index d3c9d8ccec..f1c3b55138 100644 --- a/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs +++ b/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs @@ -15,6 +15,7 @@ public static bool IsInternalCommand(string commandName) { return commandName == UnityCliLoopConstants.COMMAND_NAME_GET_VERSION || commandName == UnityCliLoopConstants.COMMAND_NAME_GET_COMPILE_STATUS || + commandName == UnityCliLoopConstants.COMMAND_NAME_GET_PLAY_MODE_STATE || commandName == UnityCliLoopConstants.COMMAND_NAME_GET_TOOL_DETAILS; } @@ -37,6 +38,11 @@ public static UnityCliLoopToolResponse Execute(string commandName, JToken params return CompileStatusBridgeCommand.Execute(paramsToken); } + if (commandName == UnityCliLoopConstants.COMMAND_NAME_GET_PLAY_MODE_STATE) + { + return PlayModeStateBridgeCommand.Execute(); + } + throw new ArgumentException($"Unknown internal bridge command: {commandName}", nameof(commandName)); } } diff --git a/Packages/src/Editor/Infrastructure/Api/PlayModeStateBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/PlayModeStateBridgeCommand.cs new file mode 100644 index 0000000000..c9880eddad --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/PlayModeStateBridgeCommand.cs @@ -0,0 +1,40 @@ +using UnityEditor; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Reports PlayMode pause state for CLI-side Debug.Break waiting without entering the normal tool execution slot. + /// + internal static class PlayModeStateBridgeCommand + { + public static GetPlayModeStateResponse Execute() + { + return BuildResponse(EditorApplication.isPlaying, EditorApplication.isPaused); + } + + internal static GetPlayModeStateResponse BuildResponse(bool isPlaying, bool isPaused) + { + return new GetPlayModeStateResponse + { + IsPlaying = isPlaying, + IsPaused = isPaused, + Message = CreateMessage(isPlaying, isPaused) + }; + } + + private static string CreateMessage(bool isPlaying, bool isPaused) + { + if (isPaused) + { + return "Unity Editor is paused."; + } + + if (isPlaying) + { + return "Unity Editor is playing and not paused."; + } + + return "Unity Editor is not playing and not paused."; + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/PlayModeStateBridgeCommand.cs.meta b/Packages/src/Editor/Infrastructure/Api/PlayModeStateBridgeCommand.cs.meta new file mode 100644 index 0000000000..18b4ab1393 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/PlayModeStateBridgeCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: db410abbf9d641cf9b9a485426a28882 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs index d92ca6a235..25a354405e 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs @@ -68,6 +68,7 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo public const string COMMAND_NAME_GET_TOOL_DETAILS = "get-tool-details"; public const string COMMAND_NAME_GET_VERSION = "get-version"; public const string COMMAND_NAME_GET_COMPILE_STATUS = "get-compile-status"; + public const string COMMAND_NAME_GET_PLAY_MODE_STATE = "get-play-mode-state"; // Optional package names public const string PACKAGE_NAME_TEST_FRAMEWORK = "com.unity.test-framework"; diff --git a/cli/contract.json b/cli/contract.json index 6279babbe5..06e57c6b20 100644 --- a/cli/contract.json +++ b/cli/contract.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "cliVersion": "3.0.0-beta.24" + "cliVersion": "3.0.0-beta.25" } diff --git a/cli/internal/cli/command_help.go b/cli/internal/cli/command_help.go index 9cf86f6733..55f6472382 100644 --- a/cli/internal/cli/command_help.go +++ b/cli/internal/cli/command_help.go @@ -211,7 +211,7 @@ func nativeCommandDescription(command string) (string, bool) { func nativeCommandUsesProject(command string) bool { switch command { - case launchCommandName, "list", "sync", "focus-window", skillsCommandName: + case launchCommandName, "list", "sync", "focus-window", waitForDebugBreakCommandName, skillsCommandName: return true default: return false diff --git a/cli/internal/cli/command_registry.go b/cli/internal/cli/command_registry.go index 78c7bafb50..165f016d2c 100644 --- a/cli/internal/cli/command_registry.go +++ b/cli/internal/cli/command_registry.go @@ -10,6 +10,7 @@ var nativeCommands = []nativeCommandEntry{ {name: "list", description: "Show Unity tools currently exposed by the Editor"}, {name: "sync", description: "Refresh .uloop/tools.json from the running Editor"}, {name: "focus-window", description: "Bring the Unity Editor window to the foreground"}, + {name: waitForDebugBreakCommandName, description: "Wait until Unity Editor pauses after Debug.Break"}, {name: "skills", description: "List, install, or uninstall agent skills"}, {name: "completion", description: "Print or install shell completion"}, {name: "install", description: "Configure the global uloop launcher binary"}, diff --git a/cli/internal/cli/completion_test.go b/cli/internal/cli/completion_test.go index 957cf64243..727e892b4a 100644 --- a/cli/internal/cli/completion_test.go +++ b/cli/internal/cli/completion_test.go @@ -26,7 +26,7 @@ func TestCompletionListCommandsIncludesNativeCommandsAndDefaultTools(t *testing. } output := stdout.String() - for _, command := range []string{"completion", "focus-window", "sync", "uninstall"} { + for _, command := range []string{"completion", "focus-window", "sync", "uninstall", waitForDebugBreakCommandName} { if !strings.Contains(output, command) { t.Fatalf("command %s was not listed: %s", command, output) } diff --git a/cli/internal/cli/control_play_mode_wait_test.go b/cli/internal/cli/control_play_mode_wait_test.go index d4256e6697..f7a184c73d 100644 --- a/cli/internal/cli/control_play_mode_wait_test.go +++ b/cli/internal/cli/control_play_mode_wait_test.go @@ -168,6 +168,13 @@ func TestControlPlayModeTimeoutSecondsAcceptsFloatSchemaValue(t *testing.T) { } } +// Verifies that one-frame stepping returns the direct tool response instead of polling PlayMode state. +func TestControlPlayModeActionCanWaitSkipsNextFrame(t *testing.T) { + if controlPlayModeActionCanWait("NextFrame") { + t.Fatal("NextFrame should not wait for a PlayMode state transition") + } +} + func serveRepeatedControlPlayModeResponse( listener net.Listener, serverErr chan<- error, diff --git a/cli/internal/cli/error_envelope.go b/cli/internal/cli/error_envelope.go index 0ae8169f76..8cafaf1a8b 100644 --- a/cli/internal/cli/error_envelope.go +++ b/cli/internal/cli/error_envelope.go @@ -23,6 +23,8 @@ const ( errorCodeCLIUpdateRequired = "CLI_UPDATE_REQUIRED" errorCodeCompileWaitTimeout = "COMPILE_WAIT_TIMEOUT" errorCodeControlPlayModeWaitTimeout = "CONTROL_PLAY_MODE_WAIT_TIMEOUT" + errorCodeDebugBreakAlreadyPaused = "DEBUG_BREAK_ALREADY_PAUSED" + errorCodeDebugBreakNotPlaying = "DEBUG_BREAK_NOT_PLAYING" errorCodeInternalError = "INTERNAL_ERROR" errorPhaseArgumentParsing = "argument_parsing" @@ -488,7 +490,7 @@ func availableCommandNames(cache toolsCache) []string { func isSafeRetryCommand(command string) bool { switch command { - case "list", "sync", "get-version", "get-logs", "get-tool-details": + case "list", "sync", "get-version", "get-logs", "get-tool-details", "get-play-mode-state", waitForDebugBreakCommandName: return true default: return false diff --git a/cli/internal/cli/error_envelope_test.go b/cli/internal/cli/error_envelope_test.go index 83f384d978..ca9523a210 100644 --- a/cli/internal/cli/error_envelope_test.go +++ b/cli/internal/cli/error_envelope_test.go @@ -437,7 +437,7 @@ func TestClassifyConnectionAttemptUsesContextProjectRootFallback(t *testing.T) { func TestAvailableCommandNamesIncludesBuiltIns(t *testing.T) { names := availableCommandNames(toolsCache{}) - expectedBuiltIns := []string{"launch", "list", "sync", "focus-window", "skills", "completion", "install", "update"} + expectedBuiltIns := []string{"launch", "list", "sync", "focus-window", waitForDebugBreakCommandName, "skills", "completion", "install", "update"} for index, expected := range expectedBuiltIns { if names[index] != expected { t.Fatalf("built-in command mismatch: %#v", names) diff --git a/cli/internal/cli/run.go b/cli/internal/cli/run.go index 7f9ec89f3a..54eee9625c 100644 --- a/cli/internal/cli/run.go +++ b/cli/internal/cli/run.go @@ -86,6 +86,8 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder return runSync(ctx, connection, stdout, stderr) case "focus-window": return runFocusWindow(ctx, connection.ProjectRoot, stdout, stderr) + case waitForDebugBreakCommandName: + return runWaitForDebugBreak(ctx, connection, commandArgs, stdout, stderr) default: tool, cache, ok, err := findToolForCommand(connection.ProjectRoot, command) if err != nil { diff --git a/cli/internal/cli/wait_for_debug_break.go b/cli/internal/cli/wait_for_debug_break.go new file mode 100644 index 0000000000..aa7db2a00b --- /dev/null +++ b/cli/internal/cli/wait_for_debug_break.go @@ -0,0 +1,204 @@ +package cli + +import ( + "context" + "encoding/json" + "io" + "time" + + "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" +) + +const ( + waitForDebugBreakCommandName = "wait-for-debug-break" + playModeStateCommandName = "get-play-mode-state" + waitForDebugBreakStatusProbeTimeout = 5 * time.Second +) + +var ( + waitForDebugBreakPollInterval = 50 * time.Millisecond + queryPlayModeState = queryPlayModeStateFromUnity +) + +type playModeStateResponse struct { + IsPlaying bool `json:"IsPlaying"` + IsPaused bool `json:"IsPaused"` + Message string `json:"Message"` +} + +type waitForDebugBreakResponse struct { + Success bool `json:"Success"` + IsPlaying bool `json:"IsPlaying"` + IsPaused bool `json:"IsPaused"` + ElapsedMilliseconds int64 `json:"ElapsedMilliseconds"` + Message string `json:"Message"` +} + +func runWaitForDebugBreak( + ctx context.Context, + connection unityipc.Connection, + args []string, + stdout io.Writer, + stderr io.Writer, +) int { + if err := parseWaitForDebugBreakArgs(args); err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: waitForDebugBreakCommandName, + }) + return 1 + } + + startedAt := time.Now() + spinner := newToolSpinner(stderr, waitForDebugBreakCommandName) + spinner.Update("Checking Unity play mode state...") + + initialState, err := queryPlayModeState(ctx, connection) + if err != nil { + spinner.Stop() + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: waitForDebugBreakCommandName, + }) + return 1 + } + if initialState.IsPaused { + spinner.Stop() + writeErrorEnvelope(stderr, debugBreakAlreadyPausedError(connection.ProjectRoot, initialState)) + return 1 + } + if !initialState.IsPlaying { + spinner.Stop() + writeErrorEnvelope(stderr, debugBreakNotPlayingError(connection.ProjectRoot, initialState)) + return 1 + } + + spinner.Update("Waiting for Debug.Break...") + finalState, waitErr := waitForDebugBreak(ctx, connection, initialState) + spinner.Stop() + if waitErr != nil { + writeClassifiedError(stderr, waitErr, errorContext{ + projectRoot: connection.ProjectRoot, + command: waitForDebugBreakCommandName, + }) + return 1 + } + + response := waitForDebugBreakResponse{ + Success: true, + IsPlaying: finalState.IsPlaying, + IsPaused: finalState.IsPaused, + ElapsedMilliseconds: time.Since(startedAt).Milliseconds(), + Message: "Debug break observed.", + } + result, marshalErr := json.Marshal(response) + if marshalErr != nil { + writeClassifiedError(stderr, marshalErr, errorContext{ + projectRoot: connection.ProjectRoot, + command: waitForDebugBreakCommandName, + }) + return 1 + } + writeJSON(stdout, result) + return 0 +} + +func parseWaitForDebugBreakArgs(args []string) error { + for _, arg := range args { + return &argumentError{ + message: "Unknown wait-for-debug-break option: " + arg, + option: arg, + command: waitForDebugBreakCommandName, + nextActions: []string{"Run `uloop wait-for-debug-break --help` to inspect supported options."}, + } + } + + return nil +} + +func waitForDebugBreak( + ctx context.Context, + connection unityipc.Connection, + initialState playModeStateResponse, +) (playModeStateResponse, error) { + lastState := initialState + var lastErr error + for { + state, err := queryPlayModeState(ctx, connection) + if err == nil { + lastState = state + if state.IsPaused { + return state, nil + } + } else { + lastErr = err + } + + select { + case <-ctx.Done(): + if lastErr != nil { + return lastState, lastErr + } + return lastState, ctx.Err() + case <-time.After(waitForDebugBreakPollInterval): + } + } +} + +func queryPlayModeStateFromUnity(ctx context.Context, connection unityipc.Connection) (playModeStateResponse, error) { + probeContext, cancel := context.WithTimeout(ctx, waitForDebugBreakStatusProbeTimeout) + defer cancel() + + result, err := unityipc.NewClient(connection, version).Send( + probeContext, + playModeStateCommandName, + map[string]any{}, + ) + if err != nil { + return playModeStateResponse{}, err + } + + response := playModeStateResponse{} + if err := json.Unmarshal(result, &response); err != nil { + return playModeStateResponse{}, err + } + return response, nil +} + +func debugBreakAlreadyPausedError(projectRoot string, state playModeStateResponse) cliError { + return cliError{ + ErrorCode: errorCodeDebugBreakAlreadyPaused, + Phase: errorPhaseResponseWaiting, + Message: "Unity Editor is already paused before waiting for Debug.Break.", + Retryable: true, + SafeToRetry: true, + ProjectRoot: projectRoot, + Command: waitForDebugBreakCommandName, + NextActions: []string{ + "Run `uloop control-play-mode --action Play` to resume Unity from the existing pause, then run `uloop wait-for-debug-break` before triggering the target action.", + }, + Details: map[string]any{ + "isPlaying": state.IsPlaying, + "isPaused": state.IsPaused, + }, + } +} + +func debugBreakNotPlayingError(projectRoot string, state playModeStateResponse) cliError { + return cliError{ + ErrorCode: errorCodeDebugBreakNotPlaying, + Phase: errorPhaseResponseWaiting, + Message: "Unity Editor is not in PlayMode before waiting for Debug.Break.", + Retryable: true, + SafeToRetry: true, + ProjectRoot: projectRoot, + Command: waitForDebugBreakCommandName, + NextActions: []string{ + "Run `uloop control-play-mode --action Play`, then run `uloop wait-for-debug-break` before triggering the target action.", + }, + Details: map[string]any{ + "isPlaying": state.IsPlaying, + "isPaused": state.IsPaused, + }, + } +} diff --git a/cli/internal/cli/wait_for_debug_break_test.go b/cli/internal/cli/wait_for_debug_break_test.go new file mode 100644 index 0000000000..ad26e160df --- /dev/null +++ b/cli/internal/cli/wait_for_debug_break_test.go @@ -0,0 +1,268 @@ +package cli + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" +) + +// Verifies wait-for-debug-break succeeds only after Unity transitions from running to paused. +func TestRunWaitForDebugBreakCompletesAfterPauseTransition(t *testing.T) { + states := []playModeStateResponse{ + {IsPlaying: true, IsPaused: false, Message: "Unity Editor is playing and not paused."}, + {IsPlaying: true, IsPaused: true, Message: "Unity Editor is paused."}, + } + replaceQueryPlayModeState(t, func(context.Context, unityipc.Connection) (playModeStateResponse, error) { + if len(states) == 0 { + t.Fatal("unexpected extra play mode state query") + } + state := states[0] + states = states[1:] + return state, nil + }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForDebugBreak( + context.Background(), + unityipc.Connection{ProjectRoot: t.TempDir()}, + []string{}, + &stdout, + &stderr) + + if code != 0 { + t.Fatalf("wait-for-debug-break failed with %d: %s", code, stderr.String()) + } + response := waitForDebugBreakResponse{} + if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + t.Fatalf("failed to decode stdout: %v\n%s", err, stdout.String()) + } + if !response.Success || !response.IsPaused { + t.Fatalf("response state mismatch: %#v", response) + } + if response.Message != "Debug break observed." { + t.Fatalf("response message mismatch: %s", response.Message) + } +} + +// Verifies wait-for-debug-break rejects an already-paused Editor to avoid reporting a stale break. +func TestRunWaitForDebugBreakFailsWhenUnityAlreadyPaused(t *testing.T) { + replaceQueryPlayModeState(t, func(context.Context, unityipc.Connection) (playModeStateResponse, error) { + return playModeStateResponse{IsPlaying: true, IsPaused: true}, nil + }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForDebugBreak( + context.Background(), + unityipc.Connection{ProjectRoot: t.TempDir()}, + []string{}, + &stdout, + &stderr) + + if code != 1 { + t.Fatalf("expected already-paused failure, got %d with stdout %s", code, stdout.String()) + } + if !strings.Contains(stderr.String(), errorCodeDebugBreakAlreadyPaused) { + t.Fatalf("already-paused error missing from stderr: %s", stderr.String()) + } +} + +// Verifies wait-for-debug-break rejects EditMode before entering the wait loop. +func TestRunWaitForDebugBreakFailsWhenUnityIsNotPlaying(t *testing.T) { + replaceQueryPlayModeState(t, func(context.Context, unityipc.Connection) (playModeStateResponse, error) { + return playModeStateResponse{IsPlaying: false, IsPaused: false}, nil + }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForDebugBreak( + context.Background(), + unityipc.Connection{ProjectRoot: t.TempDir()}, + []string{}, + &stdout, + &stderr) + + if code != 1 { + t.Fatalf("expected not-playing failure, got %d with stdout %s", code, stdout.String()) + } + if !strings.Contains(stderr.String(), errorCodeDebugBreakNotPlaying) { + t.Fatalf("not-playing error missing from stderr: %s", stderr.String()) + } +} + +// Verifies Debug.Break polling keeps the last state when the caller cancels the wait. +func TestWaitForDebugBreakReturnsLastStateWhenCanceled(t *testing.T) { + originalPollInterval := waitForDebugBreakPollInterval + waitForDebugBreakPollInterval = time.Millisecond + t.Cleanup(func() { + waitForDebugBreakPollInterval = originalPollInterval + }) + replaceQueryPlayModeState(t, func(context.Context, unityipc.Connection) (playModeStateResponse, error) { + return playModeStateResponse{IsPlaying: true, IsPaused: false}, nil + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + state, err := waitForDebugBreak( + ctx, + unityipc.Connection{ProjectRoot: t.TempDir()}, + playModeStateResponse{IsPlaying: true, IsPaused: false}) + if err == nil { + t.Fatalf("wait should be canceled: %#v", state) + } + if state.IsPaused { + t.Fatalf("last state should remain unpaused: %#v", state) + } +} + +// Verifies Debug.Break polling reports the last probe error instead of hiding it behind timeout. +func TestWaitForDebugBreakReturnsLastProbeErrorWhenCanceled(t *testing.T) { + originalPollInterval := waitForDebugBreakPollInterval + waitForDebugBreakPollInterval = time.Millisecond + t.Cleanup(func() { + waitForDebugBreakPollInterval = originalPollInterval + }) + expectedErr := errors.New("play mode state probe failed") + replaceQueryPlayModeState(t, func(context.Context, unityipc.Connection) (playModeStateResponse, error) { + return playModeStateResponse{}, expectedErr + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + state, err := waitForDebugBreak( + ctx, + unityipc.Connection{ProjectRoot: t.TempDir()}, + playModeStateResponse{IsPlaying: true, IsPaused: false}) + if !errors.Is(err, expectedErr) { + t.Fatalf("expected last probe error, got %v with state %#v", err, state) + } +} + +// Verifies the Unity probe uses the internal get-play-mode-state bridge command. +func TestQueryPlayModeStateFromUnityUsesInternalBridgeCommand(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer func() { + _ = listener.Close() + }() + + requests := make(chan string, 1) + serverErr := make(chan error, 1) + go servePlayModeStateResponse( + listener, + requests, + serverErr, + `{"IsPlaying":true,"IsPaused":true,"Message":"Unity Editor is paused."}`) + + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{ + Network: "tcp", + Address: listener.Addr().String(), + }, + ProjectRoot: t.TempDir(), + } + + response, err := queryPlayModeStateFromUnity(context.Background(), connection) + if err != nil { + t.Fatalf("queryPlayModeStateFromUnity failed: %v", err) + } + if !response.IsPaused { + t.Fatalf("response state mismatch: %#v", response) + } + if method := readPlayModeStateMethod(t, requests); method != playModeStateCommandName { + t.Fatalf("method mismatch: %s", method) + } + + select { + case err := <-serverErr: + t.Fatalf("server failed: %v", err) + default: + } +} + +// Verifies wait-for-debug-break accepts no command-specific options. +func TestParseWaitForDebugBreakArgsAcceptsNoOptions(t *testing.T) { + if err := parseWaitForDebugBreakArgs([]string{}); err != nil { + t.Fatalf("parseWaitForDebugBreakArgs failed: %v", err) + } +} + +// Verifies wait-for-debug-break rejects extra options before contacting Unity. +func TestParseWaitForDebugBreakArgsRejectsOptions(t *testing.T) { + if err := parseWaitForDebugBreakArgs([]string{"--unknown"}); err == nil { + t.Fatal("expected option validation error") + } +} + +func replaceQueryPlayModeState( + t *testing.T, + replacement func(context.Context, unityipc.Connection) (playModeStateResponse, error), +) { + t.Helper() + original := queryPlayModeState + queryPlayModeState = replacement + t.Cleanup(func() { + queryPlayModeState = original + }) +} + +func servePlayModeStateResponse( + listener net.Listener, + requests chan<- string, + serverErr chan<- error, + result string, +) { + conn, err := listener.Accept() + if err != nil { + serverErr <- err + return + } + + payload, err := unityipc.Read(bufio.NewReader(conn)) + if err != nil { + _ = conn.Close() + serverErr <- err + return + } + + request := struct { + Method string `json:"method"` + }{} + if err := json.Unmarshal(payload, &request); err != nil { + _ = conn.Close() + serverErr <- err + return + } + requests <- request.Method + + response := []byte(fmt.Sprintf(`{"jsonrpc":"2.0","result":%s,"id":1}`, result)) + if err := unityipc.Write(conn, response); err != nil { + _ = conn.Close() + serverErr <- err + return + } + _ = conn.Close() +} + +func readPlayModeStateMethod(t *testing.T, requests <-chan string) string { + t.Helper() + select { + case method := <-requests: + return method + case <-time.After(time.Second): + t.Fatal("timed out waiting for request") + return "" + } +} diff --git a/cli/internal/tools/default-tools.json b/cli/internal/tools/default-tools.json index 904923ef8f..cbafb1f4fa 100644 --- a/cli/internal/tools/default-tools.json +++ b/cli/internal/tools/default-tools.json @@ -1,5 +1,5 @@ { - "version": "3.0.0-beta.24", + "version": "3.0.0-beta.25", "tools": [ { "name": "compile", @@ -306,17 +306,18 @@ }, { "name": "control-play-mode", - "description": "Control Unity Editor play mode (play/stop/pause)", + "description": "Control Unity Editor play mode (play/stop/pause/next frame)", "inputSchema": { "type": "object", "properties": { "Action": { "type": "string", - "description": "Action to perform: Play - Start play mode, Stop - Stop play mode, Pause - Pause play mode", + "description": "Action to perform: Play - Start play mode, Stop - Stop play mode, Pause - Pause play mode, NextFrame - Advance one frame only when paused in play mode", "enum": [ "Play", "Stop", - "Pause" + "Pause", + "NextFrame" ], "default": "Play" }, diff --git a/scripts/check-build-link-go-cli.sh b/scripts/check-build-link-go-cli.sh index 450456bb9f..fd810d34d5 100755 --- a/scripts/check-build-link-go-cli.sh +++ b/scripts/check-build-link-go-cli.sh @@ -25,31 +25,18 @@ normalize_path_dir() { printf '%s\n' "$path_dir" } -ensure_symlink_target() { - link_path="$1" - - if [ -e "$link_path" ] && [ ! -L "$link_path" ]; then - echo "Go CLI source checks passed and dist binaries were rebuilt." - echo "Refusing to overwrite non-symlink global uloop: $link_path" >&2 - exit 1 - fi -} - -update_uloop_link() { - link_path="$1" - - if [ -L "$link_path" ]; then - current_target=$(readlink "$link_path") - echo "Updating global uloop symlink: $link_path -> $current_target" - else - echo "Creating global uloop symlink: $link_path" - fi - - ln -sfn "$cli_path" "$link_path" - echo "Global uloop now points at the rebuilt native CLI: $link_path -> $(readlink "$link_path")" +copy_uloop_binary() { + destination_path="$1" + tmp_destination_path="$destination_path.tmp.$$" + + rm -f "$tmp_destination_path" + cp "$cli_path" "$tmp_destination_path" + chmod +x "$tmp_destination_path" + mv -f "$tmp_destination_path" "$destination_path" + echo "Copied rebuilt native CLI to global uloop: $destination_path" } -ensure_global_uloop_resolves_to_link() { +ensure_global_uloop_resolves_to_updated_binary() { resolved_uloop_path=$(command -v "$global_command_name" || true) if [ "$resolved_uloop_path" = "$global_uloop_path" ]; then @@ -59,7 +46,7 @@ ensure_global_uloop_resolves_to_link() { return 0 fi - echo "Global $global_command_name symlink was updated, but shell resolution does not point at it." >&2 + echo "Global $global_command_name was updated, but shell resolution does not point at it." >&2 echo "Resolved $global_command_name: ${resolved_uloop_path:-not found}" >&2 echo "Expected $global_command_name: $global_uloop_path" >&2 echo "Add $global_bin_dir to PATH or set ULOOP_GLOBAL_BIN_DIR to a directory earlier in PATH." >&2 @@ -136,16 +123,11 @@ fi echo "Go CLI source checks passed and dist binaries were rebuilt." -ensure_symlink_target "$global_uloop_path" -if [ -n "$extra_global_uloop_path" ]; then - ensure_symlink_target "$extra_global_uloop_path" -fi - -update_uloop_link "$global_uloop_path" +copy_uloop_binary "$global_uloop_path" if [ -n "$extra_global_uloop_path" ]; then - update_uloop_link "$extra_global_uloop_path" + copy_uloop_binary "$extra_global_uloop_path" fi -ensure_global_uloop_resolves_to_link +ensure_global_uloop_resolves_to_updated_binary "$global_command_name" --version diff --git a/scripts/test-use-local-uloop.sh b/scripts/test-use-local-uloop.sh index 15334c9640..388270cb1d 100755 --- a/scripts/test-use-local-uloop.sh +++ b/scripts/test-use-local-uloop.sh @@ -26,8 +26,8 @@ chmod +x "$BIN_DIR/uloop" ULOOP_GLOBAL_BIN_DIR="$BIN_DIR" "$ROOT_DIR/scripts/use-local-uloop.sh" link >/dev/null -if [ ! -L "$BIN_DIR/uloop" ]; then - echo "Expected global uloop to be a symlink after link." >&2 +if [ -L "$BIN_DIR/uloop" ]; then + echo "Expected global uloop to be a copied executable, not a symlink." >&2 exit 1 fi @@ -36,32 +36,28 @@ if [ "$(uname -m)" = "x86_64" ] || [ "$(uname -m)" = "amd64" ]; then expected_target="$ROOT_DIR/cli/dist/darwin-amd64/uloop" fi -actual_target=$(readlink "$BIN_DIR/uloop") -if [ "$actual_target" != "$expected_target" ]; then - echo "Unexpected symlink target: $actual_target" >&2 - echo "Expected: $expected_target" >&2 +if ! cmp -s "$BIN_DIR/uloop" "$expected_target"; then + echo "Expected global uloop to match the local development binary." >&2 exit 1 fi -if [ ! -x "$BIN_DIR/uloop.before-local-link" ]; then - echo "Expected original global uloop backup to exist." >&2 +if [ -e "$BIN_DIR/uloop.before-local-link" ]; then + echo "Expected link to avoid creating a backup." >&2 exit 1 fi -ULOOP_GLOBAL_BIN_DIR="$BIN_DIR" "$ROOT_DIR/scripts/use-local-uloop.sh" restore >/dev/null +printf '%s\n' '#!/bin/sh' 'echo stale-backup' > "$BIN_DIR/uloop.before-local-link" +chmod +x "$BIN_DIR/uloop.before-local-link" -if [ -L "$BIN_DIR/uloop" ]; then - echo "Expected restored global uloop to no longer be a symlink." >&2 - exit 1 -fi +ULOOP_GLOBAL_BIN_DIR="$BIN_DIR" "$ROOT_DIR/scripts/use-local-uloop.sh" link >/dev/null -if [ "$("$BIN_DIR/uloop")" != "old-global-uloop" ]; then - echo "Restored global uloop did not execute the original script." >&2 +if ! cmp -s "$BIN_DIR/uloop" "$expected_target"; then + echo "Expected link to overwrite the global uloop even when a stale backup exists." >&2 exit 1 fi -if [ -e "$BIN_DIR/uloop.before-local-link" ]; then - echo "Expected backup to be consumed after restore." >&2 +if [ "$("$BIN_DIR/uloop.before-local-link")" != "stale-backup" ]; then + echo "Expected stale backup to be left untouched." >&2 exit 1 fi @@ -71,9 +67,14 @@ mkdir -p "$TRAILING_BIN" HOME="$TRAILING_HOME" PATH="$TRAILING_BIN/:/usr/bin:/bin:/usr/sbin:/sbin" "$ROOT_DIR/scripts/use-local-uloop.sh" link >/dev/null -if [ ! -L "$TRAILING_BIN/uloop" ]; then +if [ -L "$TRAILING_BIN/uloop" ] || [ ! -x "$TRAILING_BIN/uloop" ]; then echo "Expected PATH entry with trailing slash to be selected for global uloop." >&2 exit 1 fi -echo "use-local-uloop link/restore test passed" +if ! cmp -s "$TRAILING_BIN/uloop" "$expected_target"; then + echo "Expected trailing PATH global uloop to match the local development binary." >&2 + exit 1 +fi + +echo "use-local-uloop copy-link test passed" diff --git a/scripts/use-local-uloop.sh b/scripts/use-local-uloop.sh index bcbbffccd1..ad06f03aa8 100755 --- a/scripts/use-local-uloop.sh +++ b/scripts/use-local-uloop.sh @@ -83,19 +83,13 @@ select_global_bin_dir() { print_status() { echo "Global uloop path: $global_uloop" if [ -L "$global_uloop" ]; then - echo "Global uloop symlink target: $(readlink "$global_uloop")" + echo "Global uloop is a symlink to: $(readlink "$global_uloop")" elif [ -e "$global_uloop" ]; then - echo "Global uloop is a regular file or directory." + echo "Global uloop is a copied executable." else echo "Global uloop does not exist." fi - if [ -e "$backup_uloop" ] || [ -L "$backup_uloop" ]; then - echo "Backup path: $backup_uloop" - else - echo "Backup path: none" - fi - echo "Local branch uloop: $local_uloop" if [ -x "$global_uloop" ]; then echo "Selected global uloop version:" @@ -110,52 +104,23 @@ print_status() { link_local_uloop() { mkdir -p "$global_bin_dir" - if [ -L "$global_uloop" ] && [ "$(readlink "$global_uloop")" = "$local_uloop" ]; then - echo "Global uloop already points to this checkout." - print_status - return - fi - - if [ -e "$global_uloop" ] || [ -L "$global_uloop" ]; then - if [ -e "$backup_uloop" ] || [ -L "$backup_uloop" ]; then - echo "Backup already exists, refusing to overwrite it: $backup_uloop" >&2 - echo "Run '$0 restore' first or move the backup manually." >&2 - exit 1 - fi - - mv "$global_uloop" "$backup_uloop" - echo "Backed up existing global uloop to $backup_uloop" - fi - - ln -s "$local_uloop" "$global_uloop" + tmp_global_uloop="$global_uloop.tmp.$$" + rm -f "$tmp_global_uloop" + cp "$local_uloop" "$tmp_global_uloop" + chmod +x "$tmp_global_uloop" + mv -f "$tmp_global_uloop" "$global_uloop" + echo "Copied local uloop to $global_uloop" print_status } restore_global_uloop() { - if [ ! -e "$backup_uloop" ] && [ ! -L "$backup_uloop" ]; then - echo "No backup found: $backup_uloop" >&2 - exit 1 - fi - - if [ -L "$global_uloop" ]; then - current_target=$(readlink "$global_uloop") - if [ "$current_target" != "$local_uloop" ]; then - echo "Refusing to remove unexpected uloop symlink: $global_uloop -> $current_target" >&2 - exit 1 - fi - - rm "$global_uloop" - elif [ -e "$global_uloop" ]; then - echo "Refusing to overwrite unexpected global uloop: $global_uloop" >&2 - exit 1 - fi - - mv "$backup_uloop" "$global_uloop" - print_status + echo "Restore is no longer supported by this script." >&2 + echo "Run '$0 link' to overwrite the global uloop with this checkout's binary." >&2 + exit 1 } usage() { - echo "Usage: $0 link|restore|status" >&2 + echo "Usage: $0 link|status" >&2 exit 1 } @@ -163,7 +128,6 @@ select_local_uloop select_global_bin_dir global_uloop="$global_bin_dir/$command_name" -backup_uloop="$global_bin_dir/$command_name.before-local-link" case "$ACTION" in link)