diff --git a/CHANGELOG.md b/CHANGELOG.md index b514538..17d2497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.5.3] - 2026-08-15 + +### Added +- Long-running commands now finish faster. Test runs, package operations, profiler sampling, recording and `qa run-sequence` all advance one step per Editor update tick, and an idle Editor runs that loop slowly — measured at roughly six ticks per second, whether or not its window is in front. While one of those commands is in flight the bridge now keeps the Editor ticking at full rate and lets it settle back the moment the work finishes, which measured about 1.5× faster end to end (60 ticks: 9.4–10.4 s → 6.0–6.1 s on Unity 6000.3 / macOS). Nothing to turn on, and on an Editor version that does not expose the internal API this relies on, commands simply run at the old speed. +- Component values may now be written as JSON arrays: `"m_Center": [1, 2, 3]` alongside the existing `{"x": 1, "y": 2, "z": 3}`. Vector2/3/4, Vector2Int/Vector3Int, Quaternion, Rect, RectInt and Color all accept the short form, and a Color array may leave off alpha. +- Structured values that arrive quoted — `"[1,2,3]"` or `"{\"x\":1,\"y\":2,\"z\":3}"` instead of the JSON value itself — are now parsed instead of rejected. AI agents produce this shape often, and the previous error read as though the value were wrong rather than the quoting. Only strings that open a JSON object or array are re-read, so asset paths, object-reference handles, enum names and plain text are untouched, and a string that fails to parse still produces the original validation error. +- The bridge can now be kept from starting, for build machines that have no use for it. Set `UNITY_CLI_BRIDGE_DISABLE=1` or pass `-noUnityCliBridge` on the Editor command line and the bridge builds nothing at all — no socket, no instance registration, no token file, no console warnings if any of that would have failed. A CI job that only opens the Editor to produce a player build gets a quiet log and no registry lock to contend over with parallel builds. + +### Changed +- **`screenshot` now defaults to a JPEG capped at 1024px wide** (quality 75) instead of a full-resolution PNG. A capture is by far the most expensive thing an AI agent can ask for — a 1920×1080 PNG bills at roughly 2,040 image tokens, against about 576 for the same frame capped at 1024px — and the capped JPEG is still perfectly readable for checking UI state, which is what nearly every capture is for. Three things keep the change from surprising anyone: `--format png` still gives you lossless, a `--path` ending in `.png` selects PNG on its own, and an explicit `--width`/`--height` is left exactly as you asked. `--max-width 0` turns the cap off while keeping the automatic size. Tap and dump coordinates are unaffected — they scale from the last capture's size, so a downscaled screenshot's coordinates still land correctly. +- Unity Recorder is no longer installed alongside the package. Only the `record` commands and `qa run-sequence --record` ever used it, so projects that do not record no longer carry the dependency. If you do record, add `com.unity.recorder` to your project — `record start` now fails with that exact instruction when it is missing, and `record stop` / `record status` keep working on recordings you already have. `com.unity.test-framework`, which the test commands genuinely require, is now declared outright instead of arriving as a side effect of the Recorder dependency. + +### Fixed +- The package no longer compiles its editor-side plumbing into your game. Everything the bridge uses to talk to the CLI — the instance registry, file-backup transactions, the command catalog and its help text — was being built into player builds as unreachable code, roughly 150 KB of it, along with the strings it carries. Player builds now receive only the QA marker types (`[QaTarget]`, `IQaTappable`, `IQaQueryable`, `QaTappable`) that a project references on purpose, about 7 KB. Nothing changes for editor use, and no code needs updating. +- An Editor whose IPC listener died is no longer advertised as reachable. The listener could stop accepting connections for good — an unexpected socket failure, or a first bind that never succeeded — while the instance kept publishing itself, so the CLI would route to it and fail to connect over and over. The bridge now watches its own listener and re-binds it in place within a few seconds; if it cannot be revived, the instance removes itself from the registry with an Editor console message telling you to restart, instead of staying on the list as a target that never answers. +- The IPC auth token is now compared in fixed time, so the check cannot leak the expected token through timing to other processes on the same machine. Authentication behavior is otherwise unchanged. + ## [0.5.2] - 2026-08-13 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 5b45d32..0ce83e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ The repo is a single solution (`UnityCliBridge.sln`) split across four projects: **CLI (`cli/UnityCli.Cli/`)** — `.NET 9`, published self-contained for `osx-arm64` and `win-x64`. `CliApp.RunAsync` is the dispatcher: local-only flows (`status`, `instances`, `doctor`) are answered without IPC; everything else goes through `Services/CliArgumentParser` → `Models/ParsedCommand` → `Services/LocalIpcClient` to the running Editor. `Services/InstanceRegistryStore` reads `InstanceRegistryFile` (see Protocol below) to find the right Editor for a given project root. `Services/EditorLauncher` implements the local `editor launch` flow: pre-flight (live-instance reuse, stray-process detection), editor-binary resolution, spawn with detached stdio, and registry-readiness polling. -**Shared protocol (`cli/UnityCli.Protocol/` ↔ `unity-package/.../Runtime/Protocol/`)** — The `.csproj` uses `` links to compile the same `.cs` files from the Unity package. **A change to any protocol file is a change to both sides; keep them buildable for both `.NET 9` and Unity's runtime.** Shared protocol source files must live in `unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/`; the CLI project enforces this via a build-time guard. Hot spots: +**Shared protocol (`cli/UnityCli.Protocol/` ↔ `unity-package/.../Runtime/Protocol/`)** — The `.csproj` uses `` links to compile the same `.cs` files from the Unity package. **A change to any protocol file is a change to both sides; keep them buildable for both `.NET 9` and Unity's runtime.** Shared protocol source files must live in `unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/`; the CLI project enforces this via a build-time guard. That folder sits under `Runtime/` only so the CLI can link it — its own `UnityCliBridge.Bridge.Protocol.asmdef` is `includePlatforms: ["Editor"]`, so none of it reaches a player build (see the release-build convention below). Hot spots: - `CliCommandCatalog.cs` is the single source of truth for command metadata, including `ForceRule` (None / OnOverwrite / OnDestructiveOp / Always) — every force-gating decision must trace back here. - `FileBackupTransaction.cs` is `.NET`-testable and powers all backup/restore flows. - `InstanceRegistryFile.cs` owns the atomic registry-lock protocol (atomic `FileMode.CreateNew`, PID + UTC timestamp content, stale-reclaim via open-then-rename-then-delete) used by both `BridgeHost` and the CLI's `InstanceRegistryStore`. It also owns per-instance 0600 auth-token sidecars (`/tokens/.token`) so mixed-version registry rewrites cannot strip live tokens. @@ -44,12 +44,13 @@ The repo is a single solution (`UnityCliBridge.sln`) split across four projects: **Bridge runtime (`unity-package/com.yhc509.unity-cli-bridge/Editor/`)** — Hosted in the Unity Editor. - `BridgeHost.cs` is the bootstrap and dispatcher: it registers the project in the instance registry, starts the IPC listener (Named Pipe on Windows / Unix socket on macOS+Linux), and routes commands to one of the `*CommandHandler` classes (`Asset`, `AssetCreate`, `Scene`, `Prefab`, `Material`, `Package`, `Qa`, `Record`, `Profile`, `Screenshot`, `ExecuteCode`, `Custom`). It runs in any main editor process including `-batchmode`; secondary processes (MPE / `-adb2` AssetImportWorker) are excluded. - `ClientDisconnectMonitor.cs` is the `.NET`-testable stream watcher that lets `BridgeHost` cancel queued requests when the CLI connection closes before dispatch. +- `EditorTickPump.cs` wraps `EditorApplication.update` subscription for deferred flows and forces `EditorApplication.SignalTick` (reflection) while any of them is subscribed — see the auto-tick convention below. - Scene/prefab patch logic is deliberately split across `SceneCommandHandler.Patching.cs` and `PrefabCommandHandler.Patching.cs` (partial classes) so the entry-point file stays small and the op-application code lives next to its inspector. -- `SerializedValueApplier.cs` (+ `.ComplexTypes.cs` partial) is the most fragile layer — it translates JSON values into `SerializedProperty.propertyPath` mutations with friendly-key fallback. Run `*-inspect --with-values` before patching to verify paths. +- `SerializedValueApplier.cs` (+ `.ComplexTypes.cs` partial) is the most fragile layer — it translates JSON values into `SerializedProperty.propertyPath` mutations with friendly-key fallback. Run `*-inspect --with-values` before patching to verify paths. `ApplyToken` is the single funnel for every value (top-level, nested, array element), so agent-input coercion belongs there and nowhere else. - `ExecuteValueSerializer.cs` serializes values returned from `execute` and custom commands with a safe whitelist and round-trip float/double formatting. - `AssetBackupTransaction.cs` wraps `FileBackupTransaction` with an `AssetDatabase.Refresh` discipline (always in `finally`) and is the rollback core for scene patch / prefab patch / asset overwrite. Backups land in `Library/com.yhc509.unity-cli-bridge/backups/` to stay outside `Assets/` and `AssetDatabase` scanning. - `PackageCommandHandler.cs` polls Unity Package Manager from `EditorApplication.update` (deferred dispatch) with a single-flight guard and a 300 s `PACKAGE_TIMEOUT` — never re-introduce blocking polls here. -- `RecordCommandHandler.cs` starts/stops Unity Recorder mp4 capture for Play Mode and writes finalized result sidecars under `Library/com.yhc509.unity-cli-bridge/recordings/`. +- `RecordCommandHandler.cs` starts/stops Unity Recorder mp4 capture for Play Mode and writes finalized result sidecars under `Library/com.yhc509.unity-cli-bridge/recordings/`. Every Recorder API touch is behind `#if UNITY_CLI_BRIDGE_RECORDER` — see the optional-dependency convention below. - `ProfileCommandHandler.cs` + `.Capture.cs` (partial) sample `ProfilerRecorder` counters for `profile stats` and drive `profile capture`/`status` via `ProfilerDriver`/`HierarchyFrameDataView`, writing capture summaries to `Library/com.yhc509.unity-cli-bridge/profiles/.json` sidecars that `profile analyze` reads locally without an Editor round-trip. - `TestCommandHandler.cs` + `.EditMode.cs` + `.PlayMode.cs` (partial)와 `TestRunnerCallbacks.cs` (ScriptableObject)는 `TestRunnerApi`를 래핑한다. EditMode는 동기 응답, PlayMode는 비동기(즉시 `STARTED+runId`, 결과는 `Library/com.yhc509.unity-cli-bridge/test-runs/.json` atomic write). `DomainReloadDisableScope`는 `--no-domain-reload` 옵션을 구현하지만 디폴트는 Unity 정상 동작. - `CliInstallerWindow.cs` / `CliInstallerState.cs` / `CliDownloader.cs` / `SkillInstaller.cs` form the `Window > Unity CLI Manager` flow that fetches the matching CLI binary from GitHub Releases and installs the AI-agent skill. @@ -70,8 +71,15 @@ Tests live in `tests/UnityCli.Cli.Tests/` (xUnit, `.NET`-testable surface only). - **macOS paths:** Use real paths (`pwd -P`), not symlinks, for hashing and registry lookups. - **Instance primary identity:** Registry and CLI routing use canonical `projectRoot` first. The 12-character hash is only for socket/pipe names and user-input fallback; if a hash matches multiple instances, require a project path. - **No-`--project` routing:** With no `--project`, routing resolves CWD → pinned `activeProjectRoot` (set via `instances use`) → the single live instance. If two or more live instances remain and none is pinned, the command fails with a `CLI_USAGE` error listing candidates instead of silently picking one. Auto-promoted `activeProjectRoot` (most-recent live Editor) is not trusted for this fallback; only an explicit `instances use` pin is. +- **Auto-tick pump:** deferred flows (test/package/profile/record/qa run-sequence) must subscribe their `EditorApplication.update` poll through `EditorTickPump.Add/Remove`, never directly — a direct subscription silently opts that flow out of the pump. While at least one is subscribed the pump forces `EditorApplication.SignalTick` (bound once by reflection, throttled to `DefaultIntervalMs` = 16) and disarms when the set empties, so the tick state reverts on its own. The subscriber set holds the very delegates on `EditorApplication.update`, so a pump leak would be a poll leak; `Remove` is idempotent to match the defensive double-unsubscribes in the poll bodies. Measured effect on Unity 6000.3 / macOS: ~160 ms → ~100 ms per tick (≈1.55×), **the same focused or unfocused** — on that version focus itself barely changes the edit-mode update rate, so the pump is a general speed-up rather than an unfocused-only fix. `SignalTick` is internal API: a bind failure degrades to a no-op with one warning, never a throw. `BridgeHost.OnEditorUpdate` stays a direct subscription — routing it through the pump would pin the editor at full rate for the whole session. +- **Listener watchdog:** the accept loops have no retry of their own (the Unix loop ends permanently on any unexpected `AcceptAsync` failure; both loops give up if the first acquire fails), so `BridgeHost` runs `ListenerWatchdogPolicy` (`Runtime/Protocol/`, Unity-free and unit-tested) on every editor tick. Both loops must clear `_isListenerReady` in their `finally`, and every bind outcome must clear `_isListenerStarting` — those two flags are the entire input. The watchdog skips while a bind is in flight or the editor is compiling/updating (domain-reload teardown is not a fault), rebinds in place up to 5 times at 5 s intervals, re-publishes registry + token sidecar on recovery (deleting the old sidecar if the rebind landed on a different hash), and on exhaustion unregisters the instance so it never advertises as live while unreachable. +- **Agent value coercion:** a value that is a string, targets a property that cannot hold a raw string, and starts with `{` or `[` after trimming is re-parsed as JSON at the `ApplyToken` entry. Keep the guard exactly that narrow — asset paths, object-reference handles, enum names and char values never open with those characters, so they keep the original path, and a re-parse failure falls back to the original token so the existing validation error is preserved. Vector-family readers additionally accept a flat numeric array (`[1,2,3]`) next to the canonical member object; `Bounds`/`BoundsInt` deliberately do not (their nested `center`/`size` accept arrays instead). +- **Auth token comparison:** `AuthTokenComparison.FixedTimeEquals` (no early exit, length folded into the same accumulator) is the only comparison allowed on the token path. Hand-rolled rather than `CryptographicOperations.FixedTimeEquals` so the shared source compiles on Unity's runtime profile and .NET 9. - **Auth token storage:** Live IPC auth tokens are stored in per-instance 0600 sidecars (`/tokens/.token`), not in the shared registry. The CLI reads the sidecar during resolve/load, and `InstanceRecord.token` is a non-serialized in-memory field. The token sidecar is written as soon as the listener key is acquired, before a client can connect; the registry entry is only written on the first editor tick after the listener is ready. A live socket with no registry entry is therefore a real (if brief) state, and the CLI must treat it as a retryable `LIVE_UNAVAILABLE` rather than connecting with an empty token — do not remove that guard. Both the entry and the sidecar survive domain reloads and are removed only when the Editor exits; during a reload only the socket/listener is closed. - **Token sidecar is overwrite-if-different:** `EnsureTokenSidecar` replaces a stale sidecar left by a killed/crashed session; never restore the old "skip when file exists" behavior — it makes every CLI call fail `UNAUTHORIZED` for the whole next session. +- **Player-build surface is three files:** the package ships two assemblies to a consuming project. `UnityCliBridge.Bridge.Runtime` (`includePlatforms: []`) is the only one that reaches a player, and it must contain nothing but the four QA marker sources a game references on purpose (`IQaTappable`, `IQaQueryable`, `QaTappable`, `QaTargetAttribute` — ~7 KB compiled). Everything else — the whole `Runtime/Protocol/` tree plus `Editor/` — is Editor-only. `ReleaseBuildSurfaceTests` pins this: the asmdefs and `package.json` are plain JSON that no compiler validates, and a one-character edit there silently pushes registry file I/O, `Process.Start`, and the full command catalog into a shipped game. Verify a change with `CompilationPipeline.GetAssemblies(AssembliesType.Player)`, not by reading the asmdef. +- **Bridge disable switch:** `UNITY_CLI_BRIDGE_DISABLE` (any value but `0`/`false`/empty) or the `-noUnityCliBridge` editor flag keeps `BridgeBootstrap` from constructing the host at all — no session-lock restore, no registry entry, no token sidecar, no update hook. It exists for CI/release build jobs, where a bridge is pointless and its watchdog errors are log noise. The check must stay *before* `new BridgeHost()`; `BridgeDisableSwitch` (`Runtime/Protocol/`) holds the parsing so it is unit-tested, and a source-ordering test guards the call site. +- **Optional Unity packages are versionDefine-gated, never dependencies:** a missing asmdef reference is silently dropped by Unity (it is not a compile error), but any code using its types then fails `CS0246`. So an optional package needs both halves — keep the asmdef `references` entry *and* wrap every API touch in the matching `versionDefines` symbol. `com.unity.inputsystem` → `ENABLE_INPUT_SYSTEM` and `com.unity.recorder` → `UNITY_CLI_BRIDGE_RECORDER` follow this; `record start` degrades to a `RECORD_FAILED` install hint (same shape as the memory-profiler gate) while `record stop`/`status` keep serving existing sidecars. `com.unity.test-framework` is a real dependency by contrast — the test handlers use `TestRunnerApi` types unguarded — and must stay declared in `package.json`; it used to arrive transitively through Recorder, which is exactly the trap this convention exists to avoid. - **Headless editors are first-class:** the bridge starts in any main editor process (GUI or `-batchmode`); only secondary Unity processes (MPE / `-adb2` AssetImportWorker) are excluded. Recommended headless launch is `-batchmode` *without* `-nographics` so the GPU stays available. Commands whose catalog entry sets `requiresGraphics` fail with `HEADLESS_NO_GRAPHICS` when `SystemInfo.graphicsDeviceType == Null`. - **`editor launch` / `editor stop`:** `editor launch` is a local command — pre-flight (live registry match → idempotent reuse; stray editor process → `EDITOR_ALREADY_RUNNING_CONFLICT`) then spawn + registry-readiness polling (default 300 s). The spawned editor's stdio is detached (Unix: `sh -c 'exec …'` wrapper redirecting to the null device, so `Process.Id` stays the editor PID) — never let it inherit the CLI's streams, or `editor launch | grep …` pipelines hang forever after the CLI exits. `editor stop` is the `editor-quit` wire command (ForceRule OnDestructiveOp): the bridge replies first and schedules `EditorApplication.Exit` via an `EditorApplication.update` one-shot callback (+0.5 s grace), then the CLI waits for PID exit (default 30 s). The deferred quit must stay `update`-based, not `delayCall` — `delayCall` rides the inspector-update cycle and starves in an unfocused GUI editor, timing out every stop. A graceful quit removes the registry entry and token sidecar. The CLI is installed per version under `~/.unity-cli-bridge/versions//` (binary + `meta.json` = `{"cliVersion","protocolVersion"}`), and `~/.unity-cli-bridge/unity-cli/` stays the PATH target — a symlink to the newest version on macOS/Linux, a copy on Windows, plus a `meta.json` marker naming the version it resolves to. `CliInstallLayout` (`Runtime/Protocol/`) owns that layout for both sides. On a `PROTOCOL_MISMATCH` the CLI reads the bridge's protocol off `response.protocolVersion`, finds the newest installed version speaking it, and re-execs with the original argv (`execve` on macOS/Linux, child process on Windows) with `UNITY_CLI_DISPATCHED=1` set; if that guard is already set it reports the mismatch instead of dispatching again. This works because the bridge checks the protocol before auth and before dispatch and `return`s, so nothing ran and re-sending cannot double-execute — do not move that check. `LocalIpcClient.EnsureCompatibleResponse` must keep the *peer's* `protocolVersion` on the envelope it synthesizes; that field is what routing depends on. Local-only commands (`status`, `instances`, `doctor`) never dispatch. Happy path pays nothing: the decision short-circuits before touching disk. - **CLI install target = package version:** The Manager downloads the CLI release matching *its own package version*, never the newest release. Only the same-version CLI is guaranteed to speak the package's protocol, and the Manager writes that protocol into `meta.json` from `ProtocolConstants.ProtocolVersion`. Old versions are never garbage-collected; removal is a per-version button in the Manager. @@ -79,7 +87,7 @@ Tests live in `tests/UnityCli.Cli.Tests/` (xUnit, `.NET`-testable surface only). - **Scene paths:** Format `/Root[0]/Child[0]` with array notation for sibling indexing; `/` is the virtual scene root. - **Scene/prefab node flags:** Convenience commands that point at a hierarchy node use `--node`; JSON patch specs still use `target`/`parent`. - **Prefab editing:** Based on `SerializedProperty.propertyPath` (run `prefab inspect --with-values` to verify paths before patching). -- **Screenshot lightweight options:** `screenshot` defaults to PNG for compatibility; use `--format jpg|jpeg`, `--quality 1-100`, and `--max-width ` to reduce agent-facing image size without changing explicit `--width`/`--height` behavior. +- **Screenshot agent-facing defaults:** `screenshot` defaults to **JPEG quality 75 downscaled to 1024px wide** — a capture is the heaviest single response an agent can request (~2,040 image tokens at 1080p PNG vs ~576 capped), so the default is tuned for the common caller rather than for archival fidelity. `ScreenshotDefaults` (`Runtime/Protocol/`) owns the resolution so the CLI and the bridge agree and it stays unit-tested. Three rules keep the default from surprising anyone: an explicit `--format` always wins; with no `--format`, a `--path` ending in `.png` selects PNG (writing JPEG bytes into a file the caller named `.png` is worse than the tokens saved); and an explicit `--width`/`--height` suppresses the cap entirely — that gate predates the default and is why sized captures did not silently shrink. `--max-width 0` is the opt-out for the cap alone, and travels as the `MaxWidthUncapped` (-1) wire sentinel because `maxWidth == 0` already means "unspecified" (no `Nullable` on the wire). Downscaling does not break QA coordinates: `qa tap`/`ui-dump`/`world-dump` scale from `LastCapturedWidth`/`Height`. - **Record:** Play Mode 전용 Unity Recorder 기반 mp4. `record start`는 `STARTED+recordingId`를 즉시 반환하고, `--duration` 또는 600초 안전캡으로 자동 stop한다. `--wait`는 CLI가 `record status` sidecar를 폴링한다. force-rule 없음. 글로벌 busy에 참여하지 않고 자체 single-flight만 사용한다. - **Console/test/list trims:** `read-console --no-stacktrace`, `test list --no-detail`, `test run/results --failures-only`, and `instances list --brief` are opt-in response trims only. Defaults preserve full output. Test failure trimming never changes summary counts or cached result files. - **Test runner:** EditMode는 동기, PlayMode는 비동기(STARTED+runId 즉시 반환). `--wait`로 CLI 측 폴링. `--no-domain-reload`는 PlayMode 전용 속도 옵션이며 정합성 결정과 분리. 동시 실행 1회(`TEST_RUN_IN_PROGRESS` 거부). non-`Completed` 결과는 error envelope/exit code 1로 반환한다. force-rule 없음. 결과는 `Library/com.yhc509.unity-cli-bridge/test-runs/.json`. @@ -95,7 +103,9 @@ Tests live in `tests/UnityCli.Cli.Tests/` (xUnit, `.NET`-testable surface only). 1. `dotnet run --project cli/UnityCli.DocGen -- --write` — auto-updates `docs/cli-reference.md` 2. `README.md` — update examples for new/changed commands in both Scene and Prefab sections 3. `CLAUDE.md` — update Architecture tree if new files are added, update Key Conventions if behavior changes - 4. `tools/skills/unity-cli-operator/SKILL.md` — update command workflows and examples for AI agent usage + 4. **The AI-agent skill ships in two copies — update both.** `tools/skills/unity-cli-operator/` is the maintainer copy; `unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/` is what `SkillInstaller` actually installs into a user's project, and nothing in CI compares them. + - `references/*.md` must stay **byte-identical** between the two (`diff -rq` on the two `references/` folders reports nothing). + - `SKILL.md` is a deliberate fork: the shipped copy drops maintainer-only content (`## 진입 규칙`). Sync it *where the content applies to end users* — do not make the two identical. 5. `dotnet run --project cli/UnityCli.DocGen -- --check` — verify cli-reference is up to date - **Release checklist:** Cutting a new version: 1. `CHANGELOG.md` — move `[Unreleased]` entries to new version section with date, then mirror the file to `unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md` (the UPM package ships its own copy; keep the two identical so Package Manager shows the current changelog) diff --git a/README.md b/README.md index 5ca81b5..656c300 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,10 @@ Add the following to your `Packages/manifest.json`: The bridge starts automatically when the Editor opens. No configuration needed. +`#main` always tracks the latest release. For a build you need to reproduce later, pin a release tag instead — `...#v0.5.3` — and commit `Packages/packages-lock.json`. + +Unity Recorder is optional and is not installed for you. Add `com.unity.recorder` if you want the `record` commands or `qa run-sequence --record`; without it those commands fail with an install hint and everything else works normally. + > **Upgrading from 0.3.x?** `0.4.0` bumps the wire protocol to `5`, so a `0.3.x` CLI cannot talk to a `0.4.0` package. From `0.4.1` on you no longer have to choose: install the CLI from each project's **Window > Unity CLI Manager**, and the `unity-cli` on your PATH hands off to the version that matches whichever project you are pointing at. ### 2. Install the CLI @@ -152,8 +156,9 @@ unity-cli play / pause / stop # Play Mode control unity-cli compile # Trigger recompile unity-cli compile --wait # Wait until compile/import finishes and bridge is reachable unity-cli refresh --wait # Refresh assets and wait for Editor readiness -unity-cli screenshot --path /tmp/shot.png # Game View capture (default), or --view scene -unity-cli screenshot --format jpg --quality 70 --max-width 1024 --path /tmp/shot.jpg +unity-cli screenshot --path /tmp/shot.jpg # Game View capture (default), or --view scene + # Defaults: JPEG q75, downscaled to 1024px wide +unity-cli screenshot --format png --max-width 0 --path /tmp/shot.png # Lossless, full resolution unity-cli record start --duration 5 --wait --path /tmp/play.mp4 unity-cli record start # Manual recording; stop with record stop unity-cli record status @@ -285,8 +290,8 @@ A typical AI repair loop is: make a focused code change, `unity-cli refresh`, ru ```bash unity-cli qa click --qa-id StartButton -unity-cli screenshot --view game --path /tmp/qa-reference.png -unity-cli screenshot --view game --format jpg --quality 70 --max-width 1024 --path /tmp/qa-reference.jpg +unity-cli screenshot --view game --path /tmp/qa-reference.jpg +unity-cli screenshot --view game --format png --max-width 0 --path /tmp/qa-reference.png unity-cli qa ui-dump --json unity-cli qa ui-dump --text Start --interactable-only --limit 20 --omit-rect --json unity-cli qa world-dump --json @@ -406,8 +411,25 @@ docs/ Generated CLI reference, specs - **Scene paths:** `/Root[0]/Child[0]` format with sibling indices. `/` is the virtual scene root. - **Inspect before patch:** Always `scene inspect --with-values` or `prefab inspect --with-values` before writing patch specs. - **Friendly component keys:** Common Rigidbody, Collider, Renderer, Light, and Camera patch keys are resolved to Unity `SerializedProperty.propertyPath` values. +- **Component value shapes:** Vector2/3/4, Vector2Int/Vector3Int, Quaternion, Rect, RectInt, and Color accept both the member object (`{"x":1,"y":2,"z":3}`) and the array shorthand (`[1,2,3]`); a Color array may omit alpha. A structured value that arrives quoted (`"[1,2,3]"`) is re-parsed, but send real JSON values — the fallback never applies to genuine string fields such as asset paths or enum names. - **set-node warnings:** Unrecognized keys now return warnings instead of silent success. +## Release Builds & CI + +The bridge is editor-only. The single assembly that reaches a shipped player is `UnityCliBridge.Bridge.Runtime`, which holds nothing but the QA marker types you reference on purpose (`[QaTarget]`, `IQaTappable`, `IQaQueryable`, `QaTappable`). No listener is bound, no registry entry is written, and no bridge code runs in a build. + +The bridge *does* start in any main editor process, including the headless editor a build job launches. That is usually harmless but pointless on a build machine — it binds a socket, publishes a registry entry, and contends for the registry lock when several builds share a host. Turn it off for those jobs: + +```bash +# Environment variable — anything but 0/false/empty counts as "disable" +UNITY_CLI_BRIDGE_DISABLE=1 Unity -batchmode -quit -projectPath . -executeMethod Build.Run + +# Or the editor command-line flag, for jobs that cannot set an environment variable +Unity -batchmode -quit -projectPath . -noUnityCliBridge -executeMethod Build.Run +``` + +A disabled bridge constructs nothing and logs one line saying so. + ## Documentation - [CLI Reference (generated)](docs/cli-reference.md) diff --git a/cli/UnityCli.Cli/Models/ParsedCommand.cs b/cli/UnityCli.Cli/Models/ParsedCommand.cs index a87f632..eea111b 100644 --- a/cli/UnityCli.Cli/Models/ParsedCommand.cs +++ b/cli/UnityCli.Cli/Models/ParsedCommand.cs @@ -383,7 +383,7 @@ private string BuildArgumentsJson() height = ScreenshotHeight ?? 0, format = ScreenshotFormat, quality = ScreenshotQuality ?? 0, - maxWidth = ScreenshotMaxWidth ?? 0, + maxWidth = ResolveScreenshotMaxWidth(), }, CommandKind.PackageList => new PackageListArgs { @@ -749,6 +749,23 @@ private string BuildRawArgumentsJson(JsonElement root) : ScreenshotView; } + /// + /// Maps --max-width onto the wire, where 0 means "unspecified, apply the default cap". + /// A caller who passed --max-width 0 is asking for the opposite — no cap at all — so it + /// travels as the uncapped sentinel rather than as a value the bridge would read as silence. + /// + private int ResolveScreenshotMaxWidth() + { + if (ScreenshotMaxWidth is null) + { + return 0; + } + + return ScreenshotMaxWidth.Value > 0 + ? ScreenshotMaxWidth.Value + : ScreenshotDefaults.MaxWidthUncapped; + } + private string? BuildAssetCreateOptionsJson() { var options = new Dictionary(AssetCustomOptions, StringComparer.OrdinalIgnoreCase); diff --git a/cli/UnityCli.Cli/Services/CliArgumentParser.cs b/cli/UnityCli.Cli/Services/CliArgumentParser.cs index c2134d4..45d4cbc 100644 --- a/cli/UnityCli.Cli/Services/CliArgumentParser.cs +++ b/cli/UnityCli.Cli/Services/CliArgumentParser.cs @@ -613,7 +613,9 @@ private static void ParseCommandOptions(ParsedCommand parsed, Queue toke parsed.ScreenshotQuality = RequireScreenshotQuality(RequireValue(tokens, "--quality")); break; case CommandKind.Screenshot when token == "--max-width": - parsed.ScreenshotMaxWidth = RequireInt(RequireValue(tokens, "--max-width"), "--max-width"); + // 0 is allowed and means "no cap" — the way to opt out of the default downscale + // without having to state an explicit --width/--height. + parsed.ScreenshotMaxWidth = RequireInt(RequireValue(tokens, "--max-width"), "--max-width", minimumValue: 0); break; case CommandKind.ExecuteCode when token == "--code": parsed.ExecuteCodeSnippet = RequireValue(tokens, "--code"); diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 1667354..c86d866 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -39,7 +39,7 @@ Commands for editor state, compilation, play state, menus, arbitrary code execut | `editor launch` | `editor launch [--gui] [--nographics] [--no-wait] [--timeout ] [--editor-path ]` | local | `None` | Launches the Unity Editor for the selected project (headless -batchmode by default, GPU kept for rendering commands). Idempotent: reuses a live instance when one is already running. Waits for bridge readiness unless --no-wait. | | `editor stop` | `editor stop [--force] [--no-wait] [--timeout ]` | live | `OnDestructiveOp` | Gracefully quits the running editor for the selected project. Refuses with EDITOR_DIRTY when unsaved scene/prefab-stage changes exist; --force discards them. Waits for process exit unless --no-wait. | | `execute-menu` | `execute-menu (--path "Menu/Item" \| --list "Prefix")` | live | `None` | Executes a Unity menu item or lists registered menu items matching a prefix in a running editor. | -| `screenshot` | `screenshot [--view game\|scene (default: game) \| --camera ] [--path ] [--width N] [--height N] [--format png\|jpg\|jpeg] [--quality 1-100] [--max-width N]` | live | `None` | Captures a screenshot from the Game View, Scene View, or a named camera. Defaults to Game View; encoding defaults to PNG. Use --format jpg with --quality to reduce file size, and --max-width to downscale proportionally when --width/--height are not specified. The response includes image size, actual saved format, and screen-space metadata (`screenWidth`, `screenHeight`, `imageOrigin`, `coordinateOrigin`) for QA coordinate alignment. In Play Mode, --view game can downscale the native Game View capture but does not upscale it. | +| `screenshot` | `screenshot [--view game\|scene (default: game) \| --camera ] [--path ] [--width N] [--height N] [--format png\|jpg\|jpeg] [--quality 1-100] [--max-width N\|0]` | live | `None` | Captures a screenshot from the Game View, Scene View, or a named camera. Defaults to Game View, JPEG at quality 75, and a 1024px width cap — agent-friendly defaults that cut image tokens by roughly 72% at 1080p. Override with --format png for lossless, --quality for the JPEG setting, --max-width N for a different cap, or --max-width 0 for no cap. The cap only applies when neither --width nor --height is given, and --path with a .png extension selects PNG when --format is omitted. The response includes image size, actual saved format, and screen-space metadata (`screenWidth`, `screenHeight`, `imageOrigin`, `coordinateOrigin`) for QA coordinate alignment; qa tap/ui-dump scale from the last captured size, so downscaled shots keep their coordinates usable. In Play Mode, --view game can downscale the native Game View capture but does not upscale it. | | `record start` | `record start [--path ] [--fps (default: 30)] [--max-width ] [--duration ] [--wait]` | live | `None` | Starts recording the Game View to an mp4 file via Unity Recorder. Returns immediately with a recordingId. Requires Play Mode. | | `record stop` | `record stop` | live | `None` | Stops the active recording, finalizes the mp4, and returns the output path. | | `record status` | `record status [--recording-id ]` | live | `None` | Reports whether a recording is active and the result of a finished recording. | diff --git a/tests/UnityCli.Cli.Tests/AuthTokenComparisonTests.cs b/tests/UnityCli.Cli.Tests/AuthTokenComparisonTests.cs new file mode 100644 index 0000000..84639b8 --- /dev/null +++ b/tests/UnityCli.Cli.Tests/AuthTokenComparisonTests.cs @@ -0,0 +1,58 @@ +using UnityCli.Protocol; + +namespace UnityCli.Cli.Tests; + +public sealed class AuthTokenComparisonTests +{ + [Fact] + public void FixedTimeEquals_WhenIdentical_ReturnsTrue() + { + string token = new string('a', 64); + + Assert.True(AuthTokenComparison.FixedTimeEquals(token, new string('a', 64))); + } + + [Fact] + public void FixedTimeEquals_WhenLastCharacterDiffers_ReturnsFalse() + { + string expected = new string('a', 64); + string candidate = expected.Substring(0, 63) + "b"; + + Assert.False(AuthTokenComparison.FixedTimeEquals(expected, candidate)); + } + + [Fact] + public void FixedTimeEquals_WhenFirstCharacterDiffers_ReturnsFalse() + { + string expected = new string('a', 64); + string candidate = "b" + expected.Substring(1); + + Assert.False(AuthTokenComparison.FixedTimeEquals(expected, candidate)); + } + + [Fact] + public void FixedTimeEquals_IsCaseSensitive() + { + Assert.False(AuthTokenComparison.FixedTimeEquals("abcdef", "ABCDEF")); + } + + [Theory] + [InlineData("token", "")] + [InlineData("", "token")] + [InlineData("", "")] + [InlineData("token", null)] + [InlineData(null, "token")] + [InlineData(null, null)] + public void FixedTimeEquals_WhenEitherSideIsMissing_ReturnsFalse(string? expected, string? candidate) + { + Assert.False(AuthTokenComparison.FixedTimeEquals(expected!, candidate!)); + } + + [Theory] + [InlineData("abcdef", "abcde")] + [InlineData("abcde", "abcdef")] + public void FixedTimeEquals_WhenPrefixMatchesButLengthDiffers_ReturnsFalse(string expected, string candidate) + { + Assert.False(AuthTokenComparison.FixedTimeEquals(expected, candidate)); + } +} diff --git a/tests/UnityCli.Cli.Tests/BridgeDisableSwitchTests.cs b/tests/UnityCli.Cli.Tests/BridgeDisableSwitchTests.cs new file mode 100644 index 0000000..56bebb2 --- /dev/null +++ b/tests/UnityCli.Cli.Tests/BridgeDisableSwitchTests.cs @@ -0,0 +1,88 @@ +using UnityCli.DocGen; +using UnityCli.Protocol; + +namespace UnityCli.Cli.Tests; + +/// +/// The opt-out switch a CI/release build job uses to keep the bridge from booting. Parsing is +/// covered directly; the ordering guard below is the part that cannot be unit-tested, because the +/// switch is only worth anything if it runs before the host is constructed. +/// +public sealed class BridgeDisableSwitchTests +{ + private static readonly string[] NoArgs = Array.Empty(); + + [Theory] + [InlineData("1")] + [InlineData("true")] + [InlineData("TRUE")] + [InlineData("yes")] + [InlineData(" 1 ")] + public void IsDisabled_WhenEnvironmentValueIsSet_DisablesTheBridge(string value) + { + Assert.True(BridgeDisableSwitch.IsDisabled(value, NoArgs)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("0")] + [InlineData("false")] + [InlineData("False")] + public void IsDisabled_WhenEnvironmentValueMeansOff_LeavesTheBridgeRunning(string? value) + { + Assert.False(BridgeDisableSwitch.IsDisabled(value!, NoArgs)); + } + + [Fact] + public void IsDisabled_WhenCommandLineFlagIsPresent_DisablesTheBridge() + { + string[] args = { "/Applications/Unity", "-batchmode", "-noUnityCliBridge", "-quit" }; + + Assert.True(BridgeDisableSwitch.IsDisabled(null!, args)); + } + + [Fact] + public void IsDisabled_WhenCommandLineFlagCasingDiffers_StillDisablesTheBridge() + { + string[] args = { "-nounityclibridge" }; + + Assert.True(BridgeDisableSwitch.IsDisabled(null!, args)); + } + + [Fact] + public void IsDisabled_WhenAnUnrelatedFlagIsPresent_LeavesTheBridgeRunning() + { + string[] args = { "-batchmode", "-nographics", "-quit" }; + + Assert.False(BridgeDisableSwitch.IsDisabled(null!, args)); + } + + [Fact] + public void IsDisabled_WhenArgumentsAreNull_DoesNotThrow() + { + Assert.False(BridgeDisableSwitch.IsDisabled(null!, null!)); + } + + [Fact] + public void Bootstrap_ChecksTheSwitchBeforeConstructingTheHost() + { + string repoRoot = RepositoryPaths.FindRepoRoot(AppContext.BaseDirectory); + string source = File.ReadAllText(Path.Combine( + repoRoot, + "unity-package", + "com.yhc509.unity-cli-bridge", + "Editor", + "BridgeHost.cs")); + + int switchIndex = source.IndexOf("BridgeDisableSwitch.IsDisabled(", StringComparison.Ordinal); + int constructionIndex = source.IndexOf("new BridgeHost()", StringComparison.Ordinal); + + Assert.True(switchIndex >= 0, "BridgeHost.cs must consult BridgeDisableSwitch."); + Assert.True(constructionIndex >= 0, "BridgeHost.cs must still construct the host."); + Assert.True( + switchIndex < constructionIndex, + "The disable switch must be evaluated before the host is constructed."); + } +} diff --git a/tests/UnityCli.Cli.Tests/CliArgumentParserTests.cs b/tests/UnityCli.Cli.Tests/CliArgumentParserTests.cs index c3531df..a822807 100644 --- a/tests/UnityCli.Cli.Tests/CliArgumentParserTests.cs +++ b/tests/UnityCli.Cli.Tests/CliArgumentParserTests.cs @@ -1487,6 +1487,39 @@ public void ToEnvelope_Screenshot_IncludesLightweightOptions() Assert.Equal(1024, arguments.GetProperty("maxWidth").GetInt32()); } + [Fact] + public void ToEnvelope_Screenshot_WithoutMaxWidth_LeavesTheDefaultToTheBridge() + { + var parsed = CliArgumentParser.Parse(["screenshot"]); + + using var document = JsonDocument.Parse(parsed.ToEnvelope().argumentsJson); + + Assert.Equal(0, document.RootElement.GetProperty("maxWidth").GetInt32()); + } + + [Fact] + public void ToEnvelope_Screenshot_WithZeroMaxWidth_SendsTheUncappedSentinel() + { + // 0 on the wire already means "unspecified", so an explicit opt-out has to travel as + // something the bridge cannot read as silence. + var parsed = CliArgumentParser.Parse(["screenshot", "--max-width", "0"]); + + using var document = JsonDocument.Parse(parsed.ToEnvelope().argumentsJson); + + Assert.Equal(0, parsed.ScreenshotMaxWidth); + Assert.Equal( + ScreenshotDefaults.MaxWidthUncapped, + document.RootElement.GetProperty("maxWidth").GetInt32()); + } + + [Fact] + public void Parse_Screenshot_RejectsNegativeMaxWidth() + { + Assert.Throws(() => CliArgumentParser.Parse([ + "screenshot", "--max-width", "-1" + ])); + } + [Fact] public void Parse_Screenshot_AcceptsCameraName() { diff --git a/tests/UnityCli.Cli.Tests/EditorTickPumpPolicyTests.cs b/tests/UnityCli.Cli.Tests/EditorTickPumpPolicyTests.cs new file mode 100644 index 0000000..c76f858 --- /dev/null +++ b/tests/UnityCli.Cli.Tests/EditorTickPumpPolicyTests.cs @@ -0,0 +1,111 @@ +using UnityCli.DocGen; + +namespace UnityCli.Cli.Tests; + +/// +/// The tick pump only speeds up work that is subscribed through it. These are source-level guards +/// for the wiring — the pump itself needs a live editor, so it cannot be exercised here. +/// +public sealed class EditorTickPumpPolicyTests +{ + public static TheoryData DeferredHandlerFiles() + { + var files = new TheoryData(); + files.Add("TestCommandHandler.cs"); + files.Add("TestCommandHandler.PlayMode.cs"); + files.Add("PackageCommandHandler.cs"); + files.Add("ProfileCommandHandler.cs"); + files.Add("ProfileCommandHandler.Capture.cs"); + files.Add("ProfileCommandHandler.Memory.cs"); + files.Add("QaCommandHandler.cs"); + files.Add("QaCommandHandler.Sequence.cs"); + files.Add("RecordCommandHandler.cs"); + return files; + } + + private static string ReadEditorSource(string fileName) + { + string repoRoot = RepositoryPaths.FindRepoRoot(AppContext.BaseDirectory); + return File.ReadAllText(Path.Combine( + repoRoot, + "unity-package", + "com.yhc509.unity-cli-bridge", + "Editor", + fileName)); + } + + [Theory] + [MemberData(nameof(DeferredHandlerFiles))] + public void DeferredHandlers_SubscribeThroughTheTickPump(string fileName) + { + string source = ReadEditorSource(fileName); + + // A direct subscription still works, it just silently opts that flow out of the pump and + // leaves it running at the unfocused editor's throttled tick rate. + Assert.DoesNotContain("EditorApplication.update +=", source); + Assert.DoesNotContain("EditorApplication.update -=", source); + Assert.Contains("EditorTickPump.", source); + } + + [Theory] + [MemberData(nameof(DeferredHandlerFiles))] + public void DeferredHandlers_ReleaseThePumpOnEveryPathThatSubscribes(string fileName) + { + string source = ReadEditorSource(fileName); + int adds = CountOccurrences(source, "EditorTickPump.Add("); + int removes = CountOccurrences(source, "EditorTickPump.Remove("); + + // Removes are idempotent and the poll bodies exit on several paths, so the useful + // invariant is that nothing subscribes without a matching teardown somewhere. + Assert.True(adds > 0, fileName + " should drive at least one deferred poll."); + Assert.True( + removes >= adds, + fileName + " subscribes " + adds + " poll(s) but only unsubscribes " + removes + " time(s)."); + } + + [Fact] + public void TickPump_BindsSignalTickReflectivelyAndDegradesWhenMissing() + { + string source = ReadEditorSource("EditorTickPump.cs"); + + Assert.Contains("\"SignalTick\"", source); + Assert.Contains("BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public", source); + Assert.Contains("Delegate.CreateDelegate(typeof(Action), method)", source); + + // An internal API that disappears must cost speed, not availability. + Assert.DoesNotContain("throw new", source); + } + + [Fact] + public void TickPump_RunsOnlyWhileDeferredWorkIsSubscribed() + { + string source = ReadEditorSource("EditorTickPump.cs"); + + Assert.Contains("_subscribers.Count > 0", source); + Assert.Contains("EditorApplication.update -= Pump;", source); + } + + [Fact] + public void BridgeHost_KeepsItsOwnUpdateHookOutOfThePump() + { + string source = ReadEditorSource("BridgeHost.cs"); + + // The host update hook is permanent; routing it through the pump would pin the editor at + // full tick rate for the entire editor session. + Assert.Contains("EditorApplication.update += OnEditorUpdate;", source); + Assert.DoesNotContain("EditorTickPump.Add(OnEditorUpdate)", source); + } + + private static int CountOccurrences(string source, string value) + { + int count = 0; + int index = source.IndexOf(value, StringComparison.Ordinal); + while (index >= 0) + { + count++; + index = source.IndexOf(value, index + value.Length, StringComparison.Ordinal); + } + + return count; + } +} diff --git a/tests/UnityCli.Cli.Tests/ListenerWatchdogPolicyTests.cs b/tests/UnityCli.Cli.Tests/ListenerWatchdogPolicyTests.cs new file mode 100644 index 0000000..0dfd551 --- /dev/null +++ b/tests/UnityCli.Cli.Tests/ListenerWatchdogPolicyTests.cs @@ -0,0 +1,129 @@ +using UnityCli.Protocol; + +namespace UnityCli.Cli.Tests; + +public sealed class ListenerWatchdogPolicyTests +{ + private const double Interval = 5.0; + private const int MaxAttempts = 3; + + private static ListenerWatchdogPolicy CreatePolicy() + { + return new ListenerWatchdogPolicy(Interval, MaxAttempts, 0.0); + } + + private static ListenerWatchdogDecision Healthy(ListenerWatchdogPolicy policy, double now) + { + return policy.Evaluate(isListenerReady: true, isListenerStarting: false, isEditorBusy: false, nowSeconds: now); + } + + private static ListenerWatchdogDecision Dead(ListenerWatchdogPolicy policy, double now) + { + return policy.Evaluate(isListenerReady: false, isListenerStarting: false, isEditorBusy: false, nowSeconds: now); + } + + [Fact] + public void Evaluate_WhenListenerHealthy_StaysQuiet() + { + ListenerWatchdogPolicy policy = CreatePolicy(); + + Assert.Equal(ListenerWatchdogDecision.None, Healthy(policy, 1.0)); + Assert.Equal(ListenerWatchdogDecision.None, Healthy(policy, 100.0)); + Assert.Equal(0, policy.RecoveryAttempts); + } + + [Fact] + public void Evaluate_WhenListenerDiesButIntervalHasNotElapsed_Waits() + { + ListenerWatchdogPolicy policy = CreatePolicy(); + + Assert.Equal(ListenerWatchdogDecision.None, Dead(policy, 4.9)); + } + + [Fact] + public void Evaluate_WhenListenerStaysDeadPastInterval_RequestsRestart() + { + ListenerWatchdogPolicy policy = CreatePolicy(); + + Assert.Equal(ListenerWatchdogDecision.Restart, Dead(policy, 5.0)); + Assert.Equal(1, policy.RecoveryAttempts); + } + + [Fact] + public void Evaluate_WhileBindIsInFlight_DoesNotRaceIt() + { + ListenerWatchdogPolicy policy = CreatePolicy(); + + Assert.Equal(ListenerWatchdogDecision.Restart, Dead(policy, 5.0)); + + // The restart is running: not ready yet, but not dead either. + for (double now = 5.1; now <= 30.0; now += 1.0) + { + Assert.Equal( + ListenerWatchdogDecision.None, + policy.Evaluate(isListenerReady: false, isListenerStarting: true, isEditorBusy: false, nowSeconds: now)); + } + + Assert.Equal(1, policy.RecoveryAttempts); + } + + [Fact] + public void Evaluate_WhileEditorIsBusy_DoesNotFightDomainReload() + { + ListenerWatchdogPolicy policy = CreatePolicy(); + + for (double now = 1.0; now <= 60.0; now += 1.0) + { + Assert.Equal( + ListenerWatchdogDecision.None, + policy.Evaluate(isListenerReady: false, isListenerStarting: false, isEditorBusy: true, nowSeconds: now)); + } + + Assert.Equal(0, policy.RecoveryAttempts); + } + + [Fact] + public void Evaluate_WhenListenerComesBack_ReportsRecoveredOnceAndResetsAttempts() + { + ListenerWatchdogPolicy policy = CreatePolicy(); + + Assert.Equal(ListenerWatchdogDecision.Restart, Dead(policy, 5.0)); + Assert.Equal(ListenerWatchdogDecision.Recovered, Healthy(policy, 6.0)); + Assert.Equal(0, policy.RecoveryAttempts); + Assert.Equal(ListenerWatchdogDecision.None, Healthy(policy, 7.0)); + } + + [Fact] + public void Evaluate_AfterRecovery_AllowsTheFullAttemptBudgetAgain() + { + ListenerWatchdogPolicy policy = CreatePolicy(); + + Assert.Equal(ListenerWatchdogDecision.Restart, Dead(policy, 5.0)); + Assert.Equal(ListenerWatchdogDecision.Recovered, Healthy(policy, 6.0)); + + for (int attempt = 1; attempt <= MaxAttempts; attempt++) + { + Assert.Equal(ListenerWatchdogDecision.Restart, Dead(policy, 6.0 + (attempt * Interval))); + } + + Assert.False(policy.HasAbandoned); + } + + [Fact] + public void Evaluate_WhenRecoveryAttemptsAreExhausted_AbandonsExactlyOnce() + { + ListenerWatchdogPolicy policy = CreatePolicy(); + + for (int attempt = 1; attempt <= MaxAttempts; attempt++) + { + Assert.Equal(ListenerWatchdogDecision.Restart, Dead(policy, attempt * Interval)); + } + + Assert.Equal(ListenerWatchdogDecision.Abandon, Dead(policy, (MaxAttempts + 1) * Interval)); + Assert.True(policy.HasAbandoned); + + // Never advertises again, and never re-reports the abandon. + Assert.Equal(ListenerWatchdogDecision.None, Dead(policy, (MaxAttempts + 5) * Interval)); + Assert.Equal(ListenerWatchdogDecision.None, Healthy(policy, (MaxAttempts + 6) * Interval)); + } +} diff --git a/tests/UnityCli.Cli.Tests/ListenerWatchdogWiringTests.cs b/tests/UnityCli.Cli.Tests/ListenerWatchdogWiringTests.cs new file mode 100644 index 0000000..cd5b979 --- /dev/null +++ b/tests/UnityCli.Cli.Tests/ListenerWatchdogWiringTests.cs @@ -0,0 +1,100 @@ +using UnityCli.DocGen; + +namespace UnityCli.Cli.Tests; + +/// +/// Source-level guards for the listener watchdog wiring in BridgeHost. The policy itself is +/// covered by ; what cannot be unit-tested is that the +/// host actually reports listener death and stops advertising when recovery fails. +/// +public sealed class ListenerWatchdogWiringTests +{ + private static string ReadBridgeHost() + { + string repoRoot = RepositoryPaths.FindRepoRoot(AppContext.BaseDirectory); + return File.ReadAllText(Path.Combine( + repoRoot, + "unity-package", + "com.yhc509.unity-cli-bridge", + "Editor", + "BridgeHost.cs")); + } + + [Fact] + public void BridgeHost_RunsTheWatchdogFromTheEditorUpdateHook() + { + string source = ReadBridgeHost(); + + Assert.Contains("RunListenerWatchdog();", source); + Assert.Contains("new ListenerWatchdogPolicy(", source); + Assert.Contains("EditorApplication.isCompiling || EditorApplication.isUpdating", source); + } + + [Fact] + public void BridgeHost_ClearsListenerReadinessWhenAnAcceptLoopEnds() + { + string source = ReadBridgeHost(); + + // Both accept loops must report death, otherwise the heartbeat keeps advertising an + // instance the CLI can resolve but never connect to. + Assert.True( + CountOccurrences(source, "_isListenerReady = false;") >= 2, + "Each accept loop should clear listener readiness in its finally block."); + } + + [Fact] + public void BridgeHost_NeverRacesAnInFlightBind() + { + string source = ReadBridgeHost(); + + Assert.Contains("_isListenerStarting = true;", source); + Assert.True( + CountOccurrences(source, "_isListenerStarting = false;") >= 4, + "Every bind outcome — success, cancellation, failure — must clear the starting flag."); + } + + [Fact] + public void BridgeHost_StopsAdvertisingWhenRecoveryIsAbandoned() + { + string source = ReadBridgeHost(); + + Assert.Contains("case ListenerWatchdogDecision.Abandon:", source); + Assert.Contains("private void AbandonListener()", source); + + int abandonIndex = source.IndexOf("private void AbandonListener()", StringComparison.Ordinal); + string abandonBody = source.Substring(abandonIndex); + Assert.Contains("UnregisterInstance();", abandonBody); + + // The heartbeat branch has to honour the abandoned state. + Assert.Contains("if (_isListenerAbandoned)", source); + } + + [Fact] + public void BridgeHost_RepublishesRegistryStateAfterARebind() + { + string source = ReadBridgeHost(); + + int recoveredIndex = source.IndexOf("private void OnListenerRecovered()", StringComparison.Ordinal); + Assert.True(recoveredIndex >= 0, "The watchdog needs a recovery handler."); + + string recoveredBody = source.Substring(recoveredIndex); + Assert.Contains("WriteTokenSidecarSafely();", recoveredBody); + Assert.Contains("RegisterInstance();", recoveredBody); + + // A rebind can land on a different hash; the old sidecar would otherwise keep a live token. + Assert.Contains("DeleteTokenSidecar(_registryFilePath, _projectHashBeforeRestart)", recoveredBody); + } + + private static int CountOccurrences(string source, string value) + { + int count = 0; + int index = source.IndexOf(value, StringComparison.Ordinal); + while (index >= 0) + { + count++; + index = source.IndexOf(value, index + value.Length, StringComparison.Ordinal); + } + + return count; + } +} diff --git a/tests/UnityCli.Cli.Tests/PackageDeferredPolicyTests.cs b/tests/UnityCli.Cli.Tests/PackageDeferredPolicyTests.cs index 14697e6..69a5a7d 100644 --- a/tests/UnityCli.Cli.Tests/PackageDeferredPolicyTests.cs +++ b/tests/UnityCli.Cli.Tests/PackageDeferredPolicyTests.cs @@ -77,7 +77,7 @@ public void EditorPackageDispatch_UsesDeferredRouteWithoutSleepPolling() Assert.DoesNotContain("Thread.Sleep", packageHandler); Assert.DoesNotContain("WaitForRequest", packageHandler); - Assert.Contains("EditorApplication.update += Poll;", packageHandler); + Assert.Contains("EditorTickPump.Add(Poll);", packageHandler); Assert.Contains("ProtocolConstants.ErrorPackageTimeout", packageHandler); } @@ -124,7 +124,7 @@ public void EditorPackageDispatch_KeepsActiveRequestAfterTimeoutUntilUnityReques Assert.Contains("private static void StartBackgroundActiveRequestTracker(Request request)", packageHandler); Assert.Contains("new ActiveRequestCompletionTracker(", packageHandler); Assert.Contains("() => request.IsCompleted", packageHandler); - Assert.Contains("() => EditorApplication.update -= BackgroundPoll", packageHandler); + Assert.Contains("() => EditorTickPump.Remove(BackgroundPoll)", packageHandler); } private static string ReadPackageHandler() diff --git a/tests/UnityCli.Cli.Tests/ProtocolConstantsTests.cs b/tests/UnityCli.Cli.Tests/ProtocolConstantsTests.cs index 050684b..ae45e5f 100644 --- a/tests/UnityCli.Cli.Tests/ProtocolConstantsTests.cs +++ b/tests/UnityCli.Cli.Tests/ProtocolConstantsTests.cs @@ -5,9 +5,9 @@ namespace UnityCli.Cli.Tests; public sealed class ProtocolConstantsTests { [Fact] - public void ProtocolVersion_BumpedToSeven_ForHeadlessEditorCommands() + public void ProtocolVersion_BumpedToEight_ForAgentFacingScreenshotDefaults() { - Assert.Equal("7", ProtocolConstants.ProtocolVersion); + Assert.Equal("8", ProtocolConstants.ProtocolVersion); } [Fact] diff --git a/tests/UnityCli.Cli.Tests/ReleaseBuildSurfaceTests.cs b/tests/UnityCli.Cli.Tests/ReleaseBuildSurfaceTests.cs new file mode 100644 index 0000000..b8f572f --- /dev/null +++ b/tests/UnityCli.Cli.Tests/ReleaseBuildSurfaceTests.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using UnityCli.DocGen; + +namespace UnityCli.Cli.Tests; + +/// +/// Guards for what the UPM package contributes to a consuming project's *player* build. +/// +/// The bridge itself is editor-only, but assembly definitions and package dependencies are plain +/// JSON that nothing else in the build validates — a one-character edit can silently push the +/// protocol layer (registry file I/O, the full command catalog, process spawning for chmod) into +/// a shipped game, or re-impose Unity Recorder on every consumer. These tests pin that surface. +/// +public sealed class ReleaseBuildSurfaceTests +{ + private static readonly string[] PlayerFacingRuntimeFiles = + { + "IQaQueryable.cs", + "IQaTappable.cs", + "QaTappable.cs", + "QaTargetAttribute.cs", + }; + + private static string PackageRoot() + { + return Path.Combine( + RepositoryPaths.FindRepoRoot(AppContext.BaseDirectory), + "unity-package", + "com.yhc509.unity-cli-bridge"); + } + + private static JsonElement ReadJson(params string[] relativeParts) + { + string path = Path.Combine(PackageRoot(), Path.Combine(relativeParts)); + return JsonDocument.Parse(File.ReadAllText(path)).RootElement.Clone(); + } + + private static string[] StringArray(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out JsonElement array)) + { + return Array.Empty(); + } + + return array.EnumerateArray().Select(item => item.GetString() ?? string.Empty).ToArray(); + } + + [Fact] + public void RuntimeAssembly_ShipsOnlyTheQaMarkersToPlayerBuilds() + { + // The runtime assembly is the one thing that reaches a shipped game, so its contents are + // the contract: hand-authored marker types a project references on purpose, nothing else. + string runtimeDirectory = Path.Combine(PackageRoot(), "Runtime"); + string[] topLevelSources = Directory + .GetFiles(runtimeDirectory, "*.cs", SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) + .OfType() + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(PlayerFacingRuntimeFiles.OrderBy(name => name, StringComparer.Ordinal), topLevelSources); + } + + [Fact] + public void RuntimeAssembly_StaysAvailableOnEveryPlatform() + { + JsonElement asmdef = ReadJson("Runtime", "UnityCliBridge.Bridge.Runtime.asmdef"); + + Assert.Empty(StringArray(asmdef, "includePlatforms")); + Assert.True(asmdef.GetProperty("autoReferenced").GetBoolean()); + } + + [Fact] + public void ProtocolAssembly_IsEditorOnly() + { + // Everything under Runtime/Protocol/ is bridge infrastructure. It lives under Runtime/ only + // because the CLI compiles the same files (see UnityCli.Protocol.csproj); its own asmdef is + // what keeps it out of player builds. + JsonElement asmdef = ReadJson("Runtime", "Protocol", "UnityCliBridge.Bridge.Protocol.asmdef"); + + Assert.Equal(new[] { "Editor" }, StringArray(asmdef, "includePlatforms")); + Assert.False( + asmdef.GetProperty("autoReferenced").GetBoolean(), + "Protocol types are internal plumbing; consuming projects must not pick them up implicitly."); + } + + [Fact] + public void ProtocolSources_StayWhereTheCliCompilesThemFrom() + { + string protocolDirectory = Path.Combine(PackageRoot(), "Runtime", "Protocol"); + + Assert.True(Directory.Exists(protocolDirectory)); + Assert.NotEmpty(Directory.GetFiles(protocolDirectory, "*.cs", SearchOption.TopDirectoryOnly)); + } + + [Fact] + public void EditorAssembly_ReferencesTheProtocolAssembly() + { + JsonElement asmdef = ReadJson("Editor", "UnityCliBridge.Bridge.Editor.asmdef"); + string[] references = StringArray(asmdef, "references"); + + Assert.Contains("UnityCliBridge.Bridge.Protocol", references); + Assert.Contains("UnityCliBridge.Bridge.Runtime", references); + Assert.Equal(new[] { "Editor" }, StringArray(asmdef, "includePlatforms")); + } + + [Fact] + public void Package_DoesNotForceUnityRecorderOnConsumers() + { + JsonElement dependencies = ReadJson("package.json").GetProperty("dependencies"); + + Assert.False( + dependencies.TryGetProperty("com.unity.recorder", out _), + "Unity Recorder is optional; gate it with the UNITY_CLI_BRIDGE_RECORDER versionDefine instead."); + } + + [Fact] + public void Package_DeclaresTheTestFrameworkItActuallyRequires() + { + // The test handlers use TestRunnerApi types unguarded, so the package is only installable + // in a project that has the test framework. It used to arrive transitively through Unity + // Recorder; once Recorder became optional, the requirement had to be stated outright. + JsonElement dependencies = ReadJson("package.json").GetProperty("dependencies"); + + Assert.True(dependencies.TryGetProperty("com.unity.test-framework", out _)); + } + + [Fact] + public void EditorAssembly_DefinesTheOptionalRecorderSymbol() + { + JsonElement asmdef = ReadJson("Editor", "UnityCliBridge.Bridge.Editor.asmdef"); + + bool hasRecorderDefine = asmdef.GetProperty("versionDefines").EnumerateArray().Any(entry => + entry.GetProperty("name").GetString() == "com.unity.recorder" + && entry.GetProperty("define").GetString() == "UNITY_CLI_BRIDGE_RECORDER"); + + Assert.True(hasRecorderDefine, "com.unity.recorder must map to UNITY_CLI_BRIDGE_RECORDER."); + } + + [Fact] + public void RecordHandler_KeepsEveryRecorderApiBehindTheDefine() + { + string source = File.ReadAllText( + Path.Combine(PackageRoot(), "Editor", "RecordCommandHandler.cs")); + + Assert.Contains("#if UNITY_CLI_BRIDGE_RECORDER\nusing UnityEditor.Recorder;", source.Replace("\r\n", "\n")); + Assert.Contains("#if !UNITY_CLI_BRIDGE_RECORDER", source); + Assert.Contains("unity-cli package add --name com.unity.recorder", source); + + // A bare `using UnityEditor.Recorder` outside the guard would break projects without the + // package, which is exactly what the guard exists to prevent. + int guardedUsings = source.Split("using UnityEditor.Recorder").Length - 1; + Assert.Equal(2, guardedUsings); + } +} diff --git a/tests/UnityCli.Cli.Tests/ScreenshotDefaultsTests.cs b/tests/UnityCli.Cli.Tests/ScreenshotDefaultsTests.cs new file mode 100644 index 0000000..89e68da --- /dev/null +++ b/tests/UnityCli.Cli.Tests/ScreenshotDefaultsTests.cs @@ -0,0 +1,100 @@ +using UnityCli.Protocol; +using Xunit; + +namespace UnityCli.Cli.Tests; + +public class ScreenshotDefaultsTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void TryResolveFormat_WithNoRequestAndNoPath_DefaultsToJpg(string? requestedFormat) + { + Assert.True(ScreenshotDefaults.TryResolveFormat(requestedFormat!, null!, out string format)); + Assert.Equal(ScreenshotDefaults.FormatJpg, format); + } + + [Theory] + [InlineData("/tmp/shot.png")] + [InlineData("/tmp/shot.PNG")] + [InlineData(" /tmp/nested.dir/shot.Png ")] + public void TryResolveFormat_WithPngOutputPath_KeepsPng(string outputPath) + { + // Writing JPEG bytes into a file the caller named .png would be a worse outcome than the + // tokens the default saves, so an explicit extension outranks the default. + Assert.True(ScreenshotDefaults.TryResolveFormat(null!, outputPath, out string format)); + Assert.Equal(ScreenshotDefaults.FormatPng, format); + } + + [Theory] + [InlineData("/tmp/shot.jpg")] + [InlineData("/tmp/shot.jpeg")] + [InlineData("/tmp/shot.webp")] + [InlineData("/tmp/shot")] + [InlineData(".png")] + public void TryResolveFormat_WithNonPngOutputPath_UsesTheJpgDefault(string outputPath) + { + Assert.True(ScreenshotDefaults.TryResolveFormat(null!, outputPath, out string format)); + Assert.Equal(ScreenshotDefaults.FormatJpg, format); + } + + [Theory] + [InlineData("png", "png")] + [InlineData("PNG", "png")] + [InlineData(" jpg ", "jpg")] + [InlineData("jpeg", "jpg")] + [InlineData("JPEG", "jpg")] + public void TryResolveFormat_WithExplicitFormat_OverridesTheOutputExtension(string requested, string expected) + { + Assert.True(ScreenshotDefaults.TryResolveFormat(requested, "/tmp/shot.png", out string format)); + Assert.Equal(expected, format); + } + + [Theory] + [InlineData("bmp")] + [InlineData("gif")] + [InlineData("jpgg")] + public void TryResolveFormat_WithUnknownFormat_Fails(string requested) + { + Assert.False(ScreenshotDefaults.TryResolveFormat(requested, null!, out _)); + } + + [Fact] + public void ResolveMaxWidth_WithNothingSpecified_AppliesTheDefaultCap() + { + Assert.Equal(ScreenshotDefaults.DefaultMaxWidth, ScreenshotDefaults.ResolveMaxWidth(0, 0, 0)); + } + + [Fact] + public void ResolveMaxWidth_WithExplicitCap_UsesIt() + { + Assert.Equal(640, ScreenshotDefaults.ResolveMaxWidth(640, 0, 0)); + } + + [Fact] + public void ResolveMaxWidth_WithUncappedSentinel_DisablesTheCap() + { + Assert.Equal(0, ScreenshotDefaults.ResolveMaxWidth(ScreenshotDefaults.MaxWidthUncapped, 0, 0)); + } + + [Theory] + [InlineData(1920, 0)] + [InlineData(0, 1080)] + [InlineData(1920, 1080)] + public void ResolveMaxWidth_WithAnExplicitSize_LeavesTheSizeAlone(int width, int height) + { + // The explicit-size gate predates the default cap; keeping it is what stops this change + // from silently shrinking captures that already state the size they want. + Assert.Equal(0, ScreenshotDefaults.ResolveMaxWidth(0, width, height)); + Assert.Equal(0, ScreenshotDefaults.ResolveMaxWidth(512, width, height)); + } + + [Theory] + [InlineData("jpg", ".jpg")] + [InlineData("png", ".png")] + public void FileExtension_MatchesTheResolvedFormat(string format, string expected) + { + Assert.Equal(expected, ScreenshotDefaults.FileExtension(format)); + } +} diff --git a/tools/skills/unity-cli-operator/SKILL.md b/tools/skills/unity-cli-operator/SKILL.md index ec356cd..1f051ba 100644 --- a/tools/skills/unity-cli-operator/SKILL.md +++ b/tools/skills/unity-cli-operator/SKILL.md @@ -84,8 +84,8 @@ for (int i = 0; i < workItems.Count; i++) - scene path는 `/Root[0]/Child[0]` 형식으로 쓰고 `/`는 virtual scene root로 본다. - root prefab 이름은 Unity 저장 규칙 때문에 파일 이름으로 정규화된다고 가정한다. - `screenshot`은 `--view` 생략 시 game이 기본이다. Scene View가 필요하면 `--view scene`을 명시한다. -- **에이전트가 읽을 스크린샷은 `--format jpg --quality 75 --max-width 1024`를 기본으로 붙인다.** 기본 PNG full-resolution은 이미지 토큰을 크게 소비한다(1080p 기준 ~72% 절약). lossless가 필요할 때만 `--format png`. -- Play Mode 영상을 남겨야 하면 `record start --duration N --wait --path /tmp/out.mp4`를 쓴다. 수동 녹화는 `record start` 후 `record status`, `record stop` 순서로 종료한다. `record start`는 Play Mode 전용이며 Unity Recorder dependency가 필요하다. +- **스크린샷은 옵션 없이 그대로 찍으면 된다.** 기본값이 이미 JPEG quality 75 + 1024px 가로 축소라 에이전트가 읽기 좋은 크기로 나온다(1080p PNG 대비 이미지 토큰 ~72% 절약). lossless 원본이 필요할 때만 `--format png --max-width 0`을 붙인다. `--path`가 `.png`로 끝나면 `--format` 없이도 PNG로 저장된다. +- Play Mode 영상을 남겨야 하면 `record start --duration N --wait --path /tmp/out.mp4`를 쓴다. 수동 녹화는 `record start` 후 `record status`, `record stop` 순서로 종료한다. `record start`는 Play Mode 전용이고 `com.unity.recorder`가 설치된 프로젝트에서만 동작한다 — 미설치면 `RECORD_FAILED`와 함께 `unity-cli package add --name com.unity.recorder` 안내가 돌아오므로 그대로 설치한 뒤 재시도한다. - `qa tap --x --y`에는 `screenshot`에서 확인한 이미지 좌표를 그대로 넣는다. 응답의 `imageOrigin`은 `top-left`, `coordinateOrigin`은 `bottom-left`다. - `qa click`, `qa tap`, `qa swipe`는 기본 좌클릭/좌드래그이며, 우클릭 입력 경로를 검증할 때는 `--button right`를 붙인다. - 별도 Y-flip이나 해상도 스케일 변환은 하지 않는다. Bridge가 마지막 `screenshot` 크기 또는 명시한 `--screenshot-width`/`--screenshot-height`를 기준으로 내부 처리한다. @@ -161,6 +161,8 @@ unity-cli does not have dedicated script create/delete commands. Use this combin **Friendly key mapping:** Rigidbody, Collider, Renderer, Light, and Camera values accept common keys like `mass`, `damping`, `isTrigger`, `materials[0]`, `shadowStrength`, and `fieldOfView`, which resolve to Unity `SerializedProperty.propertyPath` values. If a key is not found, use `list-components` then `inspect --with-values`. +**Value 형식:** Vector2/3/4, Vector2Int/Vector3Int, Quaternion, Rect, RectInt, Color는 member object(`{"x":1,"y":2,"z":3}`)와 배열 축약(`[1,2,3]`) 둘 다 받는다. Color 배열은 alpha를 생략하면 1이다. 값이 문자열로 감싸여 들어와도(`"[1,2,3]"`, `"{\"x\":1}"`) 브릿지가 다시 파싱하지만, **정상 JSON 값으로 보내는 것이 원칙**이다 — 문자열 감싸기는 asset path나 enum 이름 같은 진짜 문자열 필드에서는 풀리지 않는다. + ## Convenience Commands — 편의 명령 우선 사용 원칙 아래 작업에는 `scene patch --spec-json` 대신 전용 편의 명령을 우선 사용한다. 호출 횟수와 토큰을 절약할 수 있다. diff --git a/tools/skills/unity-cli-operator/references/command-flows.md b/tools/skills/unity-cli-operator/references/command-flows.md index 0713e8b..b699e08 100644 --- a/tools/skills/unity-cli-operator/references/command-flows.md +++ b/tools/skills/unity-cli-operator/references/command-flows.md @@ -200,14 +200,14 @@ ucli material info --project "$PROJECT" --path Assets/Materials/MyMat.mat --omit ## 스크린샷 ```bash -# Game View 캡처 — 에이전트가 읽을 캡처는 jpg + max-width로 토큰 절약 (--view 생략 시 game이 기본) -ucli screenshot --project "$PROJECT" --path /tmp/capture.jpg --format jpg --quality 75 --max-width 1024 --output compact +# Game View 캡처 — 기본값이 이미 jpg q75 + 1024px 축소다 (--view 생략 시 game이 기본) +ucli screenshot --project "$PROJECT" --path /tmp/capture.jpg --output compact -# lossless가 필요할 때만 PNG -ucli screenshot --project "$PROJECT" --path /tmp/capture.png --output compact +# lossless 원본이 필요할 때만 +ucli screenshot --project "$PROJECT" --path /tmp/capture.png --format png --max-width 0 --output compact # Scene View 캡처 -ucli screenshot --project "$PROJECT" --path /tmp/scene.png --view scene --output compact +ucli screenshot --project "$PROJECT" --path /tmp/scene.jpg --view scene --output compact ``` ## 테스트 러너 diff --git a/tools/skills/unity-cli-operator/references/qa-testing.md b/tools/skills/unity-cli-operator/references/qa-testing.md index 5b9e9c1..ce3ad0d 100644 --- a/tools/skills/unity-cli-operator/references/qa-testing.md +++ b/tools/skills/unity-cli-operator/references/qa-testing.md @@ -12,7 +12,7 @@ status → play → (입력 시뮬레이션) → 검증 (로그 + 스크린샷) 2. `play`로 Play Mode 진입 (자동으로 `runInBackground = true` 설정됨) 3. QA 커맨드로 입력 시뮬레이션 4. **로그 검증**: `read-console --type error --limit N --no-stacktrace` + `read-console --type log --limit N --no-stacktrace` -5. **시각 검증**: `screenshot --view game --format jpg --quality 75 --max-width 1024 --path /tmp/qa-check.jpg` 후 이미지 확인 (lossless가 필요할 때만 `--format png`) +5. **시각 검증**: `screenshot --view game --path /tmp/qa-check.jpg` 후 이미지 확인 (기본이 jpg q75 + 1024px 축소, lossless 원본은 `--format png --max-width 0`) 6. `stop`으로 Play Mode 종료 (`runInBackground` 원래값 복원됨) ## 입력 방식 선택 @@ -135,10 +135,10 @@ ucli read-console --type error --limit 5 --no-stacktrace --project "$P" --json `screenshot --view game`으로 Game View를 캡처해서 시각적 변화를 확인한다. ```bash -# 토큰 절약: 에이전트가 읽을 스크린샷은 jpg + max-width를 기본으로 한다 -ucli screenshot --view game --format jpg --quality 75 --max-width 1024 --path /tmp/qa-check.jpg --project "$P" --json -# lossless가 필요할 때만 PNG -ucli screenshot --view game --path /tmp/qa-check.png --project "$P" --json +# 기본값이 이미 jpg q75 + 1024px 축소라 옵션 없이 그대로 찍으면 된다 +ucli screenshot --view game --path /tmp/qa-check.jpg --project "$P" --json +# lossless 원본이 필요할 때만 +ucli screenshot --view game --format png --max-width 0 --path /tmp/qa-check.png --project "$P" --json # 이후 이미지를 Read 도구로 확인 ``` @@ -186,7 +186,7 @@ ucli qa run-sequence --spec-json @seq-ok.json --timeout 60000 --project "$P" --j ucli qa run-sequence --spec-json @seq-ok.json --record --record-path /tmp/qa-seq.mp4 --timeout 60000 --project "$P" --json ``` -`--record`를 붙이면 sequence가 실행되는 구간만 Unity Recorder로 mp4 캡처하고, 완료 또는 타임아웃 응답의 `recordingPath`에 최종 경로를 담는다. `--record-path`를 생략하면 `Library/com.yhc509.unity-cli-bridge/recordings/` 아래에 저장된다. +`--record`를 붙이면 sequence가 실행되는 구간만 Unity Recorder로 mp4 캡처하고(`com.unity.recorder` 필요), 완료 또는 타임아웃 응답의 `recordingPath`에 최종 경로를 담는다. `--record-path`를 생략하면 `Library/com.yhc509.unity-cli-bridge/recordings/` 아래에 저장된다. `seq-ok.json`: diff --git a/unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md b/unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md index b514538..17d2497 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md +++ b/unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md @@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.5.3] - 2026-08-15 + +### Added +- Long-running commands now finish faster. Test runs, package operations, profiler sampling, recording and `qa run-sequence` all advance one step per Editor update tick, and an idle Editor runs that loop slowly — measured at roughly six ticks per second, whether or not its window is in front. While one of those commands is in flight the bridge now keeps the Editor ticking at full rate and lets it settle back the moment the work finishes, which measured about 1.5× faster end to end (60 ticks: 9.4–10.4 s → 6.0–6.1 s on Unity 6000.3 / macOS). Nothing to turn on, and on an Editor version that does not expose the internal API this relies on, commands simply run at the old speed. +- Component values may now be written as JSON arrays: `"m_Center": [1, 2, 3]` alongside the existing `{"x": 1, "y": 2, "z": 3}`. Vector2/3/4, Vector2Int/Vector3Int, Quaternion, Rect, RectInt and Color all accept the short form, and a Color array may leave off alpha. +- Structured values that arrive quoted — `"[1,2,3]"` or `"{\"x\":1,\"y\":2,\"z\":3}"` instead of the JSON value itself — are now parsed instead of rejected. AI agents produce this shape often, and the previous error read as though the value were wrong rather than the quoting. Only strings that open a JSON object or array are re-read, so asset paths, object-reference handles, enum names and plain text are untouched, and a string that fails to parse still produces the original validation error. +- The bridge can now be kept from starting, for build machines that have no use for it. Set `UNITY_CLI_BRIDGE_DISABLE=1` or pass `-noUnityCliBridge` on the Editor command line and the bridge builds nothing at all — no socket, no instance registration, no token file, no console warnings if any of that would have failed. A CI job that only opens the Editor to produce a player build gets a quiet log and no registry lock to contend over with parallel builds. + +### Changed +- **`screenshot` now defaults to a JPEG capped at 1024px wide** (quality 75) instead of a full-resolution PNG. A capture is by far the most expensive thing an AI agent can ask for — a 1920×1080 PNG bills at roughly 2,040 image tokens, against about 576 for the same frame capped at 1024px — and the capped JPEG is still perfectly readable for checking UI state, which is what nearly every capture is for. Three things keep the change from surprising anyone: `--format png` still gives you lossless, a `--path` ending in `.png` selects PNG on its own, and an explicit `--width`/`--height` is left exactly as you asked. `--max-width 0` turns the cap off while keeping the automatic size. Tap and dump coordinates are unaffected — they scale from the last capture's size, so a downscaled screenshot's coordinates still land correctly. +- Unity Recorder is no longer installed alongside the package. Only the `record` commands and `qa run-sequence --record` ever used it, so projects that do not record no longer carry the dependency. If you do record, add `com.unity.recorder` to your project — `record start` now fails with that exact instruction when it is missing, and `record stop` / `record status` keep working on recordings you already have. `com.unity.test-framework`, which the test commands genuinely require, is now declared outright instead of arriving as a side effect of the Recorder dependency. + +### Fixed +- The package no longer compiles its editor-side plumbing into your game. Everything the bridge uses to talk to the CLI — the instance registry, file-backup transactions, the command catalog and its help text — was being built into player builds as unreachable code, roughly 150 KB of it, along with the strings it carries. Player builds now receive only the QA marker types (`[QaTarget]`, `IQaTappable`, `IQaQueryable`, `QaTappable`) that a project references on purpose, about 7 KB. Nothing changes for editor use, and no code needs updating. +- An Editor whose IPC listener died is no longer advertised as reachable. The listener could stop accepting connections for good — an unexpected socket failure, or a first bind that never succeeded — while the instance kept publishing itself, so the CLI would route to it and fail to connect over and over. The bridge now watches its own listener and re-binds it in place within a few seconds; if it cannot be revived, the instance removes itself from the registry with an Editor console message telling you to restart, instead of staying on the list as a target that never answers. +- The IPC auth token is now compared in fixed time, so the check cannot leak the expected token through timing to other processes on the same machine. Authentication behavior is otherwise unchanged. + ## [0.5.2] - 2026-08-13 ### Added diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/BridgeHost.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/BridgeHost.cs index d54cbf6..2f1bb9c 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/BridgeHost.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/BridgeHost.cs @@ -28,10 +28,26 @@ namespace UnityCliBridge.Bridge.Editor [InitializeOnLoad] internal static class BridgeBootstrap { - private static readonly BridgeHost _host; + private static readonly BridgeHost? _host; static BridgeBootstrap() { + if (BridgeDisableSwitch.IsDisabled( + Environment.GetEnvironmentVariable(BridgeDisableSwitch.EnvironmentVariable), + Environment.GetCommandLineArgs())) + { + // Construct nothing: a disabled bridge must not take the session locks, publish a + // registry entry or a token sidecar, or join the editor update loop. One line so a + // CI job that set the switch by accident can see why the CLI cannot reach it. + UnityEngine.Debug.Log( + "[unity-cli-bridge] Bridge disabled via " + + BridgeDisableSwitch.EnvironmentVariable + + " / " + + BridgeDisableSwitch.CommandLineFlag + + " — CLI commands will not reach this editor."); + return; + } + _host = new BridgeHost(); _host.Start(); } @@ -71,11 +87,17 @@ internal sealed class BridgeHost : IDisposable private bool _isDisposed; private bool _isInstanceRegistered; private volatile bool _isListenerReady; + private volatile bool _isListenerStarting; + private bool _isListenerAbandoned; + private ListenerWatchdogPolicy? _listenerWatchdog; + private string _projectHashBeforeRestart = string.Empty; private string _projectHash = string.Empty; private string _pipeName = string.Empty; private const string AuthTokenSessionStateKey = "UnityCliBridge.AuthToken"; private const int AuthTokenByteLength = 32; private const int ListenerAcquireMaxAttempts = 16; + private const double ListenerWatchdogIntervalSeconds = 5.0; + private const int ListenerWatchdogMaxRecoveryAttempts = 5; private const int NamedPipeMaxServerInstances = 2; private const int NamedPipeProbeTimeoutMilliseconds = 50; @@ -115,6 +137,10 @@ public void Start() _isStarted = true; ConsoleLogBuffer.Start(); _lastHeartbeatTime = EditorApplication.timeSinceStartup; + _listenerWatchdog = new ListenerWatchdogPolicy( + ListenerWatchdogIntervalSeconds, + ListenerWatchdogMaxRecoveryAttempts, + EditorApplication.timeSinceStartup); StartListener(); EditorApplication.update += OnEditorUpdate; @@ -191,6 +217,8 @@ private void Dispose(bool unregisterInstance) private void StartListener() { + // Read by the watchdog: a bind in flight must never be mistaken for a dead listener. + _isListenerStarting = true; #if !UNITY_5_3_OR_NEWER || UNITY_6000_0_OR_NEWER // Unity 6+ / non-Unity: use raw Unix domain sockets for non-Windows. if (Path.DirectorySeparatorChar != '\\') @@ -405,14 +433,18 @@ private async Task RunNamedPipeLoopAsync(CancellationToken cancellationToken) } catch (OperationCanceledException) { + _isListenerStarting = false; return; } catch (Exception exception) { + // Leaves the listener visibly dead; the watchdog retries the acquire. + _isListenerStarting = false; ReportBackgroundException("named pipe listener", exception); return; } + _isListenerStarting = false; NamedPipeServerStream? server = initialServer; try { @@ -445,6 +477,7 @@ private async Task RunNamedPipeLoopAsync(CancellationToken cancellationToken) } finally { + _isListenerReady = false; server?.Dispose(); DisposeNamedPipeOwnershipLock(); } @@ -538,16 +571,20 @@ private async Task RunUnixSocketLoopAsync(CancellationToken cancellationToken) } catch (OperationCanceledException) { + _isListenerStarting = false; return; } catch (Exception exception) { + // Leaves the listener visibly dead; the watchdog retries the acquire. + _isListenerStarting = false; ReportBackgroundException("unix socket listener", exception); return; } _unixListener = listener; _isListenerReady = true; + _isListenerStarting = false; try { @@ -566,6 +603,10 @@ private async Task RunUnixSocketLoopAsync(CancellationToken cancellationToken) } finally { + // The accept loop is gone: an unexpected AcceptAsync failure ends it for good, so + // the listener must stop reading as ready or the registry heartbeat would keep + // advertising an instance nothing can connect to. The watchdog rebinds from here. + _isListenerReady = false; if (ReferenceEquals(_unixListener, listener)) { _unixListener = null; @@ -729,8 +770,9 @@ private async Task HandleStreamClientAsync(Stream stream, CancellationToken canc return; } - if (string.IsNullOrEmpty(command.token) - || !string.Equals(command.token, _authToken, StringComparison.Ordinal)) + // Fixed-time compare: this is the bridge's only authentication gate, and an + // early-exit comparison is observable to other local processes. + if (!AuthTokenComparison.FixedTimeEquals(_authToken, command.token)) { var error = ResponseEnvelope.Failure( command.requestId, @@ -838,7 +880,13 @@ private void OnEditorUpdate() return; } - if (!_isInstanceRegistered && _isListenerReady) + RunListenerWatchdog(); + + if (_isListenerAbandoned) + { + // Nothing can connect any more; heartbeating would only re-advertise a zombie. + } + else if (!_isInstanceRegistered && _isListenerReady) { WriteTokenSidecarSafely(); RegisterInstance(); @@ -889,6 +937,85 @@ private void OnEditorUpdate() } } + // The accept loops have no retry of their own: the Unix loop ends permanently on any + // unexpected AcceptAsync failure, and both loops give up if the very first listener + // acquire fails. Without this the registry would keep advertising a live instance the + // CLI cannot connect to. + private void RunListenerWatchdog() + { + ListenerWatchdogPolicy? watchdog = _listenerWatchdog; + if (watchdog == null) + { + return; + } + + ListenerWatchdogDecision decision = watchdog.Evaluate( + _isListenerReady, + _isListenerStarting, + EditorApplication.isCompiling || EditorApplication.isUpdating, + EditorApplication.timeSinceStartup); + + switch (decision) + { + case ListenerWatchdogDecision.Restart: + _projectHashBeforeRestart = _projectHash; + UnityEngine.Debug.LogWarning(string.Format( + "Unity CLI bridge listener가 죽어 재바인딩을 시도합니다 ({0}/{1}).", + watchdog.RecoveryAttempts, + ListenerWatchdogMaxRecoveryAttempts)); + StartListener(); + break; + case ListenerWatchdogDecision.Recovered: + OnListenerRecovered(); + break; + case ListenerWatchdogDecision.Abandon: + AbandonListener(); + break; + } + } + + private void OnListenerRecovered() + { + // A rebind can settle on a different hash if the old socket name is still taken; the + // sidecar is keyed by hash, so the old one would linger with a live token in it. + if (!string.IsNullOrEmpty(_projectHashBeforeRestart) + && !string.Equals(_projectHashBeforeRestart, _projectHash, StringComparison.Ordinal)) + { + try + { + InstanceRegistryFile.DeleteTokenSidecar(_registryFilePath, _projectHashBeforeRestart); + } + catch (Exception exception) + { + UnityEngine.Debug.LogWarning(string.Format( + "Unity CLI bridge 이전 auth token 정리 실패: {0}", + exception.Message)); + } + } + + _projectHashBeforeRestart = string.Empty; + WriteTokenSidecarSafely(); + RegisterInstance(); + _isInstanceRegistered = true; + _lastHeartbeatTime = EditorApplication.timeSinceStartup; + UnityEngine.Debug.Log("Unity CLI bridge listener 재바인딩 완료: " + _pipeName); + } + + private void AbandonListener() + { + _isListenerAbandoned = true; + UnityEngine.Debug.LogError(string.Format( + "Unity CLI bridge listener를 {0}회 재바인딩했지만 복구하지 못했습니다. " + + "연결되지 않는 인스턴스를 광고하지 않도록 레지스트리에서 제거합니다. Unity Editor를 재시작하세요.", + ListenerWatchdogMaxRecoveryAttempts)); + + if (_isInstanceRegistered) + { + UnregisterInstance(); + _isInstanceRegistered = false; + } + } + private void StartDeferredTestRequest(PendingRequest pending) { CommandEnvelope command = pending.Command; diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/EditorTickPump.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/EditorTickPump.cs new file mode 100644 index 0000000..4d52c1d --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/EditorTickPump.cs @@ -0,0 +1,182 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; +using UnityEditor; + +namespace UnityCliBridge.Bridge.Editor +{ + /// + /// Keeps the editor ticking at full rate while deferred bridge work is in flight. + /// + /// An idle editor runs EditorApplication.update at roughly 6 ticks/second, and every + /// deferred command in the bridge — test runs, package operations, profiler sampling, + /// recording, qa run-sequence — advances one step per tick. Forcing the internal + /// EditorApplication.SignalTick() schedules the next tick immediately, which measured + /// ~1.55x faster on Unity 6000.3.10f1 / macOS (60 ticks: 9.4–10.4s → 6.0–6.1s), focused and + /// unfocused alike. + /// + /// Activation is automatic and scoped: deferred flows subscribe their poll through + /// instead of EditorApplication.update directly, and the pump runs + /// only while at least one of them is subscribed. Because the subscriber set holds the very + /// delegates that are on EditorApplication.update, the pump cannot outlive the work — + /// a leaked pump lease would mean a leaked poll subscription, which the flows already cannot + /// afford. is idempotent, matching the defensive double-unsubscribes the + /// poll bodies already do. + /// + /// SignalTick is an internal API. If reflection cannot bind it the pump degrades to a + /// no-op with a single warning: deferred work still completes, just at the throttled rate. + /// Statics do not survive a domain reload, but neither do the poll subscriptions — flows that + /// restore themselves after a reload re-subscribe through here and re-arm the pump. + /// + internal static class EditorTickPump + { + /// + /// Minimum milliseconds between forced ticks (~60Hz). 0 would signal on every update. + /// Measured on Unity 6000.3.10f1 / macOS the editor update loop settles at ~100ms per tick + /// under the pump either way, so the throttle costs nothing there and caps the forced rate + /// on setups whose loop can run faster. + /// + internal const int DefaultIntervalMs = 16; + + private static readonly HashSet _subscribers = + new HashSet(); + private static readonly Stopwatch _sinceLastSignal = new Stopwatch(); + private static Action? _signalTick; + private static bool _hasResolvedSignalTick; + private static bool _isPumping; + + /// Minimum spacing between forced ticks; see . + internal static int IntervalMs { get; set; } = DefaultIntervalMs; + + /// True while the pump is subscribed to the editor update loop. + internal static bool IsPumping + { + get { return _isPumping; } + } + + /// Deferred polls currently holding the pump open. + internal static int SubscriberCount + { + get { return _subscribers.Count; } + } + + /// + /// Subscribe a deferred poll to the editor update loop and arm the pump. + /// + internal static void Add(EditorApplication.CallbackFunction? callback) + { + if (callback == null) + { + return; + } + + EditorApplication.update += callback; + if (_subscribers.Add(callback)) + { + SyncPumpState(); + } + } + + /// + /// Unsubscribe a deferred poll and disarm the pump once nothing is left. Safe to call for + /// a callback that was never added, or twice for the same one. + /// + internal static void Remove(EditorApplication.CallbackFunction? callback) + { + if (callback == null) + { + return; + } + + EditorApplication.update -= callback; + if (_subscribers.Remove(callback)) + { + SyncPumpState(); + } + } + + private static void SyncPumpState() + { + bool shouldPump = _subscribers.Count > 0 && ResolveSignalTick() != null; + if (shouldPump == _isPumping) + { + return; + } + + _isPumping = shouldPump; + if (shouldPump) + { + _sinceLastSignal.Restart(); + EditorApplication.update += Pump; + return; + } + + _sinceLastSignal.Stop(); + EditorApplication.update -= Pump; + } + + private static void Pump() + { + Action? signalTick = _signalTick; + if (signalTick == null) + { + return; + } + + if (IntervalMs > 0 && _sinceLastSignal.ElapsedMilliseconds < IntervalMs) + { + return; + } + + _sinceLastSignal.Restart(); + try + { + signalTick(); + } + catch (Exception exception) + { + // Losing the pump only costs speed, so drop it rather than log once per tick. + _signalTick = null; + SyncPumpState(); + UnityEngine.Debug.LogWarning( + "Unity CLI bridge editor tick pump 중단 (EditorApplication.SignalTick 호출 실패): " + exception.Message); + } + } + + private static Action? ResolveSignalTick() + { + if (_hasResolvedSignalTick) + { + return _signalTick; + } + + _hasResolvedSignalTick = true; + try + { + MethodInfo? method = typeof(EditorApplication).GetMethod( + "SignalTick", + BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); + if (method == null) + { + UnityEngine.Debug.LogWarning( + "Unity CLI bridge editor tick pump 비활성화: EditorApplication.SignalTick을 찾지 못했습니다. " + + "unfocused 에디터에서 deferred 명령이 느리게 진행될 수 있습니다."); + return null; + } + + _signalTick = (Action)Delegate.CreateDelegate(typeof(Action), method); + } + catch (Exception exception) + { + UnityEngine.Debug.LogWarning( + "Unity CLI bridge editor tick pump 비활성화 (EditorApplication.SignalTick 바인딩 실패): " + + exception.Message); + _signalTick = null; + } + + return _signalTick; + } + } +} diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/EditorTickPump.cs.meta b/unity-package/com.yhc509.unity-cli-bridge/Editor/EditorTickPump.cs.meta new file mode 100644 index 0000000..36a4d20 --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/EditorTickPump.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e5410d7ba60654f37b1a0acb7d0ed306 \ No newline at end of file diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/PackageCommandHandler.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/PackageCommandHandler.cs index 96cb65f..9244136 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/PackageCommandHandler.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/PackageCommandHandler.cs @@ -253,7 +253,7 @@ void StopPolling() } isFinished = true; - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); stopwatch.Stop(); } @@ -327,7 +327,7 @@ void Poll() } } - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); Poll(); } @@ -342,10 +342,10 @@ void BackgroundPoll() tracker = new ActiveRequestCompletionTracker( () => request.IsCompleted, - () => EditorApplication.update -= BackgroundPoll, + () => EditorTickPump.Remove(BackgroundPoll), EndActiveRequest); - EditorApplication.update += BackgroundPoll; + EditorTickPump.Add(BackgroundPoll); BackgroundPoll(); } diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Capture.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Capture.cs index 71b427b..2707e9d 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Capture.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Capture.cs @@ -218,7 +218,7 @@ void Poll() { if (_phase != CapturePhase.Capturing) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); return; } @@ -230,7 +230,7 @@ void Poll() bool playExited = !EditorApplication.isPlaying; if (framesHit || durationHit || safetyHit || playExited) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); try { BeginProcessing(); @@ -243,7 +243,7 @@ void Poll() } } - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); } private static void BeginProcessing() @@ -274,7 +274,7 @@ private static void BeginProcessing() _walkMarkers = new Dictionary(512, StringComparer.Ordinal); _walkGpuMs = new List(); - EditorApplication.update += WalkStep; + EditorTickPump.Add(WalkStep); } private static void WalkStep() @@ -291,13 +291,13 @@ private static void WalkStep() if (_walkFrame > _walkLastFrame) { - EditorApplication.update -= WalkStep; + EditorTickPump.Remove(WalkStep); FinishProcessing("Completed"); } } catch (Exception ex) { - EditorApplication.update -= WalkStep; + EditorTickPump.Remove(WalkStep); Debug.LogError("[UCB] profile walk failed: " + ex); try { diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Memory.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Memory.cs index b9ebe9e..3d3c21a 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Memory.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Memory.cs @@ -78,7 +78,7 @@ void Poll() { if (completion.Task.IsCompleted) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); DisposeAll(); return; } @@ -88,7 +88,7 @@ void Poll() ticks++; if (stopwatch.Elapsed.TotalSeconds >= ProtocolConstants.ProfileStatsTimeoutSeconds) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); DisposeAll(); completion.TrySetResult(ResponseEnvelope.Failure( requestId, @@ -106,7 +106,7 @@ void Poll() return; } - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); var counters = new List(); var samples = new List(frames); foreach ((ProfileCounterSpec spec, ProfilerRecorder recorder) in recorders) @@ -156,13 +156,13 @@ void Poll() } catch (Exception exception) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); DisposeAll(); completion.TrySetResult(CreateFailureResponse(requestId, projectHash, exception, stopwatch.ElapsedMilliseconds)); } } - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); } private static void WriteMemorySidecar(ProfileMemoryPayload report) @@ -262,7 +262,7 @@ void Watchdog() { if (finished) { - EditorApplication.update -= Watchdog; + EditorTickPump.Remove(Watchdog); return; } @@ -271,7 +271,7 @@ void Watchdog() return; } - EditorApplication.update -= Watchdog; + EditorTickPump.Remove(Watchdog); Finish(() => ResponseEnvelope.Failure( requestId, projectHash, @@ -282,7 +282,7 @@ void Watchdog() ProtocolConstants.TransportLive)); } - EditorApplication.update += Watchdog; + EditorTickPump.Add(Watchdog); try { @@ -291,7 +291,7 @@ void Watchdog() snapshotPath, (resultPath, success) => { - EditorApplication.update -= Watchdog; + EditorTickPump.Remove(Watchdog); if (!success) { Finish(() => ResponseEnvelope.Failure( @@ -330,7 +330,7 @@ void Watchdog() } catch (Exception exception) { - EditorApplication.update -= Watchdog; + EditorTickPump.Remove(Watchdog); Finish(() => CreateFailureResponse(requestId, projectHash, exception, stopwatch.ElapsedMilliseconds)); } } diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.cs index cd4c811..368e669 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.cs @@ -206,7 +206,7 @@ void Poll() { if (completion.Task.IsCompleted) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); DisposeAll(); return; } @@ -216,7 +216,7 @@ void Poll() ticks++; if (stopwatch.Elapsed.TotalSeconds >= ProtocolConstants.ProfileStatsTimeoutSeconds) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); DisposeAll(); completion.TrySetResult(ResponseEnvelope.Failure( requestId, @@ -234,7 +234,7 @@ void Poll() return; } - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); var counters = new List(); var samples = new List(frames); foreach ((ProfileCounterSpec spec, ProfilerRecorder recorder) in recorders) @@ -287,13 +287,13 @@ void Poll() } catch (Exception exception) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); DisposeAll(); completion.TrySetResult(CreateFailureResponse(requestId, projectHash, exception, stopwatch.ElapsedMilliseconds)); } } - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); } private static string GetRequestId(TaskCompletionSource completion) diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/QaCommandHandler.Sequence.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/QaCommandHandler.Sequence.cs index 2f4642c..eb47bec 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/QaCommandHandler.Sequence.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/QaCommandHandler.Sequence.cs @@ -65,7 +65,7 @@ void Poll() { if (completion.Task.IsCompleted) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); AbortActiveSwipe(); StopSequenceRecordingIfNeeded(); StopSequenceProfileIfNeeded(); @@ -152,7 +152,7 @@ void Poll() void CompleteSuccess() { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); AbortActiveSwipe(); stopwatch.Stop(); string finalRecordingPath = StopSequenceRecordingIfNeeded(); @@ -175,7 +175,7 @@ void FailTimeout( List unmet, List snapshot) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); AbortActiveSwipe(); stopwatch.Stop(); string finalRecordingPath = StopSequenceRecordingIfNeeded(); @@ -201,7 +201,7 @@ void FailTimeout( void CompleteFailure(Exception exception) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); AbortActiveSwipe(); stopwatch.Stop(); StopSequenceRecordingIfNeeded(); @@ -274,7 +274,7 @@ void AbortActiveSwipe() } } - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); Poll(); } diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/QaCommandHandler.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/QaCommandHandler.cs index f4c3dc1..df50955 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/QaCommandHandler.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/QaCommandHandler.cs @@ -929,7 +929,7 @@ void Poll() { if (completion.Task.IsCompleted) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); return; } @@ -952,7 +952,7 @@ void Poll() if (elapsedMs >= timeoutMs) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); stopwatch.Stop(); completion.TrySetResult(ResponseEnvelope.Failure( requestId, @@ -974,19 +974,19 @@ void Poll() void CompleteSuccess(QaWaitUntilPayload payload) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); stopwatch.Stop(); completion.TrySetResult(CreateSuccessResponse(requestId, projectHash, payload, stopwatch.ElapsedMilliseconds)); } void CompleteFailure(Exception exception) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); stopwatch.Stop(); completion.TrySetResult(CreateFailureResponse(requestId, projectHash, exception, stopwatch.ElapsedMilliseconds)); } - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); Poll(); } @@ -1010,7 +1010,7 @@ void Poll() { if (completion.Task.IsCompleted) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); swipe.Abort(); return; } @@ -1021,7 +1021,7 @@ void Poll() if (swipe.Advance()) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); stopwatch.Stop(); completion.TrySetResult(CreateSuccessResponse( requestId, @@ -1035,14 +1035,14 @@ void Poll() } catch (Exception exception) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); swipe.Abort(); stopwatch.Stop(); completion.TrySetResult(CreateFailureResponse(requestId, projectHash, exception, stopwatch.ElapsedMilliseconds)); } } - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); Poll(); } #endif diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/RecordCommandHandler.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/RecordCommandHandler.cs index c67df9f..10c08a3 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/RecordCommandHandler.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/RecordCommandHandler.cs @@ -3,19 +3,29 @@ using System.IO; using UnityCli.Protocol; using UnityEditor; +#if UNITY_CLI_BRIDGE_RECORDER using UnityEditor.Recorder; using UnityEditor.Recorder.Input; +#endif using UnityEngine; namespace UnityCliBridge.Bridge.Editor { + /// + /// Unity Recorder is an optional dependency: the package does not force it on projects that + /// never record, so every Recorder API touch sits behind UNITY_CLI_BRIDGE_RECORDER + /// (a versionDefine on com.unity.recorder). Without it, `record start` fails with an install + /// hint while `record stop`/`record status` keep working against existing sidecars. + /// internal sealed class RecordCommandHandler { private static readonly object _activeLock = new object(); private static bool _hasActiveRecording; +#if UNITY_CLI_BRIDGE_RECORDER private static RecorderController? _controller; private static RecorderControllerSettings? _controllerSettings; private static MovieRecorderSettings? _movieSettings; +#endif private static string? _recordingId; private static string? _targetPath; private static string? _outputBasePath; @@ -76,7 +86,7 @@ private static string HandleStart(string argumentsJson) private static string HandleStop() { - if (!HasActiveRecording() || _controller == null || string.IsNullOrWhiteSpace(_recordingId)) + if (!HasActiveRecording() || !IsRecorderEngaged || string.IsNullOrWhiteSpace(_recordingId)) { throw new CommandFailureException( ProtocolConstants.ErrorRecordNotActive, @@ -134,6 +144,11 @@ private static string HandleStatus(string argumentsJson) private static string StartRecording(RecordStartArgs args) { +#if !UNITY_CLI_BRIDGE_RECORDER + throw new CommandFailureException( + ProtocolConstants.ErrorRecordFailed, + "Unity Recorder 패키지가 설치되어 있지 않습니다. `unity-cli package add --name com.unity.recorder`로 설치한 뒤 다시 실행하세요."); +#else if (!EditorApplication.isPlaying) { throw new CommandFailureException( @@ -223,6 +238,35 @@ private static string StartRecording(RecordStartArgs args) ClearState(); throw; } +#endif + } + + /// True only while a Recorder controller is actually driving a capture. + private static bool IsRecorderEngaged + { + get + { +#if UNITY_CLI_BRIDGE_RECORDER + return _controller != null; +#else + return false; +#endif + } + } + + private static void StopActiveRecorder() + { +#if UNITY_CLI_BRIDGE_RECORDER + _controller?.StopRecording(); +#endif + } + + private static void ReleaseRecorder() + { +#if UNITY_CLI_BRIDGE_RECORDER + _controller = null; + DestroyRecorderSettings(); +#endif } private static string FinalizeAndBuildResult(string status) @@ -234,7 +278,7 @@ private static string FinalizeAndBuildResult(string status) try { - _controller?.StopRecording(); + StopActiveRecorder(); finalPath = MoveProducedFileToTargetIfNeeded(producedPath, _targetPath); var result = BuildResultPayload(recordingId, status, finalPath); @@ -375,8 +419,7 @@ private static void ClearState() _hasActiveRecording = false; } - _controller = null; - DestroyRecorderSettings(); + ReleaseRecorder(); _recordingId = null; _targetPath = null; _outputBasePath = null; @@ -416,7 +459,7 @@ void Poll() { if (!HasActiveRecording()) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); return; } @@ -426,7 +469,7 @@ void Poll() bool playExited = !EditorApplication.isPlaying; if (durationHit || safetyHit || playExited) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); try { FinalizeAndBuildResult("Completed"); @@ -438,7 +481,7 @@ void Poll() } } - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); } private static string SidecarPath(string recordingId) @@ -475,6 +518,7 @@ private static bool TryBeginRecording() } } +#if UNITY_CLI_BRIDGE_RECORDER private static void DestroyRecorderSettings() { if (_movieSettings != null) @@ -509,5 +553,6 @@ private static void DestroyRecorderSettings() } } } +#endif } } diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/ScreenshotCommandHandler.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/ScreenshotCommandHandler.cs index 3c35d4d..68a0005 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/ScreenshotCommandHandler.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/ScreenshotCommandHandler.cs @@ -10,9 +10,7 @@ namespace UnityCliBridge.Bridge.Editor { internal sealed class ScreenshotCommandHandler { - private const string FormatPng = "png"; - private const string FormatJpg = "jpg"; - private const int DefaultJpegQuality = 75; + private const string FormatJpg = ScreenshotDefaults.FormatJpg; internal static int LastCapturedWidth { get; private set; } internal static int LastCapturedHeight { get; private set; } @@ -57,12 +55,16 @@ public string Handle(string command, string argumentsJson) string outputPath; int capturedWidth; int capturedHeight; - string format = NormalizeScreenshotFormat(args.format); + string format = NormalizeScreenshotFormat(args.format, args.outputPath); int jpegQuality = NormalizeJpegQuality(args.quality); + // Resolved once here so every capture path shares one answer; the explicit-size gate + // that used to live at each call site is folded into ResolveMaxWidth. + int maxWidth = ScreenshotDefaults.ResolveMaxWidth(args.maxWidth, args.width, args.height); + if (!string.IsNullOrWhiteSpace(args.camera)) { - var result = CaptureFromCamera(args.camera!, args.width, args.height, args.maxWidth, format, jpegQuality); + var result = CaptureFromCamera(args.camera!, args.width, args.height, maxWidth, format, jpegQuality); outputPath = result.path; capturedWidth = result.width; capturedHeight = result.height; @@ -70,7 +72,7 @@ public string Handle(string command, string argumentsJson) else { string view = string.IsNullOrWhiteSpace(args.view) ? "game" : args.view!; - var result = CaptureView(view, args.width, args.height, args.maxWidth, format, jpegQuality); + var result = CaptureView(view, args.width, args.height, maxWidth, format, jpegQuality); outputPath = result.path; capturedWidth = result.width; capturedHeight = result.height; @@ -145,8 +147,7 @@ public string Handle(string command, string argumentsJson) throw new CommandFailureException("SCREENSHOT_FAILED", "Scene View 캡처를 위한 카메라가 없습니다.", false, null); } - int effectiveMaxWidth = ShouldApplyMaxWidth(requestedWidth, requestedHeight) ? maxWidth : 0; - var result = CaptureCameraToPath(camera, width, height, effectiveMaxWidth, format, jpegQuality, tempPath); + var result = CaptureCameraToPath(camera, width, height, maxWidth, format, jpegQuality, tempPath); return (tempPath, result.width, result.height); } @@ -181,8 +182,7 @@ public string Handle(string command, string argumentsJson) } string tempPath = CreateTempScreenshotPath(format); - int effectiveMaxWidth = ShouldApplyMaxWidth(requestedWidth, requestedHeight) ? maxWidth : 0; - var result = CaptureCameraToPath(camera, width, height, effectiveMaxWidth, format, jpegQuality, tempPath); + var result = CaptureCameraToPath(camera, width, height, maxWidth, format, jpegQuality, tempPath); return (tempPath, result.width, result.height); } @@ -265,7 +265,7 @@ private static (int width, int height, bool shouldResize) ResolvePlayModeGameVie return (capturedTexture.width, capturedTexture.height, false); } - if (ShouldApplyMaxWidth(requestedWidth, requestedHeight) && maxWidth > 0 && capturedTexture.width > maxWidth) + if (maxWidth > 0 && capturedTexture.width > maxWidth) { return (maxWidth, CalculateAspectFitHeight(capturedTexture.width, capturedTexture.height, maxWidth), true); } @@ -398,8 +398,7 @@ private static (string path, int width, int height) CaptureGameViewFromCamera( height = gameView.height; } - int effectiveMaxWidth = ShouldApplyMaxWidth(requestedWidth, requestedHeight) ? maxWidth : 0; - var result = CaptureCameraToPath(camera, width, height, effectiveMaxWidth, format, jpegQuality, path); + var result = CaptureCameraToPath(camera, width, height, maxWidth, format, jpegQuality, path); return (path, result.width, result.height); } @@ -500,36 +499,25 @@ private static int CalculateAspectFitHeight(int width, int height, int maxWidth) return Mathf.Max(1, Mathf.RoundToInt(height * maxWidth / (float)width)); } - private static bool ShouldApplyMaxWidth(int requestedWidth, int requestedHeight) + private static string NormalizeScreenshotFormat(string? format, string? outputPath) { - return requestedWidth <= 0 && requestedHeight <= 0; - } - - private static string NormalizeScreenshotFormat(string? format) - { - if (string.IsNullOrWhiteSpace(format)) + if (ScreenshotDefaults.TryResolveFormat(format, outputPath, out string resolved)) { - return FormatPng; + return resolved; } - return format!.Trim().ToLowerInvariant() switch - { - FormatPng => FormatPng, - FormatJpg => FormatJpg, - "jpeg" => FormatJpg, - _ => throw new CommandFailureException( - "SCREENSHOT_INVALID_FORMAT", - "screenshot format은 png, jpg, jpeg 중 하나여야 합니다.", - false, - null), - }; + throw new CommandFailureException( + "SCREENSHOT_INVALID_FORMAT", + "screenshot format은 png, jpg, jpeg 중 하나여야 합니다.", + false, + null); } private static int NormalizeJpegQuality(int quality) { if (quality == 0) { - return DefaultJpegQuality; + return ScreenshotDefaults.DefaultJpegQuality; } if (quality >= 1 && quality <= 100) @@ -546,8 +534,9 @@ private static int NormalizeJpegQuality(int quality) private static string CreateTempScreenshotPath(string format) { - string extension = string.Equals(format, FormatJpg, StringComparison.Ordinal) ? ".jpg" : ".png"; - return Path.Combine(Path.GetTempPath(), $"puc-screenshot-{Guid.NewGuid():N}{extension}"); + return Path.Combine( + Path.GetTempPath(), + $"puc-screenshot-{Guid.NewGuid():N}{ScreenshotDefaults.FileExtension(format)}"); } private static void WriteTextureToPath(Texture2D texture, string path, string format, int jpegQuality) diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/SerializedValueApplier.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/SerializedValueApplier.cs index 839427b..e36c6d7 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/SerializedValueApplier.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/SerializedValueApplier.cs @@ -197,6 +197,8 @@ private static string BuildUnsupportedSerializedPropertyTypeMessage(SerializedPr private static void ApplyToken(SerializedProperty property, JToken token, string propertyPath) { + token = CoerceStringEncodedJson(property, token); + if (property.isArray && property.propertyType != SerializedPropertyType.String) { ApplyArray(property, token, propertyPath); @@ -289,6 +291,71 @@ private static void ApplyToken(SerializedProperty property, JToken token, string } } + /// + /// Accept a structured value that arrived as a JSON-encoded string. + /// + /// Agents routinely send "[1,2,3]" for a Vector3 or "{\"m_Mass\":0.17}" for a + /// nested object — the value is right, the quoting is not, and strict token typing rejects + /// it with an error that reads like the field itself was wrong. The guard is deliberately + /// narrow: only a string token, only where the target cannot hold a raw string, and only + /// when the trimmed text opens a JSON object or array. Asset paths, object-reference + /// handles, enum names and char values never start with '{' or '[' and so keep taking the + /// original path — as does any string that fails to parse, which preserves the existing + /// validation message. + /// + private static JToken CoerceStringEncodedJson(SerializedProperty property, JToken token) + { + if (token == null || token.Type != JTokenType.String || AcceptsRawStringToken(property)) + { + return token; + } + + string value = token.Value(); + if (string.IsNullOrWhiteSpace(value)) + { + return token; + } + + string trimmed = value.TrimStart(); + if (trimmed[0] != '{' && trimmed[0] != '[') + { + return token; + } + + try + { + return JToken.Parse(value); + } + catch (Newtonsoft.Json.JsonException) + { + return token; + } + } + + private static bool AcceptsRawStringToken(SerializedProperty property) + { + if (property.isArray && property.propertyType == SerializedPropertyType.String) + { + return true; + } + + switch (property.propertyType) + { + case SerializedPropertyType.String: + case SerializedPropertyType.Character: + case SerializedPropertyType.Enum: + case SerializedPropertyType.Hash128: + case SerializedPropertyType.Integer: + case SerializedPropertyType.Boolean: + case SerializedPropertyType.Float: + case SerializedPropertyType.LayerMask: + case SerializedPropertyType.ArraySize: + return true; + default: + return false; + } + } + private static void ApplyArray(SerializedProperty property, JToken token, string propertyPath) { if (token.Type != JTokenType.Array) @@ -777,6 +844,79 @@ private static JObject ReadObject(JToken token, string propertyPath) throw new CommandFailureException("PREFAB_FIELD_INVALID", "object 값이 필요합니다: " + propertyPath); } + /// + /// Read a fixed-arity numeric tuple written as a JSON array — [1,2,3] for a Vector3, + /// [1,0,0,1] for a Color. The member-object form stays the canonical one (it is what + /// inspect emits); this is the shorthand agents reach for, and it is unambiguous for the + /// component types below. Entries past are optional and + /// keep the caller's defaults. + /// + private static float[] ReadFloatTuple( + JArray array, + string propertyPath, + int requiredCount, + float[] defaults) + { + if (array.Count < requiredCount || array.Count > defaults.Length) + { + throw new CommandFailureException( + "PREFAB_FIELD_INVALID", + "배열 값은 숫자 " + + (requiredCount == defaults.Length + ? requiredCount.ToString() + : requiredCount + "~" + defaults.Length) + + "개여야 합니다: " + propertyPath); + } + + var values = (float[])defaults.Clone(); + for (int index = 0; index < array.Count; index++) + { + JToken element = array[index]; + if (element.Type != JTokenType.Integer && element.Type != JTokenType.Float) + { + throw new CommandFailureException( + "PREFAB_FIELD_INVALID", + "숫자 값이 필요합니다: " + propertyPath + "[" + index + "]"); + } + + values[index] = element.Value(); + } + + return values; + } + + private static int[] ReadIntTuple(JArray array, string propertyPath, int count) + { + if (array.Count != count) + { + throw new CommandFailureException( + "PREFAB_FIELD_INVALID", + "배열 값은 정수 " + count + "개여야 합니다: " + propertyPath); + } + + var values = new int[count]; + for (int index = 0; index < count; index++) + { + JToken element = array[index]; + if (element.Type != JTokenType.Integer) + { + throw new CommandFailureException( + "PREFAB_FIELD_INVALID", + "정수 값이 필요합니다: " + propertyPath + "[" + index + "]"); + } + + values[index] = element.Value(); + } + + return values; + } + + private static bool TryReadTupleArray(JToken token, out JArray array) + { + array = token as JArray; + return array != null; + } + private static float ReadFloatMember(JObject obj, string memberName, string propertyPath, float fallback = 0f) { JToken member = obj[memberName]; @@ -811,6 +951,12 @@ private static int ReadIntMember(JObject obj, string memberName, string property private static Color ReadColor(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + float[] values = ReadFloatTuple(array, propertyPath, 3, new[] { 0f, 0f, 0f, 1f }); + return new Color(values[0], values[1], values[2], values[3]); + } + JObject obj = ReadObject(token, propertyPath); return new Color( ReadFloatMember(obj, "r", propertyPath), @@ -821,6 +967,12 @@ private static Color ReadColor(JToken token, string propertyPath) private static Vector2 ReadVector2(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + float[] values = ReadFloatTuple(array, propertyPath, 2, new[] { 0f, 0f }); + return new Vector2(values[0], values[1]); + } + JObject obj = ReadObject(token, propertyPath); return new Vector2( ReadFloatMember(obj, "x", propertyPath), @@ -829,6 +981,12 @@ private static Vector2 ReadVector2(JToken token, string propertyPath) private static Vector3 ReadVector3(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + float[] values = ReadFloatTuple(array, propertyPath, 3, new[] { 0f, 0f, 0f }); + return new Vector3(values[0], values[1], values[2]); + } + JObject obj = ReadObject(token, propertyPath); return new Vector3( ReadFloatMember(obj, "x", propertyPath), @@ -838,6 +996,12 @@ private static Vector3 ReadVector3(JToken token, string propertyPath) private static Vector4 ReadVector4(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + float[] values = ReadFloatTuple(array, propertyPath, 4, new[] { 0f, 0f, 0f, 0f }); + return new Vector4(values[0], values[1], values[2], values[3]); + } + JObject obj = ReadObject(token, propertyPath); return new Vector4( ReadFloatMember(obj, "x", propertyPath), @@ -848,6 +1012,12 @@ private static Vector4 ReadVector4(JToken token, string propertyPath) private static Rect ReadRect(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + float[] values = ReadFloatTuple(array, propertyPath, 4, new[] { 0f, 0f, 0f, 0f }); + return new Rect(values[0], values[1], values[2], values[3]); + } + JObject obj = ReadObject(token, propertyPath); return new Rect( ReadFloatMember(obj, "x", propertyPath), @@ -866,6 +1036,12 @@ private static Bounds ReadBounds(JToken token, string propertyPath) private static Quaternion ReadQuaternion(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + float[] values = ReadFloatTuple(array, propertyPath, 4, new[] { 0f, 0f, 0f, 0f }); + return new Quaternion(values[0], values[1], values[2], values[3]); + } + JObject obj = ReadObject(token, propertyPath); return new Quaternion( ReadFloatMember(obj, "x", propertyPath), @@ -876,6 +1052,12 @@ private static Quaternion ReadQuaternion(JToken token, string propertyPath) private static Vector2Int ReadVector2Int(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + int[] values = ReadIntTuple(array, propertyPath, 2); + return new Vector2Int(values[0], values[1]); + } + JObject obj = ReadObject(token, propertyPath); return new Vector2Int( ReadIntMember(obj, "x", propertyPath), @@ -884,6 +1066,12 @@ private static Vector2Int ReadVector2Int(JToken token, string propertyPath) private static Vector3Int ReadVector3Int(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + int[] values = ReadIntTuple(array, propertyPath, 3); + return new Vector3Int(values[0], values[1], values[2]); + } + JObject obj = ReadObject(token, propertyPath); return new Vector3Int( ReadIntMember(obj, "x", propertyPath), @@ -893,6 +1081,12 @@ private static Vector3Int ReadVector3Int(JToken token, string propertyPath) private static RectInt ReadRectInt(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + int[] values = ReadIntTuple(array, propertyPath, 4); + return new RectInt(values[0], values[1], values[2], values[3]); + } + JObject obj = ReadObject(token, propertyPath); return new RectInt( ReadIntMember(obj, "x", propertyPath), diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/TestCommandHandler.PlayMode.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/TestCommandHandler.PlayMode.cs index 7f7e691..377ecc1 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/TestCommandHandler.PlayMode.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/TestCommandHandler.PlayMode.cs @@ -337,7 +337,7 @@ void RespondToPendingStart() _playModeWatchdog = Poll; _playModePendingStartResponder = completion == null ? null : (Action)RespondToPendingStart; - EditorApplication.update += _playModeWatchdog; + EditorTickPump.Add(_playModeWatchdog); Poll(); } @@ -421,7 +421,7 @@ private static void StopPlayModeWatchdog() return; } - EditorApplication.update -= _playModeWatchdog; + EditorTickPump.Remove(_playModeWatchdog); _playModeWatchdog = null; } diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/TestCommandHandler.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/TestCommandHandler.cs index 5e65788..79dbba5 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/TestCommandHandler.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/TestCommandHandler.cs @@ -169,7 +169,7 @@ void Cleanup() { if (pollRegistered) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); pollRegistered = false; } @@ -279,7 +279,7 @@ void RequestList(TestMode mode, string modeLabel, List targetEntr try { - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); pollRegistered = true; if (includeEditMode) @@ -365,7 +365,7 @@ void Cleanup() { if (pollRegistered) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); pollRegistered = false; } @@ -418,7 +418,7 @@ void Poll() try { api.RetrieveTestList(mode, adaptor => root = adaptor); - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); pollRegistered = true; Poll(); } @@ -1020,7 +1020,7 @@ void Poll() } _editModeRestoreWatchdog = Poll; - EditorApplication.update += _editModeRestoreWatchdog; + EditorTickPump.Add(_editModeRestoreWatchdog); Poll(); } @@ -1031,7 +1031,7 @@ private static void StopRestoredEditModeWatchdog() return; } - EditorApplication.update -= _editModeRestoreWatchdog; + EditorTickPump.Remove(_editModeRestoreWatchdog); _editModeRestoreWatchdog = null; } diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/UnityCliBridge.Bridge.Editor.asmdef b/unity-package/com.yhc509.unity-cli-bridge/Editor/UnityCliBridge.Bridge.Editor.asmdef index 3b99c59..f9bd88f 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/UnityCliBridge.Bridge.Editor.asmdef +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/UnityCliBridge.Bridge.Editor.asmdef @@ -2,6 +2,7 @@ "name": "UnityCliBridge.Bridge.Editor", "rootNamespace": "UnityCliBridge.Bridge.Editor", "references": [ + "UnityCliBridge.Bridge.Protocol", "UnityCliBridge.Bridge.Runtime", "UnityEditor.TestRunner", "Unity.InputSystem", @@ -23,6 +24,11 @@ "name": "com.unity.inputsystem", "expression": "", "define": "ENABLE_INPUT_SYSTEM" + }, + { + "name": "com.unity.recorder", + "expression": "", + "define": "UNITY_CLI_BRIDGE_RECORDER" } ], "noEngineReferences": false diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/AuthTokenComparison.cs b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/AuthTokenComparison.cs new file mode 100644 index 0000000..268b970 --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/AuthTokenComparison.cs @@ -0,0 +1,36 @@ +namespace UnityCli.Protocol +{ + /// + /// Fixed-time equality for the IPC auth token. + /// + /// The transport is an owner-only local socket / named pipe, so the practical risk from a + /// timing side channel is low — but the bridge's token check is the only authentication + /// gate it has, and a length-prefixed early-exit comparison is observable to any other + /// local process. Hand-rolled instead of CryptographicOperations.FixedTimeEquals so the + /// same source compiles on Unity's runtime profile and on .NET 9. + /// + internal static class AuthTokenComparison + { + /// + /// Compare two tokens without an early exit. Length differences are folded into the + /// same accumulator instead of short-circuiting; an empty or missing token is always + /// a mismatch. + /// + internal static bool FixedTimeEquals(string expected, string candidate) + { + if (string.IsNullOrEmpty(expected) || string.IsNullOrEmpty(candidate)) + { + return false; + } + + int difference = expected.Length ^ candidate.Length; + int comparedLength = expected.Length < candidate.Length ? expected.Length : candidate.Length; + for (int index = 0; index < comparedLength; index++) + { + difference |= expected[index] ^ candidate[index]; + } + + return difference == 0; + } + } +} diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/AuthTokenComparison.cs.meta b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/AuthTokenComparison.cs.meta new file mode 100644 index 0000000..13e5e6b --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/AuthTokenComparison.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 63bb9918693f24ce19d04b78f3cfaee6 \ No newline at end of file diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/BridgeDisableSwitch.cs b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/BridgeDisableSwitch.cs new file mode 100644 index 0000000..a1905c9 --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/BridgeDisableSwitch.cs @@ -0,0 +1,71 @@ +using System; + +namespace UnityCli.Protocol +{ + /// + /// Opt-out switch for starting the bridge at all. + /// + /// The bridge boots from [InitializeOnLoad] in every main editor process, headless + /// included — which is the right default for interactive and CLI-driven work, but wrong for a + /// CI/release build job. There the editor is launched only to produce a player, and a bridge + /// that binds a socket, publishes a registry entry and a token sidecar, and then logs watchdog + /// errors when any of that fails is pure noise on the build log (and a lock to contend over + /// when several builds share a machine). A build job sets the environment variable or passes + /// the command-line flag and gets an editor that never advertises itself. + /// + /// Kept free of Unity types so the parsing is unit-testable. + /// + internal static class BridgeDisableSwitch + { + /// Set to any value other than 0/false/empty to keep the bridge down. + internal const string EnvironmentVariable = "UNITY_CLI_BRIDGE_DISABLE"; + + /// Editor command-line equivalent, for jobs that cannot set an environment variable. + internal const string CommandLineFlag = "-noUnityCliBridge"; + + /// Raw value of ; null when unset. + /// Editor argv; null is treated as empty. + internal static bool IsDisabled(string environmentValue, string[] commandLineArgs) + { + if (IsTruthy(environmentValue)) + { + return true; + } + + if (commandLineArgs != null) + { + for (int index = 0; index < commandLineArgs.Length; index++) + { + if (string.Equals(commandLineArgs[index], CommandLineFlag, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + + return false; + } + + /// + /// Anything set counts as "on" except the two spellings people use to mean "off". A CI + /// system that exports the variable unconditionally as 0 must not disable the bridge + /// for everyone downstream. + /// + private static bool IsTruthy(string value) + { + if (string.IsNullOrEmpty(value)) + { + return false; + } + + string trimmed = value.Trim(); + if (trimmed.Length == 0) + { + return false; + } + + return !string.Equals(trimmed, "0", StringComparison.Ordinal) + && !string.Equals(trimmed, "false", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/BridgeDisableSwitch.cs.meta b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/BridgeDisableSwitch.cs.meta new file mode 100644 index 0000000..309f752 --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/BridgeDisableSwitch.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a99b6aecc331a4255bd90479f35c4d7c \ No newline at end of file diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/CliCommandCatalog.cs b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/CliCommandCatalog.cs index 0805292..4b4e69e 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/CliCommandCatalog.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/CliCommandCatalog.cs @@ -192,8 +192,8 @@ public static class CliCommandCatalog notes: new[] { "Use --list to inspect registered menu item paths before executing one." }), new CliCommandDescriptor( "screenshot", - "screenshot [--view game|scene (default: game) | --camera ] [--path ] [--width N] [--height N] [--format png|jpg|jpeg] [--quality 1-100] [--max-width N]", - "Captures a screenshot from the Game View, Scene View, or a named camera. Defaults to Game View; encoding defaults to PNG. Use --format jpg with --quality to reduce file size, and --max-width to downscale proportionally when --width/--height are not specified. The response includes image size, actual saved format, and screen-space metadata (`screenWidth`, `screenHeight`, `imageOrigin`, `coordinateOrigin`) for QA coordinate alignment. In Play Mode, --view game can downscale the native Game View capture but does not upscale it.", + "screenshot [--view game|scene (default: game) | --camera ] [--path ] [--width N] [--height N] [--format png|jpg|jpeg] [--quality 1-100] [--max-width N|0]", + "Captures a screenshot from the Game View, Scene View, or a named camera. Defaults to Game View, JPEG at quality 75, and a 1024px width cap — agent-friendly defaults that cut image tokens by roughly 72% at 1080p. Override with --format png for lossless, --quality for the JPEG setting, --max-width N for a different cap, or --max-width 0 for no cap. The cap only applies when neither --width nor --height is given, and --path with a .png extension selects PNG when --format is omitted. The response includes image size, actual saved format, and screen-space metadata (`screenWidth`, `screenHeight`, `imageOrigin`, `coordinateOrigin`) for QA coordinate alignment; qa tap/ui-dump scale from the last captured size, so downscaled shots keep their coordinates usable. In Play Mode, --view game can downscale the native Game View capture but does not upscale it.", CliCommandGroup.EditorControl, ProtocolConstants.CommandScreenshot, canUseLocal: false, diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ListenerWatchdogPolicy.cs b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ListenerWatchdogPolicy.cs new file mode 100644 index 0000000..19f9406 --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ListenerWatchdogPolicy.cs @@ -0,0 +1,108 @@ +namespace UnityCli.Protocol +{ + /// + /// What the listener watchdog wants the host to do on this tick. + /// + internal enum ListenerWatchdogDecision + { + /// Nothing to do — healthy, throttled, or already given up. + None, + + /// The listener came back after a recovery attempt; re-publish the registry entry. + Recovered, + + /// The listener is dead; try to bind it again in place. + Restart, + + /// Recovery attempts are exhausted; stop advertising this instance as live. + Abandon, + } + + /// + /// Decision logic for the bridge's listener watchdog, kept free of Unity types so it is + /// unit-testable. The accept loops can die on an unexpected exception without any retry, + /// while the registry heartbeat keeps advertising the instance — a zombie the CLI routes to + /// and then fails to connect to. This policy drives a bounded in-place rebind and, once the + /// attempts are spent, tells the host to stop advertising instead. + /// + internal sealed class ListenerWatchdogPolicy + { + private readonly double _checkIntervalSeconds; + private readonly int _maxRecoveryAttempts; + private double _lastCheckSeconds; + private int _recoveryAttempts; + private bool _hasAbandoned; + + internal ListenerWatchdogPolicy(double checkIntervalSeconds, int maxRecoveryAttempts, double startedAtSeconds) + { + _checkIntervalSeconds = checkIntervalSeconds; + _maxRecoveryAttempts = maxRecoveryAttempts; + _lastCheckSeconds = startedAtSeconds; + } + + /// Recovery attempts spent so far; reset once the listener is healthy again. + internal int RecoveryAttempts + { + get { return _recoveryAttempts; } + } + + /// True once the policy has stopped trying to recover. + internal bool HasAbandoned + { + get { return _hasAbandoned; } + } + + /// The listener is bound and accepting. + /// A bind attempt is still in flight — never race it. + /// Compiling/updating; the listener teardown is expected then. + /// Monotonic-ish clock, same source across calls. + internal ListenerWatchdogDecision Evaluate( + bool isListenerReady, + bool isListenerStarting, + bool isEditorBusy, + double nowSeconds) + { + if (_hasAbandoned) + { + // The host has already stopped advertising this instance; there is nothing left to + // report, and a listener cannot come back because nothing is retrying it. + return ListenerWatchdogDecision.None; + } + + if (isListenerReady) + { + _lastCheckSeconds = nowSeconds; + if (_recoveryAttempts == 0) + { + return ListenerWatchdogDecision.None; + } + + _recoveryAttempts = 0; + return ListenerWatchdogDecision.Recovered; + } + + if (isListenerStarting || isEditorBusy) + { + // A bind in flight or a domain reload in progress resets the clock: the interval + // measures time spent visibly dead, not time spent waiting for a legitimate start. + _lastCheckSeconds = nowSeconds; + return ListenerWatchdogDecision.None; + } + + if (nowSeconds - _lastCheckSeconds < _checkIntervalSeconds) + { + return ListenerWatchdogDecision.None; + } + + _lastCheckSeconds = nowSeconds; + if (_recoveryAttempts >= _maxRecoveryAttempts) + { + _hasAbandoned = true; + return ListenerWatchdogDecision.Abandon; + } + + _recoveryAttempts++; + return ListenerWatchdogDecision.Restart; + } + } +} diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ListenerWatchdogPolicy.cs.meta b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ListenerWatchdogPolicy.cs.meta new file mode 100644 index 0000000..19354f6 --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ListenerWatchdogPolicy.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f8d04951e77c14298b3857684534ea2c \ No newline at end of file diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ProtocolConstants.cs b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ProtocolConstants.cs index 15c9220..f50014b 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ProtocolConstants.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ProtocolConstants.cs @@ -10,7 +10,7 @@ namespace UnityCli.Protocol public static class ProtocolConstants { public const string AppName = "unity-cli"; - public const string ProtocolVersion = "7"; + public const string ProtocolVersion = "8"; public const int DefaultLiveTimeoutMs = 30_000; public const int DefaultTimeoutMs = DefaultLiveTimeoutMs; public const int DefaultExecuteTimeoutMs = 30_000; diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ScreenshotDefaults.cs b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ScreenshotDefaults.cs new file mode 100644 index 0000000..d6299c4 --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ScreenshotDefaults.cs @@ -0,0 +1,129 @@ +using System; + +namespace UnityCli.Protocol +{ + /// + /// Encoding and downscale defaults for screenshot. + /// + /// A screenshot is the heaviest single response an agent can ask for: a 1920x1080 PNG bills at + /// roughly 2,000 image tokens, so a handful of captures costs more than every text response in + /// the session combined. The defaults here trade a lossless full-resolution image — which almost + /// no caller needs and no vision model can use at full detail anyway — for a JPEG capped at + /// , which is still wide enough to read UI text. + /// + /// Every knob stays overridable: --format png restores lossless, --width/ + /// --height take precedence over the cap, and --max-width 0 turns the cap off + /// while leaving the size auto-detected. + /// + /// Kept free of Unity types so both the CLI and the bridge resolve the defaults from one place, + /// and so the resolution is unit-testable. + /// + public static class ScreenshotDefaults + { + public const string FormatPng = "png"; + public const string FormatJpg = "jpg"; + + /// Visually lossless enough for UI verification at a fraction of the PNG size. + public const int DefaultJpegQuality = 75; + + /// + /// Wide enough to keep UI text legible, narrow enough that a 16:9 capture bills at roughly + /// 576 image tokens instead of 2,040. + /// + public const int DefaultMaxWidth = 1024; + + /// + /// Wire sentinel for "the caller explicitly asked for no downscale cap". The wire model + /// cannot use Nullable<int> (Unity's JsonUtility drops it), and 0 already + /// means "unspecified", so the opt-out has to be a distinct value. + /// + public const int MaxWidthUncapped = -1; + + /// + /// Resolves the encoding to write. An explicit format always wins; otherwise an explicit + /// output extension decides, because writing JPEG bytes into a file the caller named + /// .png is a worse outcome than the tokens the default saves. + /// + /// Value of --format; null or blank when unspecified. + /// Value of --path; null or blank when unspecified. + /// False when is set but not a known format. + public static bool TryResolveFormat(string requestedFormat, string outputPath, out string format) + { + if (!IsBlank(requestedFormat)) + { + return TryNormalizeFormat(requestedFormat, out format); + } + + format = HasExtension(outputPath, ".png") ? FormatPng : FormatJpg; + return true; + } + + /// Normalizes an explicitly requested format, folding jpeg into jpg. + public static bool TryNormalizeFormat(string requestedFormat, out string format) + { + string normalized = IsBlank(requestedFormat) + ? string.Empty + : requestedFormat.Trim().ToLowerInvariant(); + + switch (normalized) + { + case FormatPng: + format = FormatPng; + return true; + case FormatJpg: + case "jpeg": + format = FormatJpg; + return true; + default: + format = FormatPng; + return false; + } + } + + /// + /// Resolves the downscale cap to apply, where 0 means "do not downscale". + /// + /// An explicit --width/--height already states the size the caller wants, so + /// the cap stays out of its way — that gate predates the default and is why raising it does + /// not silently shrink sized captures. + /// + /// Wire maxWidth: 0 unspecified, negative uncapped. + public static int ResolveMaxWidth(int requestedMaxWidth, int requestedWidth, int requestedHeight) + { + if (requestedMaxWidth < 0) + { + return 0; + } + + if (requestedWidth > 0 || requestedHeight > 0) + { + return 0; + } + + return requestedMaxWidth > 0 ? requestedMaxWidth : DefaultMaxWidth; + } + + /// File extension matching the resolved format, dot included. + public static string FileExtension(string format) + { + return string.Equals(format, FormatJpg, StringComparison.Ordinal) ? ".jpg" : ".png"; + } + + private static bool HasExtension(string path, string extension) + { + if (IsBlank(path)) + { + return false; + } + + string trimmed = path.Trim(); + return trimmed.Length > extension.Length + && trimmed.EndsWith(extension, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsBlank(string value) + { + return value == null || value.Trim().Length == 0; + } + } +} diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ScreenshotDefaults.cs.meta b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ScreenshotDefaults.cs.meta new file mode 100644 index 0000000..582d389 --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ScreenshotDefaults.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 83a62c1513afe4c8fafee6472c727c62 \ No newline at end of file diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/UnityCliBridge.Bridge.Protocol.asmdef b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/UnityCliBridge.Bridge.Protocol.asmdef new file mode 100644 index 0000000..4bc922f --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/UnityCliBridge.Bridge.Protocol.asmdef @@ -0,0 +1,16 @@ +{ + "name": "UnityCliBridge.Bridge.Protocol", + "rootNamespace": "UnityCli.Protocol", + "references": [], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/UnityCliBridge.Bridge.Protocol.asmdef.meta b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/UnityCliBridge.Bridge.Protocol.asmdef.meta new file mode 100644 index 0000000..dd07b68 --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/UnityCliBridge.Bridge.Protocol.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9e403a1143ecc491a8a13e698547447f +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/UnityCliBridge.Bridge.Runtime.asmdef b/unity-package/com.yhc509.unity-cli-bridge/Runtime/UnityCliBridge.Bridge.Runtime.asmdef index de34a75..72f5e5b 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Runtime/UnityCliBridge.Bridge.Runtime.asmdef +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/UnityCliBridge.Bridge.Runtime.asmdef @@ -1,6 +1,6 @@ { "name": "UnityCliBridge.Bridge.Runtime", - "rootNamespace": "UnityCli.Protocol", + "rootNamespace": "UnityCliBridge.Bridge", "references": [], "includePlatforms": [], "excludePlatforms": [], diff --git a/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/SKILL.md b/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/SKILL.md index b47c1a5..16bb10d 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/SKILL.md +++ b/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/SKILL.md @@ -66,8 +66,8 @@ unity-cli editor stop --project # graceful 종료 (미저장 변 - scene path는 `/Root[0]/Child[0]` 형식으로 쓰고 `/`는 virtual scene root로 본다. - root prefab 이름은 Unity 저장 규칙 때문에 파일 이름으로 정규화된다고 가정한다. - `screenshot`은 `--view` 생략 시 game이 기본이다. Scene View가 필요하면 `--view scene`을 명시한다. -- **에이전트가 읽을 스크린샷은 `--format jpg --quality 75 --max-width 1024`를 기본으로 붙인다.** 기본 PNG full-resolution은 이미지 토큰을 크게 소비한다(1080p 기준 ~72% 절약). lossless가 필요할 때만 `--format png`. -- Play Mode 영상을 남겨야 하면 `record start --duration N --wait --path /tmp/out.mp4`를 쓴다. 수동 녹화는 `record start` 후 `record status`, `record stop` 순서로 종료한다. `record start`는 Play Mode 전용이며 Unity Recorder dependency가 필요하다. +- **스크린샷은 옵션 없이 그대로 찍으면 된다.** 기본값이 이미 JPEG quality 75 + 1024px 가로 축소라 에이전트가 읽기 좋은 크기로 나온다(1080p PNG 대비 이미지 토큰 ~72% 절약). lossless 원본이 필요할 때만 `--format png --max-width 0`을 붙인다. `--path`가 `.png`로 끝나면 `--format` 없이도 PNG로 저장된다. +- Play Mode 영상을 남겨야 하면 `record start --duration N --wait --path /tmp/out.mp4`를 쓴다. 수동 녹화는 `record start` 후 `record status`, `record stop` 순서로 종료한다. `record start`는 Play Mode 전용이고 `com.unity.recorder`가 설치된 프로젝트에서만 동작한다 — 미설치면 `RECORD_FAILED`와 함께 `unity-cli package add --name com.unity.recorder` 안내가 돌아오므로 그대로 설치한 뒤 재시도한다. - `qa tap --x --y`에는 `screenshot`에서 확인한 이미지 좌표를 그대로 넣는다. 응답의 `imageOrigin`은 `top-left`, `coordinateOrigin`은 `bottom-left`다. - 별도 Y-flip이나 해상도 스케일 변환은 하지 않는다. Bridge가 마지막 `screenshot` 크기 또는 명시한 `--screenshot-width`/`--screenshot-height`를 기준으로 내부 처리한다. - 좌표를 추측하지 말고 탭 대상을 먼저 열거한다: uGUI 버튼은 `qa ui-dump --limit 30 --interactable-only --omit-rect --output compact`, 비-UI 월드 오브젝트(전투 그리드 유닛 등)는 `qa world-dump --limit 30 --output compact`. 찾을 텍스트/라벨을 알면 `--text `으로 서버사이드 필터링한다. 둘 다 `centerX`/`centerY` 이미지 좌표를 그대로 반환하며, 대형 화면 dump에서는 envelope 제거 효과가 특히 크다. @@ -89,6 +89,10 @@ unity-cli does not have dedicated script create/delete commands. Use this combin - `unity-cli asset delete --path Assets/Scripts/MyScript.cs --force` (handles .meta cleanup and refresh automatically) +### Test Runner Workflow + +코드 수정 뒤 테스트 러너 기본 호출, `--failures-only`/`--wait`/`--no-domain-reload` 사용 기준, `refresh` 후 재실행 루프는 [references/command-flows.md](references/command-flows.md)의 `테스트 러너` 절을 따른다. + ### Profile Workflow 성능 진단은 다음 루프를 따른다: @@ -138,6 +142,8 @@ unity-cli does not have dedicated script create/delete commands. Use this combin **Friendly key mapping:** Rigidbody, Collider, Renderer, Light, and Camera values accept common keys like `mass`, `damping`, `isTrigger`, `materials[0]`, `shadowStrength`, and `fieldOfView`, which resolve to Unity `SerializedProperty.propertyPath` values. If a key is not found, use `list-components` then `inspect --with-values`. +**Value 형식:** Vector2/3/4, Vector2Int/Vector3Int, Quaternion, Rect, RectInt, Color는 member object(`{"x":1,"y":2,"z":3}`)와 배열 축약(`[1,2,3]`) 둘 다 받는다. Color 배열은 alpha를 생략하면 1이다. 값이 문자열로 감싸여 들어와도(`"[1,2,3]"`, `"{\"x\":1}"`) 브릿지가 다시 파싱하지만, **정상 JSON 값으로 보내는 것이 원칙**이다 — 문자열 감싸기는 asset path나 enum 이름 같은 진짜 문자열 필드에서는 풀리지 않는다. + ## Convenience Commands — 편의 명령 우선 사용 원칙 아래 작업에는 `scene patch --spec-json` 대신 전용 편의 명령을 우선 사용한다. 호출 횟수와 토큰을 절약할 수 있다. diff --git a/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/references/command-flows.md b/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/references/command-flows.md index 0713e8b..b699e08 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/references/command-flows.md +++ b/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/references/command-flows.md @@ -200,14 +200,14 @@ ucli material info --project "$PROJECT" --path Assets/Materials/MyMat.mat --omit ## 스크린샷 ```bash -# Game View 캡처 — 에이전트가 읽을 캡처는 jpg + max-width로 토큰 절약 (--view 생략 시 game이 기본) -ucli screenshot --project "$PROJECT" --path /tmp/capture.jpg --format jpg --quality 75 --max-width 1024 --output compact +# Game View 캡처 — 기본값이 이미 jpg q75 + 1024px 축소다 (--view 생략 시 game이 기본) +ucli screenshot --project "$PROJECT" --path /tmp/capture.jpg --output compact -# lossless가 필요할 때만 PNG -ucli screenshot --project "$PROJECT" --path /tmp/capture.png --output compact +# lossless 원본이 필요할 때만 +ucli screenshot --project "$PROJECT" --path /tmp/capture.png --format png --max-width 0 --output compact # Scene View 캡처 -ucli screenshot --project "$PROJECT" --path /tmp/scene.png --view scene --output compact +ucli screenshot --project "$PROJECT" --path /tmp/scene.jpg --view scene --output compact ``` ## 테스트 러너 diff --git a/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/references/qa-testing.md b/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/references/qa-testing.md index 5b9e9c1..ce3ad0d 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/references/qa-testing.md +++ b/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/references/qa-testing.md @@ -12,7 +12,7 @@ status → play → (입력 시뮬레이션) → 검증 (로그 + 스크린샷) 2. `play`로 Play Mode 진입 (자동으로 `runInBackground = true` 설정됨) 3. QA 커맨드로 입력 시뮬레이션 4. **로그 검증**: `read-console --type error --limit N --no-stacktrace` + `read-console --type log --limit N --no-stacktrace` -5. **시각 검증**: `screenshot --view game --format jpg --quality 75 --max-width 1024 --path /tmp/qa-check.jpg` 후 이미지 확인 (lossless가 필요할 때만 `--format png`) +5. **시각 검증**: `screenshot --view game --path /tmp/qa-check.jpg` 후 이미지 확인 (기본이 jpg q75 + 1024px 축소, lossless 원본은 `--format png --max-width 0`) 6. `stop`으로 Play Mode 종료 (`runInBackground` 원래값 복원됨) ## 입력 방식 선택 @@ -135,10 +135,10 @@ ucli read-console --type error --limit 5 --no-stacktrace --project "$P" --json `screenshot --view game`으로 Game View를 캡처해서 시각적 변화를 확인한다. ```bash -# 토큰 절약: 에이전트가 읽을 스크린샷은 jpg + max-width를 기본으로 한다 -ucli screenshot --view game --format jpg --quality 75 --max-width 1024 --path /tmp/qa-check.jpg --project "$P" --json -# lossless가 필요할 때만 PNG -ucli screenshot --view game --path /tmp/qa-check.png --project "$P" --json +# 기본값이 이미 jpg q75 + 1024px 축소라 옵션 없이 그대로 찍으면 된다 +ucli screenshot --view game --path /tmp/qa-check.jpg --project "$P" --json +# lossless 원본이 필요할 때만 +ucli screenshot --view game --format png --max-width 0 --path /tmp/qa-check.png --project "$P" --json # 이후 이미지를 Read 도구로 확인 ``` @@ -186,7 +186,7 @@ ucli qa run-sequence --spec-json @seq-ok.json --timeout 60000 --project "$P" --j ucli qa run-sequence --spec-json @seq-ok.json --record --record-path /tmp/qa-seq.mp4 --timeout 60000 --project "$P" --json ``` -`--record`를 붙이면 sequence가 실행되는 구간만 Unity Recorder로 mp4 캡처하고, 완료 또는 타임아웃 응답의 `recordingPath`에 최종 경로를 담는다. `--record-path`를 생략하면 `Library/com.yhc509.unity-cli-bridge/recordings/` 아래에 저장된다. +`--record`를 붙이면 sequence가 실행되는 구간만 Unity Recorder로 mp4 캡처하고(`com.unity.recorder` 필요), 완료 또는 타임아웃 응답의 `recordingPath`에 최종 경로를 담는다. `--record-path`를 생략하면 `Library/com.yhc509.unity-cli-bridge/recordings/` 아래에 저장된다. `seq-ok.json`: diff --git a/unity-package/com.yhc509.unity-cli-bridge/Tests~/Editor/SerializedValueApplierTests.cs b/unity-package/com.yhc509.unity-cli-bridge/Tests~/Editor/SerializedValueApplierTests.cs new file mode 100644 index 0000000..08e704a --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Tests~/Editor/SerializedValueApplierTests.cs @@ -0,0 +1,150 @@ +#nullable enable +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEngine; + +namespace UnityCliBridge.Bridge.Editor.Tests +{ + public sealed class SerializedValueApplierTests + { + private GameObject? _gameObject; + private SerializedValueProbe _probe = null!; + + [SetUp] + public void SetUp() + { + _gameObject = new GameObject("SerializedValueApplierProbe"); + _probe = _gameObject.AddComponent(); + } + + [TearDown] + public void TearDown() + { + if (_gameObject != null) + { + Object.DestroyImmediate(_gameObject); + _gameObject = null; + } + } + + private void Apply(string valuesJson) + { + SerializedValueApplier.Apply(_probe, JObject.Parse(valuesJson)); + } + + [Test] + public void Apply_WhenVector3IsAnArray_UsesComponentOrder() + { + Apply("{\"vector3Value\":[1,2,3]}"); + + Assert.That(_probe.vector3Value, Is.EqualTo(new Vector3(1f, 2f, 3f))); + } + + [Test] + public void Apply_WhenVector3IsAStringEncodedArray_ParsesItAnyway() + { + Apply("{\"vector3Value\":\"[1,2,3]\"}"); + + Assert.That(_probe.vector3Value, Is.EqualTo(new Vector3(1f, 2f, 3f))); + } + + [Test] + public void Apply_WhenVector3IsAStringEncodedObject_ParsesItAnyway() + { + Apply("{\"vector3Value\":\"{\\\"x\\\":1,\\\"y\\\":2,\\\"z\\\":3}\"}"); + + Assert.That(_probe.vector3Value, Is.EqualTo(new Vector3(1f, 2f, 3f))); + } + + [Test] + public void Apply_WhenVector3IsAnObject_StillWorks() + { + Apply("{\"vector3Value\":{\"x\":1,\"y\":2,\"z\":3}}"); + + Assert.That(_probe.vector3Value, Is.EqualTo(new Vector3(1f, 2f, 3f))); + } + + [Test] + public void Apply_WhenTupleShorthandIsUsed_CoversTheVectorFamily() + { + Apply("{\"vector2Value\":[1,2],\"vector4Value\":[1,2,3,4],\"vector3IntValue\":[1,2,3]," + + "\"quaternionValue\":[0,0,0,1],\"rectValue\":[1,2,3,4]}"); + + Assert.That(_probe.vector2Value, Is.EqualTo(new Vector2(1f, 2f))); + Assert.That(_probe.vector4Value, Is.EqualTo(new Vector4(1f, 2f, 3f, 4f))); + Assert.That(_probe.vector3IntValue, Is.EqualTo(new Vector3Int(1, 2, 3))); + Assert.That(_probe.quaternionValue, Is.EqualTo(new Quaternion(0f, 0f, 0f, 1f))); + Assert.That(_probe.rectValue, Is.EqualTo(new Rect(1f, 2f, 3f, 4f))); + } + + [Test] + public void Apply_WhenColorArrayOmitsAlpha_DefaultsToOpaque() + { + Apply("{\"colorValue\":[1,0,0]}"); + + Assert.That(_probe.colorValue, Is.EqualTo(new Color(1f, 0f, 0f, 1f))); + } + + [Test] + public void Apply_WhenNestedVectorsAreArrays_AppliesThroughTheParentObject() + { + Apply("{\"boundsValue\":{\"center\":[1,2,3],\"size\":[4,5,6]}}"); + + Assert.That(_probe.boundsValue.center, Is.EqualTo(new Vector3(1f, 2f, 3f))); + Assert.That(_probe.boundsValue.size, Is.EqualTo(new Vector3(4f, 5f, 6f))); + } + + [Test] + public void Apply_WhenTupleArityIsWrong_Fails() + { + CommandFailureException failure = Assert.Throws( + () => Apply("{\"vector3Value\":[1,2]}"))!; + + Assert.That(failure.Message, Does.Contain("vector3Value")); + } + + [Test] + public void Apply_WhenStringLooksLikeJsonButTargetIsAString_LeavesItAlone() + { + Apply("{\"stringValue\":\"[not,really,json]\"}"); + + Assert.That(_probe.stringValue, Is.EqualTo("[not,really,json]")); + } + + [Test] + public void Apply_WhenStringIsValidJsonButTargetIsAString_LeavesItAlone() + { + Apply("{\"stringValue\":\"{\\\"x\\\":1}\"}"); + + Assert.That(_probe.stringValue, Is.EqualTo("{\"x\":1}")); + } + + [Test] + public void Apply_WhenStringEncodedJsonIsMalformed_KeepsTheOriginalValidationError() + { + CommandFailureException failure = Assert.Throws( + () => Apply("{\"vector3Value\":\"[1,2,\"}"))!; + + Assert.That(failure.ErrorCode, Is.EqualTo("PREFAB_FIELD_INVALID")); + Assert.That(failure.Message, Does.Contain("object 값이 필요합니다")); + } + + [Test] + public void Apply_WhenArrayFieldIsAStringEncodedArray_ParsesItAnyway() + { + Apply("{\"intArrayValue\":\"[1,2,3]\"}"); + + Assert.That(_probe.intArrayValue, Is.EqualTo(new[] { 1, 2, 3 })); + } + + [Test] + public void Apply_WhenPrimitivesAndEnumsAreUsed_BehaviorIsUnchanged() + { + Apply("{\"floatValue\":1.5,\"intValue\":7,\"enumValue\":\"Running\"}"); + + Assert.That(_probe.floatValue, Is.EqualTo(1.5f)); + Assert.That(_probe.intValue, Is.EqualTo(7)); + Assert.That(_probe.enumValue, Is.EqualTo(SerializedValueProbe.ProbeMode.Running)); + } + } +} diff --git a/unity-package/com.yhc509.unity-cli-bridge/Tests~/Editor/SerializedValueProbe.cs b/unity-package/com.yhc509.unity-cli-bridge/Tests~/Editor/SerializedValueProbe.cs new file mode 100644 index 0000000..b66d4dc --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Tests~/Editor/SerializedValueProbe.cs @@ -0,0 +1,35 @@ +#nullable enable +using System; +using UnityEngine; + +namespace UnityCliBridge.Bridge.Editor.Tests +{ + /// + /// Component with one serialized field per shape has to + /// translate. Kept as a real MonoBehaviour (rather than borrowing a built-in component) so the + /// tests can cover every branch without depending on engine field names. + /// + public sealed class SerializedValueProbe : MonoBehaviour + { + public enum ProbeMode + { + Idle, + Running, + Stopped, + } + + public Vector2 vector2Value; + public Vector3 vector3Value; + public Vector4 vector4Value; + public Vector3Int vector3IntValue; + public Quaternion quaternionValue; + public Rect rectValue; + public Color colorValue = Color.black; + public Bounds boundsValue; + public string stringValue = string.Empty; + public float floatValue; + public int intValue; + public ProbeMode enumValue; + public int[] intArrayValue = Array.Empty(); + } +} diff --git a/unity-package/com.yhc509.unity-cli-bridge/Tests~/Editor/UnityCliBridge.Bridge.Editor.Tests.asmdef b/unity-package/com.yhc509.unity-cli-bridge/Tests~/Editor/UnityCliBridge.Bridge.Editor.Tests.asmdef index 3074d85..5180dfd 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Tests~/Editor/UnityCliBridge.Bridge.Editor.Tests.asmdef +++ b/unity-package/com.yhc509.unity-cli-bridge/Tests~/Editor/UnityCliBridge.Bridge.Editor.Tests.asmdef @@ -3,6 +3,7 @@ "rootNamespace": "UnityCliBridge.Bridge.Editor.Tests", "references": [ "UnityCliBridge.Bridge.Editor", + "UnityCliBridge.Bridge.Protocol", "UnityEngine.TestRunner", "UnityEditor.TestRunner" ], diff --git a/unity-package/com.yhc509.unity-cli-bridge/package.json b/unity-package/com.yhc509.unity-cli-bridge/package.json index 0d983d8..a7d566a 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/package.json +++ b/unity-package/com.yhc509.unity-cli-bridge/package.json @@ -1,13 +1,13 @@ { "name": "com.yhc509.unity-cli-bridge", "displayName": "Unity CLI Bridge", - "version": "0.5.2", + "version": "0.5.3", "unity": "2023.1", "description": "Project-aware Unity Editor bridge for CLI control without manual servers or per-project ports.", "author": { "name": "yhc509" }, "dependencies": { - "com.unity.recorder": "5.1.6" + "com.unity.test-framework": "1.4.5" } }