From fd28a8ab930a2afc2e5c9783de4cc00284d599bb Mon Sep 17 00:00:00 2001 From: yhc5091 Date: Wed, 12 Aug 2026 09:38:37 +0900 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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 09/13] 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 10/13] 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 11/13] 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 12/13] 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 13/13] 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):