-
Notifications
You must be signed in to change notification settings - Fork 897
feat: add manage_profiler tool with session control, counters, memory snapshots, and Frame Debugger #1000
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: beta
Are you sure you want to change the base?
feat: add manage_profiler tool with session control, counters, memory snapshots, and Frame Debugger #1000
Changes from all commits
0011a7a
92b4116
c0a2a2d
42b020b
5dcecd3
771fdf1
f4e98d8
7c21671
09bfced
0fe485b
97937c1
a5fabef
4095937
1f0fb1d
25357a0
fbaa7ce
fa64235
c120fdd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| using System; | ||
| using System.Threading.Tasks; | ||
| using MCPForUnity.Editor.Helpers; | ||
| using Newtonsoft.Json.Linq; | ||
|
|
||
| namespace MCPForUnity.Editor.Tools.Profiler | ||
| { | ||
| [McpForUnityTool("manage_profiler", AutoRegister = false, Group = "profiling")] | ||
| public static class ManageProfiler | ||
| { | ||
| public static async Task<object> HandleCommand(JObject @params) | ||
| { | ||
| if (@params == null) | ||
| return new ErrorResponse("Parameters cannot be null."); | ||
|
|
||
| var p = new ToolParams(@params); | ||
| string action = p.Get("action")?.ToLowerInvariant(); | ||
|
|
||
| if (string.IsNullOrEmpty(action)) | ||
| return new ErrorResponse("'action' parameter is required."); | ||
|
|
||
| try | ||
| { | ||
| switch (action) | ||
| { | ||
| // Session | ||
| case "profiler_start": | ||
| return SessionOps.Start(@params); | ||
| case "profiler_stop": | ||
| return SessionOps.Stop(@params); | ||
| case "profiler_status": | ||
| return SessionOps.Status(@params); | ||
| case "profiler_set_areas": | ||
| return SessionOps.SetAreas(@params); | ||
|
|
||
| // Counters | ||
| case "get_frame_timing": | ||
| return FrameTimingOps.GetFrameTiming(@params); | ||
| case "get_counters": | ||
| return await CounterOps.GetCountersAsync(@params); | ||
| case "get_object_memory": | ||
| return ObjectMemoryOps.GetObjectMemory(@params); | ||
|
|
||
| // Memory Snapshot | ||
| case "memory_take_snapshot": | ||
| return await MemorySnapshotOps.TakeSnapshotAsync(@params); | ||
| case "memory_list_snapshots": | ||
| return MemorySnapshotOps.ListSnapshots(@params); | ||
| case "memory_compare_snapshots": | ||
| return MemorySnapshotOps.CompareSnapshots(@params); | ||
|
|
||
| // Frame Debugger | ||
| case "frame_debugger_enable": | ||
| return FrameDebuggerOps.Enable(@params); | ||
| case "frame_debugger_disable": | ||
| return FrameDebuggerOps.Disable(@params); | ||
| case "frame_debugger_get_events": | ||
| return FrameDebuggerOps.GetEvents(@params); | ||
|
|
||
| // Utility | ||
| case "ping": | ||
| return new SuccessResponse("manage_profiler is available.", new | ||
| { | ||
| tool = "manage_profiler", | ||
| group = "profiling" | ||
| }); | ||
|
|
||
| default: | ||
| return new ErrorResponse( | ||
| $"Unknown action: '{action}'. Valid actions: " | ||
| + "profiler_start, profiler_stop, profiler_status, profiler_set_areas, " | ||
| + "get_frame_timing, get_counters, get_object_memory, " | ||
| + "memory_take_snapshot, memory_list_snapshots, memory_compare_snapshots, " | ||
| + "frame_debugger_enable, frame_debugger_disable, frame_debugger_get_events, " | ||
| + "ping."); | ||
| } | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| McpLog.Error($"[ManageProfiler] Action '{action}' failed: {ex}"); | ||
| return new ErrorResponse($"Error in action '{action}': {ex.Message}"); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
| using System.Threading.Tasks; | ||
| using MCPForUnity.Editor.Helpers; | ||
| using Newtonsoft.Json.Linq; | ||
| using Unity.Profiling; | ||
| using Unity.Profiling.LowLevel.Unsafe; | ||
| using UnityEditor; | ||
|
|
||
| namespace MCPForUnity.Editor.Tools.Profiler | ||
| { | ||
| internal static class CounterOps | ||
| { | ||
| internal static async Task<object> GetCountersAsync(JObject @params) | ||
| { | ||
| var p = new ToolParams(@params); | ||
| var categoryResult = p.GetRequired("category"); | ||
| if (!categoryResult.IsSuccess) | ||
| return new ErrorResponse(categoryResult.ErrorMessage); | ||
|
|
||
| string categoryName = categoryResult.Value; | ||
| var resolved = ResolveCategory(categoryName, out string categoryError); | ||
| if (resolved == null) | ||
| return new ErrorResponse(categoryError); | ||
| ProfilerCategory category = resolved.Value; | ||
|
|
||
| // Get counter names: explicit list or discover all in category | ||
| var counterNames = GetRequestedCounters(p, category); | ||
| if (counterNames.Count == 0) | ||
| return new SuccessResponse($"No counters found in category '{categoryName}'.", new | ||
| { | ||
| category = categoryName, | ||
| counters = new Dictionary<string, object>() | ||
| }); | ||
|
|
||
| // Start recorders | ||
| var recorders = new List<ProfilerRecorder>(); | ||
| foreach (string name in counterNames) | ||
| { | ||
| recorders.Add(ProfilerRecorder.StartNew(category, name)); | ||
| } | ||
|
Comment on lines
+29
to
+42
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.csRepository: CoplayDev/unity-mcp Length of output: 6643 🌐 Web query:
💡 Result: According to the official Unity documentation for ProfilerRecorder.StartNew (Unity 6.3 LTS and earlier versions), the method throws an exception if the capacity parameter is not greater than 0. The note states: "capacity parameter must be greater than 0, otherwise StartNew throws an exception." This is the primary documented exception-throwing behavior. No other specific exception conditions (e.g., invalid category, statName, or options) are explicitly mentioned in the API docs. Unity forum discussions suggest potential issues like passing negative capacity (e.g., -1 from targetFrameRate) can lead to memory allocator overflows or crashes, but these are not formally documented as exceptions from StartNew itself. Always ensure capacity > 0 when calling ProfilerRecorder.StartNew(ProfilerCategory category, string statName, int capacity, ProfilerRecorderOptions options) or similar overloads. Dispose recorders properly when done. Citations:
🌐 Web query:
💡 Result: There is no overload of ProfilerRecorder.StartNew(ProfilerCategory, string). The documented overloads require additional parameters: StartNew(ProfilerCategory category, string statName, int capacity, ProfilerRecorderOptions options) or StartNew(ProfilerMarker marker, int capacity, ProfilerRecorderOptions options). Calling a non-existent overload will result in a compile-time error, not a runtime exception for "invalid counter name". The official documentation explicitly states that StartNew throws an exception if capacity <= 0. Examples in the docs use the full 4-parameter overload, such as ProfilerRecorder.StartNew(ProfilerCategory.Memory, "System Used Memory") which implies default values for capacity and options in some contexts or C# overload resolution. If using the correct overload with an invalid statName (non-existent marker/counter in the category), the ProfilerRecorder.Valid property will be false, but no exception is documented for invalid names. Forum discussions indicate ProfilerRecorder may not work or return invalid for some statNames that don't exist. To check available stats, use ProfilerRecorderHandle.GetAvailable and ProfilerRecorderDescription. Citations:
🏁 Script executed: grep -n "GetCountersAsync" MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.csRepository: CoplayDev/unity-mcp Length of output: 142 🏁 Script executed: rg "ProfilerRecorder.StartNew" --type cs -B 2 -A 2Repository: CoplayDev/unity-mcp Length of output: 1059 🏁 Script executed: cat -n MCPForUnity/Editor/Tools/Graphics/RenderingStatsOps.cs | head -50Repository: CoplayDev/unity-mcp Length of output: 2433 Wrap recorder lifetime in try/finally and validate explicit counter names. The explicit 🤖 Prompt for AI Agents |
||
|
|
||
| var data = new Dictionary<string, object>(); | ||
| try | ||
| { | ||
| // Wait 1 frame for recorders to accumulate data | ||
| await WaitOneFrameAsync(); | ||
|
|
||
| // Read values | ||
| for (int i = 0; i < recorders.Count; i++) | ||
| { | ||
| var recorder = recorders[i]; | ||
| string name = counterNames[i]; | ||
| data[name] = recorder.Valid ? recorder.CurrentValueAsDouble : 0.0; | ||
| data[name + "_valid"] = recorder.Valid; | ||
| data[name + "_unit"] = recorder.Valid ? recorder.UnitType.ToString() : "Unknown"; | ||
| } | ||
| } | ||
| finally | ||
| { | ||
| foreach (var recorder in recorders) | ||
| recorder.Dispose(); | ||
| } | ||
|
|
||
| return new SuccessResponse($"Captured {counterNames.Count} counter(s) from '{categoryName}'.", new | ||
| { | ||
| category = categoryName, | ||
| counters = data, | ||
| }); | ||
| } | ||
|
|
||
| private static List<string> GetRequestedCounters(ToolParams p, ProfilerCategory category) | ||
| { | ||
| var explicitCounters = p.GetStringArray("counters"); | ||
| if (explicitCounters != null && explicitCounters.Length > 0) | ||
| return explicitCounters.ToList(); | ||
|
|
||
| var allHandles = new List<ProfilerRecorderHandle>(); | ||
| ProfilerRecorderHandle.GetAvailable(allHandles); | ||
| return allHandles | ||
| .Select(h => ProfilerRecorderHandle.GetDescription(h)) | ||
| .Where(d => string.Equals(d.Category.Name, category.Name, StringComparison.OrdinalIgnoreCase)) | ||
| .Select(d => d.Name) | ||
| .OrderBy(n => n) | ||
| .ToList(); | ||
| } | ||
|
|
||
| private static Task WaitOneFrameAsync() | ||
| { | ||
| var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); | ||
|
|
||
| void Tick() | ||
| { | ||
| EditorApplication.update -= Tick; | ||
| tcs.TrySetResult(true); | ||
| } | ||
|
|
||
| EditorApplication.update += Tick; | ||
| try { EditorApplication.QueuePlayerLoopUpdate(); } catch { /* throttled editor */ } | ||
| return tcs.Task; | ||
| } | ||
|
|
||
| private static readonly string[] ValidCategories = new[] | ||
| { | ||
| "Render", "Scripts", "Memory", "Physics", "Physics2D", "Animation", | ||
| "Audio", "Lighting", "Network", "Gui", "UI", "Ai", "Video", | ||
| "Loading", "Input", "Vr", "Internal", "Particles", "FileIO", "VirtualTexturing" | ||
| }; | ||
|
|
||
| internal static ProfilerCategory? ResolveCategory(string name, out string error) | ||
| { | ||
| error = null; | ||
| switch (name.ToLowerInvariant()) | ||
| { | ||
| case "render": return ProfilerCategory.Render; | ||
| case "scripts": return ProfilerCategory.Scripts; | ||
| case "memory": return ProfilerCategory.Memory; | ||
| case "physics": return ProfilerCategory.Physics; | ||
| case "physics2d": return ProfilerCategory.Physics2D; | ||
| case "animation": return ProfilerCategory.Animation; | ||
| case "audio": return ProfilerCategory.Audio; | ||
| case "lighting": return ProfilerCategory.Lighting; | ||
| case "network": return ProfilerCategory.Network; | ||
| case "gui": case "ui": return ProfilerCategory.Gui; | ||
| case "ai": return ProfilerCategory.Ai; | ||
| case "video": return ProfilerCategory.Video; | ||
| case "loading": return ProfilerCategory.Loading; | ||
| case "input": return ProfilerCategory.Input; | ||
| case "vr": return ProfilerCategory.Vr; | ||
| case "internal": return ProfilerCategory.Internal; | ||
| case "particles": return ProfilerCategory.Particles; | ||
| case "fileio": return ProfilerCategory.FileIO; | ||
| case "virtualtexturing": return ProfilerCategory.VirtualTexturing; | ||
| default: | ||
| error = $"Unknown category '{name}'. Valid: {string.Join(", ", ValidCategories)}"; | ||
| return null; | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ManageProfiler.HandleCommand() exception handling returns
ErrorResponsewithout a stack trace payload, which is inconsistent with the pattern used by other tools (e.g., ManageBuild/ManageMaterial returnnew ErrorResponse(ex.Message, new { stackTrace = ex.StackTrace })). IncludingstackTracehere would improve debuggability and keep error shapes consistent across tools.