From e4bdad9e06f769a5c1717ec419ca285beb2f8951 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Tue, 11 Aug 2026 08:02:04 +0900 Subject: [PATCH 01/33] docs: retire the protocol-bump-forces-minor versioning rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wire-protocol bump no longer needs a minor version. The CLI records protocolVersion in each installed version's meta.json and re-execs to the installed version that speaks the bridge's protocol, so a mismatch is self-describing and self-resolving rather than a silent break. Versions now track the user-facing change: additive commands and fixes are patch bumps, a substantial new capability is a minor. The release ordering requirement is unchanged and now stated explicitly in the rule — dispatch only rescues a user who already has a CLI speaking the new protocol, so the matching CLI must be published before main moves. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a708673..1892a0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,8 +122,8 @@ Hard rules: - All changes go through PRs. Direct push to `main` is blocked by branch ruleset; admin bypass exists for emergencies only — do not use it for routine work. - CI (`test` job) must pass before merge. - GitHub Codex bot (`@codex`) is enabled as a PR reviewer on this repo. -- **Never merge a breaking wire-protocol bump straight to `main`.** A protocol bump makes already-released CLIs incompatible with `main`'s package (and vice versa), so a user installing from `#main` hits `PROTOCOL_MISMATCH`. Land it on `dev`, and when it ships, publish the Unity package and the CLI binary in the same release so `#main` users never see a mismatch. -- Versioning (SemVer): bug fixes and non-breaking changes are patch bumps (`v0.3.1` → `v0.3.2`); a wire-protocol bump or any other breaking change is at least a minor bump (`v0.3.x` → `v0.4.0`). Major bumps only when explicitly requested. +- **Never merge a wire-protocol bump straight to `main`.** A protocol bump leaves already-released CLIs unable to talk to `main`'s package (and vice versa), so a user installing from `#main` hits `PROTOCOL_MISMATCH` with no installed version to dispatch to. Land it on `dev`, and when it ships, publish the Unity package and the CLI binary in the same release so `#main` users never see a mismatch. +- Versioning (SemVer): version the *user-facing* change, not the wire format. A wire-protocol bump on its own does not force a minor — `ProtocolVersion` is recorded in each installed CLI's `meta.json`, and a mismatch is a self-describing condition the CLI resolves by dispatching to the installed version that speaks the bridge's protocol. Size the bump by what the release does for users: additive commands and fixes are patch bumps, a substantial new capability is a minor. Major bumps only when explicitly requested. This does not relax the release ordering in the checklist above — a protocol bump still requires the matching CLI to be *published* before `main` moves, or a user who has no version speaking the new protocol has nothing to dispatch to. - **Reverted work on `main` never merges back cleanly.** When `main` carries a revert of something `dev` still builds on, a content merge resurrects the revert and silently deletes that work — the deletions do not surface as conflicts. Take the `dev` tree whole (`git merge -s ours main` from the release branch) and hand-carry anything `main` uniquely owns. ## Verification After Changes From a737d1a81dfb97d3c9f4708d60eda6eaa0bcf6f2 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Tue, 11 Aug 2026 22:36:39 +0900 Subject: [PATCH 02/33] feat(protocol): add editor-quit command and headless error codes, bump protocol to 7 Co-Authored-By: Claude Fable 5 --- .../Runtime/Protocol/ProtocolConstants.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 153fe29..490f205 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 = "6"; + public const string ProtocolVersion = "7"; public const int DefaultLiveTimeoutMs = 30_000; public const int DefaultTimeoutMs = DefaultLiveTimeoutMs; public const int DefaultExecuteTimeoutMs = 30_000; @@ -58,6 +58,12 @@ public static class ProtocolConstants public const string ErrorProfileFailed = "PROFILE_FAILED"; public const string ErrorProfileInterrupted = "PROFILE_INTERRUPTED"; public const string ErrorProfileTimeout = "PROFILE_TIMEOUT"; + public const string ErrorEditorDirty = "EDITOR_DIRTY"; + public const string ErrorHeadlessNoGraphics = "HEADLESS_NO_GRAPHICS"; + public const string ErrorEditorLaunchFailed = "EDITOR_LAUNCH_FAILED"; + public const string ErrorEditorWaitTimeout = "EDITOR_WAIT_TIMEOUT"; + public const string ErrorEditorStopTimeout = "EDITOR_STOP_TIMEOUT"; + public const string ErrorEditorAlreadyRunning = "EDITOR_ALREADY_RUNNING_CONFLICT"; public const string ErrorCompileWaitTimeout = "COMPILE_WAIT_TIMEOUT"; public const string ErrorRefreshWaitTimeout = "REFRESH_WAIT_TIMEOUT"; public const string ErrorSceneForceRequired = "SCENE_FORCE_REQUIRED"; @@ -128,6 +134,7 @@ public static class ProtocolConstants public const string CommandProfileCaptureStart = "profile-capture-start"; public const string CommandProfileCaptureStop = "profile-capture-stop"; public const string CommandProfileStatus = "profile-status"; + public const string CommandEditorQuit = "editor-quit"; public const int DefaultQaWaitUntilTimeoutMs = 10_000; public const int DefaultQaSwipeDurationMs = 300; public const int DefaultQaRunSequenceTimeoutMs = 60_000; From bb290e184c3a1d1229eb4e88b5f1b542e0effb3a Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Tue, 11 Aug 2026 22:37:15 +0900 Subject: [PATCH 03/33] feat(protocol): add editor-quit wire models Co-Authored-By: Claude Fable 5 --- .../Runtime/Protocol/CommandModels.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/CommandModels.cs b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/CommandModels.cs index 59b0c4b..8437f7c 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/CommandModels.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/CommandModels.cs @@ -33,6 +33,19 @@ public sealed class MessagePayload public string message = string.Empty; } + [Serializable] + public sealed class EditorQuitArgs + { + public bool force; + } + + [Serializable] + public sealed class EditorQuitPayload + { + public bool stopping; + public int editorProcessId; + } + [Serializable] public sealed class PlayStatePayload { From a8d66e0f1e67f1375658272b1e138d8a355f272e Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Tue, 11 Aug 2026 22:39:09 +0900 Subject: [PATCH 04/33] feat(protocol): editor launch/stop catalog entries and requiresGraphics metadata Co-Authored-By: Claude Fable 5 --- docs/cli-reference.md | 2 + .../EditorCommandCatalogTests.cs | 52 ++++++++++++++++ .../Runtime/Protocol/CliCommandCatalog.cs | 62 ++++++++++++++++--- 3 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 tests/UnityCli.Cli.Tests/EditorCommandCatalogTests.cs diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 89ba77a..2b9b44c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -36,6 +36,8 @@ Commands for editor state, compilation, play state, menus, arbitrary code execut | `play` | `play` | live | `None` | Starts Play Mode in a running editor. | | `pause` | `pause` | live | `None` | Pauses Play Mode in a running editor. | | `stop` | `stop` | live | `None` | Stops Play Mode in a running editor. | +| `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. | | `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. | diff --git a/tests/UnityCli.Cli.Tests/EditorCommandCatalogTests.cs b/tests/UnityCli.Cli.Tests/EditorCommandCatalogTests.cs new file mode 100644 index 0000000..719b558 --- /dev/null +++ b/tests/UnityCli.Cli.Tests/EditorCommandCatalogTests.cs @@ -0,0 +1,52 @@ +using UnityCli.Protocol; +using Xunit; + +namespace UnityCli.Cli.Tests; + +public class EditorCommandCatalogTests +{ + [Fact] + public void EditorStop_IsWireCommand_WithDestructiveForceRule() + { + var descriptor = CliCommandCatalog.FindByCommand("editor stop"); + Assert.NotNull(descriptor); + Assert.Equal(ProtocolConstants.CommandEditorQuit, descriptor!.ProtocolCommand); + Assert.Equal(ForceRule.OnDestructiveOp, descriptor.ForceRule); + Assert.True(descriptor.CanUseLive); + Assert.False(descriptor.CanUseLocal); + } + + [Fact] + public void EditorLaunch_IsLocalOnly() + { + var descriptor = CliCommandCatalog.FindByCommand("editor launch"); + Assert.NotNull(descriptor); + Assert.Null(descriptor!.ProtocolCommand); + Assert.True(descriptor.CanUseLocal); + Assert.False(descriptor.CanUseLive); + } + + [Fact] + public void EditorQuit_IsInSupportedProtocolCommands() + { + Assert.Contains(ProtocolConstants.CommandEditorQuit, CliCommandCatalog.GetSupportedProtocolCommands()); + } + + [Theory] + [InlineData(ProtocolConstants.CommandScreenshot, true)] + [InlineData(ProtocolConstants.CommandRecordStart, true)] + [InlineData(ProtocolConstants.CommandQaUiDump, true)] + [InlineData(ProtocolConstants.CommandQaWorldDump, true)] + [InlineData(ProtocolConstants.CommandQaClick, true)] + [InlineData(ProtocolConstants.CommandQaTap, true)] + [InlineData(ProtocolConstants.CommandQaSwipe, true)] + [InlineData(ProtocolConstants.CommandQaWaitUntil, false)] + [InlineData(ProtocolConstants.CommandQaRunSequence, false)] + [InlineData(ProtocolConstants.CommandRecordStop, false)] + [InlineData(ProtocolConstants.CommandStatus, false)] + [InlineData(ProtocolConstants.CommandEditorQuit, false)] + public void RequiresGraphics_MatchesRenderingSurface(string protocolCommand, bool expected) + { + Assert.Equal(expected, CliCommandCatalog.RequiresGraphics(protocolCommand)); + } +} 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 59c79c7..bbe3cdb 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 @@ -39,7 +39,8 @@ public CliCommandDescriptor( bool isAllowedWhileBusy, string[]? notes = null, ForceRule forceRule = ForceRule.None, - int? defaultLiveTimeoutMs = null) + int? defaultLiveTimeoutMs = null, + bool requiresGraphics = false) { Command = command; Synopsis = synopsis; @@ -52,6 +53,7 @@ public CliCommandDescriptor( Notes = notes ?? Array.Empty(); ForceRule = forceRule; DefaultLiveTimeoutMs = defaultLiveTimeoutMs; + RequiresGraphics = requiresGraphics; } public string Command { get; } @@ -70,6 +72,7 @@ public CliCommandDescriptor( public bool IsAllowedWhileBusy { get; } public ForceRule ForceRule { get; } public int? DefaultLiveTimeoutMs { get; } + public bool RequiresGraphics { get; } [Obsolete("Use ForceRule instead.")] public bool RequiresForce => ForceRule != ForceRule.None; @@ -147,6 +150,36 @@ public static class CliCommandCatalog canUseLocal: false, canUseLive: true, isAllowedWhileBusy: false), + new CliCommandDescriptor( + "editor launch", + "editor launch [--gui] [--nographics] [--no-wait] [--timeout ] [--editor-path ]", + "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.", + CliCommandGroup.EditorControl, + protocolCommand: null, + canUseLocal: true, + canUseLive: false, + isAllowedWhileBusy: true, + notes: new[] + { + "Default headless mode passes -batchmode only; the GPU stays initialized so screenshot/record/qa keep working without a window.", + "--nographics disables the GPU entirely; rendering commands then fail with HEADLESS_NO_GRAPHICS.", + "Pre-flight refuses to double-launch: a live registry match is reused, and an editor process outside the registry fails with EDITOR_ALREADY_RUNNING_CONFLICT.", + }), + new CliCommandDescriptor( + "editor stop", + "editor stop [--force] [--no-wait] [--timeout ]", + "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.", + CliCommandGroup.EditorControl, + ProtocolConstants.CommandEditorQuit, + canUseLocal: false, + canUseLive: true, + isAllowedWhileBusy: false, + forceRule: ForceRule.OnDestructiveOp, + notes: new[] + { + "The bridge replies first and exits on the next editor tick, so the CLI receives a normal success response.", + "A graceful quit removes the instance registry entry and the auth-token sidecar.", + }), new CliCommandDescriptor( "execute-menu", "execute-menu (--path \"Menu/Item\" | --list \"Prefix\")", @@ -172,7 +205,8 @@ public static class CliCommandCatalog "--format controls encoding regardless of --path extension; temporary paths use .png or .jpg to match the selected format.", "--max-width is ignored when --width or --height is specified.", "Play Mode --view game captures at the native Game View size first; larger --width/--height requests warn and save at native resolution instead of upscaling.", - }), + }, + requiresGraphics: true), new CliCommandDescriptor( "record start", "record start [--path ] [--fps (default: 30)] [--max-width ] [--duration ] [--wait]", @@ -188,7 +222,8 @@ public static class CliCommandCatalog "--duration auto-stops after N seconds; without it, recording runs until `record stop` or the 600s safety cap.", "--wait polls until the recording is finalized and requires --duration.", "--max-width scales the captured frame width; unset keeps the native Game View size.", - }), + }, + requiresGraphics: true), new CliCommandDescriptor( "record stop", "record stop", @@ -696,7 +731,8 @@ public static class CliCommandCatalog ProtocolConstants.CommandQaClick, canUseLocal: false, canUseLive: true, - isAllowedWhileBusy: false), + isAllowedWhileBusy: false, + requiresGraphics: true), new CliCommandDescriptor( "qa tap", "qa tap (--x --y | --target ) [--button left|right] [--screenshot-width --screenshot-height ]", @@ -705,7 +741,8 @@ public static class CliCommandCatalog ProtocolConstants.CommandQaTap, canUseLocal: false, canUseLive: true, - isAllowedWhileBusy: false), + isAllowedWhileBusy: false, + requiresGraphics: true), new CliCommandDescriptor( "qa swipe", "qa swipe [--target ] --from --to [--duration ] [--button left|right] [--screenshot-width --screenshot-height ]", @@ -714,7 +751,8 @@ public static class CliCommandCatalog ProtocolConstants.CommandQaSwipe, canUseLocal: false, canUseLive: true, - isAllowedWhileBusy: false), + isAllowedWhileBusy: false, + requiresGraphics: true), new CliCommandDescriptor( "qa key", "qa key --key ", @@ -732,7 +770,8 @@ public static class CliCommandCatalog ProtocolConstants.CommandQaUiDump, canUseLocal: false, canUseLive: true, - isAllowedWhileBusy: false), + isAllowedWhileBusy: false, + requiresGraphics: true), new CliCommandDescriptor( "qa world-dump", "qa world-dump [--include-offscreen] [--limit N] [--text ] [--screenshot-width --screenshot-height ]", @@ -741,7 +780,8 @@ public static class CliCommandCatalog ProtocolConstants.CommandQaWorldDump, canUseLocal: false, canUseLive: true, - isAllowedWhileBusy: false), + isAllowedWhileBusy: false, + requiresGraphics: true), new CliCommandDescriptor( "qa run-sequence", "qa run-sequence --spec-json [--timeout ] [--record] [--record-path ]", @@ -903,5 +943,11 @@ public static bool IsProtocolCommandInGroup(string command, CliCommandGroup grou return null; } + + public static bool RequiresGraphics(string protocolCommand) + { + CliCommandDescriptor? descriptor = FindByProtocolCommand(protocolCommand); + return descriptor != null && descriptor.RequiresGraphics; + } } } From f5120ad5ef125a57c3504e2c31c4e5c695f55703 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Tue, 11 Aug 2026 22:40:19 +0900 Subject: [PATCH 05/33] fix(protocol): overwrite stale token sidecar left by killed editor sessions Co-Authored-By: Claude Fable 5 --- .../InstanceTokenSidecarTests.cs | 59 +++++++++++++++++++ .../Runtime/Protocol/InstanceRegistryFile.cs | 17 ++++++ 2 files changed, 76 insertions(+) diff --git a/tests/UnityCli.Cli.Tests/InstanceTokenSidecarTests.cs b/tests/UnityCli.Cli.Tests/InstanceTokenSidecarTests.cs index 00b4ba1..d2b769e 100644 --- a/tests/UnityCli.Cli.Tests/InstanceTokenSidecarTests.cs +++ b/tests/UnityCli.Cli.Tests/InstanceTokenSidecarTests.cs @@ -225,6 +225,65 @@ public void TokenSidecar_WithEmptyProjectHash_NoOpsAndReturnsEmpty() } } + [Fact] + public void EnsureTokenSidecar_OverwritesStaleToken() + { + string tempRoot = CreateTempRoot(); + try + { + string registryPath = Path.Combine(tempRoot, "instances.json"); + + InstanceRegistryFile.WriteTokenSidecar(registryPath, "abcdef012345", "stale-token"); + InstanceRegistryFile.EnsureTokenSidecar(registryPath, "abcdef012345", "live-token"); + + Assert.Equal("live-token", InstanceRegistryFile.ReadTokenSidecar(registryPath, "abcdef012345")); + } + finally + { + DeleteTempRoot(tempRoot); + } + } + + [Fact] + public void EnsureTokenSidecar_CreatesWhenMissing() + { + string tempRoot = CreateTempRoot(); + try + { + string registryPath = Path.Combine(tempRoot, "instances.json"); + + InstanceRegistryFile.EnsureTokenSidecar(registryPath, "abcdef012345", "live-token"); + + Assert.Equal("live-token", InstanceRegistryFile.ReadTokenSidecar(registryPath, "abcdef012345")); + } + finally + { + DeleteTempRoot(tempRoot); + } + } + + [Fact] + public void EnsureTokenSidecar_NoRewriteWhenIdentical() + { + string tempRoot = CreateTempRoot(); + try + { + string registryPath = Path.Combine(tempRoot, "instances.json"); + + InstanceRegistryFile.WriteTokenSidecar(registryPath, "abcdef012345", "live-token"); + string sidecarPath = InstanceRegistryFile.GetTokenSidecarPath(registryPath, "abcdef012345"); + DateTime before = File.GetLastWriteTimeUtc(sidecarPath); + + InstanceRegistryFile.EnsureTokenSidecar(registryPath, "abcdef012345", "live-token"); + + Assert.Equal(before, File.GetLastWriteTimeUtc(sidecarPath)); + } + finally + { + DeleteTempRoot(tempRoot); + } + } + [Fact] public void Save_ThenLoad_DoesNotPersistInstanceToken() { diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/InstanceRegistryFile.cs b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/InstanceRegistryFile.cs index d7eee60..00ade37 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/InstanceRegistryFile.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/InstanceRegistryFile.cs @@ -199,6 +199,23 @@ public static void WriteTokenSidecar(string registryFilePath, string projectHash } } + /// + /// Writes the sidecar only when its content differs from . + /// A crashed/killed editor leaves a stale sidecar behind (normal exit deletes it); + /// the next session owns the listener, so its live token must win or every CLI + /// call fails UNAUTHORIZED until manual cleanup. + /// + public static void EnsureTokenSidecar(string registryFilePath, string projectHash, string token) + { + string existing = ReadTokenSidecar(registryFilePath, projectHash); + if (string.Equals(existing, token, StringComparison.Ordinal)) + { + return; + } + + WriteTokenSidecar(registryFilePath, projectHash, token); + } + public static string ReadTokenSidecar(string registryFilePath, string projectHash) { string fullPath = GetTokenSidecarPath(registryFilePath, projectHash); From a04ab2d8d1e89f2c91f10e68884a3edebfe13164 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Tue, 11 Aug 2026 22:40:42 +0900 Subject: [PATCH 06/33] feat(protocol): record editor mode in instance registry Co-Authored-By: Claude Fable 5 --- .../Runtime/Protocol/RegistryModels.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/RegistryModels.cs b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/RegistryModels.cs index 466b163..e6d7c92 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/RegistryModels.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/RegistryModels.cs @@ -31,6 +31,8 @@ public sealed class InstanceRecord public string unityVersion = string.Empty; public string state = "offline"; public string lastSeenUtc = string.Empty; + // "gui" | "headless" | "headless-nographics"; empty string = written by an older bridge (unknown). + public string editorMode = string.Empty; public string[] capabilities = Array.Empty(); } } From 6d9d028bf89222dd0b3a406c9cdcb59e27f23eac Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Tue, 11 Aug 2026 22:44:39 +0900 Subject: [PATCH 07/33] test: update protocol version pin and exempt bridge-gated editor stop from CLI force-gate mapping Co-Authored-By: Claude Fable 5 --- tests/UnityCli.Cli.Tests/ForceGateTests.cs | 7 +++++++ tests/UnityCli.Cli.Tests/ProtocolConstantsTests.cs | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/UnityCli.Cli.Tests/ForceGateTests.cs b/tests/UnityCli.Cli.Tests/ForceGateTests.cs index 95820e4..fa1b9fc 100644 --- a/tests/UnityCli.Cli.Tests/ForceGateTests.cs +++ b/tests/UnityCli.Cli.Tests/ForceGateTests.cs @@ -18,8 +18,15 @@ public sealed class ForceGateTests [Fact] public void ForceRequiredByCatalog_HonorsCatalogForceRules() { + // "editor stop" has ForceRule.OnDestructiveOp but its destructiveness (unsaved + // changes) is only known bridge-side (EDITOR_DIRTY); the CLI-side CommandKind + // wiring lands with the `editor` command group. Until then there is no + // ParsedCommand to construct for it. + string[] bridgeGatedCommands = ["editor stop"]; + CliCommandDescriptor[] forceCommands = CliCommandCatalog.GetCommands() .Where(command => command.ForceRule != ForceRule.None) + .Where(command => !bridgeGatedCommands.Contains(command.Command)) .ToArray(); Assert.NotEmpty(forceCommands); diff --git a/tests/UnityCli.Cli.Tests/ProtocolConstantsTests.cs b/tests/UnityCli.Cli.Tests/ProtocolConstantsTests.cs index bc28f0c..050684b 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_BumpedToSix_ForProfileCommands() + public void ProtocolVersion_BumpedToSeven_ForHeadlessEditorCommands() { - Assert.Equal("6", ProtocolConstants.ProtocolVersion); + Assert.Equal("7", ProtocolConstants.ProtocolVersion); } [Fact] From 60e4f82e19e7268d0fcb9907ce18eb99f8dd5dda Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Tue, 11 Aug 2026 22:44:39 +0900 Subject: [PATCH 08/33] feat(bridge): run bridge in headless main editor, graphics guard, editor-quit, stale sidecar fix Co-Authored-By: Claude Fable 5 --- .../Editor/BridgeHost.cs | 111 ++++++++++++++++-- 1 file changed, 103 insertions(+), 8 deletions(-) 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 40df1fa..5f171e7 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/BridgeHost.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/BridgeHost.cs @@ -107,7 +107,7 @@ public BridgeHost() public void Start() { - if (_isStarted || Application.isBatchMode) + if (_isStarted || IsSecondaryUnityProcess()) { return; } @@ -122,6 +122,34 @@ public void Start() AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; } + // The bridge must run in the main editor process only — GUI or headless alike. + // AssetImportWorker/MPE secondary processes share the same projectRoot and would + // fight over the socket name and registry entry if the bridge started there. + private static bool IsSecondaryUnityProcess() + { + try + { + if (UnityEditor.MPE.ProcessService.level != UnityEditor.MPE.ProcessLevel.Main) + { + return true; + } + } + catch (Exception) + { + // MPE unavailable: fall through to the argv check. + } + + foreach (string arg in Environment.GetCommandLineArgs()) + { + if (string.Equals(arg, "-adb2", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + public void Dispose() { Dispose(unregisterInstance: true); @@ -1069,6 +1097,16 @@ private ResponseEnvelope HandleCommand(CommandEnvelope command) return BuildBusyResponse(command, stopwatch.ElapsedMilliseconds); } + if (SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Null + && CliCommandCatalog.RequiresGraphics(command.command)) + { + throw new CommandFailureException( + ProtocolConstants.ErrorHeadlessNoGraphics, + "-nographics 에디터에서는 렌더링이 초기화되지 않아 이 명령을 사용할 수 없습니다: " + command.command, + "GPU가 없으면 캡처 결과가 무의미한 단색/깨진 이미지가 되므로 명령 차원에서 차단합니다. " + + "-nographics 없이 -batchmode로만 띄우면 창 없이도 렌더링 명령을 쓸 수 있습니다."); + } + string data; if (_assetCommandHandler.CanHandle(command.command)) { @@ -1167,6 +1205,9 @@ private ResponseEnvelope HandleCommand(CommandEnvelope command) case ProtocolConstants.CommandReadConsole: data = HandleReadConsole(command.argumentsJson); break; + case ProtocolConstants.CommandEditorQuit: + data = HandleEditorQuit(command.argumentsJson); + break; default: throw new InvalidOperationException("지원하지 않는 명령입니다: " + command.command); } @@ -1333,6 +1374,51 @@ private string HandleReadConsole(string argumentsJson) return ProtocolJson.Serialize(new ReadConsolePayload { entries = entries }); } + private string HandleEditorQuit(string argumentsJson) + { + EditorQuitArgs args = ProtocolJson.Deserialize(argumentsJson) ?? new EditorQuitArgs(); + List dirtyTargets = CollectDirtyTargets(); + if (dirtyTargets.Count > 0 && !args.force) + { + throw new CommandFailureException( + ProtocolConstants.ErrorEditorDirty, + "저장되지 않은 변경이 있어 에디터 종료를 거부했습니다. --force로 변경을 버리고 종료할 수 있습니다.", + string.Join("\n", dirtyTargets)); + } + + // Reply first, exit on the next editor tick: the response is flushed on the + // listener thread right after this method returns, so the CLI receives a + // normal success envelope instead of LIVE_UNAVAILABLE. + EditorApplication.delayCall += () => EditorApplication.Exit(0); + + return ProtocolJson.Serialize(new EditorQuitPayload + { + stopping = true, + editorProcessId = Process.GetCurrentProcess().Id, + }); + } + + private static List CollectDirtyTargets() + { + var dirtyTargets = new List(); + for (int i = 0; i < UnityEngine.SceneManagement.SceneManager.sceneCount; i++) + { + UnityEngine.SceneManagement.Scene scene = UnityEngine.SceneManagement.SceneManager.GetSceneAt(i); + if (scene.isDirty) + { + dirtyTargets.Add(string.IsNullOrEmpty(scene.path) ? "(untitled scene)" : scene.path); + } + } + + PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + if (prefabStage != null && prefabStage.scene.isDirty) + { + dirtyTargets.Add("PrefabStage:" + prefabStage.assetPath); + } + + return dirtyTargets; + } + private void RegisterInstance() { UpdateRegistrySafely(delegate(InstanceRegistry registry) @@ -1440,11 +1526,24 @@ private InstanceRecord BuildInstanceRecord() editorProcessId = Process.GetCurrentProcess().Id, unityVersion = Application.unityVersion, state = BuildStateLabel(), + editorMode = ResolveEditorModeLabel(), lastSeenUtc = DateTimeOffset.UtcNow.ToString("O"), capabilities = (string[])_capabilities.Clone(), }; } + private static string ResolveEditorModeLabel() + { + if (!Application.isBatchMode) + { + return "gui"; + } + + return SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Null + ? "headless-nographics" + : "headless"; + } + private static string EnsureSessionToken() { string existingToken = SessionState.GetString(AuthTokenSessionStateKey, string.Empty); @@ -1539,13 +1638,9 @@ private void WriteTokenSidecarSafely() { try { - string sidecarPath = InstanceRegistryFile.GetTokenSidecarPath(_registryFilePath, _projectHash); - if (!string.IsNullOrWhiteSpace(sidecarPath) && File.Exists(sidecarPath)) - { - return; - } - - InstanceRegistryFile.WriteTokenSidecar(_registryFilePath, _projectHash, _authToken); + // Overwrite-if-different: a stale sidecar from a killed session would otherwise + // make every CLI call fail UNAUTHORIZED for this whole editor session. + InstanceRegistryFile.EnsureTokenSidecar(_registryFilePath, _projectHash, _authToken); } catch (Exception exception) { From e69c3d5e45a0765eee28b5ca7a9098f604a7ef35 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Tue, 11 Aug 2026 22:48:55 +0900 Subject: [PATCH 09/33] feat(cli): parsed-command surface for editor launch/stop Co-Authored-By: Claude Fable 5 --- cli/UnityCli.Cli/Models/ParsedCommand.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cli/UnityCli.Cli/Models/ParsedCommand.cs b/cli/UnityCli.Cli/Models/ParsedCommand.cs index 4938669..b64d914 100644 --- a/cli/UnityCli.Cli/Models/ParsedCommand.cs +++ b/cli/UnityCli.Cli/Models/ParsedCommand.cs @@ -76,6 +76,8 @@ public enum CommandKind ProfileStatus, ProfileAnalyze, ProfileCompare, + EditorLaunch, + EditorStop, } public sealed class ParsedCommand @@ -229,6 +231,11 @@ public ParsedCommand(CommandKind kind) public int QaSequenceTimeoutMs { get; set; } public bool QaSequenceRecord { get; set; } public string? QaSequenceRecordPath { get; set; } + public bool EditorGui { get; set; } + public bool EditorNoGraphics { get; set; } + public string? EditorPathOverride { get; set; } + public bool EditorNoWait { get; set; } + public int? EditorWaitTimeoutSeconds { get; set; } public CommandEnvelope ToEnvelope() { @@ -337,6 +344,7 @@ public CommandEnvelope ToEnvelope() CommandKind.ProfileCaptureStart => ProtocolConstants.CommandProfileCaptureStart, CommandKind.ProfileCaptureStop => ProtocolConstants.CommandProfileCaptureStop, CommandKind.ProfileStatus => ProtocolConstants.CommandProfileStatus, + CommandKind.EditorStop => ProtocolConstants.CommandEditorQuit, _ => throw new CliUsageException($"지원하지 않는 live 명령입니다: {Kind}"), }, argumentsJson = BuildArgumentsJson(), @@ -439,6 +447,10 @@ private string BuildArgumentsJson() { captureId = ProfileCaptureId, }, + CommandKind.EditorStop => new EditorQuitArgs + { + force = Force, + }, CommandKind.ExecuteCode => new ExecuteCodeArgs { code = ResolveExecuteCode(), From 881d5b6ea5cf87fa2cb3cdb8e2bfda9bdc1b6815 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Tue, 11 Aug 2026 22:50:10 +0900 Subject: [PATCH 10/33] feat(cli): parse editor launch/stop commands Co-Authored-By: Claude Fable 5 --- .../Services/CliArgumentParser.Validation.cs | 2 + .../Services/CliArgumentParser.cs | 35 +++++++++++++++ .../CliArgumentParserTests.cs | 45 +++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/cli/UnityCli.Cli/Services/CliArgumentParser.Validation.cs b/cli/UnityCli.Cli/Services/CliArgumentParser.Validation.cs index 09fd381..5784b4b 100644 --- a/cli/UnityCli.Cli/Services/CliArgumentParser.Validation.cs +++ b/cli/UnityCli.Cli/Services/CliArgumentParser.Validation.cs @@ -674,6 +674,8 @@ internal static CliCommandDescriptor GetCatalogDescriptor(CommandKind kind) CommandKind.ProfileStatus => "profile status", CommandKind.ProfileAnalyze => "profile analyze", CommandKind.ProfileCompare => "profile compare", + CommandKind.EditorLaunch => "editor launch", + CommandKind.EditorStop => "editor stop", CommandKind.PackageList => "package list", CommandKind.PackageAdd => "package add", CommandKind.PackageRemove => "package remove", diff --git a/cli/UnityCli.Cli/Services/CliArgumentParser.cs b/cli/UnityCli.Cli/Services/CliArgumentParser.cs index 7e8d812..ae3edc2 100644 --- a/cli/UnityCli.Cli/Services/CliArgumentParser.cs +++ b/cli/UnityCli.Cli/Services/CliArgumentParser.cs @@ -75,6 +75,7 @@ public static ParsedCommand Parse(string[] args) "material" => ParseMaterial(tokens), "qa" => ParseQa(tokens), "record" => ParseRecordCommand(tokens), + "editor" => ParseEditorCommand(tokens), "profile" => ParseProfile(tokens), "instances" => ParseInstances(tokens), "doctor" => new ParsedCommand(CommandKind.Doctor), @@ -356,6 +357,22 @@ private static ParsedCommand ParseRecordCommand(Queue tokens) }; } + private static ParsedCommand ParseEditorCommand(Queue tokens) + { + if (tokens.Count == 0) + { + throw new CliUsageException("`editor` 다음에는 `launch` 또는 `stop`이 필요합니다."); + } + + var subCommand = tokens.Dequeue().ToLowerInvariant(); + return subCommand switch + { + "launch" => new ParsedCommand(CommandKind.EditorLaunch), + "stop" => new ParsedCommand(CommandKind.EditorStop), + _ => throw new CliUsageException($"알 수 없는 editor 하위 명령입니다: {subCommand}"), + }; + } + private static ParsedCommand ParseProfile(Queue tokens) { if (tokens.Count == 0) @@ -653,6 +670,24 @@ private static void ParseCommandOptions(ParsedCommand parsed, Queue toke case CommandKind.RecordStatus when token == "--recording-id": parsed.RecordRunId = RequireValue(tokens, "--recording-id"); break; + case CommandKind.EditorLaunch when token == "--gui": + parsed.EditorGui = true; + break; + case CommandKind.EditorLaunch when token == "--nographics": + parsed.EditorNoGraphics = true; + break; + case CommandKind.EditorLaunch when token == "--editor-path": + parsed.EditorPathOverride = RequireValue(tokens, "--editor-path"); + break; + case CommandKind.EditorLaunch or CommandKind.EditorStop when token == "--no-wait": + parsed.EditorNoWait = true; + break; + case CommandKind.EditorLaunch or CommandKind.EditorStop when token == "--timeout": + parsed.EditorWaitTimeoutSeconds = RequireInt(RequireValue(tokens, "--timeout"), "--timeout"); + break; + case CommandKind.EditorStop when token == "--force": + parsed.Force = true; + break; case CommandKind.MaterialInfo when token == "--path": case CommandKind.MaterialSet when token == "--path": parsed.MaterialPath = RequireAssetPath(RequireValue(tokens, "--path"), "--path"); diff --git a/tests/UnityCli.Cli.Tests/CliArgumentParserTests.cs b/tests/UnityCli.Cli.Tests/CliArgumentParserTests.cs index 785bf64..c3531df 100644 --- a/tests/UnityCli.Cli.Tests/CliArgumentParserTests.cs +++ b/tests/UnityCli.Cli.Tests/CliArgumentParserTests.cs @@ -2136,4 +2136,49 @@ public void Parse_Package_UnknownSubcommandThrows() Assert.Contains("upgrade", ex.Message); } + + [Fact] + public void Parse_EditorLaunch_Defaults() + { + var parsed = CliArgumentParser.Parse(["editor", "launch"]); + + Assert.Equal(CommandKind.EditorLaunch, parsed.Kind); + Assert.False(parsed.EditorGui); + Assert.False(parsed.EditorNoGraphics); + Assert.False(parsed.EditorNoWait); + Assert.Null(parsed.EditorWaitTimeoutSeconds); + Assert.Null(parsed.EditorPathOverride); + } + + [Fact] + public void Parse_EditorLaunch_AllOptions() + { + var parsed = CliArgumentParser.Parse([ + "editor", "launch", "--gui", "--nographics", "--no-wait", + "--timeout", "120", "--editor-path", "/tmp/Unity", + ]); + + Assert.Equal(CommandKind.EditorLaunch, parsed.Kind); + Assert.True(parsed.EditorGui); + Assert.True(parsed.EditorNoGraphics); + Assert.True(parsed.EditorNoWait); + Assert.Equal(120, parsed.EditorWaitTimeoutSeconds); + Assert.Equal("/tmp/Unity", parsed.EditorPathOverride); + } + + [Fact] + public void Parse_EditorStop_ForceAndTimeout() + { + var parsed = CliArgumentParser.Parse(["editor", "stop", "--force", "--timeout", "10"]); + + Assert.Equal(CommandKind.EditorStop, parsed.Kind); + Assert.True(parsed.Force); + Assert.Equal(10, parsed.EditorWaitTimeoutSeconds); + } + + [Fact] + public void Parse_EditorUnknownSubcommand_Throws() + { + Assert.Throws(() => CliArgumentParser.Parse(["editor", "restart"])); + } } From a4169e2e8f7ccd597c964abc39eb5353f38c8059 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Tue, 11 Aug 2026 22:51:40 +0900 Subject: [PATCH 11/33] feat(cli): editor launcher with pre-flight, spawn, readiness polling Co-Authored-By: Claude Fable 5 --- cli/UnityCli.Cli/Services/EditorLauncher.cs | 355 ++++++++++++++++++ .../UnityCli.Cli.Tests/EditorLauncherTests.cs | 60 +++ 2 files changed, 415 insertions(+) create mode 100644 cli/UnityCli.Cli/Services/EditorLauncher.cs create mode 100644 tests/UnityCli.Cli.Tests/EditorLauncherTests.cs diff --git a/cli/UnityCli.Cli/Services/EditorLauncher.cs b/cli/UnityCli.Cli/Services/EditorLauncher.cs new file mode 100644 index 0000000..c177999 --- /dev/null +++ b/cli/UnityCli.Cli/Services/EditorLauncher.cs @@ -0,0 +1,355 @@ +using System.Diagnostics; +using System.Text.Json; +using UnityCli.Cli.Models; +using UnityCli.Protocol; + +namespace UnityCli.Cli.Services; + +/// +/// Local `editor launch` flow: pre-flight double-launch detection, editor spawn, +/// and bridge-readiness polling. Never talks IPC itself — readiness is observed +/// through the shared instance registry, which the bridge writes only after the +/// listener and token sidecar are ready. +/// +public static class EditorLauncher +{ + private const int DefaultLaunchWaitTimeoutSeconds = 300; + private const int PollIntervalMilliseconds = 2000; + + public static async Task LaunchAsync( + ParsedCommand parsed, + InstanceRegistryStore registryStore, + string? projectRoot) + { + var stopwatch = Stopwatch.StartNew(); + string requestId = Guid.NewGuid().ToString("N"); + + if (string.IsNullOrWhiteSpace(projectRoot)) + { + throw new CliUsageException("editor launch에는 대상 프로젝트가 필요합니다. --project 를 지정하세요."); + } + + string canonicalRoot = ProtocolConstants.GetCanonicalPath(projectRoot); + if (!File.Exists(Path.Combine(canonicalRoot, "ProjectSettings", "ProjectVersion.txt"))) + { + return Failure( + requestId, + ProtocolConstants.ErrorEditorLaunchFailed, + "Unity 프로젝트가 아닙니다 (ProjectSettings/ProjectVersion.txt 없음): " + canonicalRoot, + retryable: false, + stopwatch); + } + + // Pre-flight ①: live registry match → idempotent reuse. + InstanceRecord? live = FindLiveInstance(registryStore, canonicalRoot); + if (live != null) + { + string requestedMode = RequestedModeLabel(parsed); + string note = string.IsNullOrEmpty(live.editorMode) || string.Equals(live.editorMode, requestedMode, StringComparison.Ordinal) + ? string.Empty + : $"요청 모드({requestedMode})와 실행 중인 에디터 모드({live.editorMode})가 다릅니다. 모드 전환이 필요하면 editor stop 후 다시 launch 하세요."; + + return Success(requestId, new + { + launched = false, + reused = true, + pid = live.editorProcessId, + mode = string.IsNullOrEmpty(live.editorMode) ? "unknown" : live.editorMode, + unityVersion = live.unityVersion, + projectRoot = canonicalRoot, + waitedMs = 0L, + note, + }, stopwatch); + } + + // Pre-flight ②: a main-editor process on this project that never registered + // (bridge package missing, or still booting). Launching again would hit Unity's + // own lock — batch fails fast, GUI blocks on a modal. Refuse with context. + int? strayPid = FindStrayEditorProcessId(canonicalRoot); + if (strayPid.HasValue) + { + return Failure( + requestId, + ProtocolConstants.ErrorEditorAlreadyRunning, + $"이 프로젝트를 연 에디터 프로세스(PID {strayPid.Value})가 이미 있지만 브릿지 인스턴스로 등록되어 있지 않습니다. " + + "부팅/컴파일 중이면 잠시 후 다시 시도하고, 패키지 미설치 프로젝트면 해당 에디터를 직접 사용하세요.", + retryable: true, + stopwatch); + } + + string? editorBinary = !string.IsNullOrWhiteSpace(parsed.EditorPathOverride) + ? (File.Exists(parsed.EditorPathOverride) ? parsed.EditorPathOverride : null) + : UnityEditorLocator.TryResolve(canonicalRoot); + if (editorBinary == null) + { + return Failure( + requestId, + ProtocolConstants.ErrorEditorLaunchFailed, + "프로젝트 버전에 맞는 Unity 에디터 바이너리를 찾지 못했습니다. Unity Hub로 해당 버전을 설치하거나 --editor-path로 지정하세요.", + retryable: false, + stopwatch); + } + + string logDirectory = Path.Combine(canonicalRoot, "Library", "com.yhc509.unity-cli-bridge"); + Directory.CreateDirectory(logDirectory); + string logFile = Path.Combine(logDirectory, "editor-launch.log"); + + var startInfo = new ProcessStartInfo + { + FileName = editorBinary, + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + }; + foreach (string argument in BuildLaunchArguments(parsed, canonicalRoot, logFile)) + { + startInfo.ArgumentList.Add(argument); + } + + Process editorProcess; + try + { + editorProcess = Process.Start(startInfo) + ?? throw new InvalidOperationException("Process.Start가 null을 반환했습니다."); + } + catch (Exception exception) + { + return Failure( + requestId, + ProtocolConstants.ErrorEditorLaunchFailed, + "에디터 기동에 실패했습니다: " + exception.Message, + retryable: false, + stopwatch); + } + + if (parsed.EditorNoWait) + { + return Success(requestId, new + { + launched = true, + reused = false, + pid = editorProcess.Id, + mode = RequestedModeLabel(parsed), + unityVersion = string.Empty, + projectRoot = canonicalRoot, + waitedMs = 0L, + note = "--no-wait: 브릿지 준비를 기다리지 않았습니다. editor-launch.log와 instances list로 상태를 확인하세요.", + }, stopwatch); + } + + int timeoutSeconds = parsed.EditorWaitTimeoutSeconds ?? DefaultLaunchWaitTimeoutSeconds; + var deadline = DateTimeOffset.UtcNow.AddSeconds(timeoutSeconds); + while (DateTimeOffset.UtcNow < deadline) + { + if (editorProcess.HasExited) + { + return Failure( + requestId, + ProtocolConstants.ErrorEditorLaunchFailed, + $"에디터 프로세스가 준비 전에 종료되었습니다 (exit {editorProcess.ExitCode}). 로그 확인: {logFile}", + retryable: false, + stopwatch); + } + + InstanceRecord? ready = FindLiveInstance(registryStore, canonicalRoot); + if (ready != null) + { + return Success(requestId, new + { + launched = true, + reused = false, + pid = ready.editorProcessId, + mode = string.IsNullOrEmpty(ready.editorMode) ? RequestedModeLabel(parsed) : ready.editorMode, + unityVersion = ready.unityVersion, + projectRoot = canonicalRoot, + waitedMs = stopwatch.ElapsedMilliseconds, + note = string.Empty, + }, stopwatch); + } + + await Task.Delay(PollIntervalMilliseconds); + } + + return Failure( + requestId, + ProtocolConstants.ErrorEditorWaitTimeout, + $"에디터(PID {editorProcess.Id})는 떠 있지만 {timeoutSeconds}초 안에 브릿지가 준비되지 않았습니다. " + + $"첫 임포트가 긴 프로젝트면 --timeout을 늘리세요. 로그: {logFile}", + retryable: true, + stopwatch); + } + + public static string[] BuildLaunchArguments(ParsedCommand parsed, string projectRoot, string logFile) + { + var arguments = new List { "-projectPath", projectRoot, "-logFile", logFile }; + if (!parsed.EditorGui) + { + arguments.Add("-batchmode"); + } + + if (parsed.EditorNoGraphics) + { + if (!arguments.Contains("-batchmode")) + { + arguments.Add("-batchmode"); + } + + arguments.Add("-nographics"); + } + + return arguments.ToArray(); + } + + public static string RequestedModeLabel(ParsedCommand parsed) + { + if (parsed.EditorNoGraphics) + { + return "headless-nographics"; + } + + return parsed.EditorGui ? "gui" : "headless"; + } + + /// + /// ps 한 줄이 "이 프로젝트를 연 메인 에디터 프로세스"인지 판별한다. + /// 오탐 두 종류를 걸러낸다 (실측 근거): -adb2 AssetImportWorker는 같은 + /// -projectPath를 갖고, Unity Hub Helper는 argv에 최근 프로젝트 경로 목록을 담는다. + /// + public static bool IsMainEditorProcessLine(string psLine, string projectRoot) + { + if (psLine.Contains("-adb2", StringComparison.OrdinalIgnoreCase) + || psLine.Contains("Unity Hub", StringComparison.Ordinal)) + { + return false; + } + + int flagIndex = psLine.IndexOf("-projectpath", StringComparison.OrdinalIgnoreCase); + if (flagIndex < 0) + { + return false; + } + + string afterFlag = psLine[(flagIndex + "-projectpath".Length)..].TrimStart(); + return afterFlag.StartsWith(projectRoot, StringComparison.Ordinal) + && (afterFlag.Length == projectRoot.Length + || afterFlag[projectRoot.Length] == ' '); + } + + private static InstanceRecord? FindLiveInstance(InstanceRegistryStore registryStore, string canonicalRoot) + { + InstanceRegistry registry = registryStore.Load(); + foreach (InstanceRecord record in registry.instances ?? Array.Empty()) + { + if (!string.Equals(record.projectRoot, canonicalRoot, StringComparison.Ordinal)) + { + continue; + } + + if (record.editorProcessId <= 0 || !IsProcessAlive(record.editorProcessId)) + { + continue; + } + + return record; + } + + return null; + } + + private static int? FindStrayEditorProcessId(string canonicalRoot) + { + if (OperatingSystem.IsWindows()) + { + // Command-line inspection needs Win32 APIs; registry + Unity's own lock + // cover the double-launch case there. (Live-verified surface is macOS.) + return null; + } + + try + { + var startInfo = new ProcessStartInfo + { + FileName = "/bin/ps", + UseShellExecute = false, + RedirectStandardOutput = true, + }; + startInfo.ArgumentList.Add("-axo"); + startInfo.ArgumentList.Add("pid=,command="); + + using Process? ps = Process.Start(startInfo); + if (ps == null) + { + return null; + } + + string output = ps.StandardOutput.ReadToEnd(); + ps.WaitForExit(5000); + foreach (string line in output.Split('\n')) + { + string trimmed = line.Trim(); + if (trimmed.Length == 0 || !IsMainEditorProcessLine(trimmed, canonicalRoot)) + { + continue; + } + + int spaceIndex = trimmed.IndexOf(' '); + if (spaceIndex > 0 && int.TryParse(trimmed[..spaceIndex], out int pid)) + { + return pid; + } + } + } + catch (Exception) + { + // Pre-flight scan is best-effort; Unity's own project lock is the backstop. + } + + return null; + } + + internal static bool IsProcessAlive(int pid) + { + try + { + Process process = Process.GetProcessById(pid); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + private static ResponseEnvelope Success(string requestId, object payload, Stopwatch stopwatch) + { + stopwatch.Stop(); + return ResponseEnvelope.Success( + requestId, + null, + JsonSerializer.SerializeToElement(payload, ProtocolJson.Default), + stopwatch.ElapsedMilliseconds, + "cli"); + } + + private static ResponseEnvelope Failure( + string requestId, + string errorCode, + string message, + bool retryable, + Stopwatch stopwatch) + { + stopwatch.Stop(); + return ResponseEnvelope.Failure( + requestId, + null, + errorCode, + message, + retryable: retryable, + durationMs: stopwatch.ElapsedMilliseconds, + transport: "cli"); + } +} diff --git a/tests/UnityCli.Cli.Tests/EditorLauncherTests.cs b/tests/UnityCli.Cli.Tests/EditorLauncherTests.cs new file mode 100644 index 0000000..4471f09 --- /dev/null +++ b/tests/UnityCli.Cli.Tests/EditorLauncherTests.cs @@ -0,0 +1,60 @@ +using UnityCli.Cli.Models; +using UnityCli.Cli.Services; + +namespace UnityCli.Cli.Tests; + +public sealed class EditorLauncherTests +{ + [Fact] + public void BuildLaunchArguments_DefaultIsHeadlessWithGpu() + { + var parsed = new ParsedCommand(CommandKind.EditorLaunch); + + string[] args = EditorLauncher.BuildLaunchArguments(parsed, "/proj/root", "/proj/root/Library/ucli-launch.log"); + + Assert.Contains("-batchmode", args); + Assert.DoesNotContain("-nographics", args); + Assert.Contains("-projectPath", args); + Assert.Contains("/proj/root", args); + } + + [Fact] + public void BuildLaunchArguments_GuiOmitsBatchmode() + { + var parsed = new ParsedCommand(CommandKind.EditorLaunch) { EditorGui = true }; + + string[] args = EditorLauncher.BuildLaunchArguments(parsed, "/proj/root", "/log"); + + Assert.DoesNotContain("-batchmode", args); + Assert.DoesNotContain("-nographics", args); + } + + [Fact] + public void BuildLaunchArguments_NographicsAddsFlag() + { + var parsed = new ParsedCommand(CommandKind.EditorLaunch) { EditorNoGraphics = true }; + + string[] args = EditorLauncher.BuildLaunchArguments(parsed, "/proj/root", "/log"); + + Assert.Contains("-batchmode", args); + Assert.Contains("-nographics", args); + } + + [Fact] + public void RequestedModeLabel_MapsFlags() + { + Assert.Equal("gui", EditorLauncher.RequestedModeLabel(new ParsedCommand(CommandKind.EditorLaunch) { EditorGui = true })); + Assert.Equal("headless", EditorLauncher.RequestedModeLabel(new ParsedCommand(CommandKind.EditorLaunch))); + Assert.Equal("headless-nographics", EditorLauncher.RequestedModeLabel(new ParsedCommand(CommandKind.EditorLaunch) { EditorNoGraphics = true })); + } + + [Theory] + [InlineData("75188 /Applications/Unity/Hub/Editor/6000.3.10f1/Unity.app/Contents/MacOS/Unity -projectpath /proj/root -useHub", true)] + [InlineData("77545 /Applications/Unity/.../Unity -adb2 -batchMode -name AssetImportWorker0 -projectPath /proj/root", false)] + [InlineData("737 /Applications/Unity Hub.app/.../Unity Hub Helper --type=renderer --hub-startup-projects=[{\"path\":\"/proj/root\"}]", false)] + [InlineData("75330 /Applications/Unity/Hub/Editor/6000.3.10f1/Unity.app/Contents/MacOS/Unity -projectpath /other/project", false)] + public void IsMainEditorProcessLine_FiltersFalsePositives(string psLine, bool expected) + { + Assert.Equal(expected, EditorLauncher.IsMainEditorProcessLine(psLine, "/proj/root")); + } +} From 59a83219b25e80f89621241d70303c0f7e456b12 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Tue, 11 Aug 2026 22:53:28 +0900 Subject: [PATCH 12/33] feat(cli): route editor launch/stop with pid-exit wait Co-Authored-By: Claude Fable 5 --- cli/UnityCli.Cli/CliApp.cs | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/cli/UnityCli.Cli/CliApp.cs b/cli/UnityCli.Cli/CliApp.cs index e6c2a8b..ea13c64 100644 --- a/cli/UnityCli.Cli/CliApp.cs +++ b/cli/UnityCli.Cli/CliApp.cs @@ -38,6 +38,8 @@ internal static async Task RunAsync(string[] args, ICliVersionDispatcher? d CommandKind.QaWait => await RunQaWait(parsed), CommandKind.ProfileAnalyze => ProfileAnalyzer.Run(parsed, projectRoot), CommandKind.ProfileCompare => ProfileComparer.Run(parsed, projectRoot), + CommandKind.EditorLaunch => await EditorLauncher.LaunchAsync(parsed, registryStore, projectRoot), + CommandKind.EditorStop => await RunEditorStopAsync(parsed, registryStore, projectRoot), _ => await ExecuteUnityCommandAsync(parsed, registryStore, projectRoot), }; @@ -521,6 +523,54 @@ internal static async Task ExecuteUnityCommandAsync( details: noTargetDetails); } + private static async Task RunEditorStopAsync( + ParsedCommand parsed, + InstanceRegistryStore registryStore, + string? projectRoot) + { + ResponseEnvelope response = await ExecuteUnityCommandAsync(parsed, registryStore, projectRoot); + if (!string.Equals(response.status, ProtocolConstants.StatusSuccess, StringComparison.Ordinal) + || parsed.EditorNoWait) + { + return response; + } + + int editorProcessId; + try + { + editorProcessId = DeserializeData(response)?.editorProcessId ?? 0; + } + catch (JsonException) + { + return response; + } + + if (editorProcessId <= 0) + { + return response; + } + + int timeoutSeconds = parsed.EditorWaitTimeoutSeconds ?? 30; + var deadline = DateTimeOffset.UtcNow.AddSeconds(timeoutSeconds); + while (DateTimeOffset.UtcNow < deadline) + { + if (!EditorLauncher.IsProcessAlive(editorProcessId)) + { + return response; + } + + await Task.Delay(1000); + } + + return ResponseEnvelope.Failure( + response.requestId, + response.target, + ProtocolConstants.ErrorEditorStopTimeout, + $"에디터가 종료 응답 후 {timeoutSeconds}초 안에 프로세스를 끝내지 않았습니다 (PID {editorProcessId}). 강제 종료가 필요하면 kill을 사용하세요.", + retryable: true, + transport: "cli"); + } + private static async Task<(ResponseEnvelope Response, InstanceRecord Target)> RetryUnauthorizedOnceAsync( InstanceRegistryStore registryStore, string? projectRoot, From 935f7727aa0895f34fc52550abe359dc6b97ac6d Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Tue, 11 Aug 2026 22:54:16 +0900 Subject: [PATCH 13/33] test: assert editor stop force gate stays bridge-side Co-Authored-By: Claude Fable 5 --- tests/UnityCli.Cli.Tests/ForceGateTests.cs | 27 +++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/UnityCli.Cli.Tests/ForceGateTests.cs b/tests/UnityCli.Cli.Tests/ForceGateTests.cs index fa1b9fc..4cd2f5e 100644 --- a/tests/UnityCli.Cli.Tests/ForceGateTests.cs +++ b/tests/UnityCli.Cli.Tests/ForceGateTests.cs @@ -19,9 +19,11 @@ public sealed class ForceGateTests public void ForceRequiredByCatalog_HonorsCatalogForceRules() { // "editor stop" has ForceRule.OnDestructiveOp but its destructiveness (unsaved - // changes) is only known bridge-side (EDITOR_DIRTY); the CLI-side CommandKind - // wiring lands with the `editor` command group. Until then there is no - // ParsedCommand to construct for it. + // changes) is only known bridge-side: the bridge answers EDITOR_DIRTY and the CLI + // deliberately never pre-requires --force (PatchContainsDestructiveOperation returns + // false for EditorStop). This generic loop expects OnDestructiveOp commands to gate + // CLI-side, so "editor stop" stays excluded; its bridge-gated contract is asserted + // by ForceRequiredByCatalog_EditorStop_IsBridgeGated below. string[] bridgeGatedCommands = ["editor stop"]; CliCommandDescriptor[] forceCommands = CliCommandCatalog.GetCommands() @@ -41,6 +43,25 @@ public void ForceRequiredByCatalog_HonorsCatalogForceRules() } } + [Fact] + public void ForceRequiredByCatalog_EditorStop_IsBridgeGated() + { + // The CLI cannot see unsaved editor state, so `editor stop` must parse and dispatch + // without --force; the bridge is the sole gate (EDITOR_DIRTY). --force still has to + // reach the wire so the bridge can discard unsaved changes when asked. + ParsedCommand withoutForce = CliArgumentParser.Parse(["editor", "stop"]); + Assert.False(CliArgumentParser.ForceRequiredByCatalog(withoutForce)); + + ParsedCommand withForce = CliArgumentParser.Parse(["editor", "stop", "--force"]); + Assert.False(CliArgumentParser.ForceRequiredByCatalog(withForce)); + + CommandEnvelope envelope = withForce.ToEnvelope(); + using JsonDocument arguments = JsonDocument.Parse(envelope.argumentsJson); + + Assert.Equal(ProtocolConstants.CommandEditorQuit, envelope.command); + Assert.True(arguments.RootElement.GetProperty("force").GetBoolean()); + } + [Fact] public void ForceRequiredByCatalog_IgnoresNonDestructivePatchSpecs() { From 11ee0e3ae1ac480a174c1bba7907e6d98983ae8b Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Tue, 11 Aug 2026 22:58:20 +0900 Subject: [PATCH 14/33] fix(cli): preserve editorMode through registry sanitize Co-Authored-By: Claude Fable 5 --- .../Services/InstanceRegistryStore.cs | 1 + .../InstanceRegistryStoreTests.cs | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/cli/UnityCli.Cli/Services/InstanceRegistryStore.cs b/cli/UnityCli.Cli/Services/InstanceRegistryStore.cs index a901691..b6cd968 100644 --- a/cli/UnityCli.Cli/Services/InstanceRegistryStore.cs +++ b/cli/UnityCli.Cli/Services/InstanceRegistryStore.cs @@ -288,6 +288,7 @@ private InstanceRegistry Sanitize(InstanceRegistry registry) state = instance.state ?? "offline", lastSeenUtc = instance.lastSeenUtc ?? string.Empty, capabilities = instance.capabilities ?? Array.Empty(), + editorMode = instance.editorMode ?? string.Empty, }; if (IsStale(normalized)) diff --git a/tests/UnityCli.Cli.Tests/InstanceRegistryStoreTests.cs b/tests/UnityCli.Cli.Tests/InstanceRegistryStoreTests.cs index 45faa93..a433332 100644 --- a/tests/UnityCli.Cli.Tests/InstanceRegistryStoreTests.cs +++ b/tests/UnityCli.Cli.Tests/InstanceRegistryStoreTests.cs @@ -58,6 +58,38 @@ public void Load_ReadsInstanceTokenFromSidecarDuringSanitize() Assert.Equal(token, registry.instances[0].token); } + [Fact] + public void Load_PreservesEditorModeThroughSanitize() + { + using var temp = new TempDirectory(); + var projectRoot = Path.Combine(temp.Path, "ProjectA"); + Directory.CreateDirectory(projectRoot); + string projectHash = ProtocolConstants.ComputeProjectHash(projectRoot); + var store = new InstanceRegistryStore(Path.Combine(temp.Path, "instances.json")); + store.Save(new InstanceRegistry + { + instances = + [ + new InstanceRecord + { + projectRoot = projectRoot, + projectName = "ProjectA", + projectHash = projectHash, + pipeName = ProtocolConstants.BuildPipeName(projectHash), + editorProcessId = Environment.ProcessId, + state = "idle", + lastSeenUtc = DateTimeOffset.UtcNow.ToString("O"), + editorMode = "headless", + }, + ], + }); + + InstanceRegistry registry = store.Load(); + + Assert.Single(registry.instances); + Assert.Equal("headless", registry.instances[0].editorMode); + } + [Fact] public void ResolveOrCreateTarget_UsesRegisteredProjectName() { From 4a5765d1b73e46dc3a572df577734bea02ec21ca Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 07:42:08 +0900 Subject: [PATCH 15/33] fix(bridge): quit via EditorApplication.update, not delayCall delayCall rides the inspector-update cycle and starves in an unfocused GUI editor, so editor stop ACKed but never exited. update keeps ticking (live verified: unfocused GUI stop timed out before, succeeds after). Co-Authored-By: Claude Fable 5 --- .../Editor/BridgeHost.cs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) 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 5f171e7..d54cbf6 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/BridgeHost.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/BridgeHost.cs @@ -1386,10 +1386,24 @@ private string HandleEditorQuit(string argumentsJson) string.Join("\n", dirtyTargets)); } - // Reply first, exit on the next editor tick: the response is flushed on the - // listener thread right after this method returns, so the CLI receives a - // normal success envelope instead of LIVE_UNAVAILABLE. - EditorApplication.delayCall += () => EditorApplication.Exit(0); + // Reply first, exit shortly after: the response is flushed on the listener + // thread right after this method returns, so the CLI receives a normal + // success envelope instead of LIVE_UNAVAILABLE. EditorApplication.update + // (not delayCall) is required here — delayCall rides the inspector-update + // cycle and starves in an unfocused GUI editor, while update keeps ticking. + double quitAtTime = EditorApplication.timeSinceStartup + 0.5; + EditorApplication.CallbackFunction quitWhenDue = null; + quitWhenDue = () => + { + if (EditorApplication.timeSinceStartup < quitAtTime) + { + return; + } + + EditorApplication.update -= quitWhenDue; + EditorApplication.Exit(0); + }; + EditorApplication.update += quitWhenDue; return ProtocolJson.Serialize(new EditorQuitPayload { From 49cc46f9ad51f00f52326c94fcc9d08c3dd95f08 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 07:42:08 +0900 Subject: [PATCH 16/33] fix(cli): detach spawned editor stdio so launch pipelines terminate The editor inherited the CLI's stdout/stderr, keeping the write end of 'editor launch | filter' pipes open forever after the CLI exited. Wrap the spawn in an sh exec that points stdio at the null device on Unix (exec keeps Process.Id = the editor PID); Unity output already goes to -logFile. Co-Authored-By: Claude Fable 5 --- cli/UnityCli.Cli/Services/EditorLauncher.cs | 52 +++++++++++++++---- .../UnityCli.Cli.Tests/EditorLauncherTests.cs | 20 +++++++ 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/cli/UnityCli.Cli/Services/EditorLauncher.cs b/cli/UnityCli.Cli/Services/EditorLauncher.cs index c177999..6d6cecf 100644 --- a/cli/UnityCli.Cli/Services/EditorLauncher.cs +++ b/cli/UnityCli.Cli/Services/EditorLauncher.cs @@ -94,17 +94,7 @@ public static async Task LaunchAsync( Directory.CreateDirectory(logDirectory); string logFile = Path.Combine(logDirectory, "editor-launch.log"); - var startInfo = new ProcessStartInfo - { - FileName = editorBinary, - UseShellExecute = false, - RedirectStandardOutput = false, - RedirectStandardError = false, - }; - foreach (string argument in BuildLaunchArguments(parsed, canonicalRoot, logFile)) - { - startInfo.ArgumentList.Add(argument); - } + ProcessStartInfo startInfo = BuildStartInfo(editorBinary, BuildLaunchArguments(parsed, canonicalRoot, logFile)); Process editorProcess; try @@ -179,6 +169,46 @@ public static async Task LaunchAsync( stopwatch); } + /// + /// stdio를 분리해 에디터를 낳는다. 그냥 상속시키면 스폰된 에디터가 CLI의 + /// stdout/stderr 파이프 write-end를 쥐고 있어, `unity-cli editor launch | grep …` + /// 같은 파이프라인이 CLI 종료 후에도 EOF를 못 받고 영원히 매달린다 (실측). + /// Unix에서는 `sh -c 'exec …'`로 감싸 /dev/null에 연결한다 — exec이 셸을 + /// 대체하므로 Process.Id는 그대로 에디터 PID다. 에디터 출력은 -logFile이 담당. + /// + public static ProcessStartInfo BuildStartInfo(string editorBinary, string[] arguments) + { + if (OperatingSystem.IsWindows()) + { + var windowsStartInfo = new ProcessStartInfo + { + FileName = editorBinary, + UseShellExecute = false, + }; + foreach (string argument in arguments) + { + windowsStartInfo.ArgumentList.Add(argument); + } + + return windowsStartInfo; + } + + var startInfo = new ProcessStartInfo + { + FileName = "/bin/sh", + UseShellExecute = false, + }; + startInfo.ArgumentList.Add("-c"); + startInfo.ArgumentList.Add("exec \"$0\" \"$@\" /dev/null 2>&1"); + startInfo.ArgumentList.Add(editorBinary); + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + return startInfo; + } + public static string[] BuildLaunchArguments(ParsedCommand parsed, string projectRoot, string logFile) { var arguments = new List { "-projectPath", projectRoot, "-logFile", logFile }; diff --git a/tests/UnityCli.Cli.Tests/EditorLauncherTests.cs b/tests/UnityCli.Cli.Tests/EditorLauncherTests.cs index 4471f09..24f8176 100644 --- a/tests/UnityCli.Cli.Tests/EditorLauncherTests.cs +++ b/tests/UnityCli.Cli.Tests/EditorLauncherTests.cs @@ -40,6 +40,26 @@ public void BuildLaunchArguments_NographicsAddsFlag() Assert.Contains("-nographics", args); } + [Fact] + public void BuildStartInfo_OnUnix_DetachesStdioViaShExec() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var startInfo = EditorLauncher.BuildStartInfo("/Applications/Unity/Unity", new[] { "-projectPath", "/proj/root", "-batchmode" }); + + Assert.Equal("/bin/sh", startInfo.FileName); + Assert.Equal("-c", startInfo.ArgumentList[0]); + Assert.Contains(">/dev/null", startInfo.ArgumentList[1]); + Assert.Contains("exec", startInfo.ArgumentList[1]); + Assert.Equal("/Applications/Unity/Unity", startInfo.ArgumentList[2]); + Assert.Equal("-projectPath", startInfo.ArgumentList[3]); + Assert.Equal("/proj/root", startInfo.ArgumentList[4]); + Assert.Equal("-batchmode", startInfo.ArgumentList[5]); + } + [Fact] public void RequestedModeLabel_MapsFlags() { From 2d002edc0454d281221598b51378807afcfe63aa Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 07:46:26 +0900 Subject: [PATCH 17/33] docs: headless editor support (editor launch/stop, HEADLESS_NO_GRAPHICS, sidecar fix) Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 +++++++ CLAUDE.md | 8 +++-- README.md | 29 ++++++++++++++++++- tools/skills/unity-cli-operator/SKILL.md | 17 ++++++++++- .../SkillTemplates~/SKILL.md | 15 ++++++++++ 5 files changed, 75 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ffc020..f4ebc90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- `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 ` 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. + +### 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. + +### Compatibility +- The wire protocol is bumped to `7` (adds the graceful-quit command behind `editor stop`). As usual, installed CLIs dispatch per version automatically, and the CLI matching this package ships in the same release. + ## [0.5.1] - 2026-07-29 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 1892a0b..1f27d7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ dotnet run --project cli/UnityCli.DocGen -- --write The repo is a single solution (`UnityCliBridge.sln`) split across four projects: the CLI executable, a shared protocol library, a doc-gen tool, and xUnit tests. The directory tree follows standard .NET / UPM conventions — use `ls` / `find` for an exhaustive file list. The notes below cover the non-obvious entry points and how the pieces fit together. -**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. +**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: - `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. @@ -42,7 +42,7 @@ The repo is a single solution (`UnityCliBridge.sln`) split across four projects: - `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. **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`). +- `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. - 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. @@ -71,7 +71,9 @@ Tests live in `tests/UnityCli.Cli.Tests/` (xUnit, `.NET`-testable surface only). - **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. - **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. -- **CLI version dispatch:** 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. +- **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. +- **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. - **AI Agent Skill install scope:** Default to project-scoped installs under `/.claude/skills/` or `/.codex/skills/`; global installs remain available, can shadow project copies, and can be updated or removed from the manager. - **Scene paths:** Format `/Root[0]/Child[0]` with array notation for sibling indexing; `/` is the virtual scene root. diff --git a/README.md b/README.md index 9e6b315..37bbcaa 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,33 @@ When `--project` is omitted, the CLI first uses the current Unity project direct ## What You Can Do +### Editor Lifecycle (headless support) + +```bash +# Launch the editor headless (-batchmode, GPU kept — screenshot/record still work) +unity-cli editor launch --project /path/to/Project + +# Launch with a visible window instead +unity-cli editor launch --project /path/to/Project --gui + +# Idempotent: if the editor is already running, the live instance is reused +unity-cli editor launch --project /path/to/Project # → "reused": true + +# Gracefully quit (refuses on unsaved changes; --force discards them) +unity-cli editor stop --project /path/to/Project +``` + +`editor launch` is a local command: it finds the matching Unity version for the project, spawns the editor, and waits until the bridge is reachable (default 300 s; tune with `--timeout`, or skip waiting with `--no-wait`). If an editor process already holds the project but never registered a bridge instance, the launch fails with `EDITOR_ALREADY_RUNNING_CONFLICT` instead of tripping Unity's own project lock. The spawned editor's log goes to `Library/com.yhc509.unity-cli-bridge/editor-launch.log`, and its output streams are detached from the CLI, so shell pipelines like `unity-cli editor launch | grep reused` terminate normally. + +`editor stop` asks the running 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 cleans up the instance registry entry on the way out. It works whether the editor is headless, focused, or sitting unfocused in the background. + +Headless notes: +- The default headless mode passes `-batchmode` **without** `-nographics`, so the GPU stays + initialized and rendering commands (`screenshot`, `record`, `qa`) keep working without a window. +- With `--nographics`, rendering commands fail fast with `HEADLESS_NO_GRAPHICS` instead of + silently returning blank images. `instances list` reports each editor's mode + (`gui` / `headless` / `headless-nographics`). + ### Editor Control ```bash @@ -400,6 +427,6 @@ dotnet run --project cli/UnityCli.DocGen -- --check # Verify docs match code ## Current Limits - macOS arm64 and Windows x64 supported -- Live IPC required — commands fail fast when no Editor is running +- Live IPC required — Unity commands fail fast when no Editor is running (`editor launch` can start one for you) - Scene patching targets saved `Assets/...unity` scenes; multi-scene orchestration is out of scope - Prefab-internal object references and nested variants are not yet supported diff --git a/tools/skills/unity-cli-operator/SKILL.md b/tools/skills/unity-cli-operator/SKILL.md index 0ff72bd..c526be5 100644 --- a/tools/skills/unity-cli-operator/SKILL.md +++ b/tools/skills/unity-cli-operator/SKILL.md @@ -10,7 +10,7 @@ description: "Unity Editor 외부 제어 1차 진입점. 씬/프리팹/에셋/ ## 진입 규칙 - Unity 외부 제어가 필요한 모든 작업은 이 스킬부터 검토. -- `Unity -batchmode` 헤드리스, Unity MCP 서버 탐색은 unity-cli로 못하는 게 확인된 뒤에만. +- `Unity -batchmode` 직접 실행, Unity MCP 서버 탐색은 unity-cli로 못하는 게 확인된 뒤에만. 헤드리스 기동 자체는 `editor launch`가 담당한다 (아래 Editor Lifecycle). - 이 레포는 MCP 없음. `batch` 서브커맨드 없음. 다중 작업은 ucli를 N번 호출 (의존성 없으면 병렬, 있으면 순차). ## Quick Workflow @@ -24,6 +24,7 @@ description: "Unity Editor 외부 제어 1차 진입점. 씬/프리팹/에셋/ 3. 쓰기 작업 전에는 상태를 본다. - 먼저 `status --json --project `으로 live 연결, busy 상태, 현재 프로젝트가 맞는지 확인한다. - 응답의 `projectName`이 의도한 프로젝트가 맞는지 반드시 확인한다. +- live 인스턴스가 없으면 사용자에게 요청하지 말고 `editor launch`로 직접 기동한다 (아래 Editor Lifecycle). 4. 작업 종류에 맞는 흐름을 고른다. - 일반 명령, 에셋 작업, scene inspect/patch는 `references/command-flows.md` @@ -33,6 +34,20 @@ description: "Unity Editor 외부 제어 1차 진입점. 씬/프리팹/에셋/ 5. 작업 뒤에는 반드시 검증한다. - live 작업 뒤 검증 루틴과 `read-console --no-stacktrace --output compact` 패턴은 [references/command-flows.md](references/command-flows.md)의 `검증 루틴`을 따른다. +## Editor Lifecycle + +대상 프로젝트의 에디터가 안 떠 있으면 사용자에게 요청하지 말고 직접 헤드리스로 기동한다: + +```bash +unity-cli editor launch --project # 헤드리스(-batchmode) 기동, 브릿지 준비까지 대기 +unity-cli editor stop --project # graceful 종료 (미저장 변경 있으면 EDITOR_DIRTY; --force로 폐기) +``` + +- `editor launch`는 멱등이다 — live 인스턴스가 있으면 재사용하고 `"reused": true`를 반환하므로, 워크플로우 시작 전에 호출해도 안전하고 이중 기동 모달 행을 예방한다. +- 기본 헤드리스는 GPU를 유지한다: 창 없이도 screenshot/record/qa가 동작한다. `--nographics`를 줄 때만 렌더링 명령이 `HEADLESS_NO_GRAPHICS`로 거부된다. 창이 필요하면 `--gui`. +- 등록 안 된 에디터 프로세스가 프로젝트를 이미 열고 있으면 `EDITOR_ALREADY_RUNNING_CONFLICT`로 거부된다(재시도 가능 — 부팅/컴파일 중일 수 있다). 기동 로그는 `Library/com.yhc509.unity-cli-bridge/editor-launch.log`. +- 첫 임포트가 긴 프로젝트에서 `EDITOR_WAIT_TIMEOUT`이 나오면 `--timeout <초>`를 늘려 재시도한다(기본 300초). + ## Operating Rules - 모든 asset 경로는 `Assets/...` 형식으로 다룬다. 조회 전용(`asset find`, `asset info`)은 `Packages/...`도 허용된다. 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 29fe01e..dc7186d 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/SKILL.md +++ b/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/SKILL.md @@ -22,6 +22,7 @@ description: "Use when the user wants to operate Unity through Unity CLI Bridge 3. 쓰기 작업 전에는 상태를 본다. - 먼저 `status --json --project `으로 live 연결, busy 상태, 현재 프로젝트가 맞는지 확인한다. - 응답의 `projectName`이 의도한 프로젝트가 맞는지 반드시 확인한다. +- live 인스턴스가 없으면 사용자에게 요청하지 말고 `editor launch`로 직접 기동한다 (아래 Editor Lifecycle). 4. 작업 종류에 맞는 흐름을 고른다. - 일반 명령, 에셋 작업, scene inspect/patch는 `references/command-flows.md` @@ -31,6 +32,20 @@ description: "Use when the user wants to operate Unity through Unity CLI Bridge 5. 작업 뒤에는 반드시 검증한다. - live 작업 뒤 검증 루틴과 `read-console --no-stacktrace --output compact` 패턴은 [references/command-flows.md](references/command-flows.md)의 `검증 루틴`을 따른다. +## Editor Lifecycle + +대상 프로젝트의 에디터가 안 떠 있으면 사용자에게 요청하지 말고 직접 헤드리스로 기동한다: + +```bash +unity-cli editor launch --project # 헤드리스(-batchmode) 기동, 브릿지 준비까지 대기 +unity-cli editor stop --project # graceful 종료 (미저장 변경 있으면 EDITOR_DIRTY; --force로 폐기) +``` + +- `editor launch`는 멱등이다 — live 인스턴스가 있으면 재사용하고 `"reused": true`를 반환하므로, 워크플로우 시작 전에 호출해도 안전하고 이중 기동 모달 행을 예방한다. +- 기본 헤드리스는 GPU를 유지한다: 창 없이도 screenshot/record/qa가 동작한다. `--nographics`를 줄 때만 렌더링 명령이 `HEADLESS_NO_GRAPHICS`로 거부된다. 창이 필요하면 `--gui`. +- 등록 안 된 에디터 프로세스가 프로젝트를 이미 열고 있으면 `EDITOR_ALREADY_RUNNING_CONFLICT`로 거부된다(재시도 가능 — 부팅/컴파일 중일 수 있다). 기동 로그는 `Library/com.yhc509.unity-cli-bridge/editor-launch.log`. +- 첫 임포트가 긴 프로젝트에서 `EDITOR_WAIT_TIMEOUT`이 나오면 `--timeout <초>`를 늘려 재시도한다(기본 300초). + ## Operating Rules - 모든 asset 경로는 `Assets/...` 형식으로 다룬다. 조회 전용(`asset find`, `asset info`)은 `Packages/...`도 허용된다. From 7111112add88fbe56628ed850a907c89e7f242bc Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 08:14:27 +0900 Subject: [PATCH 18/33] docs: drop stale codex-bot reviewer note Co-Authored-By: Claude Fable 5 --- AGENTS.md | 1 - CLAUDE.md | 1 - 2 files changed, 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3ae0d8a..e322cb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,6 @@ tests/UnityCli.Cli.Tests/ xUnit tests - All changes go through PRs to `main`. Direct push to `main` is blocked by branch ruleset. - Admin bypass exists for emergencies only — do not use it for routine work. - CI (`test` job) must pass before merge. -- GitHub Codex bot (`@codex`) is enabled as a PR reviewer on this repo. - Versioning: patch-level increments (`v0.1.0` → `v0.1.1`). Major/minor bumps only when explicitly requested. ## Verification After Changes diff --git a/CLAUDE.md b/CLAUDE.md index 1f27d7f..2867155 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,6 @@ Hard rules: - All changes go through PRs. Direct push to `main` is blocked by branch ruleset; admin bypass exists for emergencies only — do not use it for routine work. - CI (`test` job) must pass before merge. -- GitHub Codex bot (`@codex`) is enabled as a PR reviewer on this repo. - **Never merge a wire-protocol bump straight to `main`.** A protocol bump leaves already-released CLIs unable to talk to `main`'s package (and vice versa), so a user installing from `#main` hits `PROTOCOL_MISMATCH` with no installed version to dispatch to. Land it on `dev`, and when it ships, publish the Unity package and the CLI binary in the same release so `#main` users never see a mismatch. - Versioning (SemVer): version the *user-facing* change, not the wire format. A wire-protocol bump on its own does not force a minor — `ProtocolVersion` is recorded in each installed CLI's `meta.json`, and a mismatch is a self-describing condition the CLI resolves by dispatching to the installed version that speaks the bridge's protocol. Size the bump by what the release does for users: additive commands and fixes are patch bumps, a substantial new capability is a minor. Major bumps only when explicitly requested. This does not relax the release ordering in the checklist above — a protocol bump still requires the matching CLI to be *published* before `main` moves, or a user who has no version speaking the new protocol has nothing to dispatch to. - **Reverted work on `main` never merges back cleanly.** When `main` carries a revert of something `dev` still builds on, a content merge resurrects the revert and silently deletes that work — the deletions do not surface as conflicts. Take the `dev` tree whole (`git merge -s ours main` from the release branch) and hand-carry anything `main` uniquely owns. From af9cc7c57f214e74ab5305ede402b3c166c9b658 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 08:29:46 +0900 Subject: [PATCH 19/33] docs: remove unused AGENTS.md Co-Authored-By: Claude Fable 5 --- AGENTS.md | 107 ------------------------------------------------------ 1 file changed, 107 deletions(-) delete mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index e322cb5..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,107 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Unity CLI Bridge controls the Unity Editor from the command line without manual server startup. This mono-repo contains a .NET 9 CLI, the `com.yhc509.unity-cli-bridge` Unity UPM package, and shared protocol models. - -The CLI is **live IPC only**. Unity commands require a running Editor with the bridge active. - -## Build & Test Commands - -```bash -# Build -dotnet build UnityCliBridge.sln -c Debug - -# Run all tests -dotnet test UnityCliBridge.sln - -# Run a single test -dotnet test UnityCliBridge.sln --filter "FullyQualifiedName~ClassName.MethodName" - -# Publish macOS arm64 binary -./scripts/publish-osx-arm64.sh # → dist/unity-cli/unity-cli - -# Doc generation (verify docs match code) -dotnet run --project cli/UnityCli.DocGen -- --check - -# Doc generation (write/update docs) -dotnet run --project cli/UnityCli.DocGen -- --write -``` - -## Architecture - -``` -UnityCliBridge.sln Solution root for CLI, protocol, DocGen, and tests - -cli/UnityCli.Cli/ CLI executable (.NET 9, osx-arm64 + win-x64) - ├── CliApp.cs Entry point; handles local status/instances/doctor flows and routes Unity work to IPC - ├── Services/ - │ ├── CliArgumentParser Switch-based parser → ParsedCommand - │ ├── CliCommandCatalog CLI-side command metadata - │ ├── LocalIpcClient Live IPC to running Editor - │ ├── UnityProjectLocator Project-root resolution and lookup - │ └── InstanceRegistryStore Per-project instance tracking - └── Models/ParsedCommand CommandKind variants + envelope builder - -cli/UnityCli.Protocol/ Shared protocol project compiling linked files from unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ - -unity-package/com.yhc509.unity-cli-bridge/ - ├── Editor/ - │ ├── BridgeHost.cs Bridge bootstrap, registry registration, IPC listener, handler orchestration - │ ├── AssetCommandHandler.cs Asset CRUD operations and asset metadata - │ ├── BuiltInAssetCreateProviders.cs Basic built-in asset create providers - │ ├── BuiltInAssetCreateProviders.Advanced.cs Complex/dependency-aware asset providers (partial class) - │ ├── SceneCommandHandler.cs scene open/inspect/patch entry points - │ ├── SceneCommandHandler.Patching.cs Scene patch operation application (partial class) - │ ├── SceneInspector.cs Scene graph traversal, node-path resolution, inspect payload building - │ ├── SceneSpecModels.cs Scene DTO/spec models - │ ├── InspectorUtility.cs Shared inspector helpers (asset tokens, path parsing, layer resolution, transform application) - │ ├── PrefabCommandHandler.cs prefab create/inspect/patch entry points - │ ├── PrefabCommandHandler.Patching.cs Prefab patch operation application (partial class) - │ ├── PrefabInspector.cs Prefab inspection, node-path resolution, inspect payload building - │ ├── PrefabSpecModels.cs Prefab DTO/spec models - │ ├── SerializedValueApplier.cs Applies values via SerializedProperty.propertyPath - │ ├── TypeDiscoveryUtility.cs Shared component/type scanning utility - │ ├── BridgeJsonSettings.cs Shared JSON serializer settings - │ ├── CliInstallerWindow.cs EditorWindow for one-click CLI install/update - │ ├── CliInstallerState.cs CLI version detection, path resolution, EditorPrefs - │ └── CliDownloader.cs GitHub Releases download + archive extraction - └── Runtime/Protocol/ Shared models (C# 11, nullable enabled) - ├── CliCommandCatalog.cs Master command descriptor catalog - ├── CommandModels.cs Request/response envelopes - ├── ProtocolConstants.cs Registry paths, timeouts, command names - ├── ProtocolHelpers.cs Command grouping helpers - ├── ProtocolJson.cs Shared JSON serialization helpers - ├── Registry*.cs Registry persistence/path models - └── TransportModels.cs IPC transport payloads - -tests/UnityCli.Cli.Tests/ xUnit tests -``` - -**Protocol sharing:** `cli/UnityCli.Protocol/` compiles the same `.cs` files from `unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/` via `` links in the `.csproj`. Changes to protocol files affect both the CLI and the Unity package. - -## Key Conventions - -- **Nullable references enabled** throughout (`#nullable enable`, implicit usings). -- **Asset paths** always use `Assets/...` format. -- **Destructive ops require `--force`:** `asset delete` (always), `asset move/rename/create` (when overwriting). -- **macOS paths:** Use real paths (`pwd -P`), not symlinks, for hashing and registry lookups. -- **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). -- **Doc sync:** CLI command or option changes must update `README.md` examples and help text. Run `dotnet run --project cli/UnityCli.DocGen -- --check` to verify. - -## Branch Policy - -- All changes go through PRs to `main`. Direct push to `main` is blocked by branch ruleset. -- Admin bypass exists for emergencies only — do not use it for routine work. -- CI (`test` job) must pass before merge. -- Versioning: patch-level increments (`v0.1.0` → `v0.1.1`). Major/minor bumps only when explicitly requested. - -## Verification After Changes - -- CLI code changes → `dotnet build UnityCliBridge.sln -c Debug` -- Test changes → `dotnet test UnityCliBridge.sln` -- Unity integration changes → test live IPC flows with an actual Unity project From fd28a8ab930a2afc2e5c9783de4cc00284d599bb Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 09:38:37 +0900 Subject: [PATCH 20/33] feat(protocol): add profile-memory wire command and memory report models --- .../Runtime/Protocol/ProfileModels.cs | 65 +++++++++++++++++++ .../Runtime/Protocol/ProtocolConstants.cs | 5 ++ 2 files changed, 70 insertions(+) diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ProfileModels.cs b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ProfileModels.cs index 403bf0f..1b4770f 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ProfileModels.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ProfileModels.cs @@ -266,4 +266,69 @@ public sealed class ProfileComparePayload public bool truncated; public string[] notes = Array.Empty(); } + + [Serializable] + public sealed class ProfileMemoryArgs + { + public int frames; // 0 = DefaultProfileMemoryFrames + } + + [Serializable] + public sealed class ProfileMemoryPayload + { + public string reportId = string.Empty; + public string mode = string.Empty; // "editmode" | "playmode" + public int frames; + public string unityVersion = string.Empty; + public string capturedAtUtc = string.Empty; // ISO-8601 ("O") + public ProfileCounterStat[] counters = Array.Empty(); + public string[] unavailable = Array.Empty(); + } + + [Serializable] + public sealed class ProfileMemorySidecarFile + { + public int schemaVersion = 1; + public string reportId = string.Empty; + public ProfileMemoryPayload report = new ProfileMemoryPayload(); + } + + [Serializable] + public sealed class ProfileMemoryCounterDelta + { + public string name = string.Empty; + public string unit = string.Empty; // "bytes" | "count" + public double baseMedian; + public double headMedian; + public double delta; + public double deltaPercent; + + /// False when the base median is zero or negative, which leaves the percentage undefined; deltaPercent is 0 then and must be ignored. + public bool deltaPercentAvailable = true; + } + + [Serializable] + public sealed class ProfileMemoryCompareSide + { + public string reportId = string.Empty; + public int frames; + public string mode = string.Empty; + public string unityVersion = string.Empty; + public string capturedAtUtc = string.Empty; + } + + [Serializable] + public sealed class ProfileMemoryComparePayload + { + public ProfileMemoryCompareSide baseReport = new ProfileMemoryCompareSide(); + public ProfileMemoryCompareSide headReport = new ProfileMemoryCompareSide(); + public double thresholdPercent; + public string verdict = "unchanged"; // regression | improvement | unchanged + public ProfileCompareDelta totalUsedBytes = new ProfileCompareDelta(); + public ProfileCompareDelta gcUsedBytes = new ProfileCompareDelta(); + public ProfileMemoryCounterDelta[] increases = Array.Empty(); + public ProfileMemoryCounterDelta[] decreases = Array.Empty(); + public bool truncated; + public string[] notes = Array.Empty(); + } } 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 490f205..94c1301 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 @@ -134,6 +134,7 @@ public static class ProtocolConstants public const string CommandProfileCaptureStart = "profile-capture-start"; public const string CommandProfileCaptureStop = "profile-capture-stop"; public const string CommandProfileStatus = "profile-status"; + public const string CommandProfileMemory = "profile-memory"; public const string CommandEditorQuit = "editor-quit"; public const int DefaultQaWaitUntilTimeoutMs = 10_000; public const int DefaultQaSwipeDurationMs = 300; @@ -168,6 +169,10 @@ public static class ProtocolConstants public const string RecordSessionKeyStartedAt = "UCB.Record.startedAt"; public const string RecordSessionKeyDurationSeconds = "UCB.Record.durationSeconds"; public const string ProfilesDirectoryRelative = "Library/com.yhc509.unity-cli-bridge/profiles"; + public const string MemoryReportsDirectoryRelative = "Library/com.yhc509.unity-cli-bridge/memory"; + public const int DefaultProfileMemoryFrames = 30; + public const double DefaultProfileMemoryThresholdPercent = 5.0; + public const int DefaultProfileMemoryCompareLimit = 10; public const int DefaultProfileStatsFrames = 60; public const int MaxProfileStatsFrames = 1000; public const int ProfileStatsTimeoutSeconds = 120; From e7db118ccb41817e0027c25373b2c376dc2b42b4 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 09:39:26 +0900 Subject: [PATCH 21/33] feat(catalog): add profile memory and profile memory compare entries --- .../ProfileMemoryCatalogTests.cs | 36 +++++++++++++++++++ .../Runtime/Protocol/CliCommandCatalog.cs | 31 ++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/UnityCli.Cli.Tests/ProfileMemoryCatalogTests.cs diff --git a/tests/UnityCli.Cli.Tests/ProfileMemoryCatalogTests.cs b/tests/UnityCli.Cli.Tests/ProfileMemoryCatalogTests.cs new file mode 100644 index 0000000..93ed28e --- /dev/null +++ b/tests/UnityCli.Cli.Tests/ProfileMemoryCatalogTests.cs @@ -0,0 +1,36 @@ +using UnityCli.Protocol; +using Xunit; + +namespace UnityCli.Cli.Tests; + +public class ProfileMemoryCatalogTests +{ + [Fact] + public void ProfileMemory_IsLiveWireCommand_NoForce_NoGraphics() + { + var descriptor = CliCommandCatalog.FindByCommand("profile memory"); + Assert.NotNull(descriptor); + Assert.Equal(ProtocolConstants.CommandProfileMemory, descriptor!.ProtocolCommand); + Assert.Equal(ForceRule.None, descriptor.ForceRule); + Assert.True(descriptor.CanUseLive); + Assert.False(descriptor.CanUseLocal); + Assert.False(CliCommandCatalog.RequiresGraphics(ProtocolConstants.CommandProfileMemory)); + } + + [Fact] + public void ProfileMemoryCompare_IsLocalOnly() + { + var descriptor = CliCommandCatalog.FindByCommand("profile memory compare"); + Assert.NotNull(descriptor); + Assert.Null(descriptor!.ProtocolCommand); + Assert.True(descriptor.CanUseLocal); + Assert.False(descriptor.CanUseLive); + Assert.Equal(ForceRule.None, descriptor.ForceRule); + } + + [Fact] + public void ProfileMemory_IsInSupportedProtocolCommands() + { + Assert.Contains(ProtocolConstants.CommandProfileMemory, CliCommandCatalog.GetSupportedProtocolCommands()); + } +} 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 bbe3cdb..9ba2407 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 @@ -330,6 +330,37 @@ public static class CliCommandCatalog "Captures recorded with different budgets, Unity versions, or frame counts are still compared; the mismatch is reported in `notes`.", "`deltaPercent` is only meaningful when the matching `deltaPercentAvailable` is true; a zero base leaves the percentage undefined.", }), + new CliCommandDescriptor( + "profile memory", + "profile memory [--frames (default: 30)]", + "Samples memory profiler counters (total/GC/graphics plus per-asset-type count and memory) over N frames and writes the report to a local sidecar for later comparison. Works in Edit Mode and Play Mode.", + CliCommandGroup.Diagnostics, + ProtocolConstants.CommandProfileMemory, + canUseLocal: false, + canUseLive: true, + isAllowedWhileBusy: true, + notes: new[] + { + "Returns a reportId and persists the report to Library/com.yhc509.unity-cli-bridge/memory/.json.", + "Counters not available on the current Unity version/platform are listed in `unavailable` instead of failing.", + "Memory and GC values are bytes; count counters are object counts.", + }), + new CliCommandDescriptor( + "profile memory compare", + "profile memory compare [--threshold (default: 5.0)] [--limit (default: 10)]", + "Diffs two memory reports by reading both sidecar JSON files locally — no Editor round-trip. Returns a regression/improvement/unchanged verdict plus the per-counter byte/count deltas that explain it.", + CliCommandGroup.Diagnostics, + null, + canUseLocal: true, + canUseLive: false, + isAllowedWhileBusy: true, + notes: new[] + { + "Local-only. Both reports must have a sidecar under the same project root.", + "--threshold is the Total Used Memory median change (in percent) below which the verdict stays `unchanged`.", + "Reports taken in different modes (editmode vs playmode), Unity versions, or frame counts are still compared; the mismatch is reported in `notes`.", + "`deltaPercent` is only meaningful when the matching `deltaPercentAvailable` is true; a zero base leaves the percentage undefined.", + }), new CliCommandDescriptor( "execute", "execute (--code | --file ) [--args ] [--timeout <초>] --force", From 44d4efb42639c8987590f4c0d26cfcc674f4d6cd Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 09:41:03 +0900 Subject: [PATCH 22/33] feat(bridge): add profile-memory deferred counter sampling with report sidecar --- .../Editor/ProfileCommandHandler.Memory.cs | 186 ++++++++++++++++++ .../Editor/ProfileCommandHandler.cs | 29 ++- 2 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Memory.cs 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 new file mode 100644 index 0000000..ebcb70d --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Memory.cs @@ -0,0 +1,186 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Unity.Profiling; +using UnityCli.Protocol; +using UnityEditor; +using UnityEngine; + +namespace UnityCliBridge.Bridge.Editor +{ + internal sealed partial class ProfileCommandHandler + { + // Counter names missing on the current Unity version resolve to recorder.Valid == false and are + // reported through `unavailable` instead of failing the command. Prune dead names once measured live. + private static readonly ProfileCounterSpec[] MemoryReportCounters = + { + new ProfileCounterSpec(ProfilerCategory.Memory, "Total Used Memory", "bytes"), + new ProfileCounterSpec(ProfilerCategory.Memory, "Total Reserved Memory", "bytes"), + new ProfileCounterSpec(ProfilerCategory.Memory, "System Used Memory", "bytes"), + new ProfileCounterSpec(ProfilerCategory.Memory, "GC Used Memory", "bytes"), + new ProfileCounterSpec(ProfilerCategory.Memory, "GC Reserved Memory", "bytes"), + new ProfileCounterSpec(ProfilerCategory.Memory, "Gfx Used Memory", "bytes"), + new ProfileCounterSpec(ProfilerCategory.Memory, "Gfx Reserved Memory", "bytes"), + new ProfileCounterSpec(ProfilerCategory.Memory, "Audio Used Memory", "bytes"), + new ProfileCounterSpec(ProfilerCategory.Memory, "Video Used Memory", "bytes"), + new ProfileCounterSpec(ProfilerCategory.Memory, "Texture Count", "count"), + new ProfileCounterSpec(ProfilerCategory.Memory, "Texture Memory", "bytes"), + new ProfileCounterSpec(ProfilerCategory.Memory, "Mesh Count", "count"), + new ProfileCounterSpec(ProfilerCategory.Memory, "Mesh Memory", "bytes"), + new ProfileCounterSpec(ProfilerCategory.Memory, "Material Count", "count"), + new ProfileCounterSpec(ProfilerCategory.Memory, "Material Memory", "bytes"), + new ProfileCounterSpec(ProfilerCategory.Memory, "AnimationClip Count", "count"), + new ProfileCounterSpec(ProfilerCategory.Memory, "AnimationClip Memory", "bytes"), + new ProfileCounterSpec(ProfilerCategory.Memory, "Asset Count", "count"), + new ProfileCounterSpec(ProfilerCategory.Memory, "GameObject Count", "count"), + new ProfileCounterSpec(ProfilerCategory.Memory, "Scene Object Count", "count"), + new ProfileCounterSpec(ProfilerCategory.Memory, "Object Count", "count"), + }; + + private void StartMemoryDeferred( + ProfileMemoryArgs args, + TaskCompletionSource completion, + string projectHash, + string requestId) + { + int frames = args.frames > 0 + ? Math.Min(args.frames, ProtocolConstants.MaxProfileStatsFrames) + : ProtocolConstants.DefaultProfileMemoryFrames; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var recorders = new List<(ProfileCounterSpec Spec, ProfilerRecorder Recorder)>(); + var unavailable = new List(); + foreach (ProfileCounterSpec spec in MemoryReportCounters) + { + ProfilerRecorder recorder = ProfilerRecorder.StartNew(spec.Category, spec.Name, frames); + if (!recorder.Valid) + { + unavailable.Add(spec.Name); + recorder.Dispose(); + continue; + } + + recorders.Add((spec, recorder)); + } + + int ticks = 0; + + void DisposeAll() + { + foreach ((_, ProfilerRecorder recorder) in recorders) + { + recorder.Dispose(); + } + } + + void Poll() + { + if (completion.Task.IsCompleted) + { + EditorApplication.update -= Poll; + DisposeAll(); + return; + } + + try + { + ticks++; + if (stopwatch.Elapsed.TotalSeconds >= ProtocolConstants.ProfileStatsTimeoutSeconds) + { + EditorApplication.update -= Poll; + DisposeAll(); + completion.TrySetResult(ResponseEnvelope.Failure( + requestId, + projectHash, + ProtocolConstants.ErrorProfileTimeout, + $"profile memory가 {ProtocolConstants.ProfileStatsTimeoutSeconds}초 안에 {frames}프레임을 수집하지 못했습니다.", + true, + stopwatch.ElapsedMilliseconds, + ProtocolConstants.TransportLive)); + return; + } + + if (ticks < frames) + { + return; + } + + EditorApplication.update -= Poll; + var counters = new List(); + var samples = new List(frames); + foreach ((ProfileCounterSpec spec, ProfilerRecorder recorder) in recorders) + { + samples.Clear(); + recorder.CopyTo(samples); + var values = new List(samples.Count); + foreach (ProfilerRecorderSample sample in samples) + { + values.Add(sample.Value); + } + + if (values.Count == 0) + { + unavailable.Add(spec.Name); + continue; + } + + double[] array = values.ToArray(); + double[] sorted = (double[])array.Clone(); + Array.Sort(sorted); + counters.Add(new ProfileCounterStat + { + name = spec.Name, + category = spec.Category.Name, + unit = spec.Unit, + min = sorted[0], + median = ProfileStatMath.Median(array), + p95 = ProfileStatMath.Percentile(sorted, 95), + max = sorted[sorted.Length - 1], + }); + } + + DisposeAll(); + var payload = new ProfileMemoryPayload + { + reportId = Guid.NewGuid().ToString("N"), + mode = EditorApplication.isPlaying ? "playmode" : "editmode", + frames = frames, + unityVersion = Application.unityVersion, + capturedAtUtc = DateTime.UtcNow.ToString("O"), + counters = counters.ToArray(), + unavailable = unavailable.ToArray(), + }; + WriteMemorySidecar(payload); + completion.TrySetResult(CreateSuccessResponse(requestId, projectHash, payload, stopwatch.ElapsedMilliseconds)); + } + catch (Exception exception) + { + EditorApplication.update -= Poll; + DisposeAll(); + completion.TrySetResult(CreateFailureResponse(requestId, projectHash, exception, stopwatch.ElapsedMilliseconds)); + } + } + + EditorApplication.update += Poll; + } + + private static void WriteMemorySidecar(ProfileMemoryPayload report) + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string path = Path.Combine( + projectRoot, + ProtocolConstants.MemoryReportsDirectoryRelative.Replace('/', Path.DirectorySeparatorChar), + report.reportId + ".json"); + var sidecar = new ProfileMemorySidecarFile + { + schemaVersion = 1, + reportId = report.reportId, + report = report, + }; + AtomicFileUtility.WriteAllText(path, ProtocolJson.Serialize(sidecar)); + AtomicFileUtility.CleanupTempFiles(Path.GetDirectoryName(path)!); + } + } +} 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 06f631d..4fd973b 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.cs @@ -16,18 +16,21 @@ public bool CanHandle(string command) return string.Equals(command, ProtocolConstants.CommandProfileStats, StringComparison.Ordinal) || string.Equals(command, ProtocolConstants.CommandProfileCaptureStart, StringComparison.Ordinal) || string.Equals(command, ProtocolConstants.CommandProfileCaptureStop, StringComparison.Ordinal) - || string.Equals(command, ProtocolConstants.CommandProfileStatus, StringComparison.Ordinal); + || string.Equals(command, ProtocolConstants.CommandProfileStatus, StringComparison.Ordinal) + || string.Equals(command, ProtocolConstants.CommandProfileMemory, StringComparison.Ordinal); } - // stats waits N editor frames, so it must run deferred like qa wait-until. + // stats/memory wait N editor frames, so they must run deferred like qa wait-until. public bool IsDeferred(string command, string? argumentsJson = null) { - return string.Equals(command, ProtocolConstants.CommandProfileStats, StringComparison.Ordinal); + return string.Equals(command, ProtocolConstants.CommandProfileStats, StringComparison.Ordinal) + || string.Equals(command, ProtocolConstants.CommandProfileMemory, StringComparison.Ordinal); } public string Handle(string command, string argumentsJson) { - if (string.Equals(command, ProtocolConstants.CommandProfileStats, StringComparison.Ordinal)) + if (string.Equals(command, ProtocolConstants.CommandProfileStats, StringComparison.Ordinal) + || string.Equals(command, ProtocolConstants.CommandProfileMemory, StringComparison.Ordinal)) { throw new InvalidOperationException("Deferred profile command must be started through StartDeferred: " + command); } @@ -65,14 +68,22 @@ public void StartDeferred( return; } - if (!string.Equals(command, ProtocolConstants.CommandProfileStats, StringComparison.Ordinal)) + string requestId = GetRequestId(completion); + if (string.Equals(command, ProtocolConstants.CommandProfileStats, StringComparison.Ordinal)) + { + ProfileStatsArgs args = ProtocolJson.Deserialize(argumentsJson) ?? new ProfileStatsArgs(); + StartStatsDeferred(args, completion, projectHash, requestId); + return; + } + + if (string.Equals(command, ProtocolConstants.CommandProfileMemory, StringComparison.Ordinal)) { - throw new InvalidOperationException("Unhandled deferred profile command: " + command); + ProfileMemoryArgs args = ProtocolJson.Deserialize(argumentsJson) ?? new ProfileMemoryArgs(); + StartMemoryDeferred(args, completion, projectHash, requestId); + return; } - string requestId = GetRequestId(completion); - ProfileStatsArgs args = ProtocolJson.Deserialize(argumentsJson) ?? new ProfileStatsArgs(); - StartStatsDeferred(args, completion, projectHash, requestId); + throw new InvalidOperationException("Unhandled deferred profile command: " + command); } private readonly struct ProfileCounterSpec From 257ba6d4de75a5e1822dd3beba7a550bbbaf2bee Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 09:47:00 +0900 Subject: [PATCH 23/33] feat(cli): add profile memory command and local memory report compare --- cli/UnityCli.Cli/CliApp.cs | 9 +- cli/UnityCli.Cli/Models/ParsedCommand.cs | 7 + .../Services/CliArgumentParser.Validation.cs | 4 +- .../Services/CliArgumentParser.cs | 40 ++- .../Services/ProfileMemoryComparer.cs | 252 ++++++++++++++++++ .../Services/ProfileSidecarLoader.cs | 73 +++++ .../ProfileMemoryComparerTests.cs | 245 +++++++++++++++++ .../UnityCli.Cli.Tests/ProfileParserTests.cs | 37 +++ 8 files changed, 662 insertions(+), 5 deletions(-) create mode 100644 cli/UnityCli.Cli/Services/ProfileMemoryComparer.cs create mode 100644 tests/UnityCli.Cli.Tests/ProfileMemoryComparerTests.cs diff --git a/cli/UnityCli.Cli/CliApp.cs b/cli/UnityCli.Cli/CliApp.cs index ea13c64..30a9c58 100644 --- a/cli/UnityCli.Cli/CliApp.cs +++ b/cli/UnityCli.Cli/CliApp.cs @@ -38,6 +38,7 @@ internal static async Task 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), @@ -704,13 +705,15 @@ 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)); diff --git a/cli/UnityCli.Cli/Models/ParsedCommand.cs b/cli/UnityCli.Cli/Models/ParsedCommand.cs index b64d914..41d3632 100644 --- a/cli/UnityCli.Cli/Models/ParsedCommand.cs +++ b/cli/UnityCli.Cli/Models/ParsedCommand.cs @@ -76,6 +76,8 @@ public enum CommandKind ProfileStatus, ProfileAnalyze, ProfileCompare, + ProfileMemory, + ProfileMemoryCompare, EditorLaunch, EditorStop, } @@ -344,6 +346,7 @@ public CommandEnvelope ToEnvelope() CommandKind.ProfileCaptureStart => ProtocolConstants.CommandProfileCaptureStart, CommandKind.ProfileCaptureStop => ProtocolConstants.CommandProfileCaptureStop, CommandKind.ProfileStatus => ProtocolConstants.CommandProfileStatus, + CommandKind.ProfileMemory => ProtocolConstants.CommandProfileMemory, CommandKind.EditorStop => ProtocolConstants.CommandEditorQuit, _ => throw new CliUsageException($"지원하지 않는 live 명령입니다: {Kind}"), }, @@ -447,6 +450,10 @@ private string BuildArgumentsJson() { captureId = ProfileCaptureId, }, + CommandKind.ProfileMemory => new ProfileMemoryArgs + { + frames = ProfileFrames ?? 0, + }, CommandKind.EditorStop => new EditorQuitArgs { force = Force, diff --git a/cli/UnityCli.Cli/Services/CliArgumentParser.Validation.cs b/cli/UnityCli.Cli/Services/CliArgumentParser.Validation.cs index 5784b4b..57dc5ad 100644 --- a/cli/UnityCli.Cli/Services/CliArgumentParser.Validation.cs +++ b/cli/UnityCli.Cli/Services/CliArgumentParser.Validation.cs @@ -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)) { @@ -674,6 +674,8 @@ 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.EditorLaunch => "editor launch", CommandKind.EditorStop => "editor stop", CommandKind.PackageList => "package list", diff --git a/cli/UnityCli.Cli/Services/CliArgumentParser.cs b/cli/UnityCli.Cli/Services/CliArgumentParser.cs index ae3edc2..0255a6b 100644 --- a/cli/UnityCli.Cli/Services/CliArgumentParser.cs +++ b/cli/UnityCli.Cli/Services/CliArgumentParser.cs @@ -377,7 +377,7 @@ private static ParsedCommand ParseProfile(Queue tokens) { if (tokens.Count == 0) { - throw new CliUsageException("`profile` 다음에는 `stats`, `capture`, `status`, `analyze`, `compare` 중 하나가 필요합니다."); + throw new CliUsageException("`profile` 다음에는 `stats`, `capture`, `status`, `analyze`, `compare`, `memory` 중 하나가 필요합니다."); } var subCommand = tokens.Dequeue().ToLowerInvariant(); @@ -442,6 +442,41 @@ private static ParsedCommand ParseProfile(Queue tokens) parsed.ProfileCompareHeadId = tokens.Dequeue(); return parsed; } + case "memory": + { + if (tokens.Count > 0 && !tokens.Peek().StartsWith("--", StringComparison.Ordinal)) + { + var memorySub = tokens.Dequeue().ToLowerInvariant(); + switch (memorySub) + { + case "compare": + { + if (tokens.Count == 0 || tokens.Peek().StartsWith("--", StringComparison.Ordinal)) + { + throw new CliUsageException("`profile memory compare`에는 와 가 필요합니다."); + } + + var parsed = new ParsedCommand(CommandKind.ProfileMemoryCompare) + { + ProfileCompareBaseId = tokens.Dequeue(), + }; + if (tokens.Count == 0 || tokens.Peek().StartsWith("--", StringComparison.Ordinal)) + { + throw new CliUsageException("`profile memory compare`에는 비교 대상인 도 필요합니다."); + } + + parsed.ProfileCompareHeadId = tokens.Dequeue(); + return parsed; + } + + default: + throw new CliUsageException($"알 수 없는 profile memory 하위 명령입니다: {memorySub}"); + } + } + + return new ParsedCommand(CommandKind.ProfileMemory); + } + default: throw new CliUsageException($"알 수 없는 profile 하위 명령입니다: {subCommand}"); } @@ -811,6 +846,7 @@ private static void ParseCommandOptions(ParsedCommand parsed, Queue toke case CommandKind.QaRunSequence when token == "--profile": parsed.QaSequenceProfile = true; break; + case CommandKind.ProfileMemory when token == "--frames": case CommandKind.ProfileStats when token == "--frames": parsed.ProfileFrames = RequireInt(RequireValue(tokens, "--frames"), "--frames"); break; @@ -860,6 +896,7 @@ private static void ParseCommandOptions(ParsedCommand parsed, Queue toke case CommandKind.ProfileAnalyze when token == "--limit": parsed.ProfileLimit = RequireInt(RequireValue(tokens, "--limit"), "--limit"); break; + case CommandKind.ProfileMemoryCompare when token == "--threshold": case CommandKind.ProfileCompare when token == "--threshold": { string rawThreshold = RequireValue(tokens, "--threshold"); @@ -871,6 +908,7 @@ private static void ParseCommandOptions(ParsedCommand parsed, Queue toke parsed.ProfileThresholdPercent = threshold; break; } + case CommandKind.ProfileMemoryCompare when token == "--limit": case CommandKind.ProfileCompare when token == "--limit": parsed.ProfileLimit = RequireInt(RequireValue(tokens, "--limit"), "--limit"); break; diff --git a/cli/UnityCli.Cli/Services/ProfileMemoryComparer.cs b/cli/UnityCli.Cli/Services/ProfileMemoryComparer.cs new file mode 100644 index 0000000..46d3f89 --- /dev/null +++ b/cli/UnityCli.Cli/Services/ProfileMemoryComparer.cs @@ -0,0 +1,252 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using UnityCli.Cli.Models; +using UnityCli.Protocol; + +namespace UnityCli.Cli.Services; + +/// Local diff of two memory report sidecars. No IPC. +internal static class ProfileMemoryComparer +{ + private const string TotalCounterName = "Total Used Memory"; + private const string GcCounterName = "GC Used Memory"; + + internal static ResponseEnvelope Run(ParsedCommand parsed, string? projectRoot) + { + double threshold = parsed.ProfileThresholdPercent ?? ProtocolConstants.DefaultProfileMemoryThresholdPercent; + if (!double.IsFinite(threshold) || threshold < 0) + { + return ResponseEnvelope.Failure( + "local", null, "CLI_USAGE", "--threshold는 0 이상의 유한한 값이어야 합니다.", false, 0, "cli"); + } + + if (!ProfileSidecarLoader.TryLoadMemory( + projectRoot, parsed.ProfileCompareBaseId, out ProfileMemorySidecarFile baseSidecar, out ResponseEnvelope? baseFailure)) + { + return baseFailure!; + } + + if (!ProfileSidecarLoader.TryLoadMemory( + projectRoot, parsed.ProfileCompareHeadId, out ProfileMemorySidecarFile headSidecar, out ResponseEnvelope? headFailure)) + { + return headFailure!; + } + + // Check base first, then head, so the reported side is deterministic when both are unusable. + if (!TryEnsureUsable(baseSidecar, "base", out ResponseEnvelope? baseUnusable)) + { + return baseUnusable!; + } + + if (!TryEnsureUsable(headSidecar, "head", out ResponseEnvelope? headUnusable)) + { + return headUnusable!; + } + + int limit = parsed.ProfileLimit ?? ProtocolConstants.DefaultProfileMemoryCompareLimit; + ProfileMemoryComparePayload payload = Compare(baseSidecar, headSidecar, threshold, limit); + + return ResponseEnvelope.Success( + "local", + null, + JsonSerializer.SerializeToElement(payload, ProtocolJson.Default), + 0, + "cli"); + } + + private static bool TryEnsureUsable(ProfileMemorySidecarFile sidecar, string side, out ResponseEnvelope? failure) + { + if (sidecar.report.counters.Length > 0) + { + failure = null; + return true; + } + + failure = ResponseEnvelope.Failure( + "local", + null, + ProtocolConstants.ErrorProfileFailed, + $"{side} 리포트 `{sidecar.reportId}`에는 카운터가 없습니다. 다시 측정하세요.", + false, + 0, + "cli"); + return false; + } + + internal static ProfileMemoryComparePayload Compare( + ProfileMemorySidecarFile baseSidecar, + ProfileMemorySidecarFile headSidecar, + double thresholdPercent, + int limit) + { + ProfileMemoryPayload baseReport = baseSidecar.report; + ProfileMemoryPayload headReport = headSidecar.report; + + var notes = new List(); + if (!string.Equals(baseReport.mode, headReport.mode, StringComparison.Ordinal)) + { + notes.Add($"mode가 다릅니다 (base={baseReport.mode}, head={headReport.mode}) — 값 차이가 모드 차이일 수 있습니다."); + } + + if (!string.Equals(baseReport.unityVersion, headReport.unityVersion, StringComparison.Ordinal)) + { + notes.Add($"unityVersion이 다릅니다 (base={baseReport.unityVersion}, head={headReport.unityVersion})."); + } + + if (baseReport.frames != headReport.frames) + { + notes.Add($"샘플링 프레임 수가 다릅니다 (base={baseReport.frames}, head={headReport.frames})."); + } + + var payload = new ProfileMemoryComparePayload + { + baseReport = BuildSide(baseSidecar), + headReport = BuildSide(headSidecar), + thresholdPercent = thresholdPercent, + totalUsedBytes = BuildDelta(FindMedian(baseReport, TotalCounterName), FindMedian(headReport, TotalCounterName)), + gcUsedBytes = BuildDelta(FindMedian(baseReport, GcCounterName), FindMedian(headReport, GcCounterName)), + }; + + bool totalMissing = FindMedian(baseReport, TotalCounterName) is null + || FindMedian(headReport, TotalCounterName) is null; + if (totalMissing) + { + notes.Insert(0, $"`{TotalCounterName}` 카운터가 한쪽에 없어 verdict를 판정할 수 없습니다 — unchanged로 고정합니다."); + payload.verdict = "unchanged"; + } + else if (!payload.totalUsedBytes.deltaPercentAvailable) + { + notes.Insert(0, "base Total Used Memory가 0 이하라 퍼센트가 정의되지 않습니다 — verdict는 unchanged로 고정합니다."); + payload.verdict = "unchanged"; + } + else if (payload.totalUsedBytes.deltaPercent > thresholdPercent) + { + payload.verdict = "regression"; + } + else if (payload.totalUsedBytes.deltaPercent < -thresholdPercent) + { + payload.verdict = "improvement"; + } + else + { + payload.verdict = "unchanged"; + } + + // Per-counter deltas cover only counters present on both sides. The base array's + // declaration order is the tie-break ordinal, so --limit output is reproducible. + var increases = new List<(ProfileMemoryCounterDelta Delta, int Ordinal)>(); + var decreases = new List<(ProfileMemoryCounterDelta Delta, int Ordinal)>(); + for (int i = 0; i < baseReport.counters.Length; i++) + { + ProfileCounterStat baseStat = baseReport.counters[i]; + ProfileCounterStat? headStat = headReport.counters + .FirstOrDefault(c => string.Equals(c.name, baseStat.name, StringComparison.Ordinal)); + if (headStat is null) + { + notes.Add($"base에만 있는 카운터: {baseStat.name}"); + continue; + } + + double delta = headStat.median - baseStat.median; + if (delta == 0) + { + continue; + } + + var entry = new ProfileMemoryCounterDelta + { + name = baseStat.name, + unit = baseStat.unit, + baseMedian = baseStat.median, + headMedian = headStat.median, + delta = delta, + deltaPercent = baseStat.median > 0 ? delta / baseStat.median * 100.0 : 0, + deltaPercentAvailable = baseStat.median > 0, + }; + if (delta > 0) + { + increases.Add((entry, i)); + } + else + { + decreases.Add((entry, i)); + } + } + + foreach (ProfileCounterStat headStat in headReport.counters) + { + if (!baseReport.counters.Any(c => string.Equals(c.name, headStat.name, StringComparison.Ordinal))) + { + notes.Add($"head에만 있는 카운터: {headStat.name}"); + } + } + + payload.truncated = increases.Count > limit || decreases.Count > limit; + payload.increases = TakeSorted(increases, limit); + payload.decreases = TakeSorted(decreases, limit); + payload.notes = notes.ToArray(); + return payload; + } + + private static ProfileMemoryCounterDelta[] TakeSorted( + List<(ProfileMemoryCounterDelta Delta, int Ordinal)> entries, int limit) + { + return entries + .OrderByDescending(entry => Math.Abs(entry.Delta.delta)) + .ThenBy(entry => entry.Ordinal) + .Take(Math.Max(0, limit)) + .Select(entry => entry.Delta) + .ToArray(); + } + + private static ProfileMemoryCompareSide BuildSide(ProfileMemorySidecarFile sidecar) + { + return new ProfileMemoryCompareSide + { + reportId = sidecar.reportId, + frames = sidecar.report.frames, + mode = sidecar.report.mode, + unityVersion = sidecar.report.unityVersion, + capturedAtUtc = sidecar.report.capturedAtUtc, + }; + } + + private static double? FindMedian(ProfileMemoryPayload report, string counterName) + { + foreach (ProfileCounterStat stat in report.counters) + { + if (string.Equals(stat.name, counterName, StringComparison.Ordinal)) + { + return stat.median; + } + } + + return null; + } + + private static ProfileCompareDelta BuildDelta(double? baseValue, double? headValue) + { + double baseNumber = baseValue ?? 0; + double headNumber = headValue ?? 0; + var delta = new ProfileCompareDelta + { + baseValue = baseNumber, + headValue = headNumber, + delta = headNumber - baseNumber, + }; + if (baseValue is null || headValue is null || baseNumber <= 0) + { + delta.deltaPercentAvailable = false; + delta.deltaPercent = 0; + } + else + { + delta.deltaPercent = (headNumber - baseNumber) / baseNumber * 100.0; + } + + return delta; + } +} diff --git a/cli/UnityCli.Cli/Services/ProfileSidecarLoader.cs b/cli/UnityCli.Cli/Services/ProfileSidecarLoader.cs index 7ae715c..b03a041 100644 --- a/cli/UnityCli.Cli/Services/ProfileSidecarLoader.cs +++ b/cli/UnityCli.Cli/Services/ProfileSidecarLoader.cs @@ -80,4 +80,77 @@ internal static bool TryLoad( sidecar = loaded; return true; } + + /// + /// Resolves and deserializes the memory report sidecar at + /// {projectRoot}/Library/com.yhc509.unity-cli-bridge/memory/{reportId}.json. + /// Returns false and fills with the response envelope on any error. + /// + internal static bool TryLoadMemory( + string? projectRoot, + string? reportId, + out ProfileMemorySidecarFile sidecar, + out ResponseEnvelope? failure) + { + sidecar = null!; + failure = null; + + if (string.IsNullOrWhiteSpace(projectRoot)) + { + failure = ResponseEnvelope.Failure( + "local", + null, + "CLI_USAGE", + "프로젝트 루트를 찾을 수 없습니다. Unity 프로젝트 안에서 실행하거나 --project를 지정하세요.", + false, + 0, + "cli"); + return false; + } + + string sidecarPath = Path.Combine( + projectRoot, + ProtocolConstants.MemoryReportsDirectoryRelative.Replace('/', Path.DirectorySeparatorChar), + reportId + ".json"); + if (!File.Exists(sidecarPath)) + { + failure = ResponseEnvelope.Failure( + "local", + null, + ProtocolConstants.ErrorProfileNotFound, + $"reportId `{reportId}`의 memory sidecar를 찾을 수 없습니다: {sidecarPath}", + false, + 0, + "cli"); + return false; + } + + ProfileMemorySidecarFile? loaded; + try + { + loaded = ProtocolJson.Deserialize(File.ReadAllText(sidecarPath)); + } + catch (JsonException exception) + { + failure = ResponseEnvelope.Failure( + "local", + null, + ProtocolConstants.ErrorProfileFailed, + "memory sidecar JSON을 읽지 못했습니다: " + exception.Message, + false, + 0, + "cli"); + return false; + } + + if (loaded is null) + { + failure = ResponseEnvelope.Failure( + "local", null, ProtocolConstants.ErrorProfileFailed, "memory sidecar가 비어 있습니다.", false, 0, "cli"); + return false; + } + + sidecar = loaded; + return true; + } } diff --git a/tests/UnityCli.Cli.Tests/ProfileMemoryComparerTests.cs b/tests/UnityCli.Cli.Tests/ProfileMemoryComparerTests.cs new file mode 100644 index 0000000..76e22d3 --- /dev/null +++ b/tests/UnityCli.Cli.Tests/ProfileMemoryComparerTests.cs @@ -0,0 +1,245 @@ +using System.Text.Json; +using UnityCli.Cli.Models; +using UnityCli.Cli.Services; +using UnityCli.Protocol; +using Xunit; + +namespace UnityCli.Cli.Tests; + +public class ProfileMemoryComparerTests +{ + private static ProfileMemorySidecarFile Sidecar(string id, string mode, params (string Name, string Unit, double Median)[] counters) + { + var stats = new ProfileCounterStat[counters.Length]; + for (int i = 0; i < counters.Length; i++) + { + stats[i] = new ProfileCounterStat + { + name = counters[i].Name, + category = "Memory", + unit = counters[i].Unit, + min = counters[i].Median, + median = counters[i].Median, + p95 = counters[i].Median, + max = counters[i].Median, + }; + } + + return new ProfileMemorySidecarFile + { + schemaVersion = 1, + reportId = id, + report = new ProfileMemoryPayload + { + reportId = id, + mode = mode, + frames = 30, + unityVersion = "6000.3.10f1", + counters = stats, + }, + }; + } + + [Fact] + public void Compare_TotalGrowthBeyondThreshold_IsRegression() + { + var baseSide = Sidecar("base", "playmode", ("Total Used Memory", "bytes", 1000)); + var headSide = Sidecar("head", "playmode", ("Total Used Memory", "bytes", 1100)); + + var payload = ProfileMemoryComparer.Compare(baseSide, headSide, 5.0, 10); + + Assert.Equal("regression", payload.verdict); + Assert.True(payload.totalUsedBytes.deltaPercentAvailable); + Assert.Equal(10.0, payload.totalUsedBytes.deltaPercent, precision: 6); + } + + [Fact] + public void Compare_TotalShrinkBeyondThreshold_IsImprovement() + { + var baseSide = Sidecar("base", "playmode", ("Total Used Memory", "bytes", 1000)); + var headSide = Sidecar("head", "playmode", ("Total Used Memory", "bytes", 900)); + + var payload = ProfileMemoryComparer.Compare(baseSide, headSide, 5.0, 10); + + Assert.Equal("improvement", payload.verdict); + } + + [Fact] + public void Compare_WithinThreshold_IsUnchanged() + { + var baseSide = Sidecar("base", "playmode", ("Total Used Memory", "bytes", 1000)); + var headSide = Sidecar("head", "playmode", ("Total Used Memory", "bytes", 1030)); + + var payload = ProfileMemoryComparer.Compare(baseSide, headSide, 5.0, 10); + + Assert.Equal("unchanged", payload.verdict); + } + + [Fact] + public void Compare_ZeroBaseTotal_FixesVerdictUnchanged() + { + var baseSide = Sidecar("base", "playmode", ("Total Used Memory", "bytes", 0)); + var headSide = Sidecar("head", "playmode", ("Total Used Memory", "bytes", 5000)); + + var payload = ProfileMemoryComparer.Compare(baseSide, headSide, 5.0, 10); + + Assert.Equal("unchanged", payload.verdict); + Assert.False(payload.totalUsedBytes.deltaPercentAvailable); + Assert.Contains(payload.notes, note => note.Contains("0 이하")); + } + + [Fact] + public void Compare_MissingTotalCounter_FixesVerdictUnchanged() + { + var baseSide = Sidecar("base", "playmode", ("GC Used Memory", "bytes", 100)); + var headSide = Sidecar("head", "playmode", ("GC Used Memory", "bytes", 500)); + + var payload = ProfileMemoryComparer.Compare(baseSide, headSide, 5.0, 10); + + Assert.Equal("unchanged", payload.verdict); + Assert.Contains(payload.notes, note => note.Contains("Total Used Memory")); + } + + [Fact] + public void Compare_ModeMismatch_AddsNote() + { + var baseSide = Sidecar("base", "editmode", ("Total Used Memory", "bytes", 1000)); + var headSide = Sidecar("head", "playmode", ("Total Used Memory", "bytes", 1000)); + + var payload = ProfileMemoryComparer.Compare(baseSide, headSide, 5.0, 10); + + Assert.Contains(payload.notes, note => note.Contains("mode가 다릅니다")); + } + + [Fact] + public void Compare_SortsByAbsoluteDelta_TieBreaksByOrdinal() + { + var baseSide = Sidecar( + "base", "playmode", + ("Total Used Memory", "bytes", 1000), + ("Texture Memory", "bytes", 100), + ("Mesh Memory", "bytes", 200), + ("Material Count", "count", 10)); + var headSide = Sidecar( + "head", "playmode", + ("Total Used Memory", "bytes", 1000), + ("Texture Memory", "bytes", 150), // +50, ordinal 1 + ("Mesh Memory", "bytes", 250), // +50, ordinal 2 + ("Material Count", "count", 110)); // +100, ordinal 3 + + var payload = ProfileMemoryComparer.Compare(baseSide, headSide, 5.0, 10); + + Assert.Equal(3, payload.increases.Length); + Assert.Equal("Material Count", payload.increases[0].name); + Assert.Equal("Texture Memory", payload.increases[1].name); // |50| tie → ordinal 1 < 2 + Assert.Equal("Mesh Memory", payload.increases[2].name); + } + + [Fact] + public void Compare_LimitTruncates_AndFlags() + { + var baseSide = Sidecar( + "base", "playmode", + ("Total Used Memory", "bytes", 1000), + ("Texture Memory", "bytes", 100), + ("Mesh Memory", "bytes", 100)); + var headSide = Sidecar( + "head", "playmode", + ("Total Used Memory", "bytes", 1000), + ("Texture Memory", "bytes", 300), + ("Mesh Memory", "bytes", 200)); + + var payload = ProfileMemoryComparer.Compare(baseSide, headSide, 5.0, 1); + + Assert.Single(payload.increases); + Assert.Equal("Texture Memory", payload.increases[0].name); + Assert.True(payload.truncated); + } + + [Fact] + public void Compare_ZeroDeltaCounters_AreExcluded() + { + var baseSide = Sidecar( + "base", "playmode", + ("Total Used Memory", "bytes", 1000), + ("Texture Memory", "bytes", 100)); + var headSide = Sidecar( + "head", "playmode", + ("Total Used Memory", "bytes", 1000), + ("Texture Memory", "bytes", 100)); + + var payload = ProfileMemoryComparer.Compare(baseSide, headSide, 5.0, 10); + + Assert.Empty(payload.increases); + Assert.Empty(payload.decreases); + } + + [Fact] + public void Run_NegativeThreshold_IsCliUsage() + { + var parsed = new ParsedCommand(CommandKind.ProfileMemoryCompare) + { + ProfileCompareBaseId = "a", + ProfileCompareHeadId = "b", + ProfileThresholdPercent = -1, + }; + + ResponseEnvelope envelope = ProfileMemoryComparer.Run(parsed, projectRoot: "/tmp/nonexistent"); + + Assert.Equal(ProtocolConstants.StatusError, envelope.status); + Assert.Equal("CLI_USAGE", envelope.error!.code); + } + + [Fact] + public void Run_ReadsSidecarsFromMemoryDirectory() + { + using var temp = new TempDirectory(); + WriteSidecar(temp.Path, Sidecar("aaa", "playmode", ("Total Used Memory", "bytes", 1000))); + WriteSidecar(temp.Path, Sidecar("bbb", "playmode", ("Total Used Memory", "bytes", 1200))); + + var parsed = new ParsedCommand(CommandKind.ProfileMemoryCompare) + { + ProfileCompareBaseId = "aaa", + ProfileCompareHeadId = "bbb", + }; + + ResponseEnvelope envelope = ProfileMemoryComparer.Run(parsed, temp.Path); + + Assert.Equal(ProtocolConstants.StatusSuccess, envelope.status); + ProfileMemoryComparePayload payload = envelope.data!.Value + .Deserialize(ProtocolJson.Default)!; + Assert.Equal("regression", payload.verdict); + Assert.Equal("aaa", payload.baseReport.reportId); + Assert.Equal("bbb", payload.headReport.reportId); + } + + [Fact] + public void Run_MissingBaseSidecar_ReturnsProfileNotFound() + { + using var temp = new TempDirectory(); + WriteSidecar(temp.Path, Sidecar("bbb", "playmode", ("Total Used Memory", "bytes", 1200))); + + var parsed = new ParsedCommand(CommandKind.ProfileMemoryCompare) + { + ProfileCompareBaseId = "nope", + ProfileCompareHeadId = "bbb", + }; + + ResponseEnvelope envelope = ProfileMemoryComparer.Run(parsed, temp.Path); + + Assert.Equal(ProtocolConstants.StatusError, envelope.status); + Assert.Equal(ProtocolConstants.ErrorProfileNotFound, envelope.error!.code); + Assert.Contains("nope", envelope.error.message); + } + + private static void WriteSidecar(string projectRoot, ProfileMemorySidecarFile sidecar) + { + string directory = Path.Combine( + projectRoot, + ProtocolConstants.MemoryReportsDirectoryRelative.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(directory); + File.WriteAllText( + Path.Combine(directory, sidecar.reportId + ".json"), + ProtocolJson.Serialize(sidecar)); + } +} diff --git a/tests/UnityCli.Cli.Tests/ProfileParserTests.cs b/tests/UnityCli.Cli.Tests/ProfileParserTests.cs index 8c693a9..9970b98 100644 --- a/tests/UnityCli.Cli.Tests/ProfileParserTests.cs +++ b/tests/UnityCli.Cli.Tests/ProfileParserTests.cs @@ -160,4 +160,41 @@ public void Parse_Profile_RequiresSubcommand() { Assert.Throws(() => CliArgumentParser.Parse(["profile"])); } + + [Fact] + public void Parse_ProfileMemory_DefaultsAndFrames() + { + var parsed = CliArgumentParser.Parse(["profile", "memory"]); + Assert.Equal(CommandKind.ProfileMemory, parsed.Kind); + Assert.Null(parsed.ProfileFrames); + + var withFrames = CliArgumentParser.Parse(["profile", "memory", "--frames", "10"]); + Assert.Equal(CommandKind.ProfileMemory, withFrames.Kind); + Assert.Equal(10, withFrames.ProfileFrames); + } + + [Fact] + public void Parse_ProfileMemoryCompare_ParsesIdsAndOptions() + { + var parsed = CliArgumentParser.Parse( + ["profile", "memory", "compare", "aaa", "bbb", "--threshold", "10", "--limit", "3"]); + Assert.Equal(CommandKind.ProfileMemoryCompare, parsed.Kind); + Assert.Equal("aaa", parsed.ProfileCompareBaseId); + Assert.Equal("bbb", parsed.ProfileCompareHeadId); + Assert.Equal(10.0, parsed.ProfileThresholdPercent!.Value, precision: 6); + Assert.Equal(3, parsed.ProfileLimit); + } + + [Fact] + public void Parse_ProfileMemoryCompare_RequiresBothIds() + { + Assert.Throws(() => CliArgumentParser.Parse(["profile", "memory", "compare"])); + Assert.Throws(() => CliArgumentParser.Parse(["profile", "memory", "compare", "aaa"])); + } + + [Fact] + public void Parse_ProfileMemory_UnknownSub_Throws() + { + Assert.Throws(() => CliArgumentParser.Parse(["profile", "memory", "bogus"])); + } } From 058be3ab09db0b39f42450514e7038699cf0fd34 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 09:47:04 +0900 Subject: [PATCH 24/33] docs: regenerate cli-reference for the profile memory commands --- docs/cli-reference.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2b9b44c..f107beb 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -148,6 +148,8 @@ Low-level commands for environment inspection and raw protocol debugging. | `profile status` | `profile status []` | live | `None` | Reports the state of the active capture (Capturing/Processing) or the finished summary read from the profiles sidecar. | | `profile analyze` | `profile analyze (--marker \| --frame \| --gc \| --spikes) [--limit (default: 5)]` | local | `None` | Drills into a finished capture by reading its sidecar JSON locally — no Editor round-trip. Works after the Editor exits as long as the sidecar file exists. | | `profile compare` | `profile compare [--threshold (default: 5.0)] [--limit (default: 5)]` | local | `None` | Diffs two finished captures by reading both sidecar JSON files locally — no Editor round-trip. Returns a regression/improvement/unchanged verdict plus the frame-time, over-budget, GC, and per-marker deltas that explain it. | +| `profile memory` | `profile memory [--frames (default: 30)]` | live | `None` | Samples memory profiler counters (total/GC/graphics plus per-asset-type count and memory) over N frames and writes the report to a local sidecar for later comparison. Works in Edit Mode and Play Mode. | +| `profile memory compare` | `profile memory compare [--threshold (default: 5.0)] [--limit (default: 10)]` | local | `None` | Diffs two memory reports by reading both sidecar JSON files locally — no Editor round-trip. Returns a regression/improvement/unchanged verdict plus the per-counter byte/count deltas that explain it. | | `test list` | `test list [--mode ] [--no-detail]` | live | `None` | Lists EditMode and/or PlayMode test cases discovered in the running editor; add --no-detail to return only fullName and mode. | | `test run` | `test run --mode [--filter ] [--category ] [--assembly ] [--no-domain-reload] [--failures-only] [--timeout ] [--wait]` | live | `None` | Executes EditMode or PlayMode tests. EditMode returns synchronously; PlayMode returns runId immediately and persists results to Library/com.yhc509.unity-cli-bridge/test-runs/.json. Add --failures-only to trim tests[] to non-passed entries while preserving summary counts. | | `test results` | `test results [--run-id ] [--failures-only]` | live | `None` | Retrieves cached test run result (or in-progress status). Without --run-id, returns the last run. Add --failures-only to trim tests[] to non-passed entries while preserving summary counts. | From 22bb3d32bef0c9f8d1bbab4c5fdda322e6d367e6 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 09:49:51 +0900 Subject: [PATCH 25/33] feat(protocol): add profile-memory-snapshot wire command and models Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XPBXJjiKtZk6DqEScTpN5D --- .../ProfileMemoryCatalogTests.cs | 12 ++++++++++++ .../Runtime/Protocol/CliCommandCatalog.cs | 16 ++++++++++++++++ .../Runtime/Protocol/ProfileModels.cs | 17 +++++++++++++++++ .../Runtime/Protocol/ProtocolConstants.cs | 3 +++ 4 files changed, 48 insertions(+) diff --git a/tests/UnityCli.Cli.Tests/ProfileMemoryCatalogTests.cs b/tests/UnityCli.Cli.Tests/ProfileMemoryCatalogTests.cs index 93ed28e..5dc0abf 100644 --- a/tests/UnityCli.Cli.Tests/ProfileMemoryCatalogTests.cs +++ b/tests/UnityCli.Cli.Tests/ProfileMemoryCatalogTests.cs @@ -28,6 +28,18 @@ public void ProfileMemoryCompare_IsLocalOnly() Assert.Equal(ForceRule.None, descriptor.ForceRule); } + [Fact] + public void ProfileMemorySnapshot_IsLiveWireCommand_NoForce_NoGraphics() + { + var descriptor = CliCommandCatalog.FindByCommand("profile memory snapshot"); + Assert.NotNull(descriptor); + Assert.Equal(ProtocolConstants.CommandProfileMemorySnapshot, descriptor!.ProtocolCommand); + Assert.Equal(ForceRule.None, descriptor.ForceRule); + Assert.True(descriptor.CanUseLive); + Assert.False(descriptor.CanUseLocal); + Assert.False(CliCommandCatalog.RequiresGraphics(ProtocolConstants.CommandProfileMemorySnapshot)); + } + [Fact] public void ProfileMemory_IsInSupportedProtocolCommands() { 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 9ba2407..0805292 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 @@ -361,6 +361,22 @@ public static class CliCommandCatalog "Reports taken in different modes (editmode vs playmode), Unity versions, or frame counts are still compared; the mismatch is reported in `notes`.", "`deltaPercent` is only meaningful when the matching `deltaPercentAvailable` is true; a zero base leaves the percentage undefined.", }), + new CliCommandDescriptor( + "profile memory snapshot", + "profile memory snapshot", + "Takes a full memory snapshot via MemoryProfiler.TakeSnapshot and saves it under Library/com.yhc509.unity-cli-bridge/snapshots/.snap, returning only the path and metadata. Analyze the .snap in the Memory Profiler package GUI.", + CliCommandGroup.Diagnostics, + ProtocolConstants.CommandProfileMemorySnapshot, + canUseLocal: false, + canUseLive: true, + isAllowedWhileBusy: true, + notes: new[] + { + "Requires the com.unity.memoryprofiler package; without it the command fails with install guidance instead of taking a snapshot.", + "Snapshots can be hundreds of MB; the file is never transferred or parsed — only its path is returned. Old snapshots are not garbage-collected.", + "Rejected with PROFILE_IN_PROGRESS while a profile capture or another snapshot is running (and capture start is rejected while a snapshot runs).", + "The editor main thread blocks while the snapshot is being captured; expect the command to take seconds on large projects.", + }), new CliCommandDescriptor( "execute", "execute (--code | --file ) [--args ] [--timeout <초>] --force", diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ProfileModels.cs b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ProfileModels.cs index 1b4770f..37eb7b2 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ProfileModels.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ProfileModels.cs @@ -331,4 +331,21 @@ public sealed class ProfileMemoryComparePayload public bool truncated; public string[] notes = Array.Empty(); } + + [Serializable] + public sealed class ProfileMemorySnapshotArgs + { + // 옵션 예약. CaptureFlags는 브릿지 고정값을 쓴다. + } + + [Serializable] + public sealed class ProfileMemorySnapshotPayload + { + public string snapshotId = string.Empty; + public string path = string.Empty; // 절대 경로. 파일은 전송하지 않는다. + public long sizeBytes; + public string captureFlags = string.Empty; + public long elapsedMs; + public string guidance = string.Empty; + } } 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 94c1301..15c9220 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 @@ -135,6 +135,7 @@ public static class ProtocolConstants public const string CommandProfileCaptureStop = "profile-capture-stop"; public const string CommandProfileStatus = "profile-status"; public const string CommandProfileMemory = "profile-memory"; + public const string CommandProfileMemorySnapshot = "profile-memory-snapshot"; public const string CommandEditorQuit = "editor-quit"; public const int DefaultQaWaitUntilTimeoutMs = 10_000; public const int DefaultQaSwipeDurationMs = 300; @@ -170,6 +171,8 @@ public static class ProtocolConstants public const string RecordSessionKeyDurationSeconds = "UCB.Record.durationSeconds"; public const string ProfilesDirectoryRelative = "Library/com.yhc509.unity-cli-bridge/profiles"; public const string MemoryReportsDirectoryRelative = "Library/com.yhc509.unity-cli-bridge/memory"; + public const string SnapshotsDirectoryRelative = "Library/com.yhc509.unity-cli-bridge/snapshots"; + public const int ProfileMemorySnapshotTimeoutSeconds = 300; public const int DefaultProfileMemoryFrames = 30; public const double DefaultProfileMemoryThresholdPercent = 5.0; public const int DefaultProfileMemoryCompareLimit = 10; From 55c7f03d0e77ce393c9d14aaf0cafec6bfa66622 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 09:51:07 +0900 Subject: [PATCH 26/33] feat(bridge): add profile-memory-snapshot with package gate and mutual single-flight Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XPBXJjiKtZk6DqEScTpN5D --- .../Editor/ProfileCommandHandler.Capture.cs | 7 + .../Editor/ProfileCommandHandler.Memory.cs | 153 ++++++++++++++++++ .../Editor/ProfileCommandHandler.cs | 18 ++- 3 files changed, 174 insertions(+), 4 deletions(-) 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 09700d1..71b427b 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 @@ -90,6 +90,13 @@ private static string StartCapture(ProfileCaptureStartArgs args) "A profile capture is already in progress. Stop it with `profile capture stop` first."); } + if (_snapshotInFlight) + { + throw new CommandFailureException( + ProtocolConstants.ErrorProfileInProgress, + "memory snapshot이 진행 중입니다. 끝난 뒤 capture를 시작하세요."); + } + _phase = CapturePhase.Capturing; } 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 ebcb70d..00c896f 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 @@ -182,5 +182,158 @@ private static void WriteMemorySidecar(ProfileMemoryPayload report) AtomicFileUtility.WriteAllText(path, ProtocolJson.Serialize(sidecar)); AtomicFileUtility.CleanupTempFiles(Path.GetDirectoryName(path)!); } + + // Guarded by _captureLock (defined in ProfileCommandHandler.Capture.cs) so that snapshot and + // capture reject each other instead of both driving the profiler at once. + private static bool _snapshotInFlight; + + private void StartSnapshotDeferred( + TaskCompletionSource completion, + string projectHash, + string requestId) + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + try + { + if (UnityEditor.PackageManager.PackageInfo.FindForAssetPath( + "Packages/com.unity.memoryprofiler/package.json") is null) + { + throw new CommandFailureException( + ProtocolConstants.ErrorProfileFailed, + "Memory Profiler 패키지가 설치되어 있지 않습니다. `unity-cli package add com.unity.memoryprofiler`로 설치한 뒤 다시 실행하세요."); + } + + lock (_captureLock) + { + if (_phase != CapturePhase.Idle) + { + throw new CommandFailureException( + ProtocolConstants.ErrorProfileInProgress, + "profile capture가 진행 중입니다. 캡처를 끝낸 뒤 snapshot을 실행하세요."); + } + + if (_snapshotInFlight) + { + throw new CommandFailureException( + ProtocolConstants.ErrorProfileInProgress, + "다른 memory snapshot이 진행 중입니다."); + } + + _snapshotInFlight = true; + } + } + catch (Exception exception) + { + completion.TrySetResult(CreateFailureResponse(requestId, projectHash, exception, stopwatch.ElapsedMilliseconds)); + return; + } + + string snapshotId = Guid.NewGuid().ToString("N"); + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string directory = Path.Combine( + projectRoot, + ProtocolConstants.SnapshotsDirectoryRelative.Replace('/', Path.DirectorySeparatorChar)); + string snapshotPath = Path.Combine(directory, snapshotId + ".snap"); + + const Unity.Profiling.Memory.CaptureFlags flags = + Unity.Profiling.Memory.CaptureFlags.ManagedObjects + | Unity.Profiling.Memory.CaptureFlags.NativeObjects + | Unity.Profiling.Memory.CaptureFlags.NativeAllocations; + + bool finished = false; + + void Finish(Func build) + { + if (finished) + { + return; + } + + finished = true; + lock (_captureLock) + { + _snapshotInFlight = false; + } + + completion.TrySetResult(build()); + } + + // TakeSnapshot 콜백이 영영 오지 않는 경우를 대비한 워치독. + void Watchdog() + { + if (finished) + { + EditorApplication.update -= Watchdog; + return; + } + + if (stopwatch.Elapsed.TotalSeconds < ProtocolConstants.ProfileMemorySnapshotTimeoutSeconds) + { + return; + } + + EditorApplication.update -= Watchdog; + Finish(() => ResponseEnvelope.Failure( + requestId, + projectHash, + ProtocolConstants.ErrorProfileTimeout, + $"memory snapshot이 {ProtocolConstants.ProfileMemorySnapshotTimeoutSeconds}초 안에 끝나지 않았습니다.", + true, + stopwatch.ElapsedMilliseconds, + ProtocolConstants.TransportLive)); + } + + EditorApplication.update += Watchdog; + + try + { + Directory.CreateDirectory(directory); + Unity.Profiling.Memory.MemoryProfiler.TakeSnapshot( + snapshotPath, + (resultPath, success) => + { + EditorApplication.update -= Watchdog; + if (!success) + { + Finish(() => ResponseEnvelope.Failure( + requestId, + projectHash, + ProtocolConstants.ErrorProfileFailed, + "MemoryProfiler.TakeSnapshot이 실패를 보고했습니다.", + false, + stopwatch.ElapsedMilliseconds, + ProtocolConstants.TransportLive)); + return; + } + + long sizeBytes = 0; + try + { + sizeBytes = new FileInfo(resultPath).Length; + } + catch (Exception) + { + // 메타 수집 실패는 스냅샷 성공을 뒤집지 않는다. + } + + var payload = new ProfileMemorySnapshotPayload + { + snapshotId = snapshotId, + path = resultPath, + sizeBytes = sizeBytes, + captureFlags = flags.ToString(), + elapsedMs = stopwatch.ElapsedMilliseconds, + guidance = "Memory Profiler 패키지(Window > Analysis > Memory Profiler)에서 이 .snap 파일을 여세요.", + }; + Finish(() => CreateSuccessResponse(requestId, projectHash, payload, stopwatch.ElapsedMilliseconds)); + }, + flags); + } + catch (Exception exception) + { + EditorApplication.update -= 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 4fd973b..cd4c811 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.cs @@ -17,20 +17,24 @@ public bool CanHandle(string command) || string.Equals(command, ProtocolConstants.CommandProfileCaptureStart, StringComparison.Ordinal) || string.Equals(command, ProtocolConstants.CommandProfileCaptureStop, StringComparison.Ordinal) || string.Equals(command, ProtocolConstants.CommandProfileStatus, StringComparison.Ordinal) - || string.Equals(command, ProtocolConstants.CommandProfileMemory, StringComparison.Ordinal); + || string.Equals(command, ProtocolConstants.CommandProfileMemory, StringComparison.Ordinal) + || string.Equals(command, ProtocolConstants.CommandProfileMemorySnapshot, StringComparison.Ordinal); } - // stats/memory wait N editor frames, so they must run deferred like qa wait-until. + // stats/memory wait N editor frames and snapshot runs on a TakeSnapshot callback, so they + // must run deferred like qa wait-until. public bool IsDeferred(string command, string? argumentsJson = null) { return string.Equals(command, ProtocolConstants.CommandProfileStats, StringComparison.Ordinal) - || string.Equals(command, ProtocolConstants.CommandProfileMemory, StringComparison.Ordinal); + || string.Equals(command, ProtocolConstants.CommandProfileMemory, StringComparison.Ordinal) + || string.Equals(command, ProtocolConstants.CommandProfileMemorySnapshot, StringComparison.Ordinal); } public string Handle(string command, string argumentsJson) { if (string.Equals(command, ProtocolConstants.CommandProfileStats, StringComparison.Ordinal) - || string.Equals(command, ProtocolConstants.CommandProfileMemory, StringComparison.Ordinal)) + || string.Equals(command, ProtocolConstants.CommandProfileMemory, StringComparison.Ordinal) + || string.Equals(command, ProtocolConstants.CommandProfileMemorySnapshot, StringComparison.Ordinal)) { throw new InvalidOperationException("Deferred profile command must be started through StartDeferred: " + command); } @@ -83,6 +87,12 @@ public void StartDeferred( return; } + if (string.Equals(command, ProtocolConstants.CommandProfileMemorySnapshot, StringComparison.Ordinal)) + { + StartSnapshotDeferred(completion, projectHash, requestId); + return; + } + throw new InvalidOperationException("Unhandled deferred profile command: " + command); } From 5e003141e819eca39140586b2effef9405b4e164 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 09:52:00 +0900 Subject: [PATCH 27/33] feat(cli): add profile memory snapshot command Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XPBXJjiKtZk6DqEScTpN5D --- cli/UnityCli.Cli/CliApp.cs | 10 ++++++++++ cli/UnityCli.Cli/Models/ParsedCommand.cs | 3 +++ .../Services/CliArgumentParser.Validation.cs | 1 + cli/UnityCli.Cli/Services/CliArgumentParser.cs | 3 +++ tests/UnityCli.Cli.Tests/ProfileParserTests.cs | 7 +++++++ 5 files changed, 24 insertions(+) diff --git a/cli/UnityCli.Cli/CliApp.cs b/cli/UnityCli.Cli/CliApp.cs index 30a9c58..d06b004 100644 --- a/cli/UnityCli.Cli/CliApp.cs +++ b/cli/UnityCli.Cli/CliApp.cs @@ -719,6 +719,16 @@ internal static int ResolveLiveTimeoutMs(ParsedCommand parsed) 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; } diff --git a/cli/UnityCli.Cli/Models/ParsedCommand.cs b/cli/UnityCli.Cli/Models/ParsedCommand.cs index 41d3632..a87f632 100644 --- a/cli/UnityCli.Cli/Models/ParsedCommand.cs +++ b/cli/UnityCli.Cli/Models/ParsedCommand.cs @@ -78,6 +78,7 @@ public enum CommandKind ProfileCompare, ProfileMemory, ProfileMemoryCompare, + ProfileMemorySnapshot, EditorLaunch, EditorStop, } @@ -347,6 +348,7 @@ public CommandEnvelope ToEnvelope() 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}"), }, @@ -454,6 +456,7 @@ private string BuildArgumentsJson() { frames = ProfileFrames ?? 0, }, + CommandKind.ProfileMemorySnapshot => new ProfileMemorySnapshotArgs(), CommandKind.EditorStop => new EditorQuitArgs { force = Force, diff --git a/cli/UnityCli.Cli/Services/CliArgumentParser.Validation.cs b/cli/UnityCli.Cli/Services/CliArgumentParser.Validation.cs index 57dc5ad..db9693b 100644 --- a/cli/UnityCli.Cli/Services/CliArgumentParser.Validation.cs +++ b/cli/UnityCli.Cli/Services/CliArgumentParser.Validation.cs @@ -676,6 +676,7 @@ internal static CliCommandDescriptor GetCatalogDescriptor(CommandKind kind) 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", diff --git a/cli/UnityCli.Cli/Services/CliArgumentParser.cs b/cli/UnityCli.Cli/Services/CliArgumentParser.cs index 0255a6b..c2134d4 100644 --- a/cli/UnityCli.Cli/Services/CliArgumentParser.cs +++ b/cli/UnityCli.Cli/Services/CliArgumentParser.cs @@ -469,6 +469,9 @@ private static ParsedCommand ParseProfile(Queue tokens) return parsed; } + case "snapshot": + return new ParsedCommand(CommandKind.ProfileMemorySnapshot); + default: throw new CliUsageException($"알 수 없는 profile memory 하위 명령입니다: {memorySub}"); } diff --git a/tests/UnityCli.Cli.Tests/ProfileParserTests.cs b/tests/UnityCli.Cli.Tests/ProfileParserTests.cs index 9970b98..902aeb6 100644 --- a/tests/UnityCli.Cli.Tests/ProfileParserTests.cs +++ b/tests/UnityCli.Cli.Tests/ProfileParserTests.cs @@ -197,4 +197,11 @@ public void Parse_ProfileMemory_UnknownSub_Throws() { Assert.Throws(() => CliArgumentParser.Parse(["profile", "memory", "bogus"])); } + + [Fact] + public void Parse_ProfileMemorySnapshot() + { + var parsed = CliArgumentParser.Parse(["profile", "memory", "snapshot"]); + Assert.Equal(CommandKind.ProfileMemorySnapshot, parsed.Kind); + } } From 3de67fb726308ecef352d1103a14ca26a6c77ac4 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 09:52:23 +0900 Subject: [PATCH 28/33] docs: regenerate cli-reference for profile memory snapshot Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XPBXJjiKtZk6DqEScTpN5D --- docs/cli-reference.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f107beb..1667354 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -150,6 +150,7 @@ Low-level commands for environment inspection and raw protocol debugging. | `profile compare` | `profile compare [--threshold (default: 5.0)] [--limit (default: 5)]` | local | `None` | Diffs two finished captures by reading both sidecar JSON files locally — no Editor round-trip. Returns a regression/improvement/unchanged verdict plus the frame-time, over-budget, GC, and per-marker deltas that explain it. | | `profile memory` | `profile memory [--frames (default: 30)]` | live | `None` | Samples memory profiler counters (total/GC/graphics plus per-asset-type count and memory) over N frames and writes the report to a local sidecar for later comparison. Works in Edit Mode and Play Mode. | | `profile memory compare` | `profile memory compare [--threshold (default: 5.0)] [--limit (default: 10)]` | local | `None` | Diffs two memory reports by reading both sidecar JSON files locally — no Editor round-trip. Returns a regression/improvement/unchanged verdict plus the per-counter byte/count deltas that explain it. | +| `profile memory snapshot` | `profile memory snapshot` | live | `None` | Takes a full memory snapshot via MemoryProfiler.TakeSnapshot and saves it under Library/com.yhc509.unity-cli-bridge/snapshots/.snap, returning only the path and metadata. Analyze the .snap in the Memory Profiler package GUI. | | `test list` | `test list [--mode ] [--no-detail]` | live | `None` | Lists EditMode and/or PlayMode test cases discovered in the running editor; add --no-detail to return only fullName and mode. | | `test run` | `test run --mode [--filter ] [--category ] [--assembly ] [--no-domain-reload] [--failures-only] [--timeout ] [--wait]` | live | `None` | Executes EditMode or PlayMode tests. EditMode returns synchronously; PlayMode returns runId immediately and persists results to Library/com.yhc509.unity-cli-bridge/test-runs/.json. Add --failures-only to trim tests[] to non-passed entries while preserving summary counts. | | `test results` | `test results [--run-id ] [--failures-only]` | live | `None` | Retrieves cached test run result (or in-progress status). Without --run-id, returns the last run. Add --failures-only to trim tests[] to non-passed entries while preserving summary counts. | From 3ae3e34b26ea5b6192e3f5d09d676298dc12b1f1 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 09:54:55 +0900 Subject: [PATCH 29/33] chore: add Unity-generated meta for ProfileCommandHandler.Memory --- .../Editor/ProfileCommandHandler.Memory.cs.meta | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Memory.cs.meta diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Memory.cs.meta b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Memory.cs.meta new file mode 100644 index 0000000..440ebe0 --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Memory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: eced3baa3335944ce94d1beb809fd7d7 \ No newline at end of file From 6e6983affabb03c269d29914a07843363c777cb5 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 09:54:55 +0900 Subject: [PATCH 30/33] fix(bridge): drop GameObject Count counter unavailable on Unity 6000.3 --- .../Editor/ProfileCommandHandler.Memory.cs | 1 - 1 file changed, 1 deletion(-) 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 00c896f..e20bae2 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 @@ -34,7 +34,6 @@ internal sealed partial class ProfileCommandHandler new ProfileCounterSpec(ProfilerCategory.Memory, "AnimationClip Count", "count"), new ProfileCounterSpec(ProfilerCategory.Memory, "AnimationClip Memory", "bytes"), new ProfileCounterSpec(ProfilerCategory.Memory, "Asset Count", "count"), - new ProfileCounterSpec(ProfilerCategory.Memory, "GameObject Count", "count"), new ProfileCounterSpec(ProfilerCategory.Memory, "Scene Object Count", "count"), new ProfileCounterSpec(ProfilerCategory.Memory, "Object Count", "count"), }; From 61c0dc3e61b17363ed379d7914b8789855db24b6 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 09:57:59 +0900 Subject: [PATCH 31/33] fix(bridge): correct package install syntax in memory profiler gate message --- .../Editor/ProfileCommandHandler.Memory.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e20bae2..b9ebe9e 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 @@ -199,7 +199,7 @@ private void StartSnapshotDeferred( { throw new CommandFailureException( ProtocolConstants.ErrorProfileFailed, - "Memory Profiler 패키지가 설치되어 있지 않습니다. `unity-cli package add com.unity.memoryprofiler`로 설치한 뒤 다시 실행하세요."); + "Memory Profiler 패키지가 설치되어 있지 않습니다. `unity-cli package add --name com.unity.memoryprofiler`로 설치한 뒤 다시 실행하세요."); } lock (_captureLock) From cd40d592ea3470bd9fdc6b13ed7a7f02d2543d6f Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 10:00:41 +0900 Subject: [PATCH 32/33] docs: sync profile memory commands and headless regression pipeline workflow --- CHANGELOG.md | 2 + CLAUDE.md | 1 + README.md | 11 ++++ tools/skills/unity-cli-operator/SKILL.md | 10 ++++ .../references/profiling.md | 53 ++++++++++++++++++- .../com.yhc509.unity-cli-bridge/CHANGELOG.md | 13 +++++ .../SkillTemplates~/SKILL.md | 10 ++++ .../SkillTemplates~/references/profiling.md | 53 ++++++++++++++++++- 8 files changed, 151 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4ebc90..ab5f792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 ` 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. diff --git a/CLAUDE.md b/CLAUDE.md index 2867155..5b45d32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` 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/.json` sidecar, stop 후 EditorApplication.update 청크 walk로 생성). `profile analyze`는 CLI 로컬로 sidecar만 읽는다(Editor 불필요). `profile compare `도 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/.json` sidecar를 남기는 wire 명령이다. 카운터 이름이 해당 Unity 버전에 없으면 실패가 아니라 `unavailable` 배열로 강등된다(6000.3 실측: `GameObject Count`만 미지원이라 목록에서 제거함). `profile memory compare `는 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/.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` diff --git a/README.md b/README.md index 37bbcaa..5ca81b5 100644 --- a/README.md +++ b/README.md @@ -343,10 +343,21 @@ unity-cli profile capture stop --wait unity-cli profile analyze --gc unity-cli profile compare --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 --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 ` drills into the finished capture's sidecar locally with `--marker `, `--frame `, `--gc`, or `--spikes`, with no Editor round-trip required. `profile compare ` 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 ` (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 ` 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 diff --git a/tools/skills/unity-cli-operator/SKILL.md b/tools/skills/unity-cli-operator/SKILL.md index c526be5..ec356cd 100644 --- a/tools/skills/unity-cli-operator/SKILL.md +++ b/tools/skills/unity-cli-operator/SKILL.md @@ -127,6 +127,16 @@ unity-cli does not have dedicated script create/delete commands. Use this combin - budget/Unity 버전/프레임 수가 다르면 `notes`에 경고가 붙으므로 그 경우 결과를 그대로 신뢰하지 않는다. 6. Edit Mode에서 카운터만 빠르게 보고 싶으면 `unity-cli profile stats --frames 30 --preset memory`처럼 프리셋(`frame`/`render`/`gc`/`memory`/`all`)을 사용한다. +메모리 릭이 의심되면 프레임 시간 루프 대신 메모리 루프를 쓴다: + +1. `unity-cli profile memory`로 baseline reportId를 만든다. 의심 플로우를 재현한 뒤 다시 실행해 head reportId를 만든다. 반드시 **같은 모드끼리**(둘 다 playmode 또는 둘 다 editmode) 만든다. +2. `unity-cli profile memory compare `로 비교한다. verdict는 `Total Used Memory` median 기준이고(`--threshold`, 기본 5%), `increases`가 어떤 카운터가 늘었는지 알려준다. Editor가 꺼져 있어도 동작한다. +3. Count와 Memory가 함께 오르는 asset-type(예: `Texture Count` + `Texture Memory`)이 릭의 1차 후보다. `GC Used Memory` 상승은 managed 객체 잔존, `GC Reserved Memory`만 상승은 힙 확장이므로 구분한다. `Total Reserved`/`System Used`는 릭 판정에 쓰지 않는다. +4. 추세가 나쁠 때만 `unity-cli profile memory snapshot`으로 `.snap`을 뜬다. `com.unity.memoryprofiler`가 필요하고(없으면 설치 안내와 함께 거부), profile capture와 동시 실행되지 않으며, 분석은 Unity의 Memory Profiler GUI에서 한다 — CLI는 `.snap`을 파싱하지 않는다. 파일이 1GB를 넘고 자동 삭제되지 않으니 다 쓰면 지운다. +5. `notes`에 mode/Unity 버전/프레임 수 불일치 경고가 있으면 결과를 그대로 신뢰하지 않는다. `deltaPercentAvailable`이 false면 퍼센트를 무시하고 절대 `delta`만 본다. + +에디터를 직접 띄워 무인으로 회귀를 돌리려면 `editor launch`(기본 headless) → `play` → 캡처/메모리 → `stop` → `editor stop` → 로컬 `compare` 순서를 쓴다. 전체 파이프라인과 도메인 리로드 시 `LIVE_UNAVAILABLE` 재시도 규칙은 [references/profiling.md](references/profiling.md)의 `headless 회귀 파이프라인` 절에 있다. + **수치를 해석하기 전에 [references/profiling.md](references/profiling.md)를 읽는다.** 특히: - 이 수치는 Editor 안에서 잰 것이라 절대값을 출시 성능으로 보고하면 안 된다 — 상대 비교 전용이다. - hotspot 1위가 `Semaphore.WaitForSignal` / `WaitForTargetFPS` / `EditorLoop` / `Gfx.WaitFor*`면 그건 원인이 아니라 대기이거나 Editor 오버헤드다. 흔한 정상 상황이다. diff --git a/tools/skills/unity-cli-operator/references/profiling.md b/tools/skills/unity-cli-operator/references/profiling.md index d2977eb..a36f424 100644 --- a/tools/skills/unity-cli-operator/references/profiling.md +++ b/tools/skills/unity-cli-operator/references/profiling.md @@ -210,7 +210,58 @@ Editor 캡처에서만 존재하는 카운터: Texture/Mesh/Material/AnimationCl 반대로 **여전히 유효한** 할당 원인: 문자열 연결/보간, boxing(Unity GC는 generational이 아니라 더 아프다), hot path의 LINQ, 상태를 캡처하는 클로저/람다, Unity API가 반환하는 배열(매번 새 복사본), `new WaitForSeconds`, `params` 배열. -## 11. 출처 +## 11. 메모리 릭 추세 감시 (`profile memory`) + +프레임 시간이 아니라 **메모리**를 볼 때 쓴다. 스냅샷 없이 카운터 median만으로 추세를 잡는 경량 경로이고, Unity 스태프가 권하는 순서(카운터 추세 → 필요할 때만 스냅샷)를 그대로 따른다. + +```bash +unity-cli profile memory # baseline reportId +# 의심 플로우 재현 (플레이, 씬 전환 반복, 시간 경과) +unity-cli profile memory # head reportId +unity-cli profile memory compare +``` + +읽는 법: + +- **verdict는 `Total Used Memory` median 하나로 정해진다.** `--threshold`(기본 5%)를 넘게 늘면 `regression`, 그만큼 줄면 `improvement`. 나머지 카운터는 *왜* 그런지 설명하는 재료다. +- `increases`에서 **Count와 Memory가 같이 오르는 asset-type**을 먼저 봐라. `Texture Count` + `Texture Memory` 동반 상승 = 텍스처가 해제되지 않는다는 뜻이다. Memory만 오르고 Count가 그대로면 개별 에셋이 커진 것(해상도·포맷 변경)이다. +- `GC Used Memory` 상승은 managed 객체가 남아 있다는 뜻이고, `GC Reserved Memory`만 오르는 건 힙이 확장된 것으로 릭이 아닐 수 있다. 둘을 구분해라. +- **`Total Reserved`/`System Used`는 릭 판정에 쓰지 마라.** 예약 메모리는 반환되지 않은 채 유지되는 게 정상이다. +- 같은 모드끼리 비교해라. editmode ↔ playmode 비교는 Play 진입 자체가 수백 MB를 움직이므로 의미가 없고, 그 경우 `notes`에 mode 불일치 경고가 붙는다 — 경고가 보이면 결과를 신뢰하지 마라. +- `deltaPercentAvailable`이 false면 퍼센트는 무시하고 절대 `delta`만 봐라. verdict도 그때는 `unchanged`로 고정된다. +- `unavailable` 배열은 그 Unity 버전에 없는 카운터일 뿐 실패가 아니다. 다만 **`Total Used Memory`가 거기 있으면 verdict가 무의미**하므로 그때는 개별 카운터로만 판단해라. +- Editor 수치라는 §0의 한계가 여기에도 그대로 적용된다. 특히 Editor는 텍스처를 강제 read/write하므로 텍스처 메모리는 부풀려져 있다 — 절대값이 아니라 변화량만 봐라. + +추세가 나쁠 때만 정밀 단계로 간다: + +```bash +unity-cli profile memory snapshot # → .snap 경로 +``` + +`com.unity.memoryprofiler`가 필요하고(없으면 설치 안내와 함께 거부), profile capture와 동시에 실행되지 않는다. 분석은 **Window > Analysis > Memory Profiler**에서 하고, CLI는 `.snap`을 파싱하지 않는다. 파일은 에디터 메모리만큼 커지며(1GB 초과가 흔하다) 자동 삭제되지 않으니 다 쓰면 지워라. + +## 12. headless 회귀 파이프라인 + +아래는 전부 사람 개입 없이 돈다 — GUI 에디터도, 포커스도 필요 없다. + +```bash +unity-cli editor launch --project # 기본 headless (GPU는 살아 있음) +unity-cli play +unity-cli profile capture start --duration 10 +unity-cli profile capture stop --wait # → head captureId +unity-cli profile memory # → head reportId +unity-cli stop +unity-cli editor stop +# 여기서부터는 Editor 없이 로컬 연산: +unity-cli profile compare +unity-cli profile memory compare +``` + +known-good 실행의 captureId/reportId를 baseline으로 보관해라. sidecar는 `Library/com.yhc509.unity-cli-bridge/` 아래에 남아 에디터를 껐다 켜도 유지된다. + +주의: Play Mode 진입과 패키지 설치는 도메인 리로드를 일으켜 IPC 소켓을 잠깐 끊는다. 그 직후 명령이 `LIVE_UNAVAILABLE`(retryable)로 실패하면 몇 초 뒤 재시도해라 — 실패가 아니라 리로드 중이라는 뜻이다. + +## 13. 출처 전부 Unity 공식(primary): diff --git a/unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md b/unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md index 7ffc020..ab5f792 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md +++ b/unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- `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 ` 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 ` 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. + +### Compatibility +- The wire protocol is bumped to `7` (adds the graceful-quit command behind `editor stop`). As usual, installed CLIs dispatch per version automatically, and the CLI matching this package ships in the same release. + ## [0.5.1] - 2026-07-29 ### Added 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 dc7186d..b47c1a5 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/SKILL.md +++ b/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/SKILL.md @@ -104,6 +104,16 @@ unity-cli does not have dedicated script create/delete commands. Use this combin - budget/Unity 버전/프레임 수가 다르면 `notes`에 경고가 붙으므로 그 경우 결과를 그대로 신뢰하지 않는다. 6. Edit Mode에서 카운터만 빠르게 보고 싶으면 `unity-cli profile stats --frames 30 --preset memory`처럼 프리셋(`frame`/`render`/`gc`/`memory`/`all`)을 사용한다. +메모리 릭이 의심되면 프레임 시간 루프 대신 메모리 루프를 쓴다: + +1. `unity-cli profile memory`로 baseline reportId를 만든다. 의심 플로우를 재현한 뒤 다시 실행해 head reportId를 만든다. 반드시 **같은 모드끼리**(둘 다 playmode 또는 둘 다 editmode) 만든다. +2. `unity-cli profile memory compare `로 비교한다. verdict는 `Total Used Memory` median 기준이고(`--threshold`, 기본 5%), `increases`가 어떤 카운터가 늘었는지 알려준다. Editor가 꺼져 있어도 동작한다. +3. Count와 Memory가 함께 오르는 asset-type(예: `Texture Count` + `Texture Memory`)이 릭의 1차 후보다. `GC Used Memory` 상승은 managed 객체 잔존, `GC Reserved Memory`만 상승은 힙 확장이므로 구분한다. `Total Reserved`/`System Used`는 릭 판정에 쓰지 않는다. +4. 추세가 나쁠 때만 `unity-cli profile memory snapshot`으로 `.snap`을 뜬다. `com.unity.memoryprofiler`가 필요하고(없으면 설치 안내와 함께 거부), profile capture와 동시 실행되지 않으며, 분석은 Unity의 Memory Profiler GUI에서 한다 — CLI는 `.snap`을 파싱하지 않는다. 파일이 1GB를 넘고 자동 삭제되지 않으니 다 쓰면 지운다. +5. `notes`에 mode/Unity 버전/프레임 수 불일치 경고가 있으면 결과를 그대로 신뢰하지 않는다. `deltaPercentAvailable`이 false면 퍼센트를 무시하고 절대 `delta`만 본다. + +에디터를 직접 띄워 무인으로 회귀를 돌리려면 `editor launch`(기본 headless) → `play` → 캡처/메모리 → `stop` → `editor stop` → 로컬 `compare` 순서를 쓴다. 전체 파이프라인과 도메인 리로드 시 `LIVE_UNAVAILABLE` 재시도 규칙은 [references/profiling.md](references/profiling.md)의 `headless 회귀 파이프라인` 절에 있다. + **수치를 해석하기 전에 [references/profiling.md](references/profiling.md)를 읽는다.** 특히: - 이 수치는 Editor 안에서 잰 것이라 절대값을 출시 성능으로 보고하면 안 된다 — 상대 비교 전용이다. - hotspot 1위가 `Semaphore.WaitForSignal` / `WaitForTargetFPS` / `EditorLoop` / `Gfx.WaitFor*`면 그건 원인이 아니라 대기이거나 Editor 오버헤드다. 흔한 정상 상황이다. diff --git a/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/references/profiling.md b/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/references/profiling.md index d2977eb..a36f424 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/references/profiling.md +++ b/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/references/profiling.md @@ -210,7 +210,58 @@ Editor 캡처에서만 존재하는 카운터: Texture/Mesh/Material/AnimationCl 반대로 **여전히 유효한** 할당 원인: 문자열 연결/보간, boxing(Unity GC는 generational이 아니라 더 아프다), hot path의 LINQ, 상태를 캡처하는 클로저/람다, Unity API가 반환하는 배열(매번 새 복사본), `new WaitForSeconds`, `params` 배열. -## 11. 출처 +## 11. 메모리 릭 추세 감시 (`profile memory`) + +프레임 시간이 아니라 **메모리**를 볼 때 쓴다. 스냅샷 없이 카운터 median만으로 추세를 잡는 경량 경로이고, Unity 스태프가 권하는 순서(카운터 추세 → 필요할 때만 스냅샷)를 그대로 따른다. + +```bash +unity-cli profile memory # baseline reportId +# 의심 플로우 재현 (플레이, 씬 전환 반복, 시간 경과) +unity-cli profile memory # head reportId +unity-cli profile memory compare +``` + +읽는 법: + +- **verdict는 `Total Used Memory` median 하나로 정해진다.** `--threshold`(기본 5%)를 넘게 늘면 `regression`, 그만큼 줄면 `improvement`. 나머지 카운터는 *왜* 그런지 설명하는 재료다. +- `increases`에서 **Count와 Memory가 같이 오르는 asset-type**을 먼저 봐라. `Texture Count` + `Texture Memory` 동반 상승 = 텍스처가 해제되지 않는다는 뜻이다. Memory만 오르고 Count가 그대로면 개별 에셋이 커진 것(해상도·포맷 변경)이다. +- `GC Used Memory` 상승은 managed 객체가 남아 있다는 뜻이고, `GC Reserved Memory`만 오르는 건 힙이 확장된 것으로 릭이 아닐 수 있다. 둘을 구분해라. +- **`Total Reserved`/`System Used`는 릭 판정에 쓰지 마라.** 예약 메모리는 반환되지 않은 채 유지되는 게 정상이다. +- 같은 모드끼리 비교해라. editmode ↔ playmode 비교는 Play 진입 자체가 수백 MB를 움직이므로 의미가 없고, 그 경우 `notes`에 mode 불일치 경고가 붙는다 — 경고가 보이면 결과를 신뢰하지 마라. +- `deltaPercentAvailable`이 false면 퍼센트는 무시하고 절대 `delta`만 봐라. verdict도 그때는 `unchanged`로 고정된다. +- `unavailable` 배열은 그 Unity 버전에 없는 카운터일 뿐 실패가 아니다. 다만 **`Total Used Memory`가 거기 있으면 verdict가 무의미**하므로 그때는 개별 카운터로만 판단해라. +- Editor 수치라는 §0의 한계가 여기에도 그대로 적용된다. 특히 Editor는 텍스처를 강제 read/write하므로 텍스처 메모리는 부풀려져 있다 — 절대값이 아니라 변화량만 봐라. + +추세가 나쁠 때만 정밀 단계로 간다: + +```bash +unity-cli profile memory snapshot # → .snap 경로 +``` + +`com.unity.memoryprofiler`가 필요하고(없으면 설치 안내와 함께 거부), profile capture와 동시에 실행되지 않는다. 분석은 **Window > Analysis > Memory Profiler**에서 하고, CLI는 `.snap`을 파싱하지 않는다. 파일은 에디터 메모리만큼 커지며(1GB 초과가 흔하다) 자동 삭제되지 않으니 다 쓰면 지워라. + +## 12. headless 회귀 파이프라인 + +아래는 전부 사람 개입 없이 돈다 — GUI 에디터도, 포커스도 필요 없다. + +```bash +unity-cli editor launch --project # 기본 headless (GPU는 살아 있음) +unity-cli play +unity-cli profile capture start --duration 10 +unity-cli profile capture stop --wait # → head captureId +unity-cli profile memory # → head reportId +unity-cli stop +unity-cli editor stop +# 여기서부터는 Editor 없이 로컬 연산: +unity-cli profile compare +unity-cli profile memory compare +``` + +known-good 실행의 captureId/reportId를 baseline으로 보관해라. sidecar는 `Library/com.yhc509.unity-cli-bridge/` 아래에 남아 에디터를 껐다 켜도 유지된다. + +주의: Play Mode 진입과 패키지 설치는 도메인 리로드를 일으켜 IPC 소켓을 잠깐 끊는다. 그 직후 명령이 `LIVE_UNAVAILABLE`(retryable)로 실패하면 몇 초 뒤 재시도해라 — 실패가 아니라 리로드 중이라는 뜻이다. + +## 13. 출처 전부 Unity 공식(primary): From b0660e38fade0fb32fafe388774416c308df2350 Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 10:14:05 +0900 Subject: [PATCH 33/33] chore: release v0.5.2 --- CHANGELOG.md | 2 ++ unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md | 2 ++ unity-package/com.yhc509.unity-cli-bridge/package.json | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab5f792..b514538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.5.2] - 2026-08-13 + ### Added - `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 ` 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`). diff --git a/unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md b/unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md index ab5f792..b514538 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md +++ b/unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.5.2] - 2026-08-13 + ### Added - `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 ` 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`). diff --git a/unity-package/com.yhc509.unity-cli-bridge/package.json b/unity-package/com.yhc509.unity-cli-bridge/package.json index 8d5af8c..0d983d8 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/package.json +++ b/unity-package/com.yhc509.unity-cli-bridge/package.json @@ -1,7 +1,7 @@ { "name": "com.yhc509.unity-cli-bridge", "displayName": "Unity CLI Bridge", - "version": "0.5.1", + "version": "0.5.2", "unity": "2023.1", "description": "Project-aware Unity Editor bridge for CLI control without manual servers or per-project ports.", "author": {