diff --git a/.claude/skills/unity-editor/SKILL.md b/.claude/skills/unity-editor/SKILL.md index 6baaf6b..722a034 100644 --- a/.claude/skills/unity-editor/SKILL.md +++ b/.claude/skills/unity-editor/SKILL.md @@ -57,6 +57,13 @@ unityctl screenshot list-windows # List open editor windows (type, title unityctl screenshot window # Capture specific editor window by type or title unityctl screenshot window SceneView out.png # e.g. capture Scene view to out.png +# Profiling (frame stats, spikes, hierarchy drill, microbench, regression gates) +# Read .claude/skills/unity-editor/profiling.md for the full surface and workflows. +# Quick reference: `unityctl profile vitals --duration 3` (5-number report), +# `unityctl profile capture --duration 10 -t 120` (full summary with topFrames + drivers), +# `unityctl profile mark "" --repeat N` (microbench an expression). +unityctl profile --help # List subcommands + # Video Recording (requires com.unity.recorder package) # Note: record start auto-enters play mode if not already playing unityctl record start # Start recording (manual stop) diff --git a/.claude/skills/unity-editor/profiling.md b/.claude/skills/unity-editor/profiling.md new file mode 100644 index 0000000..2f6c914 --- /dev/null +++ b/.claude/skills/unity-editor/profiling.md @@ -0,0 +1,142 @@ +# unityctl profile — Unity profiler from the CLI + +Capture frame stats, find spikes, drill into hierarchies, microbenchmark expressions. +Read this when the user mentions performance, FPS, hitches, GC, draw calls, profiling, frame budget, or asks "what's slow". + +## Lifecycle + +Sessions are start → run scenario → stop. The buffer survives after stop, so `explain`/`hotspots`/`frame` can drill into captured frames until the next session clears it. + +```bash +# One-shot (most common) +unityctl profile vitals --duration 3 # Curated 5-number report +unityctl profile capture --duration 5 # Full summary + topFrames + drivers +unityctl profile capture --duration 5 --save run.data # Also save .data for the Profiler window + +# Manual session +unityctl profile start --stats main,gpu,drawcalls,gc-alloc --max-duration 30 # Returns sessionId +unityctl profile stop # Returns summary JSON + +# CI gate (non-zero exit on threshold breach) +unityctl profile assert --p99-frame-ms 33 --gc-alloc-per-frame 1024 +``` + +**Always profile in play mode** unless you explicitly want editor-tick stats. Edit-mode samples editor update frequency, which doesn't reflect game performance. + +## Capture output: topFrames + +`profile capture` and `profile vitals` always include a `topFrames` array — the worst frames in the window with top-3 driver markers attached, each carrying a `hot` field that points at the descendant with the highest self time inside the driver's subtree. **This is the spike-detection primary output**. No need to dump per-frame samples, chain `explain` calls, or drill with `profile frame` for the typical "what's slow" question. + +Ranking metric depends on what's available: +- **In play mode** (PlayerLoop in hierarchy): ranked by `playerLoopMs` — gameplay-only time, ignores editor IMGUI repaint variance. Drivers descend from inside PlayerLoop. +- **Otherwise**: ranked by `cpuMainMs` (full main thread). Drivers descend from frame root. + +Both `cpuMainMs` and `playerLoopMs` are populated when available, so consumers can rerank or read either signal. + +`hitches` (frames over an absolute total-frame-time threshold) is separate — useful for CI gates, but blind to CPU spikes when total frame time is vsync-capped (Android/iOS clamp at 16/33 ms regardless of CPU time). + +## Drilling a spike + +Pick an `absoluteFrameIndex` from `topFrames` (or any frame in the buffer): + +```bash +unityctl profile explain --top 15 # Flat top-N markers by self time + GC +unityctl profile frame --depth 3 # Hierarchy *tree*, self-pruned +unityctl profile frame --depth 4 --root PlayerLoop # Tree scoped to gameplay subtree +``` + +Use `frame --root PlayerLoop` in editor+play to skip past the EditorLoop / Application.Tick wrapping that otherwise dominates the tree. + +## Hotspots aggregate + +```bash +unityctl profile hotspots --top 20 # Across the whole captured buffer +unityctl profile hotspots --top 20 --root PlayerLoop # Gameplay-only — essential in editor+play mode +``` + +In editor+play, the unfiltered `hotspots` is dominated by `OnGUI` / `IMGUIContainer` / `EditorApplication.update:*` (editor menu polling) which can swamp game work by 40×. Always pass `--root PlayerLoop` when you care about game perf. + +## Microbenchmark a single expression + +`profile mark` wraps an expression in a `ProfilerMarker` + Stopwatch + per-thread GC accounting and runs it N times. No capture session needed. + +```bash +unityctl profile mark "GameObject.FindObjectsByType(FindObjectsSortMode.None)" --repeat 100 +# → mean / p50 / p95 / min / max ms + gcBytesPerCall +``` + +Useful for "is this hot path actually slow?" without the start/wait/stop dance. + +## Stat aliases + +`profile start --stats` accepts these aliases (resolved server-side): + +| Alias | Resolves to | +|---|---| +| `main` | CPU Main Thread Frame Time | +| `render` | CPU Render Thread Frame Time | +| `gpu` | GPU Frame Time | +| `total-frame` | CPU Total Frame Time | +| `drawcalls` | Draw Calls Count | +| `setpass` | SetPass Calls Count | +| `batches` | Batches Count | +| `triangles` / `tris` | Triangles Count | +| `gc-alloc` | GC Allocated In Frame | +| `gc-used` / `gc-reserved` | GC Used Memory / GC Reserved Memory | +| `system-memory` | System Used Memory | + +Default vitals stats: main, render, gpu, drawcalls, gc-alloc, system-memory. List all available counters with `unityctl profile list-stats [--category Render]`. + +## Memory snapshot + +```bash +unityctl profile snapshot --output mem.snap # Requires com.unity.memoryprofiler package +``` + +## Remote / Android profiling + +```bash +unityctl profile targets # List editor + connected players +unityctl profile connect 127.0.0.1:54999 # Direct-URL connect (Android via adb forward) +``` + +When the editor's profiler is connected to a remote target (autoconnect-profiler dev build, or via `connect`), captures automatically come from the remote process. The summary's `targetIsRemote: true` flags this — the human output also prints `target: REMOTE — `. + +Remote captures use the same code path as local (post-hoc `RawFrameDataView` walk), so `topFrames` / `explain` / `frame` / `hotspots` all work. + +## Typical workflows + +### "Where are my spikes?" + +```bash +unityctl play enter +sleep 2 # let it settle +unityctl profile capture --duration 10 -t 120 --json +# Inspect topFrames[] — pick worst absoluteFrameIndex +unityctl profile frame --depth 4 --root PlayerLoop +unityctl play exit +``` + +### "What's hot on average?" + +```bash +unityctl profile capture --duration 10 -t 120 +unityctl profile hotspots --top 20 --root PlayerLoop +``` + +### "Is my new code slow?" + +```bash +unityctl profile mark "MySystem.DoExpensiveThing()" --repeat 200 +# Compare mean / p95 / gcBytesPerCall before/after +``` + +### "Did this PR regress perf?" + +```bash +unityctl play enter +sleep 2 +unityctl profile assert --p99-frame-ms 33 --gc-alloc-per-frame 4096 --duration 10 -t 120 +unityctl play exit +# Exit 1 on breach — wire into CI +``` diff --git a/UnityCtl.Cli/ProfileCommands.cs b/UnityCtl.Cli/ProfileCommands.cs new file mode 100644 index 0000000..10b510f --- /dev/null +++ b/UnityCtl.Cli/ProfileCommands.cs @@ -0,0 +1,994 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using UnityCtl.Protocol; + +namespace UnityCtl.Cli; + +public static class ProfileCommands +{ + public static Command CreateCommand() + { + var profileCommand = new Command("profile", "Profiler operations: capture frame stats, detect hitches, save full profiler data"); + + profileCommand.AddCommand(BuildListStatsCommand()); + profileCommand.AddCommand(BuildStartCommand()); + profileCommand.AddCommand(BuildStopCommand()); + profileCommand.AddCommand(BuildStatusCommand()); + profileCommand.AddCommand(BuildCaptureCommand()); + profileCommand.AddCommand(BuildVitalsCommand()); + profileCommand.AddCommand(BuildAssertCommand()); + profileCommand.AddCommand(BuildSnapshotCommand()); + profileCommand.AddCommand(BuildTargetsCommand()); + profileCommand.AddCommand(BuildConnectCommand()); + profileCommand.AddCommand(BuildExplainCommand()); + profileCommand.AddCommand(BuildHotspotsCommand()); + profileCommand.AddCommand(BuildFrameCommand()); + profileCommand.AddCommand(BuildMarkCommand()); + + return profileCommand; + } + + // ---------- list-stats ---------- + + private static Command BuildListStatsCommand() + { + var cmd = new Command("list-stats", "List available profiler stats (counters)"); + var category = new Option("--category", "Filter by category (e.g., Render, Memory, Internal)"); + cmd.AddOption(category); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var args = new Dictionary + { + { "category", ctx.ParseResult.GetValueForOption(category) } + }; + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileListStats, args, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; + return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + + Console.WriteLine($"{result.Count} stats available"); + string? lastCategory = null; + foreach (var s in result.Stats) + { + if (s.Category != lastCategory) + { + Console.WriteLine(); + Console.WriteLine($"[{s.Category}]"); + lastCategory = s.Category; + } + Console.WriteLine($" {s.Name,-40} {s.Unit} ({s.DataType})"); + } + }); + + return cmd; + } + + // ---------- start ---------- + + private static Command BuildStartCommand() + { + var cmd = new Command("start", "Start a profiling session (returns sessionId)"); + var stats = new Option("--stats", + "Comma-separated stats or aliases (e.g., main,gpu,drawcalls,gc-alloc). Default: vitals."); + var maxDuration = new Option("--max-duration", + "Auto-stop after N seconds if not explicitly stopped (safety cap)"); + var target = new Option("--target", + "Target id from 'profile targets' (default: editor)"); + var savePath = new Option("--save", + "Also drive the Editor profiler buffer and save a .data file (Phase 2)"); + cmd.AddOption(stats); + cmd.AddOption(maxDuration); + cmd.AddOption(target); + cmd.AddOption(savePath); + + cmd.SetHandler(async (InvocationContext ctx) => + { + await HandleStart(ctx, stats, maxDuration, target, savePath); + }); + return cmd; + } + + private static async Task HandleStart( + InvocationContext ctx, + Option statsOpt, + Option maxOpt, + Option targetOpt, + Option saveOpt) + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var statsRaw = ctx.ParseResult.GetValueForOption(statsOpt); + string[]? statsList = null; + if (!string.IsNullOrWhiteSpace(statsRaw)) + statsList = statsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + + var args = new Dictionary + { + { "stats", statsList }, + { "maxDurationSeconds", ctx.ParseResult.GetValueForOption(maxOpt) }, + { "target", ctx.ParseResult.GetValueForOption(targetOpt) }, + { "savePath", ResolveSavePathForUnity(ctx, ctx.ParseResult.GetValueForOption(saveOpt)) } + }; + + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileStart, args, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; + return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + Console.WriteLine($"Started profiling session {result.SessionId}"); + Console.WriteLine($" stats: {string.Join(", ", result.Stats)}"); + if (result.MaxDurationSeconds.HasValue) + Console.WriteLine($" max-duration: {result.MaxDurationSeconds}s (auto-stop)"); + if (!string.IsNullOrEmpty(result.SavePath)) + Console.WriteLine($" save: {result.SavePath}"); + } + + // ---------- stop ---------- + + private static Command BuildStopCommand() + { + var cmd = new Command("stop", "Stop a profiling session and return the summary"); + var sessionArg = new Argument("session-id", "Session id from 'profile start'"); + var includeSamples = new Option("--include-samples", "Include per-frame sample arrays in output"); + var hitchMultiplier = new Option("--hitch-multiplier", "Hitch threshold = median × this (default 2.0)"); + var hitchAbsoluteMs = new Option("--hitch-ms", "Absolute hitch threshold in ms (overrides multiplier)"); + + cmd.AddArgument(sessionArg); + cmd.AddOption(includeSamples); + cmd.AddOption(hitchMultiplier); + cmd.AddOption(hitchAbsoluteMs); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var args = new Dictionary + { + { "sessionId", ctx.ParseResult.GetValueForArgument(sessionArg) }, + { "includeSamples", ctx.ParseResult.GetValueForOption(includeSamples) }, + { "hitchMultiplier", ctx.ParseResult.GetValueForOption(hitchMultiplier) }, + { "hitchAbsoluteMs", ctx.ParseResult.GetValueForOption(hitchAbsoluteMs) } + }; + + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileStop, args, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; + return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + PrintStopHuman(result); + }); + + return cmd; + } + + // ---------- status ---------- + + private static Command BuildStatusCommand() + { + var cmd = new Command("status", "List active profiling sessions"); + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileStatus, null, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; + return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + if (result.Sessions.Length == 0) + { + Console.WriteLine("No active profiling sessions"); + return; + } + foreach (var s in result.Sessions) + { + Console.WriteLine($"{s.SessionId} elapsed={s.ElapsedSeconds:F1}s frames={s.Frames} stats=[{string.Join(",", s.Stats)}]" + + (s.MaxDurationSeconds.HasValue ? $" max={s.MaxDurationSeconds}s" : "")); + } + }); + return cmd; + } + + // ---------- capture (start + sleep + stop) ---------- + + private static Command BuildCaptureCommand() + { + var cmd = new Command("capture", "One-shot capture: start, wait, stop. Sugar for start+stop."); + var duration = new Option("--duration", () => 5.0, "Duration in seconds"); + var stats = new Option("--stats", "Comma-separated stats or aliases"); + var target = new Option("--target", "Target id (default: editor)"); + var save = new Option("--save", "Also save a .data file (drives Editor profiler buffer)"); + var includeSamples = new Option("--include-samples", "Include per-frame sample arrays"); + var hitchMs = new Option("--hitch-ms", "Absolute hitch threshold in ms"); + cmd.AddOption(duration); + cmd.AddOption(stats); + cmd.AddOption(target); + cmd.AddOption(save); + cmd.AddOption(includeSamples); + cmd.AddOption(hitchMs); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var statsRaw = ctx.ParseResult.GetValueForOption(stats); + string[]? statsList = null; + if (!string.IsNullOrWhiteSpace(statsRaw)) + statsList = statsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + + var dur = ctx.ParseResult.GetValueForOption(duration); + var savePath = ResolveSavePathForUnity(ctx, ctx.ParseResult.GetValueForOption(save)); + + var startArgs = new Dictionary + { + { "stats", statsList }, + { "maxDurationSeconds", dur + 5 }, // safety cap > duration + { "target", ctx.ParseResult.GetValueForOption(target) }, + { "savePath", savePath } + }; + + var startResp = await client.SendCommandAsync(UnityCtlCommands.ProfileStart, startArgs, ContextHelper.GetTimeout(ctx)); + if (startResp == null || startResp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error starting capture: {startResp?.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + var startResult = Deser(startResp.Result); + if (startResult == null) { ctx.ExitCode = 1; return; } + + await Task.Delay(TimeSpan.FromSeconds(dur)); + + var stopArgs = new Dictionary + { + { "sessionId", startResult.SessionId }, + { "includeSamples", ctx.ParseResult.GetValueForOption(includeSamples) }, + { "hitchAbsoluteMs", ctx.ParseResult.GetValueForOption(hitchMs) } + }; + var stopResp = await client.SendCommandAsync(UnityCtlCommands.ProfileStop, stopArgs, ContextHelper.GetTimeout(ctx)); + if (stopResp == null || stopResp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error stopping capture: {stopResp?.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(stopResp.Result)); + return; + } + + var stopResult = Deser(stopResp.Result); + if (stopResult != null) PrintStopHuman(stopResult); + }); + return cmd; + } + + // ---------- vitals ---------- + + private static Command BuildVitalsCommand() + { + var cmd = new Command("vitals", "Curated 5-number perf report (avg/p99 frame, GC alloc, draw calls, GPU)"); + var duration = new Option("--duration", () => 3.0, "Sample window in seconds"); + var target = new Option("--target", "Target id (default: editor)"); + cmd.AddOption(duration); + cmd.AddOption(target); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var dur = ctx.ParseResult.GetValueForOption(duration); + var startArgs = new Dictionary + { + { "stats", new[] { "CPU Main Thread Frame Time", "CPU Render Thread Frame Time", "GPU Frame Time", + "Draw Calls Count", "GC Allocated In Frame", "System Used Memory" } }, + { "maxDurationSeconds", dur + 5 }, + { "target", ctx.ParseResult.GetValueForOption(target) } + }; + + var startResp = await client.SendCommandAsync(UnityCtlCommands.ProfileStart, startArgs, ContextHelper.GetTimeout(ctx)); + if (startResp == null || startResp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error starting vitals: {startResp?.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + var startResult = Deser(startResp.Result); + if (startResult == null) { ctx.ExitCode = 1; return; } + + await Task.Delay(TimeSpan.FromSeconds(dur)); + + var stopResp = await client.SendCommandAsync(UnityCtlCommands.ProfileStop, + new Dictionary { { "sessionId", startResult.SessionId } }, + ContextHelper.GetTimeout(ctx)); + if (stopResp == null || stopResp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error stopping vitals: {stopResp?.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(stopResp.Result)); + return; + } + + var stopResult = Deser(stopResp.Result); + if (stopResult == null) return; + PrintVitalsHuman(stopResult); + }); + return cmd; + } + + // ---------- assert ---------- + + private static Command BuildAssertCommand() + { + var cmd = new Command("assert", "Run a short capture and assert thresholds. Non-zero exit on failure."); + var duration = new Option("--duration", () => 3.0, "Sample window in seconds"); + var p99Frame = new Option("--p99-frame-ms", "Fail if p99 frame time (ms) >= this"); + var avgFrame = new Option("--avg-frame-ms", "Fail if avg frame time (ms) >= this"); + var maxGcAlloc = new Option("--gc-alloc-per-frame", "Fail if avg GC alloc/frame (bytes) >= this"); + var maxDrawCalls = new Option("--draw-calls", "Fail if avg draw calls >= this"); + var maxHitches = new Option("--max-hitches", "Fail if hitches > this"); + var hitchMs = new Option("--hitch-ms", () => 33.3, "Hitch threshold in ms (default 33.3)"); + cmd.AddOption(duration); + cmd.AddOption(p99Frame); + cmd.AddOption(avgFrame); + cmd.AddOption(maxGcAlloc); + cmd.AddOption(maxDrawCalls); + cmd.AddOption(maxHitches); + cmd.AddOption(hitchMs); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var dur = ctx.ParseResult.GetValueForOption(duration); + var startArgs = new Dictionary + { + { "stats", new[] { "CPU Main Thread Frame Time", "GPU Frame Time", "Draw Calls Count", "GC Allocated In Frame" } }, + { "maxDurationSeconds", dur + 5 } + }; + var startResp = await client.SendCommandAsync(UnityCtlCommands.ProfileStart, startArgs, ContextHelper.GetTimeout(ctx)); + if (startResp == null || startResp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error starting assert: {startResp?.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + var startResult = Deser(startResp.Result); + if (startResult == null) { ctx.ExitCode = 1; return; } + + await Task.Delay(TimeSpan.FromSeconds(dur)); + + var stopResp = await client.SendCommandAsync(UnityCtlCommands.ProfileStop, + new Dictionary + { + { "sessionId", startResult.SessionId }, + { "hitchAbsoluteMs", ctx.ParseResult.GetValueForOption(hitchMs) } + }, ContextHelper.GetTimeout(ctx)); + if (stopResp == null || stopResp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error stopping assert: {stopResp?.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + var stopResult = Deser(stopResp.Result); + if (stopResult == null) { ctx.ExitCode = 1; return; } + + var failures = new List(); + var mainThread = stopResult.Summaries.FirstOrDefault(s => s.Name == "CPU Main Thread Frame Time"); + var gcAlloc = stopResult.Summaries.FirstOrDefault(s => s.Name == "GC Allocated In Frame"); + var drawCalls = stopResult.Summaries.FirstOrDefault(s => s.Name == "Draw Calls Count"); + + var p99Limit = ctx.ParseResult.GetValueForOption(p99Frame); + var avgLimit = ctx.ParseResult.GetValueForOption(avgFrame); + var gcLimit = ctx.ParseResult.GetValueForOption(maxGcAlloc); + var dcLimit = ctx.ParseResult.GetValueForOption(maxDrawCalls); + var hitchLimit = ctx.ParseResult.GetValueForOption(maxHitches); + + if (p99Limit.HasValue && mainThread != null && mainThread.P99 >= p99Limit.Value) + failures.Add($"p99 frame time {mainThread.P99:F2}ms >= {p99Limit:F2}ms"); + if (avgLimit.HasValue && mainThread != null && mainThread.Avg >= avgLimit.Value) + failures.Add($"avg frame time {mainThread.Avg:F2}ms >= {avgLimit:F2}ms"); + if (gcLimit.HasValue && gcAlloc != null && gcAlloc.Avg >= gcLimit.Value) + failures.Add($"avg GC alloc/frame {gcAlloc.Avg:F0} bytes >= {gcLimit:F0} bytes"); + if (dcLimit.HasValue && drawCalls != null && drawCalls.Avg >= dcLimit.Value) + failures.Add($"avg draw calls {drawCalls.Avg:F0} >= {dcLimit:F0}"); + var hitchCount = stopResult.Hitches?.Length ?? 0; + if (hitchLimit.HasValue && hitchCount > hitchLimit.Value) + failures.Add($"hitches {hitchCount} > {hitchLimit.Value}"); + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(new + { + passed = failures.Count == 0, + failures, + summary = stopResult + })); + } + else + { + PrintStopHuman(stopResult); + if (failures.Count == 0) + { + Console.WriteLine(); + Console.WriteLine("PASS — all thresholds met."); + } + else + { + Console.WriteLine(); + Console.WriteLine("FAIL:"); + foreach (var f in failures) Console.WriteLine($" - {f}"); + } + } + + if (failures.Count > 0) ctx.ExitCode = 1; + }); + return cmd; + } + + // ---------- snapshot (memory) ---------- + + private static Command BuildSnapshotCommand() + { + var cmd = new Command("snapshot", "Capture a memory snapshot (.snap) via Memory Profiler package"); + var output = new Option("--output", "Output path (default: MemoryCaptures/.snap, project-relative)"); + cmd.AddOption(output); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var args = new Dictionary + { + { "output", ResolveSavePathForUnity(ctx, ctx.ParseResult.GetValueForOption(output)) } + }; + + // Snapshots can take a while. + var timeout = ContextHelper.GetTimeout(ctx) ?? 180; + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileSnapshot, args, timeout); + if (resp == null) { ctx.ExitCode = 1; return; } + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + var result = Deser(resp.Result); + if (result != null) + Console.WriteLine($"Saved snapshot: {ContextHelper.FormatPath(result.Path)} ({result.SizeBytes / 1024.0 / 1024.0:F1} MB)"); + }); + return cmd; + } + + // ---------- targets ---------- + + private static Command BuildTargetsCommand() + { + var cmd = new Command("targets", "List available profiler targets (editor + connected players)"); + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileTargets, null, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + if (result.Targets.Length == 0) + { + Console.WriteLine("No profiler targets visible (editor only)."); + return; + } + + Console.WriteLine("ID KIND CURRENT NAME"); + foreach (var t in result.Targets) + Console.WriteLine($"{t.Id,-5} {t.Kind,-9} {(t.IsCurrent ? "*" : " ")} {t.DisplayName}"); + }); + return cmd; + } + + // ---------- connect (DirectURLConnect) ---------- + + private static Command BuildConnectCommand() + { + var cmd = new Command("connect", "Connect Editor profiler to a remote target by URL (e.g. 127.0.0.1:54999 for adb-forwarded Android player)"); + var urlArg = new Argument("url", "Target URL (host:port). For Android over USB, set up `adb forward tcp:54999 tcp:` first."); + cmd.AddArgument(urlArg); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var args = new Dictionary { { "url", ctx.ParseResult.GetValueForArgument(urlArg) } }; + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileConnect, args, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; return; + } + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + Console.WriteLine($"Connected: {JsonHelper.Serialize(resp.Result)}"); + }); + return cmd; + } + + // ---------- explain ---------- + + private static Command BuildExplainCommand() + { + var cmd = new Command("explain", "Top-N markers by self time on a specific frame (use a hitch's absoluteFrameIndex)"); + var frameArg = new Argument("frame", "Absolute frame index (from a hitch's absoluteFrameIndex, or from a recent capture)"); + var threadIndex = new Option("--thread", () => 0, "Thread index (0 = main thread)"); + var topN = new Option("--top", () => 15, "Number of markers to return"); + cmd.AddArgument(frameArg); + cmd.AddOption(threadIndex); + cmd.AddOption(topN); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var args = new Dictionary + { + { "frameIndex", ctx.ParseResult.GetValueForArgument(frameArg) }, + { "threadIndex", ctx.ParseResult.GetValueForOption(threadIndex) }, + { "topN", ctx.ParseResult.GetValueForOption(topN) } + }; + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileExplain, args, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + PrintExplainHuman(result); + }); + return cmd; + } + + // ---------- hotspots ---------- + + private static Command BuildHotspotsCommand() + { + var cmd = new Command("hotspots", "Aggregate top-N markers by self time across a range of recently-captured frames"); + var startFrame = new Option("--start", "First frame in range (default: oldest in buffer)"); + var endFrame = new Option("--end", "Last frame in range (default: newest in buffer)"); + var threadIndex = new Option("--thread", () => 0, "Thread index (0 = main thread)"); + var topN = new Option("--top", () => 20, "Number of markers to return"); + var root = new Option("--root", "Restrict accumulation to a named subtree of the frame hierarchy (e.g. PlayerLoop to exclude editor IMGUI)"); + cmd.AddOption(startFrame); + cmd.AddOption(endFrame); + cmd.AddOption(threadIndex); + cmd.AddOption(topN); + cmd.AddOption(root); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var args = new Dictionary + { + { "startFrame", ctx.ParseResult.GetValueForOption(startFrame) }, + { "endFrame", ctx.ParseResult.GetValueForOption(endFrame) }, + { "threadIndex", ctx.ParseResult.GetValueForOption(threadIndex) }, + { "topN", ctx.ParseResult.GetValueForOption(topN) }, + { "rootMarker", ctx.ParseResult.GetValueForOption(root) } + }; + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileHotspots, args, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + PrintHotspotsHuman(result); + }); + return cmd; + } + + // ---------- frame (hierarchy drill-down) ---------- + + private static Command BuildFrameCommand() + { + var cmd = new Command("frame", "Hierarchy tree drill-down for one frame (use a hitch's absoluteFrameIndex)"); + var frameArg = new Argument("frame", "Absolute frame index (from a hitch, or any frame in the profiler buffer)"); + var threadIndex = new Option("--thread", () => 0, "Thread index (0 = main thread)"); + var depth = new Option("--depth", () => 3, "Max tree depth"); + var thresholdMs = new Option("--threshold-ms", () => 0.2, "Prune nodes whose totalMs is below this"); + var topPerNode = new Option("--top", () => 8, "Keep at most this many children per node"); + var root = new Option("--root", "Start the tree at a named subtree instead of the frame root (e.g. PlayerLoop to skip past EditorLoop in editor+play captures)"); + cmd.AddArgument(frameArg); + cmd.AddOption(threadIndex); + cmd.AddOption(depth); + cmd.AddOption(thresholdMs); + cmd.AddOption(topPerNode); + cmd.AddOption(root); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var args = new Dictionary + { + { "frameIndex", ctx.ParseResult.GetValueForArgument(frameArg) }, + { "threadIndex", ctx.ParseResult.GetValueForOption(threadIndex) }, + { "depth", ctx.ParseResult.GetValueForOption(depth) }, + { "thresholdMs", ctx.ParseResult.GetValueForOption(thresholdMs) }, + { "topPerNode", ctx.ParseResult.GetValueForOption(topPerNode) }, + { "rootMarker", ctx.ParseResult.GetValueForOption(root) } + }; + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileFrame, args, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + PrintFrameHuman(result); + }); + return cmd; + } + + // ---------- mark (microbenchmark) ---------- + + private static Command BuildMarkCommand() + { + var cmd = new Command("mark", "Run a C# expression wrapped in a ProfilerMarker; report timing + GC alloc per call"); + var exprArg = new Argument("expression", "C# expression to time (must be reachable in editor context, e.g. \"GameObject.FindObjectsByType(FindObjectsSortMode.None)\")"); + var name = new Option("--name", () => null, "Marker name (default: unityctl.mark)"); + var repeat = new Option("--repeat", () => 1, "Run N times, return per-call percentiles"); + cmd.AddArgument(exprArg); + cmd.AddOption(name); + cmd.AddOption(repeat); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var args = new Dictionary + { + { "expression", ctx.ParseResult.GetValueForArgument(exprArg) }, + { "name", ctx.ParseResult.GetValueForOption(name) }, + { "repeat", ctx.ParseResult.GetValueForOption(repeat) } + }; + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileMark, args, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + PrintMarkHuman(result); + }); + return cmd; + } + + // ---------- helpers ---------- + + private static BridgeClient? MakeClient(InvocationContext ctx) + { + return BridgeClient.TryCreateFromProject( + ContextHelper.GetProjectPath(ctx), + ContextHelper.GetAgentId(ctx)); + } + + private static T? Deser(object? raw) where T : class + { + if (raw == null) return null; + var json = JsonConvert.SerializeObject(raw, JsonHelper.Settings); + return JsonConvert.DeserializeObject(json, JsonHelper.Settings); + } + + private static string? ResolveSavePathForUnity(InvocationContext ctx, string? input) + { + if (string.IsNullOrEmpty(input)) return null; + // Unity-side resolves relative to its working directory (project root). Pass through as-is + // unless the user gave an absolute path, in which case keep absolute. + return input; + } + + private static void PrintStopHuman(ProfileStopResult result) + { + Console.WriteLine($"Profiling session {result.SessionId} stopped"); + Console.WriteLine($" duration: {result.DurationSeconds:F1}s ({result.Frames} frames)"); + if (result.TargetIsRemote) + Console.WriteLine($" target: REMOTE — {result.Target ?? "(connected player)"}"); + if (!string.IsNullOrEmpty(result.SavedPath)) + Console.WriteLine($" saved: {result.SavedPath}"); + + Console.WriteLine(); + Console.WriteLine($" {"Stat",-30} {"Avg",10} {"p50",10} {"p95",10} {"p99",10} {"Max",10} Unit"); + foreach (var s in result.Summaries) + { + Console.WriteLine($" {s.Name,-30} {Fmt(s.Avg),10} {Fmt(s.P50),10} {Fmt(s.P95),10} {Fmt(s.P99),10} {Fmt(s.Max),10} {s.Unit}"); + } + + if (result.TopFrames != null && result.TopFrames.Length > 0) + { + bool ranksPlayerLoop = result.TopFrames.Any(t => t.PlayerLoopMs.HasValue); + Console.WriteLine(); + Console.WriteLine(ranksPlayerLoop + ? $" Top {result.TopFrames.Length} frames by PlayerLoop time (gameplay-only ranking):" + : $" Top {result.TopFrames.Length} frames by CPU main thread:"); + foreach (var t in result.TopFrames) + { + var playerPart = t.PlayerLoopMs.HasValue ? $" player {Fmt(t.PlayerLoopMs.Value),6}ms" : ""; + if (t.Drivers.Length == 0) + { + Console.WriteLine($" frame {t.AbsoluteFrameIndex,8} cpu {Fmt(t.CpuMainMs),7}ms{playerPart} → (no hierarchy)"); + continue; + } + Console.WriteLine($" frame {t.AbsoluteFrameIndex,8} cpu {Fmt(t.CpuMainMs),7}ms{playerPart}"); + foreach (var d in t.Drivers.Take(3)) + { + var hotPart = d.Hot != null + ? $" → {Truncate(d.Hot.Name, 38)} {Fmt(d.Hot.SelfMs)}ms self" + : ""; + Console.WriteLine($" {Truncate(d.Name, 32),-32} {Fmt(d.TotalMs),7}ms total{hotPart}"); + } + } + Console.WriteLine($" (drill in: profile frame )"); + } + + if (result.Hitches != null && result.Hitches.Length > 0) + { + Console.WriteLine(); + Console.WriteLine($" {result.Hitches.Length} hitch(es) over total-frame-time threshold:"); + foreach (var h in result.Hitches.Take(10)) + Console.WriteLine($" frame {h.FrameIndex}: {h.FrameTimeMs:F2}ms"); + if (result.Hitches.Length > 10) Console.WriteLine($" ... and {result.Hitches.Length - 10} more"); + } + } + + private static void PrintVitalsHuman(ProfileStopResult r) + { + Console.WriteLine($"Vitals ({r.Frames} frames, {r.DurationSeconds:F1}s)"); + var dict = r.Summaries.ToDictionary(s => s.Name, s => s); + + var main = dict.GetValueOrDefault("CPU Main Thread Frame Time"); + var render = dict.GetValueOrDefault("CPU Render Thread Frame Time"); + var gpu = dict.GetValueOrDefault("GPU Frame Time"); + var draws = dict.GetValueOrDefault("Draw Calls Count"); + var gc = dict.GetValueOrDefault("GC Allocated In Frame"); + var sysmem = dict.GetValueOrDefault("System Used Memory"); + + if (main != null) Console.WriteLine($" Frame time: avg {Fmt(main.Avg)} ms p99 {Fmt(main.P99)} ms max {Fmt(main.Max)} ms"); + if (render != null) Console.WriteLine($" Render thread: avg {Fmt(render.Avg)} ms"); + if (gpu != null) Console.WriteLine($" GPU frame: avg {Fmt(gpu.Avg)} ms"); + if (draws != null) Console.WriteLine($" Draw calls: avg {Fmt(draws.Avg)} max {Fmt(draws.Max)}"); + if (gc != null) Console.WriteLine($" GC alloc/frame: avg {Fmt(gc.Avg)} bytes max {Fmt(gc.Max)} bytes"); + if (sysmem != null) Console.WriteLine($" System memory: {sysmem.Avg / 1024.0 / 1024.0:F1} MB"); + + if (r.TopFrames != null && r.TopFrames.Length > 0) + { + Console.WriteLine(); + Console.WriteLine($" Worst frames (CPU main thread):"); + foreach (var t in r.TopFrames.Take(3)) + { + var top = t.Drivers.FirstOrDefault(); + var driver = top != null ? $" ({Truncate(top.Name, 30)})" : ""; + Console.WriteLine($" frame {t.AbsoluteFrameIndex}: cpu {Fmt(t.CpuMainMs)}ms{driver}"); + } + } + + if (r.Hitches != null && r.Hitches.Length > 0) + { + var hitchThresh = 33.3; + Console.WriteLine($" Hitches: {r.Hitches.Length} frame(s) > {hitchThresh:F1}ms (or median × multiplier)"); + } + } + + private static string Fmt(double v) + { + if (double.IsNaN(v)) return "—"; + if (Math.Abs(v) >= 10000) return v.ToString("F0"); + if (Math.Abs(v) >= 100) return v.ToString("F1"); + return v.ToString("F2"); + } + + private static void PrintExplainHuman(ProfileExplainResult r) + { + Console.WriteLine($"Frame {r.FrameIndex} thread={r.ThreadIndex} ({r.ThreadName}) frame_time={Fmt(r.FrameTimeMs)} ms"); + Console.WriteLine(); + Console.WriteLine($" {"Marker",-50} {"Self ms",10} {"Calls",8} GC alloc"); + foreach (var m in r.TopMarkers) + { + var gc = m.GcAllocBytes.HasValue ? $"{m.GcAllocBytes.Value} B" : ""; + Console.WriteLine($" {Truncate(m.Name, 50),-50} {Fmt(m.SelfTimeMs),10} {m.Calls,8} {gc}"); + } + } + + private static void PrintHotspotsHuman(ProfileHotspotsResult r) + { + Console.WriteLine($"Hotspots frames=[{r.StartFrame}..{r.EndFrame}] processed={r.FrameCount} thread={r.ThreadIndex} ({r.ThreadName})"); + Console.WriteLine(); + Console.WriteLine($" {"Marker",-50} {"Self ms",12} {"Calls",10} GC alloc"); + foreach (var m in r.TopMarkers) + { + var gc = m.GcAllocBytes.HasValue ? $"{m.GcAllocBytes.Value} B" : ""; + Console.WriteLine($" {Truncate(m.Name, 50),-50} {Fmt(m.SelfTimeMs),12} {m.Calls,10} {gc}"); + } + } + + private static string Truncate(string s, int max) => + s.Length <= max ? s : s.Substring(0, max - 1) + "…"; + + private static void PrintFrameHuman(ProfileFrameResult r) + { + Console.WriteLine($"Frame {r.FrameIndex} thread={r.ThreadIndex} ({r.ThreadName}) frame_time={Fmt(r.FrameTimeMs)} ms"); + Console.WriteLine($" depth={r.Depth} threshold={Fmt(r.ThresholdMs)}ms pruned={r.PrunedNodes}"); + Console.WriteLine(); + if (r.Tree.Length == 0) + { + Console.WriteLine(" (no nodes above threshold)"); + return; + } + foreach (var n in r.Tree) PrintFrameNode(n, 0); + } + + private static void PrintFrameNode(ProfileFrameNode n, int indent) + { + var pad = new string(' ', 2 + indent * 2); + var gc = n.GcKb.HasValue ? $" gc={n.GcKb.Value:F2}KB" : ""; + Console.WriteLine($"{pad}{Truncate(n.Name, 60),-60} self={Fmt(n.SelfMs),8}ms total={Fmt(n.TotalMs),8}ms calls={n.Calls,4}{gc}"); + if (n.Children == null) return; + foreach (var c in n.Children) PrintFrameNode(c, indent + 1); + } + + private static void PrintMarkHuman(ProfileMarkResult r) + { + Console.WriteLine($"{r.Name} ({r.Repeat} call{(r.Repeat == 1 ? "" : "s")})"); + if (r.Repeat == 1) + { + Console.WriteLine($" time: {r.MeanMs:F4} ms"); + } + else + { + Console.WriteLine($" mean: {r.MeanMs:F4} ms"); + Console.WriteLine($" p50/p95: {r.P50Ms:F4} / {r.P95Ms:F4} ms"); + Console.WriteLine($" min/max: {r.MinMs:F4} / {r.MaxMs:F4} ms"); + } + Console.WriteLine($" gc: {r.GcBytesPerCall} bytes/call ({r.GcBytes} total)"); + if (!string.IsNullOrEmpty(r.Result)) + Console.WriteLine($" result: {Truncate(r.Result, 120)}"); + } +} diff --git a/UnityCtl.Cli/Program.cs b/UnityCtl.Cli/Program.cs index 18fadd4..78580d7 100644 --- a/UnityCtl.Cli/Program.cs +++ b/UnityCtl.Cli/Program.cs @@ -2,10 +2,19 @@ using System.Collections.Generic; using System.CommandLine; using System.CommandLine.Parsing; +using System.Globalization; using System.Linq; +using System.Threading; using System.Threading.Tasks; using UnityCtl.Cli; +// Use invariant culture for argument parsing and output. Avoids locale-specific +// decimal separators (e.g. "0,5" vs "0.5") that break --duration / threshold flags. +CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; +CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture; +Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture; +Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; + var rootCommand = new RootCommand("UnityCtl - CLI tool for controlling Unity Editor"); // Global options @@ -60,6 +69,7 @@ rootCommand.AddCommand(TestCommands.CreateCommand()); rootCommand.AddCommand(ScreenshotCommands.CreateCommand()); rootCommand.AddCommand(RecordCommands.CreateCommand()); +rootCommand.AddCommand(ProfileCommands.CreateCommand()); rootCommand.AddCommand(ScriptCommands.CreateCommand()); rootCommand.AddCommand(SnapshotCommand.CreateCommand()); rootCommand.AddCommand(PrefabCommand.CreateCommand()); diff --git a/UnityCtl.Cli/Resources/SKILL.md b/UnityCtl.Cli/Resources/SKILL.md index 5372917..6986780 100644 --- a/UnityCtl.Cli/Resources/SKILL.md +++ b/UnityCtl.Cli/Resources/SKILL.md @@ -57,6 +57,13 @@ unityctl screenshot list-windows # List open editor windows (type, title unityctl screenshot window # Capture specific editor window by type or title unityctl screenshot window SceneView out.png # e.g. capture Scene view to out.png +# Profiling (frame stats, spikes, hierarchy drill, microbench, regression gates) +# Read .claude/skills/unity-editor/profiling.md for the full surface and workflows. +# Quick reference: `unityctl profile vitals --duration 3` (5-number report), +# `unityctl profile capture --duration 10 -t 120` (full summary with topFrames + drivers), +# `unityctl profile mark "" --repeat N` (microbench an expression). +unityctl profile --help # List subcommands + # Video Recording (requires com.unity.recorder package) # Note: record start auto-enters play mode if not already playing unityctl record start # Start recording (manual stop) diff --git a/UnityCtl.Cli/Resources/profiling.md b/UnityCtl.Cli/Resources/profiling.md new file mode 100644 index 0000000..2f6c914 --- /dev/null +++ b/UnityCtl.Cli/Resources/profiling.md @@ -0,0 +1,142 @@ +# unityctl profile — Unity profiler from the CLI + +Capture frame stats, find spikes, drill into hierarchies, microbenchmark expressions. +Read this when the user mentions performance, FPS, hitches, GC, draw calls, profiling, frame budget, or asks "what's slow". + +## Lifecycle + +Sessions are start → run scenario → stop. The buffer survives after stop, so `explain`/`hotspots`/`frame` can drill into captured frames until the next session clears it. + +```bash +# One-shot (most common) +unityctl profile vitals --duration 3 # Curated 5-number report +unityctl profile capture --duration 5 # Full summary + topFrames + drivers +unityctl profile capture --duration 5 --save run.data # Also save .data for the Profiler window + +# Manual session +unityctl profile start --stats main,gpu,drawcalls,gc-alloc --max-duration 30 # Returns sessionId +unityctl profile stop # Returns summary JSON + +# CI gate (non-zero exit on threshold breach) +unityctl profile assert --p99-frame-ms 33 --gc-alloc-per-frame 1024 +``` + +**Always profile in play mode** unless you explicitly want editor-tick stats. Edit-mode samples editor update frequency, which doesn't reflect game performance. + +## Capture output: topFrames + +`profile capture` and `profile vitals` always include a `topFrames` array — the worst frames in the window with top-3 driver markers attached, each carrying a `hot` field that points at the descendant with the highest self time inside the driver's subtree. **This is the spike-detection primary output**. No need to dump per-frame samples, chain `explain` calls, or drill with `profile frame` for the typical "what's slow" question. + +Ranking metric depends on what's available: +- **In play mode** (PlayerLoop in hierarchy): ranked by `playerLoopMs` — gameplay-only time, ignores editor IMGUI repaint variance. Drivers descend from inside PlayerLoop. +- **Otherwise**: ranked by `cpuMainMs` (full main thread). Drivers descend from frame root. + +Both `cpuMainMs` and `playerLoopMs` are populated when available, so consumers can rerank or read either signal. + +`hitches` (frames over an absolute total-frame-time threshold) is separate — useful for CI gates, but blind to CPU spikes when total frame time is vsync-capped (Android/iOS clamp at 16/33 ms regardless of CPU time). + +## Drilling a spike + +Pick an `absoluteFrameIndex` from `topFrames` (or any frame in the buffer): + +```bash +unityctl profile explain --top 15 # Flat top-N markers by self time + GC +unityctl profile frame --depth 3 # Hierarchy *tree*, self-pruned +unityctl profile frame --depth 4 --root PlayerLoop # Tree scoped to gameplay subtree +``` + +Use `frame --root PlayerLoop` in editor+play to skip past the EditorLoop / Application.Tick wrapping that otherwise dominates the tree. + +## Hotspots aggregate + +```bash +unityctl profile hotspots --top 20 # Across the whole captured buffer +unityctl profile hotspots --top 20 --root PlayerLoop # Gameplay-only — essential in editor+play mode +``` + +In editor+play, the unfiltered `hotspots` is dominated by `OnGUI` / `IMGUIContainer` / `EditorApplication.update:*` (editor menu polling) which can swamp game work by 40×. Always pass `--root PlayerLoop` when you care about game perf. + +## Microbenchmark a single expression + +`profile mark` wraps an expression in a `ProfilerMarker` + Stopwatch + per-thread GC accounting and runs it N times. No capture session needed. + +```bash +unityctl profile mark "GameObject.FindObjectsByType(FindObjectsSortMode.None)" --repeat 100 +# → mean / p50 / p95 / min / max ms + gcBytesPerCall +``` + +Useful for "is this hot path actually slow?" without the start/wait/stop dance. + +## Stat aliases + +`profile start --stats` accepts these aliases (resolved server-side): + +| Alias | Resolves to | +|---|---| +| `main` | CPU Main Thread Frame Time | +| `render` | CPU Render Thread Frame Time | +| `gpu` | GPU Frame Time | +| `total-frame` | CPU Total Frame Time | +| `drawcalls` | Draw Calls Count | +| `setpass` | SetPass Calls Count | +| `batches` | Batches Count | +| `triangles` / `tris` | Triangles Count | +| `gc-alloc` | GC Allocated In Frame | +| `gc-used` / `gc-reserved` | GC Used Memory / GC Reserved Memory | +| `system-memory` | System Used Memory | + +Default vitals stats: main, render, gpu, drawcalls, gc-alloc, system-memory. List all available counters with `unityctl profile list-stats [--category Render]`. + +## Memory snapshot + +```bash +unityctl profile snapshot --output mem.snap # Requires com.unity.memoryprofiler package +``` + +## Remote / Android profiling + +```bash +unityctl profile targets # List editor + connected players +unityctl profile connect 127.0.0.1:54999 # Direct-URL connect (Android via adb forward) +``` + +When the editor's profiler is connected to a remote target (autoconnect-profiler dev build, or via `connect`), captures automatically come from the remote process. The summary's `targetIsRemote: true` flags this — the human output also prints `target: REMOTE — `. + +Remote captures use the same code path as local (post-hoc `RawFrameDataView` walk), so `topFrames` / `explain` / `frame` / `hotspots` all work. + +## Typical workflows + +### "Where are my spikes?" + +```bash +unityctl play enter +sleep 2 # let it settle +unityctl profile capture --duration 10 -t 120 --json +# Inspect topFrames[] — pick worst absoluteFrameIndex +unityctl profile frame --depth 4 --root PlayerLoop +unityctl play exit +``` + +### "What's hot on average?" + +```bash +unityctl profile capture --duration 10 -t 120 +unityctl profile hotspots --top 20 --root PlayerLoop +``` + +### "Is my new code slow?" + +```bash +unityctl profile mark "MySystem.DoExpensiveThing()" --repeat 200 +# Compare mean / p95 / gcBytesPerCall before/after +``` + +### "Did this PR regress perf?" + +```bash +unityctl play enter +sleep 2 +unityctl profile assert --p99-frame-ms 33 --gc-alloc-per-frame 4096 --duration 10 -t 120 +unityctl play exit +# Exit 1 on breach — wire into CI +``` diff --git a/UnityCtl.Cli/SkillCommands.cs b/UnityCtl.Cli/SkillCommands.cs index 49486d5..5f2a59e 100644 --- a/UnityCtl.Cli/SkillCommands.cs +++ b/UnityCtl.Cli/SkillCommands.cs @@ -27,6 +27,16 @@ private static readonly (string Folder, string Resource)[] AdditionalSkills = ("unityctl-plugins", "UnityCtl.Cli.Resources.SKILL.plugins.md") ]; + /// + /// Sidecar files written alongside the main SKILL.md inside the unity-editor skill folder. + /// Not auto-loaded — the base skill mentions them by relative path so agents can read them + /// when the topic is relevant. Each entry is (fileName, embeddedResourceName). + /// + private static readonly (string FileName, string Resource)[] Sidecars = + [ + ("profiling.md", "UnityCtl.Cli.Resources.profiling.md") + ]; + public static Command CreateCommand() { var skillCommand = new Command("skill", "Claude Code skill management"); @@ -427,6 +437,15 @@ private static async Task WriteComposedSkillAsync(string skillsDir, bool j var skillFolderPath = Path.Combine(skillsDir, SkillFolderName); Directory.CreateDirectory(skillFolderPath); await File.WriteAllTextAsync(Path.Combine(skillFolderPath, SkillFileName), content); + + // Write sidecar files (e.g. profiling.md). They aren't @-referenced by SKILL.md so they + // stay out of the auto-loaded context, but agents can read them when the topic is relevant. + foreach (var (fileName, resource) in Sidecars) + { + var sidecarContent = GetEmbeddedResourceContent(resource); + if (sidecarContent == null) continue; + await File.WriteAllTextAsync(Path.Combine(skillFolderPath, fileName), sidecarContent); + } return true; } @@ -456,6 +475,14 @@ private static bool RemoveSkill(bool global, string? claudeDir, bool json) File.Delete(skillPath); + // Remove sidecars co-located with the main skill. + foreach (var (fileName, _) in Sidecars) + { + var sidecarPath = Path.Combine(skillFolderPath, fileName); + if (File.Exists(sidecarPath)) + File.Delete(sidecarPath); + } + // Remove additional skills foreach (var (folder, _) in AdditionalSkills) { diff --git a/UnityCtl.Cli/UnityCtl.Cli.csproj b/UnityCtl.Cli/UnityCtl.Cli.csproj index ddbc6de..46d5fef 100644 --- a/UnityCtl.Cli/UnityCtl.Cli.csproj +++ b/UnityCtl.Cli/UnityCtl.Cli.csproj @@ -28,6 +28,7 @@ + diff --git a/UnityCtl.Protocol/Constants.cs b/UnityCtl.Protocol/Constants.cs index d15356e..bc1512b 100644 --- a/UnityCtl.Protocol/Constants.cs +++ b/UnityCtl.Protocol/Constants.cs @@ -40,6 +40,19 @@ public static class UnityCtlCommands public const string RecordStop = "record.stop"; public const string RecordStatus = "record.status"; + // Profiling + public const string ProfileListStats = "profile.listStats"; + public const string ProfileStart = "profile.start"; + public const string ProfileStop = "profile.stop"; + public const string ProfileStatus = "profile.status"; + public const string ProfileSnapshot = "profile.snapshot"; + public const string ProfileTargets = "profile.targets"; + public const string ProfileConnect = "profile.connect"; + public const string ProfileExplain = "profile.explain"; + public const string ProfileHotspots = "profile.hotspots"; + public const string ProfileFrame = "profile.frame"; + public const string ProfileMark = "profile.mark"; + // Snapshot public const string Snapshot = "snapshot"; public const string SnapshotQuery = "snapshot.query"; diff --git a/UnityCtl.Protocol/DTOs.cs b/UnityCtl.Protocol/DTOs.cs index e7acf2e..d4b1536 100644 --- a/UnityCtl.Protocol/DTOs.cs +++ b/UnityCtl.Protocol/DTOs.cs @@ -410,6 +410,419 @@ public class RecordFinishedPayload public required int FrameCount { get; init; } } +public class ProfileStatInfo +{ + [JsonProperty("name")] + public required string Name { get; init; } + + [JsonProperty("category")] + public required string Category { get; init; } + + [JsonProperty("unit")] + public required string Unit { get; init; } + + [JsonProperty("dataType")] + public required string DataType { get; init; } +} + +public class ProfileListStatsResult +{ + [JsonProperty("count")] + public required int Count { get; init; } + + [JsonProperty("stats")] + public required ProfileStatInfo[] Stats { get; init; } +} + +public class ProfileStartResult +{ + [JsonProperty("sessionId")] + public required string SessionId { get; init; } + + [JsonProperty("stats")] + public required string[] Stats { get; init; } + + [JsonProperty("startedAt")] + public required string StartedAt { get; init; } + + [JsonProperty("maxDurationSeconds")] + public double? MaxDurationSeconds { get; init; } + + [JsonProperty("target")] + public string? Target { get; init; } + + [JsonProperty("targetIsRemote")] + public bool TargetIsRemote { get; init; } + + [JsonProperty("savePath", NullValueHandling = NullValueHandling.Ignore)] + public string? SavePath { get; init; } +} + +public class ProfileStatSummary +{ + [JsonProperty("name")] + public required string Name { get; init; } + + [JsonProperty("unit")] + public required string Unit { get; init; } + + [JsonProperty("frames")] + public required int Frames { get; init; } + + [JsonProperty("avg")] + public required double Avg { get; init; } + + [JsonProperty("min")] + public required double Min { get; init; } + + [JsonProperty("max")] + public required double Max { get; init; } + + [JsonProperty("p50")] + public required double P50 { get; init; } + + [JsonProperty("p95")] + public required double P95 { get; init; } + + [JsonProperty("p99")] + public required double P99 { get; init; } + + [JsonProperty("samples", NullValueHandling = NullValueHandling.Ignore)] + public double[]? Samples { get; init; } +} + +public class ProfileHitch +{ + [JsonProperty("frameIndex")] + public required int FrameIndex { get; init; } + + [JsonProperty("frameTimeMs")] + public required double FrameTimeMs { get; init; } + + /// + /// Frame index in the profiler buffer (usable with `profile explain`). + /// Null for remote captures or when the profiler buffer wasn't driven for this session. + /// + [JsonProperty("absoluteFrameIndex", NullValueHandling = NullValueHandling.Ignore)] + public int? AbsoluteFrameIndex { get; init; } +} + +/// +/// Descendant of a driver where the wall-clock is actually spent. The driver itself is usually +/// an intermediate node (selfMs ~ 0); `hot` points at the marker inside its subtree with the +/// highest selfMs — the "what's slow" answer for the agent without a separate `profile frame` call. +/// +public class ProfileFrameHotLeaf +{ + [JsonProperty("name")] + public required string Name { get; init; } + + [JsonProperty("selfMs")] + public required double SelfMs { get; init; } + + [JsonProperty("totalMs")] + public required double TotalMs { get; init; } +} + +/// +/// Top-level system marker driving a frame. "Drivers" are descendants of the hierarchy root past +/// dominant-single-child levels, ranked by total time — answers "which subsystem is responsible +/// for this frame's cost", not "which leaf has the highest self-time". +/// +public class ProfileFrameDriver +{ + [JsonProperty("name")] + public required string Name { get; init; } + + [JsonProperty("totalMs")] + public required double TotalMs { get; init; } + + [JsonProperty("selfMs")] + public required double SelfMs { get; init; } + + [JsonProperty("calls")] + public required int Calls { get; init; } + + [JsonProperty("gcKb", NullValueHandling = NullValueHandling.Ignore)] + public double? GcKb { get; init; } + + /// + /// Hottest descendant by self time inside this driver's subtree. Null when the driver itself + /// is the leaf or no descendant has meaningful self time. + /// + [JsonProperty("hot", NullValueHandling = NullValueHandling.Ignore)] + public ProfileFrameHotLeaf? Hot { get; init; } +} + +/// +/// One of the worst frames in a capture. Carries both the relative index (into samples arrays) +/// and the absolute index (into the profiler buffer, usable with `profile frame` / `profile explain`). +/// +/// Ranking metric depends on what's available: when the capture was taken in play mode (PlayerLoop +/// is in the hierarchy), frames are ranked by `playerLoopMs` — the time spent in actual game work. +/// Otherwise ranked by `cpuMainMs` (full main thread including editor IMGUI). Both fields are +/// always populated when available so consumers can see both signals. +/// +public class ProfileTopFrame +{ + [JsonProperty("frameIndex")] + public required int FrameIndex { get; init; } + + [JsonProperty("absoluteFrameIndex")] + public required int AbsoluteFrameIndex { get; init; } + + [JsonProperty("cpuMainMs")] + public required double CpuMainMs { get; init; } + + [JsonProperty("frameTimeMs")] + public required double FrameTimeMs { get; init; } + + /// + /// Time spent in PlayerLoop on this frame (gameplay-only, excluding editor IMGUI/repaint). + /// Null when PlayerLoop isn't in the hierarchy (editor mode without play, or top-level + /// remote captures where the hierarchy is rooted differently). + /// + [JsonProperty("playerLoopMs", NullValueHandling = NullValueHandling.Ignore)] + public double? PlayerLoopMs { get; init; } + + [JsonProperty("drivers")] + public required ProfileFrameDriver[] Drivers { get; init; } +} + +public class ProfileStopResult +{ + [JsonProperty("sessionId")] + public required string SessionId { get; init; } + + [JsonProperty("durationSeconds")] + public required double DurationSeconds { get; init; } + + [JsonProperty("frames")] + public required int Frames { get; init; } + + [JsonProperty("summaries")] + public required ProfileStatSummary[] Summaries { get; init; } + + [JsonProperty("hitches", NullValueHandling = NullValueHandling.Ignore)] + public ProfileHitch[]? Hitches { get; init; } + + /// + /// Top frames by CPU main thread time, with the top-3 driver markers attached. Always + /// populated for non-empty captures so agents can see spikes without dumping samples. + /// Distinct from `hitches` (which gates against an absolute threshold and uses total + /// frame time — useful for CI but vsync-blind). + /// + [JsonProperty("topFrames", NullValueHandling = NullValueHandling.Ignore)] + public ProfileTopFrame[]? TopFrames { get; init; } + + [JsonProperty("savedPath", NullValueHandling = NullValueHandling.Ignore)] + public string? SavedPath { get; init; } + + [JsonProperty("target", NullValueHandling = NullValueHandling.Ignore)] + public string? Target { get; init; } + + [JsonProperty("targetIsRemote")] + public bool TargetIsRemote { get; init; } +} + +public class ProfileStatusEntry +{ + [JsonProperty("sessionId")] + public required string SessionId { get; init; } + + [JsonProperty("startedAt")] + public required string StartedAt { get; init; } + + [JsonProperty("elapsedSeconds")] + public required double ElapsedSeconds { get; init; } + + [JsonProperty("frames")] + public required int Frames { get; init; } + + [JsonProperty("stats")] + public required string[] Stats { get; init; } + + [JsonProperty("maxDurationSeconds")] + public double? MaxDurationSeconds { get; init; } + + [JsonProperty("target", NullValueHandling = NullValueHandling.Ignore)] + public string? Target { get; init; } + + [JsonProperty("targetIsRemote")] + public bool TargetIsRemote { get; init; } +} + +public class ProfileStatusResult +{ + [JsonProperty("sessions")] + public required ProfileStatusEntry[] Sessions { get; init; } +} + +public class ProfileSnapshotResult +{ + [JsonProperty("path")] + public required string Path { get; init; } + + [JsonProperty("sizeBytes")] + public long SizeBytes { get; init; } +} + +public class ProfileTargetInfo +{ + [JsonProperty("id")] + public required string Id { get; init; } + + [JsonProperty("displayName")] + public required string DisplayName { get; init; } + + [JsonProperty("kind")] + public required string Kind { get; init; } // "editor" | "player" + + [JsonProperty("isCurrent")] + public bool IsCurrent { get; init; } +} + +public class ProfileTargetsResult +{ + [JsonProperty("targets")] + public required ProfileTargetInfo[] Targets { get; init; } +} + +public class ProfileMarkerEntry +{ + [JsonProperty("name")] + public required string Name { get; init; } + + [JsonProperty("selfTimeMs")] + public required double SelfTimeMs { get; init; } + + [JsonProperty("calls")] + public required int Calls { get; init; } + + [JsonProperty("gcAllocBytes", NullValueHandling = NullValueHandling.Ignore)] + public long? GcAllocBytes { get; init; } +} + +public class ProfileExplainResult +{ + [JsonProperty("frameIndex")] + public required int FrameIndex { get; init; } + + [JsonProperty("threadIndex")] + public required int ThreadIndex { get; init; } + + [JsonProperty("threadName")] + public required string ThreadName { get; init; } + + [JsonProperty("frameTimeMs")] + public required double FrameTimeMs { get; init; } + + [JsonProperty("topMarkers")] + public required ProfileMarkerEntry[] TopMarkers { get; init; } +} + +public class ProfileHotspotsResult +{ + [JsonProperty("startFrame")] + public required int StartFrame { get; init; } + + [JsonProperty("endFrame")] + public required int EndFrame { get; init; } + + [JsonProperty("frameCount")] + public required int FrameCount { get; init; } + + [JsonProperty("threadIndex")] + public required int ThreadIndex { get; init; } + + [JsonProperty("threadName")] + public required string ThreadName { get; init; } + + [JsonProperty("topMarkers")] + public required ProfileMarkerEntry[] TopMarkers { get; init; } +} + +public class ProfileFrameNode +{ + [JsonProperty("name")] + public required string Name { get; init; } + + [JsonProperty("selfMs")] + public required double SelfMs { get; init; } + + [JsonProperty("totalMs")] + public required double TotalMs { get; init; } + + [JsonProperty("calls")] + public required int Calls { get; init; } + + [JsonProperty("gcKb", NullValueHandling = NullValueHandling.Ignore)] + public double? GcKb { get; init; } + + [JsonProperty("children", NullValueHandling = NullValueHandling.Ignore)] + public ProfileFrameNode[]? Children { get; init; } +} + +public class ProfileFrameResult +{ + [JsonProperty("frameIndex")] + public required int FrameIndex { get; init; } + + [JsonProperty("threadIndex")] + public required int ThreadIndex { get; init; } + + [JsonProperty("threadName")] + public required string ThreadName { get; init; } + + [JsonProperty("frameTimeMs")] + public required double FrameTimeMs { get; init; } + + [JsonProperty("depth")] + public required int Depth { get; init; } + + [JsonProperty("thresholdMs")] + public required double ThresholdMs { get; init; } + + [JsonProperty("prunedNodes")] + public required int PrunedNodes { get; init; } + + [JsonProperty("tree")] + public required ProfileFrameNode[] Tree { get; init; } +} + +public class ProfileMarkResult +{ + [JsonProperty("name")] + public required string Name { get; init; } + + [JsonProperty("repeat")] + public required int Repeat { get; init; } + + [JsonProperty("meanMs")] + public required double MeanMs { get; init; } + + [JsonProperty("minMs")] + public required double MinMs { get; init; } + + [JsonProperty("maxMs")] + public required double MaxMs { get; init; } + + [JsonProperty("p50Ms")] + public required double P50Ms { get; init; } + + [JsonProperty("p95Ms")] + public required double P95Ms { get; init; } + + [JsonProperty("gcBytes")] + public required long GcBytes { get; init; } + + [JsonProperty("gcBytesPerCall")] + public required long GcBytesPerCall { get; init; } + + [JsonProperty("result", NullValueHandling = NullValueHandling.Ignore)] + public string? Result { get; init; } +} + public class ProjectStatusResult { [JsonProperty("projectPath")] diff --git a/UnityCtl.UnityPackage/Editor/ProfilingManager.cs b/UnityCtl.UnityPackage/Editor/ProfilingManager.cs new file mode 100644 index 0000000..e6f6daa --- /dev/null +++ b/UnityCtl.UnityPackage/Editor/ProfilingManager.cs @@ -0,0 +1,1313 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Unity.Profiling; +using Unity.Profiling.LowLevel.Unsafe; +using UnityEditor; +using UnityEditor.Profiling; +using UnityEditorInternal; +using UnityEngine; +using UnityCtl.Protocol; + +namespace UnityCtl.Editor +{ + /// + /// Per-session profiling state. The session enables ProfilerDriver, snapshots the start + /// frame index, and on Stop() walks the profiler buffer with RawFrameDataView, reading + /// counter values per actual rendered frame. Same code path for editor and remote players — + /// the only difference is ProfilerDriver.profileEditor. + /// + /// Why post-hoc buffer reads, not per-tick ProfilerRecorder sampling: EditorApplication.update + /// ticks at editor framerate (often throttled, especially when unfocused), which mismatches + /// the actual rendered frame rate. Reading from the profiler buffer gives one record per real + /// frame — matches what the user sees in the Profiler window. + /// + internal sealed class ProfilingSession + { + public string Id { get; } + public DateTime StartedAtUtc { get; } + public string[] StatNames { get; } + public double? MaxDurationSeconds { get; } + public string? Target { get; } + public bool TargetIsRemote { get; } + public string? SavePath { get; } + public bool DriveEditorProfiler { get; } + + // Built-in counter names share units across editor and player. Resolve once at start + // from the local Editor's ProfilerRecorderHandle catalog. + private readonly Dictionary _statNameToUnit = new(); + + public int AbsoluteStartFrame { get; private set; } = -1; + public bool IsActive { get; private set; } + + public int CurrentFrameCount => + IsActive && AbsoluteStartFrame >= 0 + ? Math.Max(0, ProfilerDriver.lastFrameIndex - AbsoluteStartFrame + 1) + : 0; + + public ProfilingSession( + string id, + string[] statNames, + double? maxDurationSeconds, + string? target, + bool targetIsRemote, + string? savePath, + bool driveEditorProfiler) + { + Id = id; + StatNames = statNames; + MaxDurationSeconds = maxDurationSeconds; + Target = target; + TargetIsRemote = targetIsRemote; + SavePath = savePath; + DriveEditorProfiler = driveEditorProfiler; + StartedAtUtc = DateTime.UtcNow; + + for (int i = 0; i < statNames.Length; i++) + { + var (_, unit, _) = FindHandleByName(statNames[i]); + _statNameToUnit[statNames[i]] = unit; + } + + IsActive = true; + } + + public void StartEditorProfilerCapture() + { + TryBumpBufferSize(); + // Local: profile the editor itself. Remote: leave profileEditor false so the streamed + // remote frames populate the buffer. + ProfilerDriver.profileEditor = !TargetIsRemote; + ProfilerDriver.ClearAllFrames(); + ProfilerDriver.enabled = true; + AbsoluteStartFrame = ProfilerDriver.lastFrameIndex + 1; + } + + // ProfilerUserSettings.frameCount is internal (Unity 6) — default 300 frames evicts frames + // mid-capture for any session longer than ~1.5 s at 200 FPS. Bump to 2000 for the duration + // of a session so `profile explain` against early hitches still works after stop. Restored + // at session stop via RestoreBufferSize(). + private static int? _originalFrameCount; + private static void TryBumpBufferSize() + { + const int Target = 2000; + try + { + var t = Type.GetType("UnityEditor.Profiling.ProfilerUserSettings, UnityEditor"); + if (t == null) return; + var prop = t.GetProperty("frameCount", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + if (prop == null) return; + if (!_originalFrameCount.HasValue) + _originalFrameCount = (int)prop.GetValue(null); + var current = (int)prop.GetValue(null); + if (current < Target) prop.SetValue(null, Target); + } + catch (Exception ex) + { + Debug.LogWarning($"[UnityCtl] Failed to bump profiler frame buffer: {ex.Message}"); + } + } + private static void RestoreBufferSize() + { + if (!_originalFrameCount.HasValue) return; + try + { + var t = Type.GetType("UnityEditor.Profiling.ProfilerUserSettings, UnityEditor"); + var prop = t?.GetProperty("frameCount", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + prop?.SetValue(null, _originalFrameCount.Value); + } + catch { } + _originalFrameCount = null; + } + + public ProfileStopResult Stop(bool includeSamples, double hitchMultiplier, double? hitchAbsoluteMs) + { + IsActive = false; + + // Snapshot the end frame before disabling the driver so frames received in the same + // editor tick aren't dropped. + int endFrame = ProfilerDriver.lastFrameIndex; + ProfilerDriver.enabled = false; + + // Save .data first while the buffer still holds the frames. We leave the buffered + // frames in place (driver is just paused) so a follow-up `profile explain` / + // `hotspots` / `frame` can walk them. Next session's start clears the buffer. + TrySaveProfile(); + + int startFrame = Math.Max(AbsoluteStartFrame, ProfilerDriver.firstFrameIndex); + int totalFrames = endFrame >= startFrame ? endFrame - startFrame + 1 : 0; + + var perStat = new double[StatNames.Length][]; + for (int i = 0; i < StatNames.Length; i++) perStat[i] = new double[totalFrames]; + var frameTimesMs = new double[totalFrames]; + + for (int idx = 0; idx < totalFrames; idx++) + { + int frame = startFrame + idx; + using var view = ProfilerDriver.GetRawFrameDataView(frame, 0); + if (view == null || !view.valid) + { + for (int i = 0; i < StatNames.Length; i++) perStat[i][idx] = double.NaN; + frameTimesMs[idx] = double.NaN; + continue; + } + + for (int i = 0; i < StatNames.Length; i++) + { + perStat[i][idx] = ReadCounter(view, StatNames[i]); + } + + // Use the view's own frame time — actual wall-clock for this frame, which is what + // determines perceived FPS. More accurate than the CPU Main Thread Frame Time + // counter (which excludes GPU stalls) and immune to editor tick rate. + frameTimesMs[idx] = view.frameTimeNs / 1_000_000.0; + } + + var summaries = new List(StatNames.Length); + for (int i = 0; i < StatNames.Length; i++) + { + var unit = _statNameToUnit.TryGetValue(StatNames[i], out var u) ? u : ProfilerMarkerDataUnit.Undefined; + var converted = new double[perStat[i].Length]; + for (int j = 0; j < perStat[i].Length; j++) + converted[j] = ConvertSample(perStat[i][j], unit); + summaries.Add(BuildSummary(StatNames[i], UnitDisplay(unit), converted, includeSamples)); + } + + var ftList = new List(frameTimesMs.Length); + foreach (var v in frameTimesMs) if (!double.IsNaN(v) && v > 0) ftList.Add(v); + var threshold = ComputeHitchThreshold(ftList, hitchMultiplier, hitchAbsoluteMs); + var hitches = new List(); + for (int i = 0; i < frameTimesMs.Length; i++) + { + if (!double.IsNaN(frameTimesMs[i]) && frameTimesMs[i] > threshold) + hitches.Add(new ProfileHitch + { + FrameIndex = i, + FrameTimeMs = frameTimesMs[i], + AbsoluteFrameIndex = startFrame + i + }); + } + + // topFrames: the worst frames by CPU main thread time. This is the spike axis agents + // actually want — total frame time is vsync-capped (16/33ms) on most platforms and + // hides CPU variance. We always populate this regardless of hitch threshold. + var topFrames = ComputeTopFrames(StatNames, perStat, frameTimesMs, startFrame, _statNameToUnit, topN: 5, driversPerFrame: 3); + + return new ProfileStopResult + { + SessionId = Id, + DurationSeconds = (DateTime.UtcNow - StartedAtUtc).TotalSeconds, + Frames = totalFrames, + Summaries = summaries.ToArray(), + Hitches = hitches.Count > 0 ? hitches.ToArray() : null, + TopFrames = topFrames, + SavedPath = !string.IsNullOrEmpty(SavePath) ? SavePath : null, + Target = Target, + TargetIsRemote = TargetIsRemote + }; + } + + private static ProfileTopFrame[]? ComputeTopFrames( + string[] statNames, + double[][] perStat, + double[] frameTimesMs, + int startFrame, + Dictionary unitMap, + int topN, + int driversPerFrame) + { + int total = frameTimesMs.Length; + if (total == 0) return null; + + // Find a CPU main thread stat. Prefer the precise counter; fall back to the broader + // "Main Thread" if the precise one wasn't requested. + int cpuStatIdx = -1; + foreach (var preferred in new[] { "CPU Main Thread Frame Time", "Main Thread" }) + { + for (int i = 0; i < statNames.Length; i++) + if (statNames[i] == preferred) { cpuStatIdx = i; break; } + if (cpuStatIdx >= 0) break; + } + + var candidates = new List<(int idx, double cpuMs, double ftMs)>(total); + for (int i = 0; i < total; i++) + { + double cpuMs; + if (cpuStatIdx >= 0) + { + var raw = perStat[cpuStatIdx][i]; + cpuMs = double.IsNaN(raw) ? double.NaN : raw / 1_000_000.0; + } + else + { + cpuMs = frameTimesMs[i]; + } + if (double.IsNaN(cpuMs)) continue; + candidates.Add((i, cpuMs, frameTimesMs[i])); + } + if (candidates.Count == 0) return null; + + // First pass by CPU main thread to find the candidate pool. We over-pick (3x topN) + // so that re-ranking by PlayerLoop time has enough room to surface a different set. + // Per-frame hierarchy walks are ~1ms each; 3*topN keeps it bounded. + candidates.Sort((a, b) => b.cpuMs.CompareTo(a.cpuMs)); + int poolSize = Math.Min(candidates.Count, Math.Max(topN * 3, topN)); + var pool = candidates.GetRange(0, poolSize); + + // For each candidate, walk the hierarchy once to collect: + // 1. PlayerLoop nodeId + totalMs (gameplay-only signal) + // 2. Top-N drivers — descended from inside PlayerLoop when present (so attribution + // reflects gameplay variance), otherwise descended from the frame root. + var enriched = new List<(int idx, double cpuMs, double ftMs, double? playerLoopMs, int? playerLoopId, ProfileFrameDriver[] driversFromRoot, ProfileFrameDriver[] driversFromPlayerLoop)>(pool.Count); + foreach (var p in pool) + { + int absFrame = startFrame + p.idx; + using var hv = ProfilerDriver.GetHierarchyFrameDataView( + absFrame, 0, + HierarchyFrameDataView.ViewModes.MergeSamplesWithTheSameName, + HierarchyFrameDataView.columnTotalTime, + sortAscending: false); + double? playerLoopMs = null; + int? playerLoopId = null; + ProfileFrameDriver[] driversFromRoot = Array.Empty(); + ProfileFrameDriver[] driversFromPlayerLoop = Array.Empty(); + if (hv != null && hv.valid) + { + playerLoopId = FindDescendantIdByName(hv, hv.GetRootItemID(), "PlayerLoop"); + if (playerLoopId.HasValue) + playerLoopMs = hv.GetItemColumnDataAsDouble(playerLoopId.Value, HierarchyFrameDataView.columnTotalTime); + driversFromRoot = ResolveDrivers(hv, hv.GetRootItemID(), driversPerFrame); + if (playerLoopId.HasValue) + driversFromPlayerLoop = ResolveDrivers(hv, playerLoopId.Value, driversPerFrame); + } + enriched.Add((p.idx, p.cpuMs, p.ftMs, playerLoopMs, playerLoopId, driversFromRoot, driversFromPlayerLoop)); + } + + // Re-rank by playerLoopMs when most candidates have it (typical of editor+play and + // remote-player captures). Otherwise stay with CPU main thread ranking. + int withPlayerLoop = enriched.Count(e => e.playerLoopMs.HasValue && e.playerLoopMs.Value > 0); + bool rankByPlayerLoop = withPlayerLoop >= enriched.Count / 2 && withPlayerLoop > 0; + if (rankByPlayerLoop) + enriched.Sort((a, b) => (b.playerLoopMs ?? 0).CompareTo(a.playerLoopMs ?? 0)); + // (else already sorted by cpuMs from the first pass) + + int take = Math.Min(topN, enriched.Count); + var entries = new List(take); + for (int k = 0; k < take; k++) + { + var e = enriched[k]; + // When ranking by PlayerLoop, drivers should also come from inside PlayerLoop — + // editor IMGUI/repaint dominates the root view but isn't what the agent's tracking. + var drivers = rankByPlayerLoop && e.driversFromPlayerLoop.Length > 0 + ? e.driversFromPlayerLoop + : e.driversFromRoot; + entries.Add(new ProfileTopFrame + { + FrameIndex = e.idx, + AbsoluteFrameIndex = startFrame + e.idx, + CpuMainMs = Math.Round(e.cpuMs, 3), + FrameTimeMs = double.IsNaN(e.ftMs) ? 0 : Math.Round(e.ftMs, 3), + PlayerLoopMs = e.playerLoopMs.HasValue ? Math.Round(e.playerLoopMs.Value, 3) : (double?)null, + Drivers = drivers + }); + } + return entries.ToArray(); + } + + internal static double? FindDescendantTotalMs(HierarchyFrameDataView hv, int parentId, string name, int maxDepth = 10) + { + // PlayerLoop in editor+play mode is buried under + // EditorLoop > Application.Tick > UpdateScene > UpdateSceneIfNeeded > UpdateScene > PlayerLoop, + // and nested "PlayerLoop" markers can exist (e.g. a small outer one + the substantial + // gameplay one). We return the LARGEST match so we get the gameplay tick, not noise. + var queue = new Queue<(int Id, int Depth)>(); + queue.Enqueue((parentId, 0)); + var children = new List(); + double? best = null; + while (queue.Count > 0) + { + var (id, depth) = queue.Dequeue(); + if (depth > maxDepth) continue; + children.Clear(); + hv.GetItemChildren(id, children); + foreach (var c in children) + { + if (string.Equals(hv.GetItemName(c), name, StringComparison.Ordinal)) + { + var v = hv.GetItemColumnDataAsDouble(c, HierarchyFrameDataView.columnTotalTime); + if (!best.HasValue || v > best.Value) best = v; + } + queue.Enqueue((c, depth + 1)); + } + } + return best; + } + + internal static int? FindDescendantIdByName(HierarchyFrameDataView hv, int parentId, string name, int maxDepth = 10) + { + // Find the descendant with this name that has the LARGEST totalMs — handles nested + // markers of the same name (e.g. multiple PlayerLoop occurrences) by picking the one + // that actually wraps significant work. + var queue = new Queue<(int Id, int Depth)>(); + queue.Enqueue((parentId, 0)); + var children = new List(); + int? bestId = null; + double bestTotal = -1; + while (queue.Count > 0) + { + var (id, depth) = queue.Dequeue(); + if (depth > maxDepth) continue; + children.Clear(); + hv.GetItemChildren(id, children); + foreach (var c in children) + { + if (string.Equals(hv.GetItemName(c), name, StringComparison.Ordinal)) + { + var v = hv.GetItemColumnDataAsDouble(c, HierarchyFrameDataView.columnTotalTime); + if (v > bestTotal) { bestTotal = v; bestId = c; } + } + queue.Enqueue((c, depth + 1)); + } + } + return bestId; + } + + private static ProfileFrameDriver[] ResolveDrivers(HierarchyFrameDataView hv, int startId, int n) + { + // Walk down through dominant-single-child levels until we reach a real bifurcation, + // then return top-N children at that level. Caller picks the starting node — usually + // the hierarchy root, but PlayerLoop's id when we want gameplay-only attribution. + int currentId = startId; + const double DominanceRatio = 0.7; + const int MaxDescend = 10; + + for (int step = 0; step < MaxDescend; step++) + { + var children = new List(); + hv.GetItemChildren(currentId, children); + if (children.Count == 0) break; + + var ranked = children + .Select(c => new + { + Id = c, + Total = hv.GetItemColumnDataAsDouble(c, HierarchyFrameDataView.columnTotalTime) + }) + .Where(x => x.Total > 0.01) + .OrderByDescending(x => x.Total) + .ToList(); + if (ranked.Count == 0) break; + + double totalAtLevel = ranked.Sum(x => x.Total); + double biggestShare = totalAtLevel > 0 ? ranked[0].Total / totalAtLevel : 1.0; + + if (biggestShare < DominanceRatio) + return RankToDrivers(hv, ranked.Select(r => r.Id), n); + + currentId = ranked[0].Id; + } + + var fallback = new List(); + hv.GetItemChildren(currentId, fallback); + if (fallback.Count == 0) + return new[] { ItemToDriver(hv, currentId) }; + return RankToDrivers(hv, fallback, n); + } + + private static ProfileFrameDriver[] RankToDrivers(HierarchyFrameDataView hv, IEnumerable ids, int n) => + ids + .Select(c => ItemToDriver(hv, c)) + .OrderByDescending(d => d.TotalMs) + .Take(Math.Max(1, n)) + .ToArray(); + + private static ProfileFrameDriver ItemToDriver(HierarchyFrameDataView hv, int id) + { + var name = hv.GetItemName(id) ?? ""; + var total = hv.GetItemColumnDataAsDouble(id, HierarchyFrameDataView.columnTotalTime); + var self = hv.GetItemColumnDataAsDouble(id, HierarchyFrameDataView.columnSelfTime); + var calls = (int)hv.GetItemColumnDataAsDouble(id, HierarchyFrameDataView.columnCalls); + var gcKb = hv.GetItemColumnDataAsDouble(id, HierarchyFrameDataView.columnGcMemory) / 1024.0; + return new ProfileFrameDriver + { + Name = name, + TotalMs = Math.Round(total, 3), + SelfMs = Math.Round(self, 3), + Calls = calls, + GcKb = gcKb > 0.01 ? Math.Round(gcKb, 2) : (double?)null, + Hot = FindHotLeaf(hv, id) + }; + } + + /// + /// Walks the driver's subtree and returns the descendant with the highest self time — + /// the "what's actually slow" answer. Drivers are usually intermediate nodes (selfMs ~ 0) + /// so without this an agent gets a system-level totalMs but no clue where the wall-clock + /// goes inside it. + /// + private static ProfileFrameHotLeaf? FindHotLeaf(HierarchyFrameDataView hv, int rootId, int maxDepth = 12) + { + string? bestName = null; + double bestSelf = 0; + double bestTotal = 0; + var stack = new Stack<(int Id, int Depth)>(); + stack.Push((rootId, 0)); + var children = new List(); + while (stack.Count > 0) + { + var (id, depth) = stack.Pop(); + if (depth > maxDepth) continue; + if (id != rootId) + { + // Only consider descendants — the driver itself is reported separately. + var self = hv.GetItemColumnDataAsDouble(id, HierarchyFrameDataView.columnSelfTime); + if (self > bestSelf) + { + bestSelf = self; + bestTotal = hv.GetItemColumnDataAsDouble(id, HierarchyFrameDataView.columnTotalTime); + bestName = hv.GetItemName(id); + } + } + children.Clear(); + hv.GetItemChildren(id, children); + foreach (var c in children) stack.Push((c, depth + 1)); + } + // Filter out hot leaves that are essentially noise — anything below 0.05ms self is + // not worth reporting to the agent (Round to 3 dp would show 0.000 anyway). + if (bestName == null || bestSelf < 0.05) return null; + return new ProfileFrameHotLeaf + { + Name = bestName, + SelfMs = Math.Round(bestSelf, 3), + TotalMs = Math.Round(bestTotal, 3) + }; + } + + private static double ReadCounter(RawFrameDataView view, string statName) + { + int markerId = view.GetMarkerId(statName); + if (markerId == FrameDataView.invalidMarkerId) return double.NaN; + // Counter values arrive as long-encoded ints/ns. Conversion to ms etc. happens + // upstream based on the unit map. + return view.GetCounterValueAsLong(markerId); + } + + private void TrySaveProfile() + { + if (string.IsNullOrEmpty(SavePath)) return; + try + { + var dir = Path.GetDirectoryName(SavePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + ProfilerDriver.SaveProfile(SavePath); + } + catch (Exception ex) + { + Debug.LogWarning($"[UnityCtl] Failed to save profile to {SavePath}: {ex.Message}"); + } + } + + public void Cancel() + { + IsActive = false; + try { ProfilerDriver.enabled = false; } catch { } + } + + private static (ProfilerRecorderHandle Handle, ProfilerMarkerDataUnit Unit, ProfilerCategory Category) FindHandleByName(string name) + { + var list = new List(); + ProfilerRecorderHandle.GetAvailable(list); + foreach (var h in list) + { + var d = ProfilerRecorderHandle.GetDescription(h); + if (d.Name == name) + return (h, d.UnitType, d.Category); + } + return (default, ProfilerMarkerDataUnit.Undefined, default); + } + + private static double ConvertSample(double raw, ProfilerMarkerDataUnit unit) + { + if (double.IsNaN(raw)) return raw; + return unit switch + { + ProfilerMarkerDataUnit.TimeNanoseconds => raw / 1_000_000.0, // ns → ms + _ => raw + }; + } + + private static string UnitDisplay(ProfilerMarkerDataUnit unit) => unit switch + { + ProfilerMarkerDataUnit.TimeNanoseconds => "ms", + ProfilerMarkerDataUnit.Bytes => "bytes", + ProfilerMarkerDataUnit.Count => "count", + ProfilerMarkerDataUnit.Percent => "percent", + ProfilerMarkerDataUnit.FrequencyHz => "hz", + _ => "value" + }; + + private static double ComputeHitchThreshold(List frameTimesMs, double multiplier, double? abs) + { + if (abs.HasValue) return abs.Value; + if (frameTimesMs.Count == 0) return double.PositiveInfinity; + // Use median × multiplier as a robust baseline (avoids jitter from a few warmup frames). + var sorted = frameTimesMs.ToArray(); + Array.Sort(sorted); + var median = sorted[sorted.Length / 2]; + return median * multiplier; + } + + private static ProfileStatSummary BuildSummary(string name, string unit, double[] samples, bool includeSamples) + { + if (samples.Length == 0) + { + return new ProfileStatSummary + { + Name = name, + Unit = unit, + Frames = 0, + Avg = 0, + Min = 0, + Max = 0, + P50 = 0, + P95 = 0, + P99 = 0, + Samples = includeSamples ? samples : null + }; + } + + var valid = new List(samples.Length); + foreach (var s in samples) if (!double.IsNaN(s)) valid.Add(s); + if (valid.Count == 0) + { + return new ProfileStatSummary + { + Name = name, + Unit = unit, + Frames = samples.Length, + Avg = 0, Min = 0, Max = 0, P50 = 0, P95 = 0, P99 = 0, + Samples = includeSamples ? samples : null + }; + } + + var sorted = valid.ToArray(); + Array.Sort(sorted); + double sum = 0; + for (int i = 0; i < sorted.Length; i++) sum += sorted[i]; + + return new ProfileStatSummary + { + Name = name, + Unit = unit, + Frames = samples.Length, + Avg = sum / sorted.Length, + Min = sorted[0], + Max = sorted[sorted.Length - 1], + P50 = Percentile(sorted, 0.50), + P95 = Percentile(sorted, 0.95), + P99 = Percentile(sorted, 0.99), + Samples = includeSamples ? samples : null + }; + } + + private static double Percentile(double[] sortedValues, double p) + { + if (sortedValues.Length == 0) return 0; + var idx = (int)Math.Min(sortedValues.Length - 1, Math.Max(0, Math.Round(p * (sortedValues.Length - 1)))); + return sortedValues[idx]; + } + } + + /// + /// Singleton coordinator. Owns active profiling sessions, watches max-duration auto-stop, + /// and serves list-stats / start / stop / status / explain / hotspots / frame / mark / snapshot / targets RPCs. + /// + public class ProfilingManager + { + private static ProfilingManager _instance; + public static ProfilingManager Instance => _instance ??= new ProfilingManager(); + + private readonly Dictionary _sessions = new(); + private bool _tickHooked; + + // Curated alias map → real ProfilerRecorder stat names. + // Keep names that exist on Unity 6 / 2023.x. Aliases give agents stable handles + // independent of Unity's slightly inconsistent counter naming. + private static readonly Dictionary Aliases = new(StringComparer.OrdinalIgnoreCase) + { + // CPU/GPU frame timings (Render category — the public ones with stable names) + { "main", "CPU Main Thread Frame Time" }, + { "render", "CPU Render Thread Frame Time" }, + { "render-thread", "CPU Render Thread Frame Time" }, + { "gpu", "GPU Frame Time" }, + { "frame-time", "CPU Main Thread Frame Time" }, + { "total-frame", "CPU Total Frame Time" }, + // Internal/Main Thread fallback (slightly different units, sums all editor work) + { "main-internal", "Main Thread" }, + + // Render + { "drawcalls", "Draw Calls Count" }, + { "draw-calls", "Draw Calls Count" }, + { "setpass", "SetPass Calls Count" }, + { "batches", "Batches Count" }, + { "triangles", "Triangles Count" }, + { "tris", "Triangles Count" }, + { "vertices", "Vertices Count" }, + { "verts", "Vertices Count" }, + + // Memory + { "system-memory", "System Used Memory" }, + { "gc-reserved", "GC Reserved Memory" }, + { "gc-used", "GC Used Memory" }, + { "gc-alloc", "GC Allocated In Frame" }, + { "total-memory", "Total Reserved Memory" }, + { "total-used", "Total Used Memory" } + }; + + public static string ResolveAlias(string name) => + Aliases.TryGetValue(name, out var resolved) ? resolved : name; + + public ProfileListStatsResult ListStats(string? categoryFilter) + { + var list = new List(); + ProfilerRecorderHandle.GetAvailable(list); + + var stats = new List(list.Count); + foreach (var h in list) + { + var d = ProfilerRecorderHandle.GetDescription(h); + var category = d.Category.Name; + if (!string.IsNullOrEmpty(categoryFilter) && + !string.Equals(category, categoryFilter, StringComparison.OrdinalIgnoreCase)) + continue; + stats.Add(new ProfileStatInfo + { + Name = d.Name, + Category = category, + Unit = d.UnitType.ToString(), + DataType = d.DataType.ToString() + }); + } + + stats.Sort((a, b) => string.CompareOrdinal(a.Category + "/" + a.Name, b.Category + "/" + b.Name)); + + return new ProfileListStatsResult + { + Count = stats.Count, + Stats = stats.ToArray() + }; + } + + public ProfileStartResult Start( + string[] requestedStats, + double? maxDurationSeconds, + string? target, + bool targetIsRemote, + string? savePath, + bool driveEditorProfiler) + { + var sessionId = Guid.NewGuid().ToString("N").Substring(0, 12); + + // Resolve aliases. + var resolvedStats = requestedStats.Select(ResolveAlias).Distinct().ToArray(); + + var session = new ProfilingSession( + sessionId, + resolvedStats, + maxDurationSeconds, + target, + targetIsRemote, + savePath, + driveEditorProfiler); + + _sessions[sessionId] = session; + EnsureTickHooked(); + + session.StartEditorProfilerCapture(); + + Debug.Log($"[UnityCtl] Profiling session {sessionId} started: stats=[{string.Join(",", resolvedStats)}]" + + (maxDurationSeconds.HasValue ? $" max={maxDurationSeconds}s" : "")); + + return new ProfileStartResult + { + SessionId = sessionId, + Stats = resolvedStats, + StartedAt = session.StartedAtUtc.ToString("o"), + MaxDurationSeconds = maxDurationSeconds, + Target = target, + TargetIsRemote = targetIsRemote, + SavePath = savePath + }; + } + + public ProfileStopResult Stop(string sessionId, bool includeSamples, double hitchMultiplier, double? hitchAbsoluteMs) + { + if (!_sessions.TryGetValue(sessionId, out var session)) + throw new InvalidOperationException($"No active profiling session: {sessionId}"); + + _sessions.Remove(sessionId); + UnhookIfIdle(); + return session.Stop(includeSamples, hitchMultiplier, hitchAbsoluteMs); + } + + public ProfileStatusResult Status() + { + var entries = new List(); + foreach (var (id, s) in _sessions) + { + entries.Add(new ProfileStatusEntry + { + SessionId = id, + StartedAt = s.StartedAtUtc.ToString("o"), + ElapsedSeconds = (DateTime.UtcNow - s.StartedAtUtc).TotalSeconds, + Frames = s.CurrentFrameCount, + Stats = s.StatNames, + MaxDurationSeconds = s.MaxDurationSeconds, + Target = s.Target, + TargetIsRemote = s.TargetIsRemote + }); + } + return new ProfileStatusResult { Sessions = entries.ToArray() }; + } + + public ProfileTargetsResult Targets() + { + // Unity exposes connected profiler targets via ProfilerDriver.GetAvailableProfilers. + // We surface the raw list — selection during Start happens via ProfilerDriver.connectedProfiler. + var result = new List(); + try + { + var available = ProfilerDriver.GetAvailableProfilers(); + var current = ProfilerDriver.connectedProfiler; + foreach (var connId in available) + { + var name = ProfilerDriver.GetConnectionIdentifier(connId); + if (string.IsNullOrEmpty(name)) name = $"target-{connId}"; + result.Add(new ProfileTargetInfo + { + Id = connId.ToString(), + DisplayName = name, + Kind = connId == 0 ? "editor" : "player", + IsCurrent = connId == current + }); + } + } + catch (Exception ex) + { + Debug.LogWarning($"[UnityCtl] Failed to enumerate profiler targets: {ex.Message}"); + } + + return new ProfileTargetsResult { Targets = result.ToArray() }; + } + + public void SelectTarget(int connectionId) + { + ProfilerDriver.connectedProfiler = connectionId; + } + + public string DirectConnect(string url) + { + // Url like "127.0.0.1:54999" for an adb-forwarded Android player. + ProfilerDriver.DirectURLConnect(url); + return ProfilerDriver.directConnectionUrl; + } + + // ---------- explain / hotspots / frame ---------- + + /// + /// Walks the profiler buffer for a single frame and returns the top-N markers by self time + /// across all hierarchy depths. The agent uses this to diagnose what made a hitch frame slow: + /// hitches carry an absoluteFrameIndex that can be passed straight into Explain. + /// + public ProfileExplainResult Explain(int frameIndex, int threadIndex, int topN) + { + int first = ProfilerDriver.firstFrameIndex; + int last = ProfilerDriver.lastFrameIndex; + if (frameIndex < first || frameIndex > last) + throw new InvalidOperationException( + $"Frame {frameIndex} is outside the profiler buffer [{first}, {last}]. " + + "Run a `profile capture` first or pass a frame inside that range."); + + var bucket = new Dictionary(StringComparer.Ordinal); + string threadName = ""; + double frameTimeMs = 0; + + using (var raw = ProfilerDriver.GetRawFrameDataView(frameIndex, threadIndex)) + { + if (raw == null || !raw.valid) + throw new InvalidOperationException($"No frame data for frame {frameIndex}, thread {threadIndex}."); + threadName = raw.threadName ?? ""; + frameTimeMs = raw.frameTimeNs / 1_000_000.0; + } + + using var hv = ProfilerDriver.GetHierarchyFrameDataView( + frameIndex, threadIndex, + HierarchyFrameDataView.ViewModes.Default, + HierarchyFrameDataView.columnSelfTime, + sortAscending: false); + if (hv == null || !hv.valid) + throw new InvalidOperationException($"No hierarchy data for frame {frameIndex}, thread {threadIndex}."); + + AccumulateMarkers(hv, hv.GetRootItemID(), bucket); + + var top = bucket.Values + .OrderByDescending(m => m.SelfTimeMs) + .Take(Math.Max(1, topN)) + .Select(m => m.ToEntry()) + .ToArray(); + + return new ProfileExplainResult + { + FrameIndex = frameIndex, + ThreadIndex = threadIndex, + ThreadName = threadName, + FrameTimeMs = frameTimeMs, + TopMarkers = top + }; + } + + /// + /// Aggregates self time across a frame range and returns the top-N hottest markers. + /// Defaults to the entire profiler buffer when start/end are unspecified. + /// When `rootMarker` is set, accumulation only descends into the named child of the frame + /// root — useful for filtering to PlayerLoop in editor+play captures (excludes editor + /// IMGUI / OnGUI / menu-item update callbacks that otherwise drown out gameplay markers). + /// + public ProfileHotspotsResult Hotspots(int? startFrame, int? endFrame, int threadIndex, int topN, string? rootMarker) + { + int first = ProfilerDriver.firstFrameIndex; + int last = ProfilerDriver.lastFrameIndex; + int s = Math.Max(first, startFrame ?? first); + int e = Math.Min(last, endFrame ?? last); + if (e < s) + throw new InvalidOperationException( + $"Empty frame range [{s}, {e}]. Profiler buffer holds [{first}, {last}]."); + + var bucket = new Dictionary(StringComparer.Ordinal); + string threadName = ""; + int framesProcessed = 0; + int framesWithRoot = 0; + + for (int f = s; f <= e; f++) + { + using var hv = ProfilerDriver.GetHierarchyFrameDataView( + f, threadIndex, + HierarchyFrameDataView.ViewModes.Default, + HierarchyFrameDataView.columnSelfTime, + sortAscending: false); + if (hv == null || !hv.valid) continue; + + if (string.IsNullOrEmpty(threadName)) + { + using var raw = ProfilerDriver.GetRawFrameDataView(f, threadIndex); + if (raw != null && raw.valid) threadName = raw.threadName ?? ""; + } + + int walkFrom = hv.GetRootItemID(); + if (!string.IsNullOrEmpty(rootMarker)) + { + int? rootChild = ProfilingSession.FindDescendantIdByName(hv, walkFrom, rootMarker); + if (rootChild == null) continue; // skip frames that don't have the requested root + walkFrom = rootChild.Value; + framesWithRoot++; + } + AccumulateMarkers(hv, walkFrom, bucket); + framesProcessed++; + } + + var top = bucket.Values + .OrderByDescending(m => m.SelfTimeMs) + .Take(Math.Max(1, topN)) + .Select(m => m.ToEntry()) + .ToArray(); + + return new ProfileHotspotsResult + { + StartFrame = s, + EndFrame = e, + FrameCount = framesProcessed, + ThreadIndex = threadIndex, + ThreadName = threadName, + TopMarkers = top + }; + } + + + /// + /// Hierarchy tree drill-down for a single frame. Prunes nodes below thresholdMs and keeps + /// the top-N children per parent, so the response stays bounded for deep hierarchies. + /// Pairs with hitches[].absoluteFrameIndex — pass a hitch's index to see what's inside it. + /// When `rootMarker` is set, the tree starts at that named subtree (e.g. PlayerLoop) instead + /// of the frame root — useful for skipping past EditorLoop noise in editor+play captures. + /// + public ProfileFrameResult Frame(int frameIndex, int threadIndex, int maxDepth, double thresholdMs, int topPerNode, string? rootMarker) + { + int first = ProfilerDriver.firstFrameIndex; + int last = ProfilerDriver.lastFrameIndex; + if (frameIndex < first || frameIndex > last) + throw new InvalidOperationException( + $"Frame {frameIndex} is outside the profiler buffer [{first}, {last}]."); + + // MergeSamplesWithTheSameName collapses repeated calls to the same marker into one + // node per parent, which is what an agent wants for a readable tree. + using var hv = ProfilerDriver.GetHierarchyFrameDataView( + frameIndex, threadIndex, + HierarchyFrameDataView.ViewModes.MergeSamplesWithTheSameName, + HierarchyFrameDataView.columnTotalTime, + sortAscending: false); + if (hv == null || !hv.valid) + throw new InvalidOperationException($"No hierarchy data for frame {frameIndex}, thread {threadIndex}."); + + string threadName = ""; + double frameTimeMs; + using (var raw = ProfilerDriver.GetRawFrameDataView(frameIndex, threadIndex)) + { + if (raw != null && raw.valid) + { + threadName = raw.threadName ?? ""; + frameTimeMs = raw.frameTimeNs / 1_000_000.0; + } + else + { + var rootId = hv.GetRootItemID(); + frameTimeMs = hv.GetItemColumnDataAsDouble(rootId, HierarchyFrameDataView.columnTotalTime); + } + } + + int treeRoot = hv.GetRootItemID(); + if (!string.IsNullOrEmpty(rootMarker)) + { + int? scoped = ProfilingSession.FindDescendantIdByName(hv, treeRoot, rootMarker); + if (scoped == null) + throw new InvalidOperationException( + $"Marker '{rootMarker}' not found in frame {frameIndex}'s hierarchy. " + + $"Try without --root, or pick a different subtree (PlayerLoop / EditorLoop / Application.Tick)."); + treeRoot = scoped.Value; + } + + int prunedNodes = 0; + var tree = BuildFrameTree(hv, treeRoot, 0, maxDepth, (float)thresholdMs, topPerNode, ref prunedNodes); + + return new ProfileFrameResult + { + FrameIndex = frameIndex, + ThreadIndex = threadIndex, + ThreadName = threadName, + FrameTimeMs = frameTimeMs, + Depth = maxDepth, + ThresholdMs = thresholdMs, + PrunedNodes = prunedNodes, + Tree = tree ?? Array.Empty() + }; + } + + private static ProfileFrameNode[]? BuildFrameTree( + HierarchyFrameDataView hv, int id, int depth, int maxDepth, + float thresholdMs, int topPerNode, ref int pruned) + { + if (depth >= maxDepth) return null; + + var children = new List(); + hv.GetItemChildren(id, children); + if (children.Count == 0) return null; + + // Snapshot all children with their times, then prune. + var entries = new List<(int Id, string Name, float SelfMs, float TotalMs, int Calls, float GcKb)>(children.Count); + foreach (var c in children) + { + var name = hv.GetItemName(c) ?? ""; + var selfMs = hv.GetItemColumnDataAsFloat(c, HierarchyFrameDataView.columnSelfTime); + var totalMs = hv.GetItemColumnDataAsFloat(c, HierarchyFrameDataView.columnTotalTime); + var calls = (int)hv.GetItemColumnDataAsFloat(c, HierarchyFrameDataView.columnCalls); + var gcKb = hv.GetItemColumnDataAsFloat(c, HierarchyFrameDataView.columnGcMemory) / 1024f; + entries.Add((c, name, selfMs, totalMs, calls, gcKb)); + } + + var kept = entries + .Where(e => e.TotalMs >= thresholdMs) + .OrderByDescending(e => e.TotalMs) + .Take(Math.Max(1, topPerNode)) + .ToList(); + pruned += entries.Count - kept.Count; + + var result = new List(kept.Count); + foreach (var n in kept) + { + var sub = BuildFrameTree(hv, n.Id, depth + 1, maxDepth, thresholdMs, topPerNode, ref pruned); + result.Add(new ProfileFrameNode + { + Name = n.Name, + SelfMs = Math.Round(n.SelfMs, 3), + TotalMs = Math.Round(n.TotalMs, 3), + Calls = n.Calls, + GcKb = n.GcKb > 0.01f ? Math.Round(n.GcKb, 2) : (double?)null, + Children = (sub != null && sub.Length > 0) ? sub : null + }); + } + return result.ToArray(); + } + + private sealed class MarkerAccum + { + public string Name = ""; + public double SelfTimeMs; + public int Calls; + public long GcAllocBytes; + + public ProfileMarkerEntry ToEntry() => new() + { + Name = Name, + SelfTimeMs = SelfTimeMs, + Calls = Calls, + GcAllocBytes = GcAllocBytes > 0 ? GcAllocBytes : (long?)null + }; + } + + private static void AccumulateMarkers(HierarchyFrameDataView hv, int itemId, Dictionary bucket) + { + // Walk every node in the tree; aggregate self time / calls / GC alloc by marker name. + // Self time is additive across nesting (each call's self contribution is disjoint from + // its children's), so summing across the tree gives the right per-marker total. Total + // time is intentionally not aggregated — it would double-count along the parent chain. + var children = new List(); + hv.GetItemChildren(itemId, children); + foreach (var c in children) + { + var name = hv.GetItemName(c); + if (string.IsNullOrEmpty(name)) { AccumulateMarkers(hv, c, bucket); continue; } + + if (!bucket.TryGetValue(name, out var acc)) + { + acc = new MarkerAccum { Name = name }; + bucket[name] = acc; + } + // Unity 6 only exposes Single/Double/Float column readers — long would overflow for + // very large GC allocs, but Double has 53-bit mantissa which covers 8 PiB cleanly. + acc.SelfTimeMs += hv.GetItemColumnDataAsDouble(c, HierarchyFrameDataView.columnSelfTime); + acc.Calls += (int)hv.GetItemColumnDataAsDouble(c, HierarchyFrameDataView.columnCalls); + acc.GcAllocBytes += (long)hv.GetItemColumnDataAsDouble(c, HierarchyFrameDataView.columnGcMemory); + + AccumulateMarkers(hv, c, bucket); + } + } + + // ---------- mark ---------- + + /// + /// Wraps a user-provided C# expression in a ProfilerMarker + Stopwatch + per-thread GC + /// allocation accounting, runs it `repeat` times, and returns timing percentiles + GC bytes. + /// Re-uses the editor-side ScriptExecutor so the expression has access to the full editor + /// API surface and any runtime types the agent has loaded. + /// + public ProfileMarkResult Mark(string expression, string markerName, int repeat) + { + if (string.IsNullOrWhiteSpace(expression)) + throw new InvalidOperationException("Expression is required."); + if (repeat < 1) repeat = 1; + if (string.IsNullOrWhiteSpace(markerName)) markerName = "unityctl.mark"; + + // String literals must escape via Newtonsoft JSON — same trick fire/profile/mark.cs uses + // to safely embed user-supplied marker name inside generated source. + var nameLit = Newtonsoft.Json.JsonConvert.ToString(markerName); + + var code = $@" +using System; +using System.Diagnostics; +using System.Linq; +using Unity.Profiling; +using UnityEngine; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine.SceneManagement; +using Object = UnityEngine.Object; + +public class Marked +{{ + public static object Run() + {{ + var marker = new ProfilerMarker({nameLit}); + int repeat = {repeat}; + var samples = new double[repeat]; + long totalGcBytes = 0; + object lastResult = null; + for (int i = 0; i < repeat; i++) + {{ + long gc0 = System.GC.GetAllocatedBytesForCurrentThread(); + var sw = Stopwatch.StartNew(); + marker.Begin(); + try {{ lastResult = ({expression}); }} + finally {{ marker.End(); sw.Stop(); }} + samples[i] = sw.Elapsed.TotalMilliseconds; + totalGcBytes += System.GC.GetAllocatedBytesForCurrentThread() - gc0; + }} + var sorted = samples.OrderBy(x => x).ToArray(); + double pct(double q) => sorted[Math.Min(sorted.Length - 1, (int)Math.Round(q * (sorted.Length - 1)))]; + return new + {{ + name = {nameLit}, + repeat, + meanMs = Math.Round(samples.Average(), 4), + minMs = Math.Round(sorted[0], 4), + maxMs = Math.Round(sorted[sorted.Length - 1], 4), + p50Ms = Math.Round(pct(0.50), 4), + p95Ms = Math.Round(pct(0.95), 4), + gcBytes = totalGcBytes, + gcBytesPerCall = totalGcBytes / repeat, + result = lastResult?.ToString() + }}; + }} +}} +"; + + var res = ScriptExecutor.Execute(code, "Marked", "Run", null); + if (!res.Success) + { + var detail = res.Error ?? ""; + if (res.Diagnostics != null && res.Diagnostics.Length > 0) + detail += "\nDiagnostics:\n " + string.Join("\n ", res.Diagnostics); + throw new InvalidOperationException($"profile mark failed to compile/run expression: {detail}"); + } + + var json = res.Result ?? "null"; + var parsed = Newtonsoft.Json.Linq.JObject.Parse(json); + return new ProfileMarkResult + { + Name = (string)parsed["name"]!, + Repeat = (int)parsed["repeat"]!, + MeanMs = (double)parsed["meanMs"]!, + MinMs = (double)parsed["minMs"]!, + MaxMs = (double)parsed["maxMs"]!, + P50Ms = (double)parsed["p50Ms"]!, + P95Ms = (double)parsed["p95Ms"]!, + GcBytes = (long)parsed["gcBytes"]!, + GcBytesPerCall = (long)parsed["gcBytesPerCall"]!, + Result = parsed["result"]?.Type == Newtonsoft.Json.Linq.JTokenType.Null ? null : (string?)parsed["result"] + }; + } + + public ProfileSnapshotResult MemorySnapshot(string outputPath) + { + // Use Memory Profiler package when available; otherwise return a clear error. + // The dependency is loaded reflectively to avoid forcing the package on users. + var asm = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "Unity.MemoryProfiler.Runtime" || a.GetName().Name == "Unity.MemoryProfiler"); + if (asm == null) + { + throw new InvalidOperationException( + "Memory Profiler package not found. Install com.unity.memoryprofiler via Package Manager."); + } + + var mpType = asm.GetType("Unity.Profiling.Memory.MemoryProfiler"); + if (mpType == null) + { + throw new InvalidOperationException("Unity.Profiling.Memory.MemoryProfiler type not found in loaded assemblies."); + } + + var dir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var done = false; + string? finalPath = null; + Exception? captureError = null; + + // Find TakeSnapshot(string, Action, CaptureFlags) overload. + var captureFlagsType = asm.GetType("Unity.Profiling.Memory.CaptureFlags"); + var defaultFlags = captureFlagsType != null + ? Enum.Parse(captureFlagsType, "ManagedObjects,NativeObjects,NativeAllocations") + : null; + + var actionType = typeof(Action<,>).MakeGenericType(typeof(string), typeof(bool)); + var takeSnapshot = mpType.GetMethods() + .FirstOrDefault(m => m.Name == "TakeSnapshot" && m.GetParameters().Length == 3 && + m.GetParameters()[0].ParameterType == typeof(string)); + if (takeSnapshot == null) + throw new InvalidOperationException("TakeSnapshot(string, callback, flags) overload not found."); + + Action callback = (p, ok) => + { + done = true; + if (ok) finalPath = p; + else captureError = new InvalidOperationException("Memory snapshot capture failed."); + }; + + takeSnapshot.Invoke(null, new object?[] { outputPath, callback, defaultFlags }); + + // Pump until callback fires or timeout. The capture is asynchronous on the editor's main loop. + var deadline = DateTime.UtcNow.AddSeconds(120); + while (!done && DateTime.UtcNow < deadline) + { + System.Threading.Thread.Sleep(50); + } + + if (!done) throw new TimeoutException("Memory snapshot capture timed out after 120s."); + if (captureError != null) throw captureError; + + var path = finalPath ?? outputPath; + var size = File.Exists(path) ? new FileInfo(path).Length : 0L; + return new ProfileSnapshotResult { Path = path, SizeBytes = size }; + } + + private void EnsureTickHooked() + { + if (_tickHooked) return; + EditorApplication.update += Tick; + _tickHooked = true; + } + + private void UnhookIfIdle() + { + if (_sessions.Count > 0 || !_tickHooked) return; + EditorApplication.update -= Tick; + _tickHooked = false; + } + + // The tick is now only used to enforce max-duration auto-stop. Per-frame counter + // sampling moved into Stop() (post-hoc RawFrameDataView walk), since editor ticks + // don't line up with rendered frames. + private void Tick() + { + if (_sessions.Count == 0) return; + + string[] ids; + { + ids = new string[_sessions.Count]; + int i = 0; + foreach (var k in _sessions.Keys) ids[i++] = k; + } + + foreach (var id in ids) + { + if (!_sessions.TryGetValue(id, out var s)) continue; + + if (s.MaxDurationSeconds.HasValue && + (DateTime.UtcNow - s.StartedAtUtc).TotalSeconds >= s.MaxDurationSeconds.Value) + { + Debug.Log($"[UnityCtl] Profiling session {id} reached max-duration; auto-stopping."); + var cached = s.Stop(includeSamples: false, hitchMultiplier: 2.0, hitchAbsoluteMs: null); + _sessions.Remove(id); + _autoStopped[id] = cached; + } + } + + UnhookIfIdle(); + } + + private readonly Dictionary _autoStopped = new(); + + public bool TryGetAutoStopped(string sessionId, out ProfileStopResult result) => + _autoStopped.TryGetValue(sessionId, out result!); + + public bool ConsumeAutoStopped(string sessionId, out ProfileStopResult result) + { + if (_autoStopped.TryGetValue(sessionId, out result!)) + { + _autoStopped.Remove(sessionId); + return true; + } + return false; + } + } +} diff --git a/UnityCtl.UnityPackage/Editor/ProfilingManager.cs.meta b/UnityCtl.UnityPackage/Editor/ProfilingManager.cs.meta new file mode 100644 index 0000000..2bce223 --- /dev/null +++ b/UnityCtl.UnityPackage/Editor/ProfilingManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 402e08f0567b4561946cff481eed04ee diff --git a/UnityCtl.UnityPackage/Editor/UnityCtlClient.cs b/UnityCtl.UnityPackage/Editor/UnityCtlClient.cs index 6ce97e3..ee3faa5 100644 --- a/UnityCtl.UnityPackage/Editor/UnityCtlClient.cs +++ b/UnityCtl.UnityPackage/Editor/UnityCtlClient.cs @@ -587,6 +587,56 @@ private void HandleCommand(RequestMessage request) result = new { status = "pong" }; break; + case UnityCtlCommands.ProfileListStats: + result = HandleProfileListStats(request); + break; + + case UnityCtlCommands.ProfileStart: + result = HandleProfileStart(request); + break; + + case UnityCtlCommands.ProfileStop: + result = HandleProfileStop(request); + break; + + case UnityCtlCommands.ProfileStatus: + result = Editor.ProfilingManager.Instance.Status(); + break; + + case UnityCtlCommands.ProfileSnapshot: + result = HandleProfileMemorySnapshot(request); + break; + + case UnityCtlCommands.ProfileTargets: + result = Editor.ProfilingManager.Instance.Targets(); + break; + + case UnityCtlCommands.ProfileConnect: + { + var url = GetStringArgument(request, "url"); + if (string.IsNullOrEmpty(url)) + throw new InvalidOperationException("'url' argument is required"); + var connected = Editor.ProfilingManager.Instance.DirectConnect(url); + result = new { url = connected }; + } + break; + + case UnityCtlCommands.ProfileExplain: + result = HandleProfileExplain(request); + break; + + case UnityCtlCommands.ProfileHotspots: + result = HandleProfileHotspots(request); + break; + + case UnityCtlCommands.ProfileFrame: + result = HandleProfileFrame(request); + break; + + case UnityCtlCommands.ProfileMark: + result = HandleProfileMark(request); + break; + default: SendResponseError(request.RequestId, "unknown_command", $"Unknown command: {request.Command}"); return; @@ -2649,6 +2699,134 @@ private static string FormatQuaternion(Quaternion q) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "({0:F1}, {1:F1}, {2:F1})", euler.x, euler.y, euler.z); } + private object HandleProfileListStats(RequestMessage request) + { + var category = GetStringArgument(request, "category"); + return Editor.ProfilingManager.Instance.ListStats(category); + } + + private object HandleProfileStart(RequestMessage request) + { + string[] stats; + try + { + stats = GetStringArrayArgument(request, "stats") ?? Array.Empty(); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Invalid 'stats' argument: {ex.Message}"); + } + if (stats.Length == 0) + { + // Sensible default vitals: frame time, render thread, GPU, draw calls, gc-alloc, system memory. + stats = new[] + { + "CPU Main Thread Frame Time", "CPU Render Thread Frame Time", "GPU Frame Time", + "Draw Calls Count", "GC Allocated In Frame", "System Used Memory" + }; + } + + var maxDuration = GetDoubleArgument(request, "maxDurationSeconds"); + var target = GetStringArgument(request, "target"); + var savePath = GetStringArgument(request, "savePath"); + var driveProfiler = !string.IsNullOrEmpty(savePath); + + // Optional: route to a connected target (e.g., Android player) by id. + // If no --target was passed, infer remote-ness from the current connection + // (autoconnect-profiler builds will already have switched to the device). + if (!string.IsNullOrEmpty(target) && target != "editor") + { + if (int.TryParse(target, out var targetId)) + Editor.ProfilingManager.Instance.SelectTarget(targetId); + } + // Editor sentinel is -1 in Unity 6 (other versions: 0). Anything else = remote. + var currentConn = UnityEditorInternal.ProfilerDriver.connectedProfiler; + bool targetIsRemote = currentConn != -1 && currentConn != 0; + if (targetIsRemote) + { + // Resolve display name for the connected player so the response carries it. + var connName = UnityEditorInternal.ProfilerDriver.GetConnectionIdentifier(currentConn); + if (!string.IsNullOrEmpty(connName)) target = connName; + } + + return Editor.ProfilingManager.Instance.Start( + stats, maxDuration, target, targetIsRemote, savePath, driveProfiler); + } + + private object HandleProfileStop(RequestMessage request) + { + var sessionId = GetStringArgument(request, "sessionId"); + if (string.IsNullOrEmpty(sessionId)) + throw new InvalidOperationException("'sessionId' argument is required for profile.stop"); + + var includeSamples = GetBoolArgument(request, "includeSamples"); + var hitchMultiplier = GetDoubleArgument(request, "hitchMultiplier") ?? 2.0; + var hitchAbsoluteMs = GetDoubleArgument(request, "hitchAbsoluteMs"); + + // If the session was auto-stopped due to maxDuration, return the cached result. + if (Editor.ProfilingManager.Instance.ConsumeAutoStopped(sessionId, out var cached)) + return cached; + + return Editor.ProfilingManager.Instance.Stop(sessionId, includeSamples, hitchMultiplier, hitchAbsoluteMs); + } + + private object HandleProfileMemorySnapshot(RequestMessage request) + { + var output = GetStringArgument(request, "output"); + if (string.IsNullOrEmpty(output)) + { + var name = $"memory-{DateTime.UtcNow:yyyyMMdd-HHmmss}.snap"; + output = System.IO.Path.Combine("MemoryCaptures", name); + } + // Resolve relative to project root (parent of Assets folder). + if (!System.IO.Path.IsPathRooted(output)) + output = System.IO.Path.Combine(System.IO.Directory.GetCurrentDirectory(), output); + return Editor.ProfilingManager.Instance.MemorySnapshot(output); + } + + private object HandleProfileExplain(RequestMessage request) + { + var frameIndex = GetIntArgument(request, "frameIndex"); + if (!frameIndex.HasValue) + throw new InvalidOperationException("'frameIndex' argument is required for profile.explain"); + var threadIndex = GetIntArgument(request, "threadIndex") ?? 0; + var topN = GetIntArgument(request, "topN") ?? 20; + return Editor.ProfilingManager.Instance.Explain(frameIndex.Value, threadIndex, topN); + } + + private object HandleProfileHotspots(RequestMessage request) + { + var startFrame = GetIntArgument(request, "startFrame"); + var endFrame = GetIntArgument(request, "endFrame"); + var threadIndex = GetIntArgument(request, "threadIndex") ?? 0; + var topN = GetIntArgument(request, "topN") ?? 20; + var rootMarker = GetStringArgument(request, "rootMarker"); + return Editor.ProfilingManager.Instance.Hotspots(startFrame, endFrame, threadIndex, topN, rootMarker); + } + + private object HandleProfileFrame(RequestMessage request) + { + var frameIndex = GetIntArgument(request, "frameIndex"); + if (!frameIndex.HasValue) + throw new InvalidOperationException("'frameIndex' argument is required for profile.frame"); + var threadIndex = GetIntArgument(request, "threadIndex") ?? 0; + var depth = GetIntArgument(request, "depth") ?? 3; + var thresholdMs = GetDoubleArgument(request, "thresholdMs") ?? 0.2; + var topPerNode = GetIntArgument(request, "topPerNode") ?? 8; + var rootMarker = GetStringArgument(request, "rootMarker"); + return Editor.ProfilingManager.Instance.Frame(frameIndex.Value, threadIndex, depth, thresholdMs, topPerNode, rootMarker); + } + + private object HandleProfileMark(RequestMessage request) + { + var expression = GetStringArgument(request, "expression"); + if (string.IsNullOrEmpty(expression)) + throw new InvalidOperationException("'expression' argument is required for profile.mark"); + var name = GetStringArgument(request, "name"); + var repeat = GetIntArgument(request, "repeat") ?? 1; + return Editor.ProfilingManager.Instance.Mark(expression, name ?? "unityctl.mark", repeat); + } + private object HandleRecordStart(RequestMessage request) { var outputName = GetStringArgument(request, "outputName"); diff --git a/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll b/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll index f9b83a1..138caec 100644 Binary files a/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll and b/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll differ diff --git a/unity-project/.gitignore b/unity-project/.gitignore index a280275..939f723 100644 --- a/unity-project/.gitignore +++ b/unity-project/.gitignore @@ -62,11 +62,15 @@ ExportedObj/ sysinfo.txt # Builds +[Bb]uilds/ *.apk *.aab *.unitypackage *.app +# UnityCtl test artifacts +.utmp/ + # Crashlytics generated file crashlytics-build.properties diff --git a/unity-project/Assets/Editor/AndroidProfileBuild.cs b/unity-project/Assets/Editor/AndroidProfileBuild.cs new file mode 100644 index 0000000..7d623f1 --- /dev/null +++ b/unity-project/Assets/Editor/AndroidProfileBuild.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; +using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; +using UnityEngine; + +namespace UnityCtl.TestProject.Editor +{ + /// + /// One-off helper to produce a development+autoconnect Android build for profiling tests. + /// Use from unityctl: `./uc script eval "UnityCtl.TestProject.Editor.AndroidProfileBuild.Build()"`. + /// + public static class AndroidProfileBuild + { + public const string AppIdentifier = "com.dirtybit.unityctl.test"; + public const string ProductName = "unityctl-test"; + + public static readonly string MarkerPath = + Path.Combine(Application.dataPath, "..", "Builds", "Android", "build-status.txt"); + + /// + /// Schedule the build for the next editor tick and return immediately. The build + /// can run for many minutes; callers poll for status. + /// + [MenuItem("Tools/UnityCtl/Build Android Profile (Dev + AutoConnect)")] + public static void ScheduleBuild() + { + var dir = Path.GetDirectoryName(MarkerPath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + File.WriteAllText(MarkerPath, "RUNNING\n"); + Debug.Log("[AndroidProfileBuild] Scheduled — running on next editor tick."); + EditorApplication.delayCall += () => + { + try + { + var apk = Build(); + File.WriteAllText(MarkerPath, $"OK\n{apk}\n"); + Debug.Log($"[AndroidProfileBuild] OK: {apk}"); + } + catch (Exception ex) + { + File.WriteAllText(MarkerPath, $"FAIL\n{ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}\n"); + Debug.LogError($"[AndroidProfileBuild] FAIL: {ex}"); + } + }; + } + + public static string Build() + { + // Identifier + product name — keep separate from other Unity test apps on device. + PlayerSettings.SetApplicationIdentifier(NamedBuildTarget.Android, AppIdentifier); + PlayerSettings.productName = ProductName; + PlayerSettings.SetScriptingBackend(NamedBuildTarget.Android, ScriptingImplementation.IL2CPP); + + // Modern Android (arm64-v8a is the only ABI Google Play accepts now). + PlayerSettings.Android.targetArchitectures = AndroidArchitecture.ARM64; + + // Enable Development + Autoconnect Profiler globally for the build. + EditorUserBuildSettings.development = true; + EditorUserBuildSettings.connectProfiler = true; + EditorUserBuildSettings.allowDebugging = true; + EditorUserBuildSettings.buildAppBundle = false; + + var outputDir = Path.Combine(Application.dataPath, "..", "Builds", "Android"); + Directory.CreateDirectory(outputDir); + var outputPath = Path.Combine(outputDir, "unityctl-test.apk"); + + var options = new BuildPlayerOptions + { + scenes = new[] { "Assets/Scenes/SampleScene.unity" }, + locationPathName = outputPath, + target = BuildTarget.Android, + targetGroup = BuildTargetGroup.Android, + options = BuildOptions.Development | BuildOptions.ConnectWithProfiler | BuildOptions.AllowDebugging + }; + + var report = BuildPipeline.BuildPlayer(options); + var summary = report.summary; + if (summary.result != BuildResult.Succeeded) + { + throw new Exception($"Android build failed: {summary.result} ({summary.totalErrors} errors)"); + } + + return Path.GetFullPath(outputPath); + } + } +} diff --git a/unity-project/Assets/Editor/AndroidProfileBuild.cs.meta b/unity-project/Assets/Editor/AndroidProfileBuild.cs.meta new file mode 100644 index 0000000..9d0fa72 --- /dev/null +++ b/unity-project/Assets/Editor/AndroidProfileBuild.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0ec8fa3982694004da0161d480388632 \ No newline at end of file diff --git a/unity-project/Assets/Settings/UniversalRP.asset b/unity-project/Assets/Settings/UniversalRP.asset index fccfdda..e580da1 100644 --- a/unity-project/Assets/Settings/UniversalRP.asset +++ b/unity-project/Assets/Settings/UniversalRP.asset @@ -78,6 +78,7 @@ MonoBehaviour: m_UseAdaptivePerformance: 1 m_ColorGradingMode: 0 m_ColorGradingLutSize: 32 + m_AllowPostProcessAlphaOutput: 0 m_UseFastSRGBLinearConversion: 0 m_SupportDataDrivenLensFlare: 1 m_SupportScreenSpaceLensFlare: 1 @@ -98,35 +99,38 @@ MonoBehaviour: obsoleteHasProbeVolumes: m_Keys: [] m_Values: - m_PrefilteringModeMainLightShadows: 1 + m_PrefilteringModeMainLightShadows: 4 m_PrefilteringModeAdditionalLight: 4 - m_PrefilteringModeAdditionalLightShadows: 1 - m_PrefilterXRKeywords: 0 - m_PrefilteringModeForwardPlus: 1 - m_PrefilteringModeDeferredRendering: 1 - m_PrefilteringModeScreenSpaceOcclusion: 1 - m_PrefilterDebugKeywords: 0 - m_PrefilterWriteRenderingLayers: 0 - m_PrefilterHDROutput: 0 - m_PrefilterSSAODepthNormals: 0 - m_PrefilterSSAOSourceDepthLow: 0 - m_PrefilterSSAOSourceDepthMedium: 0 - m_PrefilterSSAOSourceDepthHigh: 0 - m_PrefilterSSAOInterleaved: 0 - m_PrefilterSSAOBlueNoise: 0 - m_PrefilterSSAOSampleCountLow: 0 - m_PrefilterSSAOSampleCountMedium: 0 - m_PrefilterSSAOSampleCountHigh: 0 - m_PrefilterDBufferMRT1: 0 - m_PrefilterDBufferMRT2: 0 - m_PrefilterDBufferMRT3: 0 - m_PrefilterSoftShadowsQualityLow: 0 - m_PrefilterSoftShadowsQualityMedium: 0 - m_PrefilterSoftShadowsQualityHigh: 0 + m_PrefilteringModeAdditionalLightShadows: 0 + m_PrefilterXRKeywords: 1 + m_PrefilteringModeForwardPlus: 0 + m_PrefilteringModeDeferredRendering: 0 + m_PrefilteringModeScreenSpaceOcclusion: 0 + m_PrefilterDebugKeywords: 1 + m_PrefilterWriteRenderingLayers: 1 + m_PrefilterHDROutput: 1 + m_PrefilterAlphaOutput: 1 + m_PrefilterSSAODepthNormals: 1 + m_PrefilterSSAOSourceDepthLow: 1 + m_PrefilterSSAOSourceDepthMedium: 1 + m_PrefilterSSAOSourceDepthHigh: 1 + m_PrefilterSSAOInterleaved: 1 + m_PrefilterSSAOBlueNoise: 1 + m_PrefilterSSAOSampleCountLow: 1 + m_PrefilterSSAOSampleCountMedium: 1 + m_PrefilterSSAOSampleCountHigh: 1 + m_PrefilterDBufferMRT1: 1 + m_PrefilterDBufferMRT2: 1 + m_PrefilterDBufferMRT3: 1 + m_PrefilterSoftShadowsQualityLow: 1 + m_PrefilterSoftShadowsQualityMedium: 1 + m_PrefilterSoftShadowsQualityHigh: 1 m_PrefilterSoftShadows: 0 - m_PrefilterScreenCoord: 0 - m_PrefilterNativeRenderPass: 0 + m_PrefilterScreenCoord: 1 + m_PrefilterNativeRenderPass: 1 m_PrefilterUseLegacyLightmaps: 0 + m_PrefilterReflectionProbeBlending: 1 + m_PrefilterReflectionProbeBoxProjection: 1 m_ShaderVariantLogLevel: 0 m_ShadowCascades: 0 m_Textures: diff --git a/unity-project/Assets/UniversalRenderPipelineGlobalSettings.asset b/unity-project/Assets/UniversalRenderPipelineGlobalSettings.asset index a02619e..0f716a2 100644 --- a/unity-project/Assets/UniversalRenderPipelineGlobalSettings.asset +++ b/unity-project/Assets/UniversalRenderPipelineGlobalSettings.asset @@ -60,7 +60,17 @@ MonoBehaviour: - rid: 4891237269306605570 - rid: 4891237269306605571 m_RuntimeSettings: - m_List: [] + m_List: + - rid: 7752762179098771456 + - rid: 7752762179098771457 + - rid: 7752762179098771459 + - rid: 7752762179098771461 + - rid: 7752762179098771462 + - rid: 7752762179098771464 + - rid: 7752762179098771466 + - rid: 7752762179098771468 + - rid: 7752762179098771472 + - rid: 7752762179098771476 m_AssetVersion: 8 m_ObsoleteDefaultVolumeProfile: {fileID: 0} m_RenderingLayerNames: diff --git a/unity-project/ProjectSettings/ProjectSettings.asset b/unity-project/ProjectSettings/ProjectSettings.asset index c0a475e..993a406 100644 --- a/unity-project/ProjectSettings/ProjectSettings.asset +++ b/unity-project/ProjectSettings/ProjectSettings.asset @@ -13,7 +13,7 @@ PlayerSettings: useOnDemandResources: 0 accelerometerFrequency: 60 companyName: DefaultCompany - productName: unity-project + productName: unityctl-test defaultCursor: {fileID: 0} cursorHotspot: {x: 0, y: 0} m_SplashScreenBackgroundColor: {r: 0.13725491, g: 0.12156863, b: 0.1254902, a: 1} @@ -70,6 +70,7 @@ PlayerSettings: androidStartInFullscreen: 1 androidRenderOutsideSafeArea: 1 androidUseSwappy: 1 + androidDisplayOptions: 1 androidBlitType: 0 androidResizeableActivity: 1 androidDefaultWindowWidth: 1920 @@ -142,7 +143,8 @@ PlayerSettings: visionOSBundleVersion: 1.0 tvOSBundleVersion: 1.0 bundleVersion: 1.0 - preloadedAssets: [] + preloadedAssets: + - {fileID: -944628639613478452, guid: 2bcd2660ca9b64942af0de543d8d7100, type: 3} metroInputSource: 0 wsaTransparentSwapchain: 0 m_HolographicPauseOnTrackingLoss: 1 @@ -164,6 +166,7 @@ PlayerSettings: androidMaxAspectRatio: 2.4 androidMinAspectRatio: 1 applicationIdentifier: + Android: com.dirtybit.unityctl.test Standalone: com.DefaultCompany.2D-URP buildNumber: Standalone: 0