From dcb9a0ff23ed55d1ad63a637895c19413d6639c1 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 30 Sep 2026 19:23:05 +0900 Subject: [PATCH 1/2] Let control-play-mode Play keep unsaved Scenes instead of always saving them Play from Edit Mode always quietly saved dirty Scenes and the Prefab Stage, added to avoid save dialogs stalling a CLI Play start. Unity itself enters Play Mode with dirty Scenes kept in memory and shows no save prompt for them; the only Play-entry dialog (for [ExecuteInEditMode] scripts in Prefab Mode) appears regardless of dirtiness. The save therefore only rewrote Scene files, produced VCS noise for Scenes that turn dirty on open, and blocked Play for Untitled scenes. - Add --unsaved-changes keep|save|fail (lowercase CLI-own values, as in run-tests). keep, the default, saves nothing; save keeps the previous behavior; fail stops with the list of unsaved items. Resuming from pause never saves or checks. - save now names every saved item at the start of Warning. Warning is used because the project runner carries the initial response's Warning into its final output while it rebuilds the other fields from its status poll, so no runner change is needed. - The embedded catalog gains the UnsavedChanges property, a structural change, so the shared-input stamps are refreshed. --- .../skills/uloop-control-play-mode/SKILL.md | 8 +- .../references/multiplayer-play-mode.md | 5 +- .../skills/uloop-control-play-mode/SKILL.md | 8 +- .../references/multiplayer-play-mode.md | 5 +- .../Editor/ControlPlayModeUseCaseTests.cs | 193 +++++++++++++++++- .../ControlPlayMode/ControlPlayModeSchema.cs | 14 ++ .../ControlPlayMode/ControlPlayModeUseCase.cs | 95 +++++++-- .../ControlPlayMode/Skill/SKILL.md | 8 +- .../Skill/references/multiplayer-play-mode.md | 5 +- cli/common/tools/default-tools.json | 10 + cli/dispatcher/shared-inputs-stamp.json | 2 +- cli/project-runner/shared-inputs-stamp.json | 2 +- docs/focus-return-asset-handling.md | 7 +- 13 files changed, 324 insertions(+), 38 deletions(-) diff --git a/.agents/skills/uloop-control-play-mode/SKILL.md b/.agents/skills/uloop-control-play-mode/SKILL.md index b3d9b254e2..9aa6b08a2e 100644 --- a/.agents/skills/uloop-control-play-mode/SKILL.md +++ b/.agents/skills/uloop-control-play-mode/SKILL.md @@ -20,6 +20,7 @@ uloop control-play-mode [options] |-----------|------|---------|-------------| | `--action` | string | `Play` | `Play` - start Play Mode, `Stop` - stop Play Mode, `Pause` - pause Play Mode, `Step` - advance one frame while paused, `Status` - report current state without changing anything, `Resume` - alias of Play in every state, including starting Play Mode when stopped | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | +| `--unsaved-changes` | string | `keep` | How `Play` from Edit Mode handles unsaved Scene/Prefab Stage changes: `keep` enters Play Mode without saving (like the Editor's Play button); `save` writes them first; `fail` stops if any exist | ## Output @@ -31,7 +32,7 @@ Returns JSON with the current play mode state: - `WasAlreadyStopped`: Whether `Stop` was requested while Play Mode was already stopped - `ResumedFromPause`: Whether `Play` resumed a paused Play Mode session instead of starting a new one - `Message`: Description of the action performed -- `Warning` (string, optional): Set when the action carries a caveat. A fresh `Play` start always notes that the session started from Edit-time scene state; additionally, when active hot-reload patches or enabled pause points exist and Domain Reload is enabled, it reports how many of them the Play-entry domain reload will discard. Pause points enabled with `--persist` are counted separately: they are re-armed after the reload, and the warning points at `pause-point-status` for the re-arm result. `Status` also reports when Play Mode is running while the Unity Editor is unfocused, because progress may be throttled; run `uloop focus-window`, or use the `pause-point --await`/`--trigger` flow instead of polling for progress. +- `Warning` (string, optional): Set when the action carries a caveat. With `--unsaved-changes save` it starts by listing every Scene and Prefab Stage it saved. A fresh `Play` start always notes that the session started from Edit-time scene state; additionally, when active hot-reload patches or enabled pause points exist and Domain Reload is enabled, it reports how many of them the Play-entry domain reload will discard. Pause points enabled with `--persist` are counted separately: they are re-armed after the reload, and the warning points at `pause-point-status` for the re-arm result. `Status` also reports when Play Mode is running while the Unity Editor is unfocused, because progress may be throttled; run `uloop focus-window`, or use the `pause-point --await`/`--trigger` flow instead of polling for progress. - `StoppedBy` (string, optional): Why Play Mode last stopped: `cli-control-play-mode`, `cli-compile-stop-setting`, `cli-run-tests-cancel`, `script-compilation`, or `unknown`. Present on `Stop` when Play Mode was already stopped, and on `Status` when Play Mode is not running. Omitted when this Editor session has no confirmed stop. - `StoppedAt` (string, optional): UTC ISO 8601 timestamp of that stop. Omitted together with `StoppedBy` when no stop is recorded. - `ActiveScenario` (string, optional): Name of the active non-default Play Mode configuration (for example a Multiplayer Play Mode scenario). Omitted when the default configuration is active. @@ -45,7 +46,8 @@ Returns JSON with the current play mode state: - `Step` advances exactly one frame and leaves PlayMode paused (the Editor's Next Frame button); it is independent of `Time.timeScale` and requires PlayMode to be running - The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. - Before relying on PlayMode behavior as verification evidence, check `uloop get-logs --log-type Error` for pre-existing errors. An error already present when PlayMode starts can otherwise be mistaken for one caused by the action under test. -- `Status` reads the current state with no side effects: `Changed` is always `false`, no waiting, no scene saving, and it is never rejected by compile errors. It reports whether compile errors would currently block `Play` (`BlockedByCompileErrors` with the `CompileErrors` list), read from the last compile result without triggering a new compile. It does not predict unsaved-changes blocking: `BlockedByUnsavedChanges` describes a failed save attempt during a `Play` request, and `Status` never attempts one. -- `Play` fails immediately with a `CONTROL_PLAY_MODE_UNSAVED_CHANGES` error when unsaved changes cannot be saved quietly — most commonly an Untitled scene, which has no path to save to. The error message lists exactly which scenes or prefab stages blocked it; save the Untitled scene to an explicit path (or discard the changes), then retry. +- `Status` reads the current state with no side effects: `Changed` is always `false`, no waiting, no scene saving, and it is never rejected by compile errors. It reports whether compile errors would currently block `Play` (`BlockedByCompileErrors` with the `CompileErrors` list), read from the last compile result without triggering a new compile. It does not predict unsaved-changes blocking: `BlockedByUnsavedChanges` describes a `Play` request that `--unsaved-changes` stopped, and `Status` never checks. +- `--unsaved-changes` applies only when `Play` enters Play Mode from Edit Mode; resuming a paused session never saves or checks. With the default `keep`, unsaved edits stay in memory: Unity restores them when Play Mode ends, and Scene files on disk are not rewritten. +- `Play` fails immediately with a `CONTROL_PLAY_MODE_UNSAVED_CHANGES` error when `fail` finds unsaved changes, or when `save` cannot save them — most commonly an Untitled scene, which has no path to save to. The error message lists exactly which scenes or prefab stages blocked it; use `keep`, or save the Untitled scene to an explicit path (or discard the changes), then retry. - `Play` and `Stop` follow the active Play Mode configuration like the Editor's Play button: with a Multiplayer Play Mode scenario selected they start and stop its Virtual Players too. To find and command Virtual Players, read [references/multiplayer-play-mode.md](references/multiplayer-play-mode.md). - `Play` from Edit mode triggers a domain reload (unless Enter Play Mode Options disable it), which discards all active hot-reload patches and every enabled pause point; pause points enabled with `--persist` are then re-armed from their saved enable request. The response `Warning` reports the counts being dropped and, separately, how many re-arm. Edits that were only hot-reloaded are not part of the compiled assemblies, so the new session runs the last compiled code — run `uloop compile` before `Play` to keep them, or re-apply `uloop hot-reload` after Play Mode starts. diff --git a/.agents/skills/uloop-control-play-mode/references/multiplayer-play-mode.md b/.agents/skills/uloop-control-play-mode/references/multiplayer-play-mode.md index 6a6c3cfbe6..df26774fe1 100644 --- a/.agents/skills/uloop-control-play-mode/references/multiplayer-play-mode.md +++ b/.agents/skills/uloop-control-play-mode/references/multiplayer-play-mode.md @@ -59,5 +59,6 @@ main Editor) do not block each other. "Play Mode Scenario - Scenario Setup Error". The CLI cannot see that failure and waits for Play Mode until `--timeout-seconds` expires. Close the dialog in the Editor and read the Console errors (`uloop get-logs --log-type Error`). -- Unsaved scenes are saved quietly before `Play`, as with the default configuration. An Untitled - scene still fails with `CONTROL_PLAY_MODE_UNSAVED_CHANGES`. +- `--unsaved-changes` applies as with the default configuration: `keep` (the default) starts + without saving, `save` writes unsaved scenes first (an Untitled scene then fails with + `CONTROL_PLAY_MODE_UNSAVED_CHANGES`), and `fail` stops if any exist. diff --git a/.claude/skills/uloop-control-play-mode/SKILL.md b/.claude/skills/uloop-control-play-mode/SKILL.md index b3d9b254e2..9aa6b08a2e 100644 --- a/.claude/skills/uloop-control-play-mode/SKILL.md +++ b/.claude/skills/uloop-control-play-mode/SKILL.md @@ -20,6 +20,7 @@ uloop control-play-mode [options] |-----------|------|---------|-------------| | `--action` | string | `Play` | `Play` - start Play Mode, `Stop` - stop Play Mode, `Pause` - pause Play Mode, `Step` - advance one frame while paused, `Status` - report current state without changing anything, `Resume` - alias of Play in every state, including starting Play Mode when stopped | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | +| `--unsaved-changes` | string | `keep` | How `Play` from Edit Mode handles unsaved Scene/Prefab Stage changes: `keep` enters Play Mode without saving (like the Editor's Play button); `save` writes them first; `fail` stops if any exist | ## Output @@ -31,7 +32,7 @@ Returns JSON with the current play mode state: - `WasAlreadyStopped`: Whether `Stop` was requested while Play Mode was already stopped - `ResumedFromPause`: Whether `Play` resumed a paused Play Mode session instead of starting a new one - `Message`: Description of the action performed -- `Warning` (string, optional): Set when the action carries a caveat. A fresh `Play` start always notes that the session started from Edit-time scene state; additionally, when active hot-reload patches or enabled pause points exist and Domain Reload is enabled, it reports how many of them the Play-entry domain reload will discard. Pause points enabled with `--persist` are counted separately: they are re-armed after the reload, and the warning points at `pause-point-status` for the re-arm result. `Status` also reports when Play Mode is running while the Unity Editor is unfocused, because progress may be throttled; run `uloop focus-window`, or use the `pause-point --await`/`--trigger` flow instead of polling for progress. +- `Warning` (string, optional): Set when the action carries a caveat. With `--unsaved-changes save` it starts by listing every Scene and Prefab Stage it saved. A fresh `Play` start always notes that the session started from Edit-time scene state; additionally, when active hot-reload patches or enabled pause points exist and Domain Reload is enabled, it reports how many of them the Play-entry domain reload will discard. Pause points enabled with `--persist` are counted separately: they are re-armed after the reload, and the warning points at `pause-point-status` for the re-arm result. `Status` also reports when Play Mode is running while the Unity Editor is unfocused, because progress may be throttled; run `uloop focus-window`, or use the `pause-point --await`/`--trigger` flow instead of polling for progress. - `StoppedBy` (string, optional): Why Play Mode last stopped: `cli-control-play-mode`, `cli-compile-stop-setting`, `cli-run-tests-cancel`, `script-compilation`, or `unknown`. Present on `Stop` when Play Mode was already stopped, and on `Status` when Play Mode is not running. Omitted when this Editor session has no confirmed stop. - `StoppedAt` (string, optional): UTC ISO 8601 timestamp of that stop. Omitted together with `StoppedBy` when no stop is recorded. - `ActiveScenario` (string, optional): Name of the active non-default Play Mode configuration (for example a Multiplayer Play Mode scenario). Omitted when the default configuration is active. @@ -45,7 +46,8 @@ Returns JSON with the current play mode state: - `Step` advances exactly one frame and leaves PlayMode paused (the Editor's Next Frame button); it is independent of `Time.timeScale` and requires PlayMode to be running - The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. - Before relying on PlayMode behavior as verification evidence, check `uloop get-logs --log-type Error` for pre-existing errors. An error already present when PlayMode starts can otherwise be mistaken for one caused by the action under test. -- `Status` reads the current state with no side effects: `Changed` is always `false`, no waiting, no scene saving, and it is never rejected by compile errors. It reports whether compile errors would currently block `Play` (`BlockedByCompileErrors` with the `CompileErrors` list), read from the last compile result without triggering a new compile. It does not predict unsaved-changes blocking: `BlockedByUnsavedChanges` describes a failed save attempt during a `Play` request, and `Status` never attempts one. -- `Play` fails immediately with a `CONTROL_PLAY_MODE_UNSAVED_CHANGES` error when unsaved changes cannot be saved quietly — most commonly an Untitled scene, which has no path to save to. The error message lists exactly which scenes or prefab stages blocked it; save the Untitled scene to an explicit path (or discard the changes), then retry. +- `Status` reads the current state with no side effects: `Changed` is always `false`, no waiting, no scene saving, and it is never rejected by compile errors. It reports whether compile errors would currently block `Play` (`BlockedByCompileErrors` with the `CompileErrors` list), read from the last compile result without triggering a new compile. It does not predict unsaved-changes blocking: `BlockedByUnsavedChanges` describes a `Play` request that `--unsaved-changes` stopped, and `Status` never checks. +- `--unsaved-changes` applies only when `Play` enters Play Mode from Edit Mode; resuming a paused session never saves or checks. With the default `keep`, unsaved edits stay in memory: Unity restores them when Play Mode ends, and Scene files on disk are not rewritten. +- `Play` fails immediately with a `CONTROL_PLAY_MODE_UNSAVED_CHANGES` error when `fail` finds unsaved changes, or when `save` cannot save them — most commonly an Untitled scene, which has no path to save to. The error message lists exactly which scenes or prefab stages blocked it; use `keep`, or save the Untitled scene to an explicit path (or discard the changes), then retry. - `Play` and `Stop` follow the active Play Mode configuration like the Editor's Play button: with a Multiplayer Play Mode scenario selected they start and stop its Virtual Players too. To find and command Virtual Players, read [references/multiplayer-play-mode.md](references/multiplayer-play-mode.md). - `Play` from Edit mode triggers a domain reload (unless Enter Play Mode Options disable it), which discards all active hot-reload patches and every enabled pause point; pause points enabled with `--persist` are then re-armed from their saved enable request. The response `Warning` reports the counts being dropped and, separately, how many re-arm. Edits that were only hot-reloaded are not part of the compiled assemblies, so the new session runs the last compiled code — run `uloop compile` before `Play` to keep them, or re-apply `uloop hot-reload` after Play Mode starts. diff --git a/.claude/skills/uloop-control-play-mode/references/multiplayer-play-mode.md b/.claude/skills/uloop-control-play-mode/references/multiplayer-play-mode.md index 6a6c3cfbe6..df26774fe1 100644 --- a/.claude/skills/uloop-control-play-mode/references/multiplayer-play-mode.md +++ b/.claude/skills/uloop-control-play-mode/references/multiplayer-play-mode.md @@ -59,5 +59,6 @@ main Editor) do not block each other. "Play Mode Scenario - Scenario Setup Error". The CLI cannot see that failure and waits for Play Mode until `--timeout-seconds` expires. Close the dialog in the Editor and read the Console errors (`uloop get-logs --log-type Error`). -- Unsaved scenes are saved quietly before `Play`, as with the default configuration. An Untitled - scene still fails with `CONTROL_PLAY_MODE_UNSAVED_CHANGES`. +- `--unsaved-changes` applies as with the default configuration: `keep` (the default) starts + without saving, `save` writes unsaved scenes first (an Untitled scene then fails with + `CONTROL_PLAY_MODE_UNSAVED_CHANGES`), and `fail` stops if any exist. diff --git a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs index abe1fc4bc3..69165e11b3 100644 --- a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs +++ b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs @@ -473,7 +473,8 @@ public async Task ExecuteAsync_WhenPlayStartSaveFails_DoesNotEnterPlayMode() Assert.That(EditorApplication.isPlaying, Is.False); StubEditorUnsavedChangesQuietSaver quietSaver = new( saveFailures: new[] { "Scene: Assets/Scenes/Sample.unity" }, - remainingAfterSave: System.Array.Empty()); + remainingAfterSave: System.Array.Empty(), + unsavedBeforeSave: new[] { "Scene: Assets/Scenes/Sample.unity" }); ControlPlayModeUseCase useCase = new ControlPlayModeUseCase( new StubCompilationFailureProvider(System.Array.Empty()), new StubCompilationFailureGate(false), @@ -481,6 +482,7 @@ public async Task ExecuteAsync_WhenPlayStartSaveFails_DoesNotEnterPlayMode() ControlPlayModeSchema schema = new ControlPlayModeSchema { Action = PlayModeAction.Play, + UnsavedChanges = ControlPlayModeUnsavedChangesMode.save, }; ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); @@ -509,12 +511,13 @@ public async Task ExecuteAsync_WhenPlayStartLeavesUnsavedChanges_DoesNotEnterPla ControlPlayModeSchema schema = new ControlPlayModeSchema { Action = PlayModeAction.Play, + UnsavedChanges = ControlPlayModeUnsavedChangesMode.save, }; ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); Assert.That(quietSaver.SaveCallCount, Is.EqualTo(1)); - Assert.That(quietSaver.DetectCallCount, Is.EqualTo(1)); + Assert.That(quietSaver.DetectCallCount, Is.EqualTo(2)); Assert.That(EditorApplication.isPlaying, Is.False); Assert.That(response.Changed, Is.False); Assert.That(response.BlockedByUnsavedChanges, Is.True); @@ -522,6 +525,167 @@ public async Task ExecuteAsync_WhenPlayStartLeavesUnsavedChanges_DoesNotEnterPla Assert.That(response.Message, Does.Contain("Prefab Stage: Assets/Prefabs/Hud.prefab")); } + [Test] + public void ControlPlayModeSchema_WhenCreated_KeepsUnsavedChanges() + { + // Verifies Play defaults to Unity's native behavior of entering Play Mode without saving. + ControlPlayModeSchema schema = new ControlPlayModeSchema(); + + Assert.That(schema.UnsavedChanges, Is.EqualTo(ControlPlayModeUnsavedChangesMode.keep)); + } + + [Test] + public async Task ExecuteAsync_WhenPlayStartKeepsUnsavedChanges_EntersPlayModeWithoutSaving() + { + // Verifies keep enters Play Mode with dirty Scenes left unsaved, without inspecting or rewriting them. + FakeControlPlayModeEditorStateService editorState = new(isPlaying: false, isPaused: false); + StubEditorUnsavedChangesQuietSaver quietSaver = new( + saveFailures: System.Array.Empty(), + remainingAfterSave: System.Array.Empty(), + unsavedBeforeSave: new[] { "Scene: Assets/Scenes/Sample.unity" }); + ControlPlayModeUseCase useCase = CreatePlayStartUseCase(quietSaver, editorState); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Play, + UnsavedChanges = ControlPlayModeUnsavedChangesMode.keep, + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That(editorState.IsPlaying, Is.True); + Assert.That(response.Message, Is.EqualTo("Play mode started")); + Assert.That(response.BlockedByUnsavedChanges, Is.False); + Assert.That(response.Warning, Is.EqualTo(ControlPlayModeUseCase.FreshPlayStartFromNewSessionWarning)); + Assert.That(quietSaver.SaveCallCount, Is.EqualTo(0)); + Assert.That(quietSaver.DetectCallCount, Is.EqualTo(0)); + } + + [Test] + public async Task ExecuteAsync_WhenPlayStartSavesUnsavedChanges_ReportsSavedItemsInWarning() + { + // Verifies save writes dirty items before Play and names every saved item so callers notice the rewrite. + FakeControlPlayModeEditorStateService editorState = new(isPlaying: false, isPaused: false); + StubEditorUnsavedChangesQuietSaver quietSaver = new( + saveFailures: System.Array.Empty(), + remainingAfterSave: System.Array.Empty(), + unsavedBeforeSave: new[] + { + "Scene: Assets/Scenes/Sample.unity", + "Prefab Stage: Assets/Prefabs/Hud.prefab" + }); + ControlPlayModeUseCase useCase = CreatePlayStartUseCase(quietSaver, editorState); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Play, + UnsavedChanges = ControlPlayModeUnsavedChangesMode.save, + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That(editorState.IsPlaying, Is.True); + Assert.That(quietSaver.SaveCallCount, Is.EqualTo(1)); + Assert.That(response.Warning, Does.StartWith(ControlPlayModeUseCase.SavedUnsavedChangesWarningPrefix)); + Assert.That(response.Warning, Does.Contain("Scene: Assets/Scenes/Sample.unity")); + Assert.That(response.Warning, Does.Contain("Prefab Stage: Assets/Prefabs/Hud.prefab")); + Assert.That(response.Warning, Does.Contain(ControlPlayModeUseCase.FreshPlayStartFromNewSessionWarning)); + } + + [Test] + public async Task ExecuteAsync_WhenPlayStartSaveModeHasNothingToSave_DoesNotReportASave() + { + // Verifies save does not claim a rewrite when no Scene or Prefab Stage was dirty. + FakeControlPlayModeEditorStateService editorState = new(isPlaying: false, isPaused: false); + StubEditorUnsavedChangesQuietSaver quietSaver = new( + saveFailures: System.Array.Empty(), + remainingAfterSave: System.Array.Empty()); + ControlPlayModeUseCase useCase = CreatePlayStartUseCase(quietSaver, editorState); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Play, + UnsavedChanges = ControlPlayModeUnsavedChangesMode.save, + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That(editorState.IsPlaying, Is.True); + Assert.That(response.Warning, Is.EqualTo(ControlPlayModeUseCase.FreshPlayStartFromNewSessionWarning)); + } + + [Test] + public async Task ExecuteAsync_WhenPlayStartFailsOnUnsavedChanges_ListsThemWithoutSavingOrPlaying() + { + // Verifies fail stops before Play Mode, leaves the dirty items untouched, and lists them. + FakeControlPlayModeEditorStateService editorState = new(isPlaying: false, isPaused: false); + StubEditorUnsavedChangesQuietSaver quietSaver = new( + saveFailures: System.Array.Empty(), + remainingAfterSave: System.Array.Empty(), + unsavedBeforeSave: new[] { "Scene: Assets/Scenes/Sample.unity" }); + RecordingRunInBackgroundStarter runInBackgroundStarter = new(); + ControlPlayModeUseCase useCase = CreatePlayStartUseCase(quietSaver, editorState, runInBackgroundStarter); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Play, + UnsavedChanges = ControlPlayModeUnsavedChangesMode.fail, + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That(editorState.IsPlayingSetCount, Is.EqualTo(0)); + Assert.That(runInBackgroundStarter.EnableCallCount, Is.EqualTo(0)); + Assert.That(quietSaver.SaveCallCount, Is.EqualTo(0)); + Assert.That(response.Changed, Is.False); + Assert.That(response.IsPlaying, Is.False); + Assert.That(response.BlockedByUnsavedChanges, Is.True); + Assert.That(response.Message, Does.Contain("--unsaved-changes fail")); + Assert.That(response.Message, Does.Contain("Scene: Assets/Scenes/Sample.unity")); + } + + [Test] + public async Task ExecuteAsync_WhenPlayStartFailModeHasNoUnsavedChanges_EntersPlayMode() + { + // Verifies fail only blocks when something is actually unsaved. + FakeControlPlayModeEditorStateService editorState = new(isPlaying: false, isPaused: false); + StubEditorUnsavedChangesQuietSaver quietSaver = new( + saveFailures: System.Array.Empty(), + remainingAfterSave: System.Array.Empty()); + ControlPlayModeUseCase useCase = CreatePlayStartUseCase(quietSaver, editorState); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Play, + UnsavedChanges = ControlPlayModeUnsavedChangesMode.fail, + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That(editorState.IsPlaying, Is.True); + Assert.That(response.BlockedByUnsavedChanges, Is.False); + Assert.That(quietSaver.SaveCallCount, Is.EqualTo(0)); + } + + [Test] + public async Task ExecuteAsync_WhenResumingFromPauseWithFailMode_IgnoresUnsavedChanges() + { + // Verifies a resume never checks unsaved changes, because it does not enter Play Mode from Edit Mode. + FakeControlPlayModeEditorStateService editorState = new(isPlaying: true, isPaused: true); + StubEditorUnsavedChangesQuietSaver quietSaver = new( + saveFailures: System.Array.Empty(), + remainingAfterSave: new[] { "Scene: Assets/Scenes/Sample.unity" }, + unsavedBeforeSave: new[] { "Scene: Assets/Scenes/Sample.unity" }); + ControlPlayModeUseCase useCase = CreatePlayStartUseCase(quietSaver, editorState); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Play, + UnsavedChanges = ControlPlayModeUnsavedChangesMode.fail, + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That(response.Message, Is.EqualTo("Play mode resumed")); + Assert.That(response.BlockedByUnsavedChanges, Is.False); + Assert.That(quietSaver.DetectCallCount, Is.EqualTo(0)); + Assert.That(quietSaver.SaveCallCount, Is.EqualTo(0)); + } + /// /// What: a Play start that fails synchronously propagates the error without leaving the CLI runInBackground override enabled. /// @@ -1021,6 +1185,20 @@ public void DomainReloadDropStateService_ReportsTheRuntimeChangeCountNotThePatch } } + private static ControlPlayModeUseCase CreatePlayStartUseCase( + StubEditorUnsavedChangesQuietSaver quietSaver, + FakeControlPlayModeEditorStateService editorState, + RecordingRunInBackgroundStarter runInBackgroundStarter = null) + { + return new ControlPlayModeUseCase( + new StubCompilationFailureProvider(System.Array.Empty()), + new StubCompilationFailureGate(false), + quietSaver, + editorState, + new StubDomainReloadDropStateProvider(), + runInBackgroundStarter: runInBackgroundStarter ?? new RecordingRunInBackgroundStarter()); + } + private sealed class RecordingRunInBackgroundStarter : ICliPlayModeRunInBackgroundStarter { public int EnableCallCount { get; private set; } @@ -1131,16 +1309,23 @@ private sealed class StubEditorUnsavedChangesQuietSaver : IEditorUnsavedChangesQ public int SaveCallCount { get; private set; } public int DetectCallCount { get; private set; } - public StubEditorUnsavedChangesQuietSaver(string[] saveFailures, string[] remainingAfterSave) + private readonly string[] _unsavedBeforeSave; + + public StubEditorUnsavedChangesQuietSaver( + string[] saveFailures, + string[] remainingAfterSave, + string[] unsavedBeforeSave = null) { _saveFailures = saveFailures; _remainingAfterSave = remainingAfterSave; + _unsavedBeforeSave = unsavedBeforeSave ?? remainingAfterSave; } + // Before a save the stub reports what is dirty; after it, what the save left behind. public string[] DetectUnsavedEditorChanges() { DetectCallCount++; - return _remainingAfterSave; + return SaveCallCount == 0 ? _unsavedBeforeSave : _remainingAfterSave; } public string[] SaveUnsavedEditorChanges() diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs index b33ae64e64..9ee36d23bc 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs @@ -15,6 +15,16 @@ public enum PlayModeAction Resume = 5 } + /// + /// How Play handles unsaved Scene and Prefab Stage changes when it enters Play Mode from Edit Mode. + /// + public enum ControlPlayModeUnsavedChangesMode + { + keep = 0, + save = 1, + fail = 2 + } + /// /// Describes the parameters accepted by the Control Play Mode tool. /// @@ -22,6 +32,10 @@ public class ControlPlayModeSchema : UnityCliLoopToolSchema { public PlayModeAction Action { get; set; } = PlayModeAction.Play; public int TimeoutSeconds { get; set; } = ControlPlayModeUseCase.DefaultTimeoutSeconds; + /// + /// How to handle unsaved Scene and Prefab Stage changes before entering Play Mode from Edit Mode. + /// + public ControlPlayModeUnsavedChangesMode UnsavedChanges { get; set; } = ControlPlayModeUnsavedChangesMode.keep; [Browsable(false)] public bool StatusOnly { get; set; } } diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs index 83d9e24c72..6c62572471 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs @@ -12,10 +12,18 @@ public class ControlPlayModeUseCase { public const int DefaultTimeoutSeconds = 180; + internal const string SavedUnsavedChangesWarningPrefix = + "Saved unsaved changes before entering Play Mode (--unsaved-changes save):"; + private const string UnsavedEditorChangesSaveFailureMessage = - "Play mode could not start because unsaved scene or prefab changes could not be saved."; + "Play mode could not start because unsaved scene or prefab changes could not be saved. " + + "Pass --unsaved-changes keep to enter Play Mode without saving them."; private const string UnsavedEditorChangesRemainingFailureMessage = - "Play mode could not start while the editor has unsaved scene or prefab changes."; + "Play mode could not start while the editor has unsaved scene or prefab changes. " + + "Pass --unsaved-changes keep to enter Play Mode without saving them."; + private const string UnsavedEditorChangesFailModeMessage = + "Play mode did not start because --unsaved-changes fail found unsaved scene or prefab changes. " + + "Pass --unsaved-changes keep to enter Play Mode without saving them, or save to write them first."; // A fresh Play start looks identical to a resume in the response's "changed"/"message" // fields unless callers already know they expected a resume; this makes the distinction @@ -72,7 +80,8 @@ public Task ExecuteAsync(ControlPlayModeSchema paramete return Task.FromResult(CreateStatusOnlyResponse(parameters)); } - ControlPlayModeActionResult actionResult = ExecuteRequestedPlayModeAction(parameters.Action); + ControlPlayModeActionResult actionResult = + ExecuteRequestedPlayModeAction(parameters.Action, parameters.UnsavedChanges); if (actionResult.HasResponse) { return Task.FromResult(actionResult.Response); @@ -99,7 +108,9 @@ private ControlPlayModeResponse CreateStatusOnlyResponse(ControlPlayModeSchema p return CreateResponse("Play mode status", false, false, action: parameters.Action); } - private ControlPlayModeActionResult ExecuteRequestedPlayModeAction(PlayModeAction action) + private ControlPlayModeActionResult ExecuteRequestedPlayModeAction( + PlayModeAction action, + ControlPlayModeUnsavedChangesMode unsavedChangesMode) { string message; bool wasPaused = _editorStateService.IsPaused; @@ -109,7 +120,7 @@ private ControlPlayModeActionResult ExecuteRequestedPlayModeAction(PlayModeActio { case PlayModeAction.Play: case PlayModeAction.Resume: - return ExecutePlayModeStart(wasPaused, wasPlaying); + return ExecutePlayModeStart(wasPaused, wasPlaying, unsavedChangesMode); case PlayModeAction.Stop: return ExecutePlayModeStop(wasPaused, wasPlaying); @@ -162,7 +173,10 @@ private bool ShouldBlockPlayForCompileErrors(PlayModeAction action, bool isPlayi _compilationFailureGate.HasScriptCompilationFailed(); } - private ControlPlayModeActionResult ExecutePlayModeStart(bool wasPaused, bool wasPlaying) + private ControlPlayModeActionResult ExecutePlayModeStart( + bool wasPaused, + bool wasPlaying, + ControlPlayModeUnsavedChangesMode unsavedChangesMode) { // Captured before this method mutates editor state, so the warning reflects the // request-start snapshot the same way CompileUseCase does. @@ -194,13 +208,17 @@ private ControlPlayModeActionResult ExecutePlayModeStart(bool wasPaused, bool wa // Why only when entering Play from Edit: SaveScene does not work while already playing, // and resume-from-pause must not rewrite Scene assets. + string savedChangesWarning = string.Empty; if (!wasPlaying) { - ControlPlayModeActionResult saveResult = SaveDirtyEditorChangesBeforePlayStart(); - if (saveResult.HasResponse) + ControlPlayModeActionResult unsavedChangesResult = + HandleUnsavedEditorChangesBeforePlayStart(unsavedChangesMode); + if (unsavedChangesResult.HasResponse) { - return saveResult; + return unsavedChangesResult; } + + savedChangesWarning = unsavedChangesResult.Warning; } if (wasPaused) @@ -220,7 +238,10 @@ private ControlPlayModeActionResult ExecutePlayModeStart(bool wasPaused, bool wa bool resumedFromPause = wasPaused && wasPlaying; string message = wasPaused ? "Play mode resumed" : "Play mode started"; string warning = JoinWarnings( - wasPlaying ? string.Empty : FreshPlayStartFromNewSessionWarning, + savedChangesWarning, + wasPlaying ? string.Empty : FreshPlayStartFromNewSessionWarning); + warning = JoinWarnings( + warning, PlayModeStartDomainReloadDropWarningBuilder.BuildWarning( wasPlaying, isDomainReloadDisabledOnEnterPlayMode, @@ -245,14 +266,57 @@ private static string JoinWarnings(string first, string second) return first + " " + second; } + // Why keep is the default: Unity itself enters Play Mode with dirty Scenes kept in memory and + // restores them afterwards, and it shows no save prompt for them, so saving only rewrites + // Scene files (and can overwrite a file that git changed on disk) without preventing a dialog. + private ControlPlayModeActionResult HandleUnsavedEditorChangesBeforePlayStart( + ControlPlayModeUnsavedChangesMode unsavedChangesMode) + { + switch (unsavedChangesMode) + { + case ControlPlayModeUnsavedChangesMode.keep: + return ControlPlayModeActionResult.FromState(string.Empty, false, false); + case ControlPlayModeUnsavedChangesMode.save: + return SaveDirtyEditorChangesBeforePlayStart(); + case ControlPlayModeUnsavedChangesMode.fail: + return FailOnUnsavedEditorChangesBeforePlayStart(); + default: + throw new ArgumentOutOfRangeException( + nameof(unsavedChangesMode), + unsavedChangesMode, + "Unknown unsaved changes mode"); + } + } + + private ControlPlayModeActionResult FailOnUnsavedEditorChangesBeforePlayStart() + { + string[] unsavedChanges = _unsavedChangesQuietSaver.DetectUnsavedEditorChanges(); + Debug.Assert(unsavedChanges != null, "Unsaved editor change detection must return an array"); + if (unsavedChanges.Length > 0) + { + return ControlPlayModeActionResult.FromResponse( + CreateUnsavedChangesBlockedResponse(UnsavedEditorChangesFailModeMessage, unsavedChanges), + false); + } + + return ControlPlayModeActionResult.FromState(string.Empty, false, false); + } + private ControlPlayModeActionResult SaveDirtyEditorChangesBeforePlayStart() { + string[] unsavedChanges = _unsavedChangesQuietSaver.DetectUnsavedEditorChanges(); + Debug.Assert(unsavedChanges != null, "Unsaved editor change detection must return an array"); + if (unsavedChanges.Length == 0) + { + return ControlPlayModeActionResult.FromState(string.Empty, false, false); + } + string[] failedChanges = _unsavedChangesQuietSaver.SaveUnsavedEditorChanges(); Debug.Assert(failedChanges != null, "Unsaved editor change save must return an array"); if (failedChanges.Length > 0) { return ControlPlayModeActionResult.FromResponse( - CreateSaveFailedResponse(UnsavedEditorChangesSaveFailureMessage, failedChanges), + CreateUnsavedChangesBlockedResponse(UnsavedEditorChangesSaveFailureMessage, failedChanges), false); } @@ -261,11 +325,14 @@ private ControlPlayModeActionResult SaveDirtyEditorChangesBeforePlayStart() if (remainingChanges.Length > 0) { return ControlPlayModeActionResult.FromResponse( - CreateSaveFailedResponse(UnsavedEditorChangesRemainingFailureMessage, remainingChanges), + CreateUnsavedChangesBlockedResponse(UnsavedEditorChangesRemainingFailureMessage, remainingChanges), false); } - return ControlPlayModeActionResult.FromState(string.Empty, false, false); + // Why a warning: callers must notice that Scene files on disk were rewritten. + string savedChangesWarning = + SavedUnsavedChangesWarningPrefix + " " + string.Join(", ", unsavedChanges) + "."; + return ControlPlayModeActionResult.FromState(string.Empty, false, false, warning: savedChangesWarning); } private ControlPlayModeActionResult ExecutePlayModeStop(bool wasPaused, bool wasPlaying) @@ -333,7 +400,7 @@ private ControlPlayModeResponse CreateResponse( return response; } - private ControlPlayModeResponse CreateSaveFailedResponse(string messagePrefix, string[] failedChanges) + private ControlPlayModeResponse CreateUnsavedChangesBlockedResponse(string messagePrefix, string[] failedChanges) { Debug.Assert(!string.IsNullOrEmpty(messagePrefix), "messagePrefix must not be null or empty"); Debug.Assert(failedChanges != null, "failedChanges must not be null"); diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md index b3d9b254e2..9aa6b08a2e 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md @@ -20,6 +20,7 @@ uloop control-play-mode [options] |-----------|------|---------|-------------| | `--action` | string | `Play` | `Play` - start Play Mode, `Stop` - stop Play Mode, `Pause` - pause Play Mode, `Step` - advance one frame while paused, `Status` - report current state without changing anything, `Resume` - alias of Play in every state, including starting Play Mode when stopped | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | +| `--unsaved-changes` | string | `keep` | How `Play` from Edit Mode handles unsaved Scene/Prefab Stage changes: `keep` enters Play Mode without saving (like the Editor's Play button); `save` writes them first; `fail` stops if any exist | ## Output @@ -31,7 +32,7 @@ Returns JSON with the current play mode state: - `WasAlreadyStopped`: Whether `Stop` was requested while Play Mode was already stopped - `ResumedFromPause`: Whether `Play` resumed a paused Play Mode session instead of starting a new one - `Message`: Description of the action performed -- `Warning` (string, optional): Set when the action carries a caveat. A fresh `Play` start always notes that the session started from Edit-time scene state; additionally, when active hot-reload patches or enabled pause points exist and Domain Reload is enabled, it reports how many of them the Play-entry domain reload will discard. Pause points enabled with `--persist` are counted separately: they are re-armed after the reload, and the warning points at `pause-point-status` for the re-arm result. `Status` also reports when Play Mode is running while the Unity Editor is unfocused, because progress may be throttled; run `uloop focus-window`, or use the `pause-point --await`/`--trigger` flow instead of polling for progress. +- `Warning` (string, optional): Set when the action carries a caveat. With `--unsaved-changes save` it starts by listing every Scene and Prefab Stage it saved. A fresh `Play` start always notes that the session started from Edit-time scene state; additionally, when active hot-reload patches or enabled pause points exist and Domain Reload is enabled, it reports how many of them the Play-entry domain reload will discard. Pause points enabled with `--persist` are counted separately: they are re-armed after the reload, and the warning points at `pause-point-status` for the re-arm result. `Status` also reports when Play Mode is running while the Unity Editor is unfocused, because progress may be throttled; run `uloop focus-window`, or use the `pause-point --await`/`--trigger` flow instead of polling for progress. - `StoppedBy` (string, optional): Why Play Mode last stopped: `cli-control-play-mode`, `cli-compile-stop-setting`, `cli-run-tests-cancel`, `script-compilation`, or `unknown`. Present on `Stop` when Play Mode was already stopped, and on `Status` when Play Mode is not running. Omitted when this Editor session has no confirmed stop. - `StoppedAt` (string, optional): UTC ISO 8601 timestamp of that stop. Omitted together with `StoppedBy` when no stop is recorded. - `ActiveScenario` (string, optional): Name of the active non-default Play Mode configuration (for example a Multiplayer Play Mode scenario). Omitted when the default configuration is active. @@ -45,7 +46,8 @@ Returns JSON with the current play mode state: - `Step` advances exactly one frame and leaves PlayMode paused (the Editor's Next Frame button); it is independent of `Time.timeScale` and requires PlayMode to be running - The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. - Before relying on PlayMode behavior as verification evidence, check `uloop get-logs --log-type Error` for pre-existing errors. An error already present when PlayMode starts can otherwise be mistaken for one caused by the action under test. -- `Status` reads the current state with no side effects: `Changed` is always `false`, no waiting, no scene saving, and it is never rejected by compile errors. It reports whether compile errors would currently block `Play` (`BlockedByCompileErrors` with the `CompileErrors` list), read from the last compile result without triggering a new compile. It does not predict unsaved-changes blocking: `BlockedByUnsavedChanges` describes a failed save attempt during a `Play` request, and `Status` never attempts one. -- `Play` fails immediately with a `CONTROL_PLAY_MODE_UNSAVED_CHANGES` error when unsaved changes cannot be saved quietly — most commonly an Untitled scene, which has no path to save to. The error message lists exactly which scenes or prefab stages blocked it; save the Untitled scene to an explicit path (or discard the changes), then retry. +- `Status` reads the current state with no side effects: `Changed` is always `false`, no waiting, no scene saving, and it is never rejected by compile errors. It reports whether compile errors would currently block `Play` (`BlockedByCompileErrors` with the `CompileErrors` list), read from the last compile result without triggering a new compile. It does not predict unsaved-changes blocking: `BlockedByUnsavedChanges` describes a `Play` request that `--unsaved-changes` stopped, and `Status` never checks. +- `--unsaved-changes` applies only when `Play` enters Play Mode from Edit Mode; resuming a paused session never saves or checks. With the default `keep`, unsaved edits stay in memory: Unity restores them when Play Mode ends, and Scene files on disk are not rewritten. +- `Play` fails immediately with a `CONTROL_PLAY_MODE_UNSAVED_CHANGES` error when `fail` finds unsaved changes, or when `save` cannot save them — most commonly an Untitled scene, which has no path to save to. The error message lists exactly which scenes or prefab stages blocked it; use `keep`, or save the Untitled scene to an explicit path (or discard the changes), then retry. - `Play` and `Stop` follow the active Play Mode configuration like the Editor's Play button: with a Multiplayer Play Mode scenario selected they start and stop its Virtual Players too. To find and command Virtual Players, read [references/multiplayer-play-mode.md](references/multiplayer-play-mode.md). - `Play` from Edit mode triggers a domain reload (unless Enter Play Mode Options disable it), which discards all active hot-reload patches and every enabled pause point; pause points enabled with `--persist` are then re-armed from their saved enable request. The response `Warning` reports the counts being dropped and, separately, how many re-arm. Edits that were only hot-reloaded are not part of the compiled assemblies, so the new session runs the last compiled code — run `uloop compile` before `Play` to keep them, or re-apply `uloop hot-reload` after Play Mode starts. diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/references/multiplayer-play-mode.md b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/references/multiplayer-play-mode.md index 6a6c3cfbe6..df26774fe1 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/references/multiplayer-play-mode.md +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/references/multiplayer-play-mode.md @@ -59,5 +59,6 @@ main Editor) do not block each other. "Play Mode Scenario - Scenario Setup Error". The CLI cannot see that failure and waits for Play Mode until `--timeout-seconds` expires. Close the dialog in the Editor and read the Console errors (`uloop get-logs --log-type Error`). -- Unsaved scenes are saved quietly before `Play`, as with the default configuration. An Untitled - scene still fails with `CONTROL_PLAY_MODE_UNSAVED_CHANGES`. +- `--unsaved-changes` applies as with the default configuration: `keep` (the default) starts + without saving, `save` writes unsaved scenes first (an Untitled scene then fails with + `CONTROL_PLAY_MODE_UNSAVED_CHANGES`), and `fail` stops if any exist. diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index fb82f7d568..ee484b8c36 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -359,6 +359,16 @@ "type": "integer", "description": "Maximum seconds to wait for the requested play mode state", "default": 180 + }, + "UnsavedChanges": { + "type": "string", + "description": "How Play from Edit Mode handles unsaved Scene/Prefab Stage changes: keep enters Play Mode without saving (like the Editor's Play button); save writes them first; fail stops if any exist", + "enum": [ + "keep", + "save", + "fail" + ], + "default": "keep" } } } diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 67c9fafe4c..aa279c7af9 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "f8f473a6fc034790868126d935b74c41693c74c9" + "sharedInputsHash": "57cbaca4732ff50e08d604fa20f7824741704e52" } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 3e74dfbaa9..30bd076238 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "9b720220b5e69a0892a157b2841984da5bb392cd" + "sharedInputsHash": "31e0608d9f17d814152f72d21305bf0cb8ad72c6" } diff --git a/docs/focus-return-asset-handling.md b/docs/focus-return-asset-handling.md index 2a6d6d97bb..6759cc0164 100644 --- a/docs/focus-return-asset-handling.md +++ b/docs/focus-return-asset-handling.md @@ -95,6 +95,7 @@ the hold is active. The compile preflight imports those changed Scene assets synchronously before reloading them. Reloading first leaves the loaded Scene tied to the stale import, so the following `AssetDatabase.Refresh` raises Unity's "modified externally" dialog. -- `uloop run-tests` and `uloop control-play-mode` save unsaved Scene and Prefab Stage changes - before starting by default; that is an explicit, documented step in those tools, not part of - focus return. +- `uloop run-tests` saves unsaved Scene and Prefab Stage changes before starting by default, + and `uloop control-play-mode --action Play --unsaved-changes save` does so on request (its + default `keep` saves nothing); that is an explicit, documented step in those tools, not part + of focus return. From 4d0e4b7396c4487d2f11b8086ef7a6eaa228ac01 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 30 Sep 2026 19:34:56 +0900 Subject: [PATCH 2/2] Name already-saved items when a save-mode Play is blocked With --unsaved-changes save, a partial save (a named Scene saved while an Untitled one fails) or a Scene that is dirty again after saving leaves rewritten files on disk, yet the blocked response listed only the failed or remaining items. The success path already names every saved item, so the blocked path now appends "Already saved: ..." to Message, the only field the CLI error envelope carries. --- .../Editor/ControlPlayModeUseCaseTests.cs | 12 +++++-- .../ControlPlayMode/ControlPlayModeUseCase.cs | 36 ++++++++++++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs index 69165e11b3..27af15bb18 100644 --- a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs +++ b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs @@ -474,7 +474,11 @@ public async Task ExecuteAsync_WhenPlayStartSaveFails_DoesNotEnterPlayMode() StubEditorUnsavedChangesQuietSaver quietSaver = new( saveFailures: new[] { "Scene: Assets/Scenes/Sample.unity" }, remainingAfterSave: System.Array.Empty(), - unsavedBeforeSave: new[] { "Scene: Assets/Scenes/Sample.unity" }); + unsavedBeforeSave: new[] + { + "Scene: Assets/Scenes/Sample.unity", + "Scene: Assets/Scenes/Other.unity" + }); ControlPlayModeUseCase useCase = new ControlPlayModeUseCase( new StubCompilationFailureProvider(System.Array.Empty()), new StubCompilationFailureGate(false), @@ -493,7 +497,9 @@ public async Task ExecuteAsync_WhenPlayStartSaveFails_DoesNotEnterPlayMode() Assert.That(response.IsPlaying, Is.False); Assert.That(response.BlockedByUnsavedChanges, Is.True); Assert.That(response.Message, Does.Contain("could not be saved")); - Assert.That(response.Message, Does.Contain("Scene: Assets/Scenes/Sample.unity")); + Assert.That(response.Message, Does.Contain("Unsaved changes: Scene: Assets/Scenes/Sample.unity.")); + // The other Scene was written to disk before the failure, so the caller must still learn about it. + Assert.That(response.Message, Does.EndWith("Already saved: Scene: Assets/Scenes/Other.unity.")); } [Test] @@ -523,6 +529,8 @@ public async Task ExecuteAsync_WhenPlayStartLeavesUnsavedChanges_DoesNotEnterPla Assert.That(response.BlockedByUnsavedChanges, Is.True); Assert.That(response.Message, Does.Contain("unsaved scene or prefab changes")); Assert.That(response.Message, Does.Contain("Prefab Stage: Assets/Prefabs/Hud.prefab")); + // The save itself succeeded, so the file on disk was rewritten even though Play did not start. + Assert.That(response.Message, Does.EndWith("Already saved: Prefab Stage: Assets/Prefabs/Hud.prefab.")); } [Test] diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs index 6c62572471..126395c8c3 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; using UnityEngine; @@ -295,7 +296,10 @@ private ControlPlayModeActionResult FailOnUnsavedEditorChangesBeforePlayStart() if (unsavedChanges.Length > 0) { return ControlPlayModeActionResult.FromResponse( - CreateUnsavedChangesBlockedResponse(UnsavedEditorChangesFailModeMessage, unsavedChanges), + CreateUnsavedChangesBlockedResponse( + UnsavedEditorChangesFailModeMessage, + unsavedChanges, + Array.Empty()), false); } @@ -316,7 +320,10 @@ private ControlPlayModeActionResult SaveDirtyEditorChangesBeforePlayStart() if (failedChanges.Length > 0) { return ControlPlayModeActionResult.FromResponse( - CreateUnsavedChangesBlockedResponse(UnsavedEditorChangesSaveFailureMessage, failedChanges), + CreateUnsavedChangesBlockedResponse( + UnsavedEditorChangesSaveFailureMessage, + failedChanges, + ExcludeChanges(unsavedChanges, failedChanges)), false); } @@ -325,7 +332,10 @@ private ControlPlayModeActionResult SaveDirtyEditorChangesBeforePlayStart() if (remainingChanges.Length > 0) { return ControlPlayModeActionResult.FromResponse( - CreateUnsavedChangesBlockedResponse(UnsavedEditorChangesRemainingFailureMessage, remainingChanges), + CreateUnsavedChangesBlockedResponse( + UnsavedEditorChangesRemainingFailureMessage, + remainingChanges, + unsavedChanges), false); } @@ -400,13 +410,29 @@ private ControlPlayModeResponse CreateResponse( return response; } - private ControlPlayModeResponse CreateUnsavedChangesBlockedResponse(string messagePrefix, string[] failedChanges) + private static string[] ExcludeChanges(string[] changes, string[] excludedChanges) + { + return changes.Where(change => !excludedChanges.Contains(change)).ToArray(); + } + + // Why savedChanges on a blocked response: a partial save has already rewritten those files, + // and the CLI error envelope carries only Message, so the list has to live there. + private ControlPlayModeResponse CreateUnsavedChangesBlockedResponse( + string messagePrefix, + string[] failedChanges, + string[] savedChanges) { Debug.Assert(!string.IsNullOrEmpty(messagePrefix), "messagePrefix must not be null or empty"); Debug.Assert(failedChanges != null, "failedChanges must not be null"); Debug.Assert(failedChanges.Length > 0, "failedChanges must not be empty"); + Debug.Assert(savedChanges != null, "savedChanges must not be null"); + + string message = messagePrefix + " Unsaved changes: " + string.Join(", ", failedChanges) + "."; + if (savedChanges.Length > 0) + { + message += " Already saved: " + string.Join(", ", savedChanges) + "."; + } - string message = messagePrefix + " Unsaved changes: " + string.Join(", ", failedChanges); ControlPlayModeResponse response = CreateResponse(message, false, false); response.BlockedByUnsavedChanges = true; return response;