diff --git a/.agents/skills/uloop-control-play-mode/SKILL.md b/.agents/skills/uloop-control-play-mode/SKILL.md index b3d9b254e..9aa6b08a2 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 6a6c3cfbe..df26774fe 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 b3d9b254e..9aa6b08a2 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 6a6c3cfbe..df26774fe 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 abe1fc4bc..27af15bb1 100644 --- a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs +++ b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs @@ -473,7 +473,12 @@ 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", + "Scene: Assets/Scenes/Other.unity" + }); ControlPlayModeUseCase useCase = new ControlPlayModeUseCase( new StubCompilationFailureProvider(System.Array.Empty()), new StubCompilationFailureGate(false), @@ -481,6 +486,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); @@ -491,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] @@ -509,17 +517,181 @@ 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); 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] + 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)); } /// @@ -1021,6 +1193,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 +1317,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 b33ae64e6..9ee36d23b 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 83d9e24c7..126395c8c 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; @@ -12,10 +13,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 +81,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 +109,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 +121,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 +174,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 +209,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 +239,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 +267,63 @@ 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, + Array.Empty()), + 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, + ExcludeChanges(unsavedChanges, failedChanges)), false); } @@ -261,11 +332,17 @@ private ControlPlayModeActionResult SaveDirtyEditorChangesBeforePlayStart() if (remainingChanges.Length > 0) { return ControlPlayModeActionResult.FromResponse( - CreateSaveFailedResponse(UnsavedEditorChangesRemainingFailureMessage, remainingChanges), + CreateUnsavedChangesBlockedResponse( + UnsavedEditorChangesRemainingFailureMessage, + remainingChanges, + unsavedChanges), 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,13 +410,29 @@ private ControlPlayModeResponse CreateResponse( return response; } - private ControlPlayModeResponse CreateSaveFailedResponse(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; diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md index b3d9b254e..9aa6b08a2 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 6a6c3cfbe..df26774fe 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 fb82f7d56..ee484b8c3 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 67c9fafe4..aa279c7af 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 3e74dfbaa..30bd07623 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 2a6d6d97b..6759cc016 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.