Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `editor launch` and `editor stop`: the CLI can now start and stop the Unity Editor itself, so a workflow no longer needs a human to open the project first. `editor launch` finds the Unity version the project asks for, starts it headless (`-batchmode`) by default, and waits until the bridge is reachable before returning (default 300 s; `--timeout <sec>` for projects with a long first import, `--no-wait` to return immediately). It is idempotent — if the editor is already running, the live instance is reused and the response says `"reused": true` — and it refuses with `EDITOR_ALREADY_RUNNING_CONFLICT` when an editor process already holds the project without a bridge, instead of tripping Unity's own project lock. Pass `--gui` for a visible window. The spawned editor logs to `Library/com.yhc509.unity-cli-bridge/editor-launch.log` and does not hold on to the CLI's output streams, so shell pipelines like `unity-cli editor launch | grep reused` finish normally instead of hanging. `editor stop` asks the editor to quit gracefully: it refuses with `EDITOR_DIRTY` while unsaved scene or prefab changes exist (`--force` discards them), waits for the process to exit (default 30 s), and works whether the editor is headless, focused, or sitting unfocused in the background.
- The bridge now starts in headless (`-batchmode`) editors, so every command — scene edits, tests, QA, profiling — works without an editor window. Unity's secondary processes (asset-import workers, MPE) stay excluded, so a project never registers twice. The default headless mode keeps the GPU initialized, which means `screenshot`, `record`, and coordinate-based `qa` commands keep producing real output with no window on screen. `instances list` now reports each editor's mode (`gui` / `headless` / `headless-nographics`).
- Rendering commands under a `-nographics` editor now fail fast with `HEADLESS_NO_GRAPHICS` instead of silently returning blank images, so an agent immediately knows the capture is impossible rather than reasoning about an all-gray screenshot.
- `profile memory` watches memory the way `profile capture` watches frame time. It samples the memory counters — total and reserved memory, GC, graphics, audio and video, plus per-asset-type object counts and bytes for textures, meshes, materials and animation clips — and saves the result as a report you can come back to. `profile memory compare <baseReportId> <headReportId>` then diffs two reports and answers whether memory grew: a `regression` / `improvement` / `unchanged` verdict based on total used memory, followed by the counters that moved the most, so a leak points at its own cause instead of just a rising total. Comparison runs entirely on the saved reports, so it works with the Editor closed, and reports taken in different modes or Unity versions are still compared with the mismatch noted rather than silently ignored. Counters that the running Unity version does not expose are listed as unavailable instead of failing the command.
- `profile memory snapshot` captures a full memory snapshot for the cases where counters are not enough. It writes a `.snap` file through the Memory Profiler package and returns its path, size, and capture flags — open it in **Window > Analysis > Memory Profiler** for the object-level view. The command requires `com.unity.memoryprofiler` and says so with install instructions when the package is missing, and it will not run at the same time as a profile capture in either direction. Snapshots are as large as the Editor's memory (often over a gigabyte) and are never deleted automatically, so remove old ones yourself.

### Fixed
- A killed or crashed editor session no longer breaks the next one. The editor's IPC auth token file could be left behind when the process died without cleaning up, and the next session then kept the stale file — every CLI command failed with `UNAUTHORIZED` until it was removed by hand. The token file is now replaced on startup.
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ Tests live in `tests/UnityCli.Cli.Tests/` (xUnit, `.NET`-testable surface only).
- **QA wait-until:** Conditions are ANDed and polled on `EditorApplication.update`. `--object-exists` waits for active resolve, `--object-interactable` additionally requires `GetInteractableValue` true (objects without an `interactable` property count as true), and `--object-gone` waits for active resolve failure.
- **QA run-sequence:** `qa run-sequence --spec-json <json|@file>` is deferred on `EditorApplication.update`: each step waits for ANDed built-in (`active`/`gone`/`transform`/`scene`/`log`/`interactable`) or `IQaQueryable` query conditions, then runs `key`/`tap`/`swipe`/`wait`/`screenshot` actions. Operators are `==`, `!=`, `>=`, `<=`, `near`, and `changed`; timeouts return `failedStep.unmet` plus `stateSnapshot`. `--record` captures the sequence interval and returns `recordingPath`. Play Mode required, force-rule None. `IQaQueryable` lives in `Runtime/`, so adding it or new implementations needs Unity reimport for `.meta`.
- **Profile:** `profile stats`는 deferred N-frame 카운터 샘플링(동기 응답), `profile capture`는 Play Mode 전용 STARTED+captureId 비동기(요약은 `Library/com.yhc509.unity-cli-bridge/profiles/<captureId>.json` sidecar, stop 후 EditorApplication.update 청크 walk로 생성). `profile analyze`는 CLI 로컬로 sidecar만 읽는다(Editor 불필요). `profile compare <base> <head>`도 CLI 로컬 전용으로, 같은 project root의 sidecar 두 개를 읽어 verdict(`regression`/`improvement`/`unchanged`)와 frame-time/overBudget/GC/마커 delta를 낸다 — 판정 기준은 median frame time의 `--threshold` 퍼센트(기본 5, 음수·비유한값 거부)이고, budget/unityVersion/프레임 수 불일치는 실패가 아니라 `notes` 경고다. 끝나지 않은 캡처(`status != "Completed"` 또는 `capturedFrames <= 0`)는 base를 먼저 검사해 `PROFILE_FAILED`로 거부한다 — 도메인 리로드가 남기는 `Interrupted` sidecar가 100% improvement로 읽히지 않게 하는 가드다. `deltaPercent`는 기준값이 0 이하면 정의되지 않으므로 0을 sentinel로 쓰지 않고 `deltaPercentAvailable=false`로 표시한다(verdict도 이때 `unchanged` 고정). 마커 분류는 `deltaMs`뿐 아니라 `gcBytesDelta`도 본다(self-time 평평 + GC 증가 = regression), 정렬 tie-break는 marker ordinal이라 `--limit` 결과가 재현 가능하다. `analyze`/`compare` 둘 다 wire 명령이 없어 프로토콜을 건드리지 않는다. GC는 bytes 전용(ms 금지), `gpuMedianMs:-1`은 미측정 sentinel, 명령군 전체 force-rule 없음, capture는 자체 single-flight(`PROFILE_IN_PROGRESS`).
- **Profile memory:** `profile memory`는 메모리 카운터 N프레임(기본 30) deferred 샘플링 후 `Library/com.yhc509.unity-cli-bridge/memory/<reportId>.json` sidecar를 남기는 wire 명령이다. 카운터 이름이 해당 Unity 버전에 없으면 실패가 아니라 `unavailable` 배열로 강등된다(6000.3 실측: `GameObject Count`만 미지원이라 목록에서 제거함). `profile memory compare <base> <head>`는 CLI 로컬 전용으로 sidecar 두 개를 diff하며, verdict 기준은 `Total Used Memory` median의 `--threshold` 퍼센트(기본 5)다 — sentinel 규칙(`deltaPercentAvailable=false`면 verdict `unchanged` 고정), mode/unityVersion/frames 불일치는 `notes` 경고, 정렬 tie-break는 카운터 ordinal이라 `--limit` 결과가 재현 가능하다는 점 모두 `profile compare`와 동일하다. `profile memory snapshot`은 `MemoryProfiler.TakeSnapshot`(flags: ManagedObjects/NativeObjects/NativeAllocations)으로 `Library/.../snapshots/<id>.snap`을 쓰고 경로+크기+flags만 반환한다 — **`.snap` 파싱은 하지 않는다**(공개 API 없음 + 포맷 churn). `com.unity.memoryprofiler` 미설치 시 `PROFILE_FAILED`로 거부하며, capture와 양방향 single-flight(`PROFILE_IN_PROGRESS`)다. 스냅샷은 에디터 메모리 크기만큼 커지고(실측 1.2GB) 자동 삭제되지 않는다. 명령군 전체 force-rule 없음.
- **New `.cs` files in `unity-package/`:** Never hand-author `.meta` files. Unity assigns the GUID — let it. After adding a new `.cs` file, trigger an Editor reimport (`unity-cli refresh` against the sample project, or focus the Editor) so Unity writes the matching `.meta`. CI's `scripts/check-unity-meta.sh` will fail if a `.cs` ships without its `.meta`. If `unity-cli refresh` is blocked by `PROTOCOL_MISMATCH` because the wire version was just bumped, ask the user to focus the Editor (or do a Reimport All in the package folder) instead — do not fall back to a hand-written GUID.
- **Doc sync:** CLI command or option changes must update all docs. Run through this checklist:
1. `dotnet run --project cli/UnityCli.DocGen -- --write` — auto-updates `docs/cli-reference.md`
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,10 +343,21 @@ unity-cli profile capture stop --wait
unity-cli profile analyze <captureId> --gc
unity-cli profile compare <baseCaptureId> <headCaptureId> --threshold 5 --limit 5
unity-cli qa run-sequence --spec-json '{"steps":[{"actions":[{"wait":3000}]}]}' --profile

# Memory leak trend watch — no snapshot needed
unity-cli profile memory # → baseline reportId
# ... play the suspect flow, let time pass ...
unity-cli profile memory # → head reportId
unity-cli profile memory compare <baseReportId> <headReportId> --threshold 5

# Precision follow-up once the trend looks bad
unity-cli profile memory snapshot # → .snap path for the Memory Profiler GUI
```

`profile stats` samples built-in `ProfilerRecorder` counters over N frames and returns min/median/p95/max per counter; it works in both Edit Mode and Play Mode. `profile capture start` records Play Mode frames and returns a `captureId` immediately; `profile capture stop --wait` polls until the summary — frame-time percentiles, spike frames, hotspots, per-marker GC bytes, and a CPU/GPU-bound verdict — is ready. `profile analyze <captureId>` drills into the finished capture's sidecar locally with `--marker <name>`, `--frame <n>`, `--gc`, or `--spikes`, with no Editor round-trip required. `profile compare <baseCaptureId> <headCaptureId>` diffs two finished captures the same way — locally, from both sidecars — and returns a `regression` / `improvement` / `unchanged` verdict plus the frame-time, over-budget, GC, and per-marker deltas behind it; `--threshold <percent>` (default 5) sets how much the median frame time may move before the result stops counting as unchanged, and mismatched budgets, Unity versions, or frame counts are called out in `notes`. A capture that never finished — for example one cut short by a script recompile — is rejected instead of being compared, so a dead capture can never read as a 100% improvement. Each percentage carries a `deltaPercentAvailable` flag; when the baseline value is zero there is no percentage to report, and only the absolute `delta` is meaningful. Add `--profile` to `qa run-sequence` to capture the sequence run and merge its summary into the response as `profileSummary`.

`profile memory` covers the other half of profiling — memory rather than frame time. It samples memory counters (total, reserved, GC, graphics, audio/video, plus per-asset-type counts and bytes) over N frames, returns them as min/median/p95/max, and persists the report to a sidecar so it stays comparable later. Counters that the running Unity version does not expose are listed in `unavailable` instead of failing the command. `profile memory compare <baseReportId> <headReportId>` diffs two reports locally — no Editor needed — and judges `regression` / `improvement` / `unchanged` on the median `Total Used Memory` against `--threshold` (default 5%), listing the counters that grew or shrank the most so a leak points at its own cause: a climbing `Texture Count` and `Texture Memory` pair means textures are not being released, climbing `GC Used Memory` means managed churn. Reports taken in different modes, Unity versions, or frame counts are still compared, with the mismatch reported in `notes`. When the trend does look bad, `profile memory snapshot` captures a full `.snap` via the Memory Profiler package and returns only its path and size — the CLI never parses snapshots, so open the file in `Window > Analysis > Memory Profiler`. It needs `com.unity.memoryprofiler` installed, refuses to run while a profile capture is in flight (and blocks a capture from starting while it runs), and can produce files well over a gigabyte that are never cleaned up automatically.

`screenshot` responses include both image size (`width`/`height`) and live input metadata (`screenWidth`/`screenHeight`, `imageOrigin=top-left`, `coordinateOrigin=bottom-left`). `qa tap` takes screenshot image coordinates as-is, reuses the last successful `screenshot` dimensions when `--screenshot-width`/`--screenshot-height` are omitted, and lets the bridge handle Y-flip plus resolution scaling into Unity screen space. See [qa-testing.md](tools/skills/unity-cli-operator/references/qa-testing.md) for the coordinate workflow.

## Token Optimization
Expand Down
19 changes: 16 additions & 3 deletions cli/UnityCli.Cli/CliApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ internal static async Task<int> RunAsync(string[] args, ICliVersionDispatcher? d
CommandKind.QaWait => await RunQaWait(parsed),
CommandKind.ProfileAnalyze => ProfileAnalyzer.Run(parsed, projectRoot),
CommandKind.ProfileCompare => ProfileComparer.Run(parsed, projectRoot),
CommandKind.ProfileMemoryCompare => ProfileMemoryComparer.Run(parsed, projectRoot),
CommandKind.EditorLaunch => await EditorLauncher.LaunchAsync(parsed, registryStore, projectRoot),
CommandKind.EditorStop => await RunEditorStopAsync(parsed, registryStore, projectRoot),
_ => await ExecuteUnityCommandAsync(parsed, registryStore, projectRoot),
Expand Down Expand Up @@ -704,18 +705,30 @@ internal static int ResolveLiveTimeoutMs(ParsedCommand parsed)
return Math.Max(parsed.TimeoutMs, executeTimeoutMs + ProtocolConstants.DefaultLiveTimeoutMs);
}

if (parsed.Kind == CommandKind.ProfileStats)
if (parsed.Kind is CommandKind.ProfileStats or CommandKind.ProfileMemory)
{
// stats waits N editor frames before the bridge responds; an unfocused editor
// stats/memory wait N editor frames before the bridge responds; an unfocused editor
// can tick as slow as ~4fps, so budget 250ms per frame. Floor it at the editor's
// own stats timeout (+base) so the CLI always outlives the bridge's PROFILE_TIMEOUT
// instead of giving up first and reporting a generic transport error.
int frames = parsed.ProfileFrames ?? ProtocolConstants.DefaultProfileStatsFrames;
int frames = parsed.ProfileFrames ?? (parsed.Kind == CommandKind.ProfileMemory
? ProtocolConstants.DefaultProfileMemoryFrames
: ProtocolConstants.DefaultProfileStatsFrames);
int frameBudgetMs = frames * 250 + ProtocolConstants.DefaultLiveTimeoutMs;
int editorFloorMs = ProtocolConstants.ProfileStatsTimeoutSeconds * 1000 + ProtocolConstants.DefaultLiveTimeoutMs;
return Math.Max(parsed.TimeoutMs, Math.Max(frameBudgetMs, editorFloorMs));
}

if (parsed.Kind == CommandKind.ProfileMemorySnapshot)
{
// TakeSnapshot blocks the editor main thread, so the CLI must outlive the bridge's own
// snapshot watchdog or it gives up first and reports a transport timeout instead of
// the bridge's PROFILE_TIMEOUT.
int editorFloorMs = ProtocolConstants.ProfileMemorySnapshotTimeoutSeconds * 1000
+ ProtocolConstants.DefaultLiveTimeoutMs;
return Math.Max(parsed.TimeoutMs, editorFloorMs);
}

return parsed.TimeoutMs;
}

Expand Down
10 changes: 10 additions & 0 deletions cli/UnityCli.Cli/Models/ParsedCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ public enum CommandKind
ProfileStatus,
ProfileAnalyze,
ProfileCompare,
ProfileMemory,
ProfileMemoryCompare,
ProfileMemorySnapshot,
EditorLaunch,
EditorStop,
}
Expand Down Expand Up @@ -344,6 +347,8 @@ public CommandEnvelope ToEnvelope()
CommandKind.ProfileCaptureStart => ProtocolConstants.CommandProfileCaptureStart,
CommandKind.ProfileCaptureStop => ProtocolConstants.CommandProfileCaptureStop,
CommandKind.ProfileStatus => ProtocolConstants.CommandProfileStatus,
CommandKind.ProfileMemory => ProtocolConstants.CommandProfileMemory,
CommandKind.ProfileMemorySnapshot => ProtocolConstants.CommandProfileMemorySnapshot,
CommandKind.EditorStop => ProtocolConstants.CommandEditorQuit,
_ => throw new CliUsageException($"지원하지 않는 live 명령입니다: {Kind}"),
},
Expand Down Expand Up @@ -447,6 +452,11 @@ private string BuildArgumentsJson()
{
captureId = ProfileCaptureId,
},
CommandKind.ProfileMemory => new ProfileMemoryArgs
{
frames = ProfileFrames ?? 0,
},
CommandKind.ProfileMemorySnapshot => new ProfileMemorySnapshotArgs(),
CommandKind.EditorStop => new EditorQuitArgs
{
force = Force,
Expand Down
5 changes: 4 additions & 1 deletion cli/UnityCli.Cli/Services/CliArgumentParser.Validation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ private static void ValidateProfileOptions(ParsedCommand parsed)

// double.TryParse accepts NaN/Infinity and overflows 1e400 to +Infinity; a non-finite value would
// otherwise reach the JSON serializer (which refuses to write it) instead of failing as a usage error.
if (parsed.Kind == CommandKind.ProfileCompare
if ((parsed.Kind is CommandKind.ProfileCompare or CommandKind.ProfileMemoryCompare)
&& parsed.ProfileThresholdPercent.HasValue
&& (!double.IsFinite(parsed.ProfileThresholdPercent.Value) || parsed.ProfileThresholdPercent.Value < 0))
{
Expand Down Expand Up @@ -674,6 +674,9 @@ internal static CliCommandDescriptor GetCatalogDescriptor(CommandKind kind)
CommandKind.ProfileStatus => "profile status",
CommandKind.ProfileAnalyze => "profile analyze",
CommandKind.ProfileCompare => "profile compare",
CommandKind.ProfileMemory => "profile memory",
CommandKind.ProfileMemoryCompare => "profile memory compare",
CommandKind.ProfileMemorySnapshot => "profile memory snapshot",
CommandKind.EditorLaunch => "editor launch",
CommandKind.EditorStop => "editor stop",
CommandKind.PackageList => "package list",
Expand Down
Loading