diff --git a/.agents/skills/uloop-screenshot/SKILL.md b/.agents/skills/uloop-screenshot/SKILL.md index 3c14efa5a..8db19912a 100644 --- a/.agents/skills/uloop-screenshot/SKILL.md +++ b/.agents/skills/uloop-screenshot/SKILL.md @@ -18,15 +18,15 @@ uloop screenshot [--window-name ] [--resolution-scale ] [--match-mo | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--window-name` | string | `Game` | Window name to capture (for example `Game`, `Scene`, `Console`, `Inspector`). Ignored when `--capture-mode rendering`. When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator. | +| `--window-name` | string | `Game` | Window name to capture (for example `Game`, `Scene`, `Console`, `Inspector`). Ignored when the resolved capture mode is rendering (auto during Play Mode resolves to rendering). When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator. | | `--resolution-scale` | number | `1.0` | Resolution scale (0.1 to 1.0) | -| `--match-mode` | enum | `exact` | Window name matching mode: `exact`, `prefix`, or `contains`. Ignored when `--capture-mode rendering`. | -| `--capture-mode` | enum | `window` | `window` - capture EditorWindow including toolbar, `rendering` - capture game rendering only (PlayMode required), `GameView` - alias for `rendering`. Rendering screenshots return `ScreenshotToInputFormula` for converting raw image pixels before calling simulate-mouse-input (including `--dry-run`) or simulate-mouse-ui. | +| `--match-mode` | enum | `exact` | Window name matching mode: `exact`, `prefix`, or `contains`. Ignored when the resolved capture mode is rendering (auto during Play Mode resolves to rendering). | +| `--capture-mode` | enum | `auto` | `auto` - rendering in PlayMode and window otherwise, `window` - capture EditorWindow including toolbar, `rendering` - capture game rendering only (PlayMode required), `GameView` - alias for `rendering`. Rendering screenshots return `ScreenshotToInputFormula` for converting raw image pixels before calling simulate-mouse-input (including `--dry-run`) or simulate-mouse-ui. | | `--output-directory` | string | `""` | Output directory path for saving screenshots. When empty, uses default path (.uloop/outputs/Screenshots/). Accepts absolute paths. | -| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). The response includes an `AnnotatedElements` array with element metadata sorted by z-order. Only works with `--capture-mode rendering` in PlayMode. | -| `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works with `--capture-mode rendering` in PlayMode. | +| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). The response includes an `AnnotatedElements` array with element metadata sorted by z-order. Only works when the resolved capture mode is rendering (auto during Play Mode resolves to rendering) in PlayMode. | +| `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works when the resolved capture mode is rendering (auto during Play Mode resolves to rendering) in PlayMode. | | `--raycast-layer-mask` | string | `""` | Comma-separated physics layer names to narrow which layers `--annotate-raycast-grid` clusters. Hits are limited to layers also visible to `Camera.main.cullingMask`. When omitted, clusters against `Physics.DefaultRaycastLayers`. | -| `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and `--capture-mode rendering` in PlayMode. | +| `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and the resolved capture mode to be rendering (auto during Play Mode resolves to rendering) in PlayMode. | ## Match Modes @@ -51,6 +51,7 @@ The window name is the text displayed in the window's title bar (tab). Common na Returns JSON with: - `ScreenshotCount`: Number of windows captured +- `ResolvedCaptureMode`: `"window"` or `"rendering"` — the mode actually used after resolving `auto` - `Warning`: Appears only for window captures taken while Play Mode is running with at least one image. - `Screenshots`: Array of screenshot info, each containing: - `ImagePath`: Absolute path to the saved PNG file. Empty when `--elements-only` is used because no image file is written. Always open the file named here — the output directory accumulates every past capture, so guessing the newest file with directory listing (`ls -t` or similar) can silently pick a stale screenshot from an earlier run. diff --git a/.claude/skills/uloop-screenshot/SKILL.md b/.claude/skills/uloop-screenshot/SKILL.md index 3c14efa5a..8db19912a 100644 --- a/.claude/skills/uloop-screenshot/SKILL.md +++ b/.claude/skills/uloop-screenshot/SKILL.md @@ -18,15 +18,15 @@ uloop screenshot [--window-name ] [--resolution-scale ] [--match-mo | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--window-name` | string | `Game` | Window name to capture (for example `Game`, `Scene`, `Console`, `Inspector`). Ignored when `--capture-mode rendering`. When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator. | +| `--window-name` | string | `Game` | Window name to capture (for example `Game`, `Scene`, `Console`, `Inspector`). Ignored when the resolved capture mode is rendering (auto during Play Mode resolves to rendering). When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator. | | `--resolution-scale` | number | `1.0` | Resolution scale (0.1 to 1.0) | -| `--match-mode` | enum | `exact` | Window name matching mode: `exact`, `prefix`, or `contains`. Ignored when `--capture-mode rendering`. | -| `--capture-mode` | enum | `window` | `window` - capture EditorWindow including toolbar, `rendering` - capture game rendering only (PlayMode required), `GameView` - alias for `rendering`. Rendering screenshots return `ScreenshotToInputFormula` for converting raw image pixels before calling simulate-mouse-input (including `--dry-run`) or simulate-mouse-ui. | +| `--match-mode` | enum | `exact` | Window name matching mode: `exact`, `prefix`, or `contains`. Ignored when the resolved capture mode is rendering (auto during Play Mode resolves to rendering). | +| `--capture-mode` | enum | `auto` | `auto` - rendering in PlayMode and window otherwise, `window` - capture EditorWindow including toolbar, `rendering` - capture game rendering only (PlayMode required), `GameView` - alias for `rendering`. Rendering screenshots return `ScreenshotToInputFormula` for converting raw image pixels before calling simulate-mouse-input (including `--dry-run`) or simulate-mouse-ui. | | `--output-directory` | string | `""` | Output directory path for saving screenshots. When empty, uses default path (.uloop/outputs/Screenshots/). Accepts absolute paths. | -| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). The response includes an `AnnotatedElements` array with element metadata sorted by z-order. Only works with `--capture-mode rendering` in PlayMode. | -| `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works with `--capture-mode rendering` in PlayMode. | +| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). The response includes an `AnnotatedElements` array with element metadata sorted by z-order. Only works when the resolved capture mode is rendering (auto during Play Mode resolves to rendering) in PlayMode. | +| `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works when the resolved capture mode is rendering (auto during Play Mode resolves to rendering) in PlayMode. | | `--raycast-layer-mask` | string | `""` | Comma-separated physics layer names to narrow which layers `--annotate-raycast-grid` clusters. Hits are limited to layers also visible to `Camera.main.cullingMask`. When omitted, clusters against `Physics.DefaultRaycastLayers`. | -| `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and `--capture-mode rendering` in PlayMode. | +| `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and the resolved capture mode to be rendering (auto during Play Mode resolves to rendering) in PlayMode. | ## Match Modes @@ -51,6 +51,7 @@ The window name is the text displayed in the window's title bar (tab). Common na Returns JSON with: - `ScreenshotCount`: Number of windows captured +- `ResolvedCaptureMode`: `"window"` or `"rendering"` — the mode actually used after resolving `auto` - `Warning`: Appears only for window captures taken while Play Mode is running with at least one image. - `Screenshots`: Array of screenshot info, each containing: - `ImagePath`: Absolute path to the saved PNG file. Empty when `--elements-only` is used because no image file is written. Always open the file named here — the output directory accumulates every past capture, so guessing the newest file with directory listing (`ls -t` or similar) can silently pick a stale screenshot from an earlier run. diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/FirstPartyToolSchemaMetadataTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/FirstPartyToolSchemaMetadataTests.cs index 682c345d4..921a0d41d 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/FirstPartyToolSchemaMetadataTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/FirstPartyToolSchemaMetadataTests.cs @@ -48,10 +48,10 @@ public void FirstPartySchemaEnumProperties_WhenLoaded_ShouldBeZeroBasedAndContig // Tests that every enum a first-party schema exposes can be resolved by its ordinal. // The schema cache stores an enum default as a number while listing the members by name, // so the CLI recovers the name shown in `--help` / `uloop list` by indexing that name - // list (Go enumValueAtIndex). Same-value aliases are allowed only after the canonical - // name: names[ordinal] must be the first declaration of that value (MetadataToken - // order). Gaps, negative values, and a [Flags] enum would make the CLI print the wrong - // default member name. + // list (Go enumValueAtIndex). Enum.GetNames sorts by value, so a same-value alias + // occupies the next index and displaces later canonical names. Members must therefore + // have distinct values. Gaps, negative values, and a [Flags] enum would make the CLI + // print the wrong default member name. Type[] schemaTypes = FirstPartySchemaTypes(); Assert.That(schemaTypes, Is.Not.Empty); @@ -80,8 +80,8 @@ public void FirstPartySchemaEnumProperties_WhenLoaded_ShouldBeZeroBasedAndContig $"{location} is a [Flags] enum, which cannot be resolved by ordinal"); // Why MetadataToken order: GetFields does not guarantee declaration order, and - // the CLI indexes Enum.GetNames by the numeric default. The first same-value - // member in declaration order is the canonical name that must occupy that index. + // the CLI indexes Enum.GetNames by the numeric default. Distinct values keep + // names[ordinal] equal to the only member declared at that value. FieldInfo[] memberFields = propertyType.GetFields( BindingFlags.Public | BindingFlags.Static) .OrderBy(field => field.MetadataToken) @@ -98,11 +98,12 @@ public void FirstPartySchemaEnumProperties_WhenLoaded_ShouldBeZeroBasedAndContig value, Is.GreaterThanOrEqualTo(0), $"{location} has a negative member value {value}"); - distinctValues.Add(value); - if (!canonicalNamesByValue.ContainsKey(value)) - { - canonicalNamesByValue[value] = memberField.Name; - } + bool added = distinctValues.Add(value); + Assert.That( + added, + Is.True, + $"{location} reuses value {value} for '{memberField.Name}'; same-value aliases displace later names in Enum.GetNames"); + canonicalNamesByValue[value] = memberField.Name; if (value > maxValue) { @@ -110,6 +111,10 @@ public void FirstPartySchemaEnumProperties_WhenLoaded_ShouldBeZeroBasedAndContig } } + Assert.That( + distinctValues.Count, + Is.EqualTo(memberFields.Length), + $"{location} has same-value aliases"); Assert.That( distinctValues.Count, Is.EqualTo(maxValue + 1), @@ -140,9 +145,17 @@ public void FirstPartySchemaEnumProperties_WhenLoaded_ShouldBeZeroBasedAndContig private static Type[] FirstPartySchemaTypes() { return TypeCache.GetTypesDerivedFrom() - .Where(type => type.Assembly.GetName().Name.StartsWith( - "UnityCLILoop.FirstPartyTools", - StringComparison.Ordinal)) + .Where(type => + { + string assemblyName = type.Assembly.GetName().Name; + return assemblyName.StartsWith( + "UnityCLILoop.FirstPartyTools", + StringComparison.Ordinal) + || string.Equals( + assemblyName, + "UnityCLILoop.ToolContracts", + StringComparison.Ordinal); + }) .ToArray(); } diff --git a/Assets/Tests/Editor/ScreenshotCaptureModeResolverTests.cs b/Assets/Tests/Editor/ScreenshotCaptureModeResolverTests.cs new file mode 100644 index 000000000..12e0d735c --- /dev/null +++ b/Assets/Tests/Editor/ScreenshotCaptureModeResolverTests.cs @@ -0,0 +1,109 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies CaptureMode.auto resolution and wire names for explicit and omitted modes. + /// + public sealed class ScreenshotCaptureModeResolverTests + { + /// + /// What: omitted auto in Play Mode resolves to rendering. + /// + [Test] + public void Resolve_WhenAutoAndPlaying_ReturnsRendering() + { + CaptureMode resolved = ScreenshotCaptureModeResolver.Resolve(CaptureMode.auto, true); + + Assert.That(resolved, Is.EqualTo(CaptureMode.rendering)); + Assert.That(ScreenshotCaptureModeResolver.ToWireName(resolved), Is.EqualTo("rendering")); + } + + /// + /// What: omitted auto in Edit Mode resolves to window. + /// + [Test] + public void Resolve_WhenAutoAndNotPlaying_ReturnsWindow() + { + CaptureMode resolved = ScreenshotCaptureModeResolver.Resolve(CaptureMode.auto, false); + + Assert.That(resolved, Is.EqualTo(CaptureMode.window)); + Assert.That(ScreenshotCaptureModeResolver.ToWireName(resolved), Is.EqualTo("window")); + } + + /// + /// What: explicit window stays window while Play Mode is running. + /// + [Test] + public void Resolve_WhenWindowAndPlaying_ReturnsWindow() + { + CaptureMode resolved = ScreenshotCaptureModeResolver.Resolve(CaptureMode.window, true); + + Assert.That(resolved, Is.EqualTo(CaptureMode.window)); + Assert.That(ScreenshotCaptureModeResolver.ToWireName(resolved), Is.EqualTo("window")); + } + + /// + /// What: explicit window stays window while Play Mode is stopped. + /// + [Test] + public void Resolve_WhenWindowAndNotPlaying_ReturnsWindow() + { + CaptureMode resolved = ScreenshotCaptureModeResolver.Resolve(CaptureMode.window, false); + + Assert.That(resolved, Is.EqualTo(CaptureMode.window)); + Assert.That(ScreenshotCaptureModeResolver.ToWireName(resolved), Is.EqualTo("window")); + } + + /// + /// What: explicit rendering stays rendering while Play Mode is running. + /// + [Test] + public void Resolve_WhenRenderingAndPlaying_ReturnsRendering() + { + CaptureMode resolved = ScreenshotCaptureModeResolver.Resolve(CaptureMode.rendering, true); + + Assert.That(resolved, Is.EqualTo(CaptureMode.rendering)); + Assert.That(ScreenshotCaptureModeResolver.ToWireName(resolved), Is.EqualTo("rendering")); + } + + /// + /// What: explicit rendering stays rendering while Play Mode is stopped. + /// + [Test] + public void Resolve_WhenRenderingAndNotPlaying_ReturnsRendering() + { + CaptureMode resolved = ScreenshotCaptureModeResolver.Resolve(CaptureMode.rendering, false); + + Assert.That(resolved, Is.EqualTo(CaptureMode.rendering)); + Assert.That(ScreenshotCaptureModeResolver.ToWireName(resolved), Is.EqualTo("rendering")); + } + + /// + /// What: explicit GameView is a rendering request even while Play Mode is running. + /// + [Test] + public void Resolve_WhenGameViewAndPlaying_ReturnsRendering() + { + CaptureMode resolved = ScreenshotCaptureModeResolver.Resolve(CaptureMode.GameView, true); + + Assert.That(resolved, Is.EqualTo(CaptureMode.rendering)); + Assert.That(ScreenshotCaptureModeResolver.ToWireName(resolved), Is.EqualTo("rendering")); + } + + /// + /// What: explicit GameView is a rendering request even while Play Mode is stopped. + /// + [Test] + public void Resolve_WhenGameViewAndNotPlaying_ReturnsRendering() + { + CaptureMode resolved = ScreenshotCaptureModeResolver.Resolve(CaptureMode.GameView, false); + + Assert.That(resolved, Is.EqualTo(CaptureMode.rendering)); + Assert.That(ScreenshotCaptureModeResolver.ToWireName(resolved), Is.EqualTo("rendering")); + } + } +} diff --git a/Assets/Tests/Editor/ScreenshotCaptureModeResolverTests.cs.meta b/Assets/Tests/Editor/ScreenshotCaptureModeResolverTests.cs.meta new file mode 100644 index 000000000..d22c06703 --- /dev/null +++ b/Assets/Tests/Editor/ScreenshotCaptureModeResolverTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 88469826f1888435da07a30387c570be +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/ScreenshotResponseWarningContractTests.cs b/Assets/Tests/Editor/ScreenshotResponseWarningContractTests.cs index 357108eb5..d55b5a029 100644 --- a/Assets/Tests/Editor/ScreenshotResponseWarningContractTests.cs +++ b/Assets/Tests/Editor/ScreenshotResponseWarningContractTests.cs @@ -57,5 +57,25 @@ public void ScreenshotResponse_WhenWarningIsSet_SerializesExactChromeWarningSent Is.EqualTo( "This window capture includes Unity Editor chrome. If you wanted the Game View image (typical during Play Mode), re-run with --capture-mode rendering.")); } + + /// + /// What: ResolvedCaptureMode is always present on the wire as window or rendering. + /// + [Test] + public void ScreenshotResponse_WhenResolvedCaptureModeIsSet_SerializesExactWireName() + { + ScreenshotResponse response = new ScreenshotResponse + { + ResolvedCaptureMode = "rendering" + }; + + string json = JsonConvert.SerializeObject( + response, + Formatting.None, + JsonRpcResponseSerializer.Settings); + JObject parsed = JObject.Parse(json); + + Assert.That(parsed.Value("ResolvedCaptureMode"), Is.EqualTo("rendering")); + } } } diff --git a/Assets/Tests/Editor/ScreenshotUseCaseTests.cs b/Assets/Tests/Editor/ScreenshotUseCaseTests.cs index 5e7aaab09..734105ce4 100644 --- a/Assets/Tests/Editor/ScreenshotUseCaseTests.cs +++ b/Assets/Tests/Editor/ScreenshotUseCaseTests.cs @@ -85,10 +85,105 @@ public void ExecuteAsync_WhenRaycastLayerMaskContainsUnknownLayer_ShouldThrowVal Assert.That(exception!.Message, Does.Contain("MissingLayerForTest")); } + /// + /// What: omitted CaptureMode deserializes to auto so Play Mode can resolve it to rendering. + /// + [Test] + public void ConvertToSchema_WhenCaptureModeIsOmitted_DefaultsToAuto() + { + JObject parameters = new JObject(); + + ScreenshotSchema schema = DeserializeScreenshotSchema(parameters); + + Assert.That(schema.CaptureMode, Is.EqualTo(CaptureMode.auto)); + } + + /// + /// What: Edit Mode auto + annotate-elements still fails validation because auto resolves to window. + /// + [Test] + public void ExecuteAsync_WhenCaptureModeOmittedWithAnnotateElementsInEditMode_ShouldThrowValidationException() + { + JObject parameters = new JObject + { + ["AnnotateElements"] = true + }; + + UnityCliLoopToolParameterValidationException? exception = + Assert.ThrowsAsync( + async () => await ExecuteScreenshot(parameters)); + + Assert.That( + exception!.Message, + Is.EqualTo("AnnotateElements is only supported when CaptureMode=rendering")); + } + + /// + /// What: explicit window + annotate-elements is still rejected while Play Mode is injected. + /// + [Test] + public void CaptureAsync_WhenWindowSpecifiedWithAnnotateElementsWhilePlaying_ShouldThrowValidationException() + { + JObject parameters = new JObject + { + ["CaptureMode"] = "window", + ["AnnotateElements"] = true + }; + ScreenshotSchema schema = DeserializeScreenshotSchema(parameters); + ScreenshotUseCase useCase = new ScreenshotUseCase(new FakeScreenshotEditorStateReader(true)); + + UnityCliLoopToolParameterValidationException? exception = + Assert.ThrowsAsync( + async () => await useCase.CaptureAsync(schema, CancellationToken.None)); + + Assert.That( + exception!.Message, + Is.EqualTo("AnnotateElements is only supported when CaptureMode=rendering")); + } + + /// + /// What: omitted CaptureMode + annotate-elements in injected Play Mode passes validation and resolves to rendering. + /// + [Test] + public async Task CaptureAsync_WhenCaptureModeOmittedWithAnnotateElementsWhilePlaying_ResolvesToRendering() + { + JObject parameters = new JObject + { + ["annotateElements"] = true + }; + ScreenshotSchema schema = DeserializeScreenshotSchema(parameters); + ScreenshotUseCase useCase = new ScreenshotUseCase(new FakeScreenshotEditorStateReader(true)); + + ScreenshotResponse response = await useCase.CaptureAsync(schema, CancellationToken.None); + + Assert.That(response.ResolvedCaptureMode, Is.EqualTo("rendering")); + Assert.That( + response.Message, + Is.EqualTo("Rendering screenshots require PlayMode, but Unity is currently in EditMode.")); + } + + private static ScreenshotSchema DeserializeScreenshotSchema(JObject parameters) + { + ScreenshotSchema? schema = parameters.ToObject( + UnityCliLoopToolParameterSerializer.CamelCaseSerializer); + Assert.That(schema, Is.Not.Null); + return schema!; + } + private static async Task ExecuteScreenshot(JObject parameters) { ScreenshotTool tool = new(); return await tool.ExecuteAsync(parameters, CancellationToken.None); } + + private sealed class FakeScreenshotEditorStateReader : IScreenshotEditorStateReader + { + public FakeScreenshotEditorStateReader(bool isPlaying) + { + IsPlaying = isPlaying; + } + + public bool IsPlaying { get; } + } } } diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotCaptureModeResolver.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotCaptureModeResolver.cs new file mode 100644 index 000000000..fc924f237 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotCaptureModeResolver.cs @@ -0,0 +1,51 @@ +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Resolves CaptureMode.auto to window or rendering from the current Play Mode state. + /// + internal static class ScreenshotCaptureModeResolver + { + /// + /// Returns window or rendering. auto follows Play Mode; GameView is an explicit rendering alias. + /// + internal static CaptureMode Resolve(CaptureMode requested, bool isPlaying) + { + if (requested == CaptureMode.GameView) + { + return CaptureMode.rendering; + } + + if (requested != CaptureMode.auto) + { + return requested; + } + + if (isPlaying) + { + return CaptureMode.rendering; + } + + return CaptureMode.window; + } + + /// + /// Returns the wire name for a resolved capture mode ("window" or "rendering"). + /// + internal static string ToWireName(CaptureMode resolved) + { + Debug.Assert( + resolved == CaptureMode.window || resolved == CaptureMode.rendering, + "ToWireName requires a resolved capture mode; auto and GameView must be resolved first."); + if (resolved == CaptureMode.window) + { + return UnityCliLoopConstants.SCREENSHOT_RESOLVED_CAPTURE_MODE_WINDOW; + } + + return UnityCliLoopConstants.SCREENSHOT_RESOLVED_CAPTURE_MODE_RENDERING; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotCaptureModeResolver.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotCaptureModeResolver.cs.meta new file mode 100644 index 000000000..2ff810203 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotCaptureModeResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: acfbc1e5ae5e948fb9bf8016134a8bde +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotEditorStateReader.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotEditorStateReader.cs new file mode 100644 index 000000000..1d66b073b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotEditorStateReader.cs @@ -0,0 +1,20 @@ +using UnityEditor; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Reads Editor Play Mode for screenshot capture-mode auto resolution. + /// + internal interface IScreenshotEditorStateReader + { + bool IsPlaying { get; } + } + + /// + /// Forwards Play Mode reads to EditorApplication. + /// + internal sealed class ScreenshotEditorStateReader : IScreenshotEditorStateReader + { + public bool IsPlaying => EditorApplication.isPlaying; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotEditorStateReader.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotEditorStateReader.cs.meta new file mode 100644 index 000000000..87328b1c1 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotEditorStateReader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9c25d475072a34518a4735a8d24d2104 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs index fb0a38b7b..5bd786fd8 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs @@ -17,12 +17,26 @@ public class ScreenshotUseCase { private const int ANNOTATION_OVERLAY_RENDER_WAIT_FRAMES = 2; + private readonly IScreenshotEditorStateReader _editorStateReader; + + internal ScreenshotUseCase(IScreenshotEditorStateReader editorStateReader = null) + { + _editorStateReader = editorStateReader ?? new ScreenshotEditorStateReader(); + } + public async Task CaptureAsync( ScreenshotSchema request, CancellationToken ct) { ct.ThrowIfCancellationRequested(); + // Why resolve before Validate: annotation flags are rejected unless CaptureMode is + // rendering. Leaving auto unresolved would reject Play Mode annotate-elements. + request.CaptureMode = ScreenshotCaptureModeResolver.Resolve( + request.CaptureMode, + _editorStateReader.IsPlaying); + string resolvedCaptureMode = ScreenshotCaptureModeResolver.ToWireName(request.CaptureMode); + string correlationId = UnityCliLoopConstants.GenerateCorrelationId(); VibeLogger.LogInfo( @@ -36,12 +50,18 @@ public async Task CaptureAsync( ScreenshotParameterValidator.Validate(request); + ScreenshotResponse response; if (request.CaptureMode == CaptureMode.rendering) { - return await CaptureRenderingAsync(request, correlationId, ct).ConfigureAwait(false); + response = await CaptureRenderingAsync(request, correlationId, ct).ConfigureAwait(false); + } + else + { + response = await CaptureWindowsAsync(request, correlationId, ct).ConfigureAwait(false); } - return await CaptureWindowsAsync(request, correlationId, ct).ConfigureAwait(false); + response.ResolvedCaptureMode = resolvedCaptureMode; + return response; } private async Task CaptureRenderingAsync( diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md index 3c14efa5a..8db19912a 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md @@ -18,15 +18,15 @@ uloop screenshot [--window-name ] [--resolution-scale ] [--match-mo | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--window-name` | string | `Game` | Window name to capture (for example `Game`, `Scene`, `Console`, `Inspector`). Ignored when `--capture-mode rendering`. When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator. | +| `--window-name` | string | `Game` | Window name to capture (for example `Game`, `Scene`, `Console`, `Inspector`). Ignored when the resolved capture mode is rendering (auto during Play Mode resolves to rendering). When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator. | | `--resolution-scale` | number | `1.0` | Resolution scale (0.1 to 1.0) | -| `--match-mode` | enum | `exact` | Window name matching mode: `exact`, `prefix`, or `contains`. Ignored when `--capture-mode rendering`. | -| `--capture-mode` | enum | `window` | `window` - capture EditorWindow including toolbar, `rendering` - capture game rendering only (PlayMode required), `GameView` - alias for `rendering`. Rendering screenshots return `ScreenshotToInputFormula` for converting raw image pixels before calling simulate-mouse-input (including `--dry-run`) or simulate-mouse-ui. | +| `--match-mode` | enum | `exact` | Window name matching mode: `exact`, `prefix`, or `contains`. Ignored when the resolved capture mode is rendering (auto during Play Mode resolves to rendering). | +| `--capture-mode` | enum | `auto` | `auto` - rendering in PlayMode and window otherwise, `window` - capture EditorWindow including toolbar, `rendering` - capture game rendering only (PlayMode required), `GameView` - alias for `rendering`. Rendering screenshots return `ScreenshotToInputFormula` for converting raw image pixels before calling simulate-mouse-input (including `--dry-run`) or simulate-mouse-ui. | | `--output-directory` | string | `""` | Output directory path for saving screenshots. When empty, uses default path (.uloop/outputs/Screenshots/). Accepts absolute paths. | -| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). The response includes an `AnnotatedElements` array with element metadata sorted by z-order. Only works with `--capture-mode rendering` in PlayMode. | -| `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works with `--capture-mode rendering` in PlayMode. | +| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). The response includes an `AnnotatedElements` array with element metadata sorted by z-order. Only works when the resolved capture mode is rendering (auto during Play Mode resolves to rendering) in PlayMode. | +| `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works when the resolved capture mode is rendering (auto during Play Mode resolves to rendering) in PlayMode. | | `--raycast-layer-mask` | string | `""` | Comma-separated physics layer names to narrow which layers `--annotate-raycast-grid` clusters. Hits are limited to layers also visible to `Camera.main.cullingMask`. When omitted, clusters against `Physics.DefaultRaycastLayers`. | -| `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and `--capture-mode rendering` in PlayMode. | +| `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and the resolved capture mode to be rendering (auto during Play Mode resolves to rendering) in PlayMode. | ## Match Modes @@ -51,6 +51,7 @@ The window name is the text displayed in the window's title bar (tab). Common na Returns JSON with: - `ScreenshotCount`: Number of windows captured +- `ResolvedCaptureMode`: `"window"` or `"rendering"` — the mode actually used after resolving `auto` - `Warning`: Appears only for window captures taken while Play Mode is running with at least one image. - `Screenshots`: Array of screenshot info, each containing: - `ImagePath`: Absolute path to the saved PNG file. Empty when `--elements-only` is used because no image file is written. Always open the file named here — the output directory accumulates every past capture, so guessing the newest file with directory listing (`ls -t` or similar) can silently pick a stale screenshot from an earlier run. diff --git a/Packages/src/Editor/ToolContracts/ScreenshotResponse.cs b/Packages/src/Editor/ToolContracts/ScreenshotResponse.cs index 9d87f23d6..ca90b8f92 100644 --- a/Packages/src/Editor/ToolContracts/ScreenshotResponse.cs +++ b/Packages/src/Editor/ToolContracts/ScreenshotResponse.cs @@ -38,6 +38,7 @@ public class ScreenshotResponse : UnityCliLoopToolResponse public bool TimedOut { get; set; } public string Message { get; set; } = ""; public string Warning { get; set; } = ""; + public string ResolvedCaptureMode { get; set; } = ""; public string[] NextActions { get; set; } = new string[0]; public int ScreenshotCount => Screenshots.Count; diff --git a/Packages/src/Editor/ToolContracts/ScreenshotSchema.cs b/Packages/src/Editor/ToolContracts/ScreenshotSchema.cs index 91068ebf1..0ef41565f 100644 --- a/Packages/src/Editor/ToolContracts/ScreenshotSchema.cs +++ b/Packages/src/Editor/ToolContracts/ScreenshotSchema.cs @@ -9,7 +9,7 @@ public class ScreenshotSchema : UnityCliLoopToolSchema public float ResolutionScale { get; set; } = 1.0f; public WindowMatchMode MatchMode { get; set; } = WindowMatchMode.exact; public string OutputDirectory { get; set; } = ""; - public CaptureMode CaptureMode { get; set; } = CaptureMode.window; + public CaptureMode CaptureMode { get; set; } = CaptureMode.auto; public bool AnnotateElements { get; set; } = false; public bool ElementsOnly { get; set; } = false; public bool AnnotateRaycastGrid { get; set; } = false; diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs index b7e281e6f..2b3b2b72e 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs @@ -104,6 +104,8 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo "unavailable: window screenshots include Unity Editor chrome; use capture-mode rendering for mouse input coordinates"; public const string SCREENSHOT_DEFAULT_WINDOW_NAME = "Game"; public const string SCREENSHOT_SIMULATOR_WINDOW_NAME = "Simulator"; + public const string SCREENSHOT_RESOLVED_CAPTURE_MODE_WINDOW = "window"; + public const string SCREENSHOT_RESOLVED_CAPTURE_MODE_RENDERING = "rendering"; public const int CORRELATION_ID_LENGTH = 8; public const string GUID_FORMAT_NO_HYPHENS = "N"; diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopScreenshotTypes.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopScreenshotTypes.cs index cc6ad7de9..7e341e4b9 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopScreenshotTypes.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopScreenshotTypes.cs @@ -11,8 +11,10 @@ public enum CaptureMode { window = 0, rendering = 1, - // Alias for rendering: agents commonly pass GameView when they mean Game View pixels. - // Same underlying value so CaptureMode comparisons against rendering keep working. - GameView = 1 + auto = 2, + // Why a distinct value: Enum.GetNames sorts by value, so a shared ordinal with + // rendering would occupy the next help index and make default auto display as GameView. + // Alias semantics live in ScreenshotCaptureModeResolver, not in a shared value. + GameView = 3 } } diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index 118324505..21faa7778 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -237,7 +237,7 @@ "properties": { "WindowName": { "type": "string", - "description": "Window name to capture (for example Game, Scene, Console, Inspector). Ignored when --capture-mode rendering. When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator.", + "description": "Window name to capture (for example Game, Scene, Console, Inspector). Ignored when the resolved capture mode is rendering (auto during Play Mode resolves to rendering). When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator.", "default": "Game" }, "ResolutionScale": { @@ -247,7 +247,7 @@ }, "MatchMode": { "type": "string", - "description": "Window name matching mode: exact, prefix, or contains. Ignored when --capture-mode rendering.", + "description": "Window name matching mode: exact, prefix, or contains. Ignored when the resolved capture mode is rendering (auto during Play Mode resolves to rendering).", "enum": [ "exact", "prefix", @@ -262,27 +262,28 @@ }, "CaptureMode": { "type": "string", - "description": "window - capture EditorWindow including toolbar, rendering - capture game rendering only (PlayMode required), GameView - alias for rendering. Rendering screenshots return ScreenshotToInputFormula for converting raw image pixels before calling simulate-mouse-input (including --dry-run) or simulate-mouse-ui.", + "description": "auto - rendering in PlayMode and window otherwise, window - capture EditorWindow including toolbar, rendering - capture game rendering only (PlayMode required), GameView - alias for rendering. Rendering screenshots return ScreenshotToInputFormula for converting raw image pixels before calling simulate-mouse-input (including --dry-run) or simulate-mouse-ui.", "enum": [ "window", "rendering", + "auto", "GameView" ], - "default": "window" + "default": "auto" }, "AnnotateElements": { "type": "boolean", - "description": "Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). The response includes an AnnotatedElements array with element metadata sorted by z-order. Only works with --capture-mode rendering in PlayMode.", + "description": "Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). The response includes an AnnotatedElements array with element metadata sorted by z-order. Only works when the resolved capture mode is rendering (auto during Play Mode resolves to rendering) in PlayMode.", "default": false }, "ElementsOnly": { "type": "boolean", - "description": "Return only annotated element JSON without capturing a screenshot image. Requires --annotate-elements or --annotate-raycast-grid, and --capture-mode rendering in PlayMode.", + "description": "Return only annotated element JSON without capturing a screenshot image. Requires --annotate-elements or --annotate-raycast-grid, and the resolved capture mode to be rendering (auto during Play Mode resolves to rendering) in PlayMode.", "default": false }, "AnnotateRaycastGrid": { "type": "boolean", - "description": "Annotate clustered 3D physics collider candidates as PhysicsCollider entries in AnnotatedElements. Uses Camera.main visibility and the same top-left Game View coordinates as simulate-mouse-input. Only works with --capture-mode rendering in PlayMode.", + "description": "Annotate clustered 3D physics collider candidates as PhysicsCollider entries in AnnotatedElements. Uses Camera.main visibility and the same top-left Game View coordinates as simulate-mouse-input. Only works when the resolved capture mode is rendering (auto during Play Mode resolves to rendering) in PlayMode.", "default": false }, "RaycastLayerMask": { diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 968aa64ce..4d822fb97 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "e1e52ec72e80d17b0f0e00e649cf571e1b2bb058" + "sharedInputsHash": "9fed9a9a549f45ecc91e381f1c780dd4af0f212b" } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index bcc99038e..c540b0d53 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "a78209f9349893f453d2f9f88dddf4f93651df0b" + "sharedInputsHash": "abce606e6b8233345a41199ba7c54c83b906ebb6" }