diff --git a/.claude/skills/unity-mcp-skill/references/tools-reference.md b/.claude/skills/unity-mcp-skill/references/tools-reference.md index bc100e25a..601e533a9 100644 --- a/.claude/skills/unity-mcp-skill/references/tools-reference.md +++ b/.claude/skills/unity-mcp-skill/references/tools-reference.md @@ -18,7 +18,9 @@ Complete reference for all MCP tools. Each tool includes parameters, types, and - [Camera Tools](#camera-tools) - [Graphics Tools](#graphics-tools) - [Package Tools](#package-tools) +- [Physics Tools](#physics-tools) - [ProBuilder Tools](#probuilder-tools) +- [Profiler Tools](#profiler-tools) - [Docs Tools](#docs-tools) --- @@ -175,26 +177,6 @@ manage_scene(action="get_build_settings") # Build settings manage_scene(action="create", name="NewScene", path="Assets/Scenes/") manage_scene(action="load", path="Assets/Scenes/Main.unity") manage_scene(action="save") - -# Scene templates — create with preset objects -manage_scene(action="create", name="Level1", template="3d_basic") # Camera + Light + Ground -manage_scene(action="create", name="Level2", template="2d_basic") # Camera (ortho) + Light -manage_scene(action="create", name="Empty", template="empty") # No default objects -manage_scene(action="create", name="Default", template="default") # Camera + Light (Unity default) - -# Multi-scene editing -manage_scene(action="load", path="Assets/Scenes/Level2.unity", additive=True) # Keep current scene -manage_scene(action="get_loaded_scenes") # List all loaded scenes -manage_scene(action="set_active_scene", scene_name="Level2") # Set active scene -manage_scene(action="close_scene", scene_name="Level2") # Unload scene -manage_scene(action="close_scene", scene_name="Level2", remove_scene=True) # Fully remove -manage_scene(action="move_to_scene", target="Player", scene_name="Level2") # Move root GO - -# Build settings — use manage_build(action="scenes") instead - -# Scene validation -manage_scene(action="validate") # Detect missing scripts, broken prefabs -manage_scene(action="validate", auto_repair=True) # Also auto-fix missing scripts (undoable) ``` ### find_gameobjects @@ -352,11 +334,6 @@ manage_components( # - "Assets/Prefabs/My.prefab" → String shorthand for asset paths # - "ObjectName" → String shorthand for scene name lookup # - 12345 → Integer shorthand for instanceID -# -# Sprite sub-asset references (for SpriteRenderer.sprite, Image.sprite, etc.): -# - {"guid": "...", "spriteName": "SubSprite"} → Sprite sub-asset from atlas -# - {"guid": "...", "fileID": 12345} → Sub-asset by fileID -# Single-sprite textures auto-resolve from guid/path alone. ``` --- @@ -549,23 +526,27 @@ manage_prefabs( components_to_add=["AudioSource"] ) -# Add child GameObjects to a prefab (single or batch) +# Delete child GameObjects from prefab manage_prefabs( action="modify_contents", prefab_path="Assets/Prefabs/Player.prefab", - create_child=[ - {"name": "Child1", "primitive_type": "Sphere", "position": [1, 0, 0]}, - {"name": "Child2", "primitive_type": "Cube", "parent": "Child1"} - ] + delete_child=["OldChild", "Turret/Barrel"] # single string or list +) + +# Create child GameObject in prefab +manage_prefabs( + action="modify_contents", + prefab_path="Assets/Prefabs/Player.prefab", + create_child={"name": "SpawnPoint", "primitive_type": "Sphere", "position": [0, 2, 0]} ) -# Add a nested prefab instance inside a prefab +# Set component properties on prefab contents manage_prefabs( action="modify_contents", prefab_path="Assets/Prefabs/Player.prefab", - create_child={"name": "Bullet", "source_prefab_path": "Assets/Prefabs/Bullet.prefab", "position": [0, 2, 0]} + target="ChildObject", + component_properties={"Rigidbody": {"mass": 5.0}, "MyScript": {"health": 100}} ) -# source_prefab_path and primitive_type are mutually exclusive ``` --- @@ -734,7 +715,7 @@ manage_ui( ### manage_editor -Control Unity Editor state, undo/redo. +Control Unity Editor state. ```python manage_editor(action="play") # Enter play mode @@ -749,12 +730,10 @@ manage_editor(action="remove_tag", tag_name="OldTag") manage_editor(action="add_layer", layer_name="Projectiles") manage_editor(action="remove_layer", layer_name="OldLayer") +manage_editor(action="open_prefab_stage", prefab_path="Assets/Prefabs/Enemy.prefab") +manage_editor(action="save_prefab_stage") # Save changes in the open prefab stage manage_editor(action="close_prefab_stage") # Exit prefab editing mode back to main scene -# Undo/Redo — returns the affected undo group name -manage_editor(action="undo") # Undo last action -manage_editor(action="redo") # Redo last undone action - # Package deployment (no confirmation dialog — designed for LLM-driven iteration) manage_editor(action="deploy_package") # Copy configured MCPForUnity source into installed package manage_editor(action="restore_package") # Revert to pre-deployment backup @@ -1215,6 +1194,121 @@ manage_packages( --- +## Physics Tools + +### `manage_physics` + +Manage 3D and 2D physics: settings, collision matrix, materials, joints, queries, validation, and simulation. All actions support `dimension="3d"` (default) or `dimension="2d"` where applicable. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `action` | string | Yes | See action groups below | +| `dimension` | string | No | `"3d"` (default) or `"2d"` | +| `settings` | object | For set_settings | Key-value physics settings dict | +| `layer_a` / `layer_b` | string | For collision matrix | Layer name or index | +| `collide` | bool | For set_collision_matrix | `true` to enable, `false` to disable | +| `name` | string | For create_physics_material | Material asset name | +| `path` | string | No | Asset folder path (create) or asset path (configure) | +| `dynamic_friction` / `static_friction` / `bounciness` | float | No | Material properties (0–1) | +| `friction_combine` / `bounce_combine` | string | No | `Average`, `Minimum`, `Multiply`, `Maximum` | +| `material_path` | string | For assign_physics_material | Path to physics material asset | +| `target` | string | For joints/queries/validate | GameObject name or instance ID | +| `joint_type` | string | For joints | 3D: `fixed`, `hinge`, `spring`, `character`, `configurable`; 2D: `distance`, `fixed`, `friction`, `hinge`, `relative`, `slider`, `spring`, `target`, `wheel` | +| `connected_body` | string | For add_joint | Connected body GameObject | +| `motor` / `limits` / `spring` / `drive` | object | For configure_joint | Joint sub-config objects | +| `properties` | object | For configure_joint/material | Direct property dict | +| `origin` / `direction` | float[] | For raycast | Ray origin and direction `[x,y,z]` or `[x,y]` | +| `max_distance` | float | No | Max raycast distance | +| `shape` | string | For overlap | `sphere`, `box`, `capsule` (3D); `circle`, `box`, `capsule` (2D) | +| `position` | float[] | For overlap | `[x,y,z]` or `[x,y]` | +| `size` | float or float[] | For overlap | Radius (sphere/circle) or half-extents `[x,y,z]` (box) | +| `layer_mask` | string | No | Layer name or int mask for queries | +| `start` / `end` | float[] | For linecast | Start and end points `[x,y,z]` or `[x,y]` | +| `point1` / `point2` | float[] | For shapecast capsule | Capsule endpoints (3D alternative) | +| `height` | float | For shapecast capsule | Capsule height | +| `capsule_direction` | int | For shapecast capsule | 0=X, 1=Y (default), 2=Z | +| `angle` | float | For 2D shapecasts | Rotation angle in degrees | +| `force` | float[] | For apply_force | Force vector `[x,y,z]` or `[x,y]` | +| `force_mode` | string | For apply_force | `Force`, `Impulse`, `Acceleration`, `VelocityChange` (3D); `Force`, `Impulse` (2D) | +| `force_type` | string | For apply_force | `normal` (default) or `explosion` (3D only) | +| `torque` | float[] | For apply_force | Torque `[x,y,z]` (3D) or `[z]` (2D) | +| `explosion_position` | float[] | For apply_force explosion | Explosion center `[x,y,z]` | +| `explosion_radius` | float | For apply_force explosion | Explosion sphere radius | +| `explosion_force` | float | For apply_force explosion | Explosion force magnitude | +| `upwards_modifier` | float | For apply_force explosion | Y-axis offset (default 0) | +| `steps` | int | For simulate_step | Number of steps (1–100) | +| `step_size` | float | No | Step size in seconds (default: `Time.fixedDeltaTime`) | + +**Action groups:** + +- **Settings:** `ping`, `get_settings`, `set_settings` +- **Collision Matrix:** `get_collision_matrix`, `set_collision_matrix` +- **Materials:** `create_physics_material`, `configure_physics_material`, `assign_physics_material` +- **Joints:** `add_joint`, `configure_joint`, `remove_joint` +- **Queries:** `raycast`, `raycast_all`, `linecast`, `shapecast`, `overlap` +- **Forces:** `apply_force` +- **Rigidbody:** `get_rigidbody`, `configure_rigidbody` +- **Validation:** `validate` +- **Simulation:** `simulate_step` + +```python +# Check physics status +manage_physics(action="ping") + +# Get/set gravity +manage_physics(action="get_settings", dimension="3d") +manage_physics(action="set_settings", dimension="3d", settings={"gravity": [0, -20, 0]}) + +# Collision matrix +manage_physics(action="get_collision_matrix") +manage_physics(action="set_collision_matrix", layer_a="Player", layer_b="Enemy", collide=False) + +# Create a bouncy physics material and assign it +manage_physics(action="create_physics_material", name="Bouncy", bounciness=0.9, dynamic_friction=0.2) +manage_physics(action="assign_physics_material", target="Ball", material_path="Assets/Physics Materials/Bouncy.physicMaterial") + +# Add and configure a hinge joint +manage_physics(action="add_joint", target="Door", joint_type="hinge", connected_body="DoorFrame") +manage_physics(action="configure_joint", target="Door", joint_type="hinge", + motor={"targetVelocity": 90, "force": 100}, + limits={"min": -90, "max": 0, "bounciness": 0}) + +# Raycast and overlap +manage_physics(action="raycast", origin=[0, 10, 0], direction=[0, -1, 0], max_distance=50) +manage_physics(action="overlap", shape="sphere", position=[0, 0, 0], size=5.0) + +# Validate scene physics setup +manage_physics(action="validate") # whole scene +manage_physics(action="validate", target="Player") # single object + +# Multi-hit raycast (returns all hits sorted by distance) +manage_physics(action="raycast_all", origin=[0, 10, 0], direction=[0, -1, 0]) + +# Linecast (point A to point B) +manage_physics(action="linecast", start=[0, 0, 0], end=[10, 0, 0]) + +# Shapecast (sphere/box/capsule sweep) +manage_physics(action="shapecast", shape="sphere", origin=[0, 5, 0], direction=[0, -1, 0], size=0.5) +manage_physics(action="shapecast", shape="box", origin=[0, 5, 0], direction=[0, -1, 0], size=[1, 1, 1]) + +# Apply force (works with simulate_step for edit-mode previewing) +manage_physics(action="apply_force", target="Ball", force=[0, 500, 0], force_mode="Impulse") +manage_physics(action="apply_force", target="Ball", torque=[0, 10, 0]) + +# Explosion force (3D only) +manage_physics(action="apply_force", target="Crate", force_type="explosion", + explosion_force=1000, explosion_position=[0, 0, 0], explosion_radius=10) + +# Configure rigidbody properties +manage_physics(action="configure_rigidbody", target="Player", + properties={"mass": 80, "drag": 0.5, "useGravity": True, "collisionDetectionMode": "Continuous"}) + +# Step physics in edit mode +manage_physics(action="simulate_step", steps=10, step_size=0.02) +``` + +--- + ## ProBuilder Tools ### manage_probuilder @@ -1330,6 +1424,77 @@ See also: [ProBuilder Workflow Guide](probuilder-guide.md) for detailed patterns --- +## Profiler Tools + +### `manage_profiler` + +Unity Profiler session control, counter reads, memory snapshots, and Frame Debugger. Group: `profiling` (opt-in via `manage_tools`). + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `action` | string | Yes | See action groups below | +| `category` | string | For get_counters | Profiler category name (e.g. `Render`, `Scripts`, `Memory`, `Physics`) | +| `counters` | list[str] | No | Specific counter names for get_counters. Omit to read all in category | +| `object_path` | string | For get_object_memory | Scene hierarchy or asset path | +| `log_file` | string | No | Path to `.raw` file for profiler_start recording | +| `enable_callstacks` | bool | No | Enable allocation callstacks for profiler_start | +| `areas` | dict[str, bool] | For profiler_set_areas | Area name to enabled/disabled mapping | +| `snapshot_path` | string | No | Output path for memory_take_snapshot | +| `search_path` | string | No | Search directory for memory_list_snapshots | +| `snapshot_a` | string | For memory_compare_snapshots | First snapshot file path | +| `snapshot_b` | string | For memory_compare_snapshots | Second snapshot file path | +| `page_size` | int | No | Page size for frame_debugger_get_events (default 50) | +| `cursor` | int | No | Cursor offset for frame_debugger_get_events | + +**Action groups:** + +- **Session:** `profiler_start`, `profiler_stop`, `profiler_status`, `profiler_set_areas` +- **Counters:** `get_frame_timing`, `get_counters`, `get_object_memory` +- **Memory Snapshot:** `memory_take_snapshot`, `memory_list_snapshots`, `memory_compare_snapshots` (requires `com.unity.memoryprofiler`) +- **Frame Debugger:** `frame_debugger_enable`, `frame_debugger_disable`, `frame_debugger_get_events` +- **Utility:** `ping` + +```python +# Check profiler availability +manage_profiler(action="ping") + +# Start profiling (optionally record to file) +manage_profiler(action="profiler_start") +manage_profiler(action="profiler_start", log_file="Assets/profiler.raw", enable_callstacks=True) + +# Check profiler status +manage_profiler(action="profiler_status") + +# Toggle profiler areas +manage_profiler(action="profiler_set_areas", areas={"CPU": True, "GPU": True, "Rendering": True, "Memory": False}) + +# Stop profiling +manage_profiler(action="profiler_stop") + +# Read frame timing data (12 fields from FrameTimingManager) +manage_profiler(action="get_frame_timing") + +# Read counters by category +manage_profiler(action="get_counters", category="Render") +manage_profiler(action="get_counters", category="Memory", counters=["Total Used Memory", "GC Used Memory"]) + +# Get memory size of a specific object +manage_profiler(action="get_object_memory", object_path="Player/Mesh") + +# Memory snapshots (requires com.unity.memoryprofiler) +manage_profiler(action="memory_take_snapshot") +manage_profiler(action="memory_take_snapshot", snapshot_path="Assets/Snapshots/baseline.snap") +manage_profiler(action="memory_list_snapshots") +manage_profiler(action="memory_compare_snapshots", snapshot_a="Assets/Snapshots/before.snap", snapshot_b="Assets/Snapshots/after.snap") + +# Frame Debugger +manage_profiler(action="frame_debugger_enable") +manage_profiler(action="frame_debugger_get_events", page_size=20, cursor=0) +manage_profiler(action="frame_debugger_disable") +``` + +--- + ## Docs Tools Tools for verifying Unity C# APIs and fetching official documentation. Group: `docs`. @@ -1391,7 +1556,7 @@ No Unity connection needed for doc fetching. The `lookup` action with asset-rela - **`get_doc`**: Fetch ScriptReference docs for a class or member. Parses HTML to extract description, signatures, parameters, return type, and code examples. - **`get_manual`**: Fetch a Unity Manual page by slug. Returns title, sections, and code examples. - **`get_package_doc`**: Fetch package documentation. Requires package name, page slug, and package version. -- **`lookup`**: Search all doc sources in parallel (ScriptReference + Manual + package docs). Supports batch queries. For asset-related queries (shader, material, texture, etc.), also searches project assets via `manage_asset`. +- **`lookup`**: Search doc sources in parallel (ScriptReference + Manual; also package docs if `package` + `pkg_version` provided). Supports batch queries. For asset-related queries (shader, material, texture, etc.), also searches project assets via `manage_asset`. ```python # Fetch ScriptReference for a class diff --git a/MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs b/MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs new file mode 100644 index 000000000..606b53abd --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs @@ -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 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}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs new file mode 100644 index 000000000..7058c7c11 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs @@ -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 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() + }); + + // Start recorders + var recorders = new List(); + foreach (string name in counterNames) + { + recorders.Add(ProfilerRecorder.StartNew(category, name)); + } + + var data = new Dictionary(); + 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 GetRequestedCounters(ToolParams p, ProfilerCategory category) + { + var explicitCounters = p.GetStringArray("counters"); + if (explicitCounters != null && explicitCounters.Length > 0) + return explicitCounters.ToList(); + + var allHandles = new List(); + 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(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; + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs new file mode 100644 index 000000000..b4094ee65 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools.Profiler +{ + internal static class FrameDebuggerOps + { + private static readonly Type UtilType; + private static readonly PropertyInfo EventCountProp; + private static readonly MethodInfo EnableMethod; + private static readonly MethodInfo GetEventDataMethod; + private static readonly bool ReflectionAvailable; + + static FrameDebuggerOps() + { + try + { + UtilType = Type.GetType("UnityEditorInternal.FrameDebuggerUtility, UnityEditor"); + if (UtilType != null) + { + EventCountProp = UtilType.GetProperty("eventsCount", BindingFlags.Public | BindingFlags.Static) + ?? UtilType.GetProperty("count", BindingFlags.Public | BindingFlags.Static); + EnableMethod = UtilType.GetMethod("SetEnabled", BindingFlags.Public | BindingFlags.Static, + null, new[] { typeof(bool), typeof(int) }, null) + ?? UtilType.GetMethod("SetEnabled", BindingFlags.Public | BindingFlags.Static); + GetEventDataMethod = UtilType.GetMethod("GetFrameEventData", BindingFlags.Public | BindingFlags.Static, + null, new[] { typeof(int) }, null) + ?? UtilType.GetMethod("GetFrameEventData", BindingFlags.Public | BindingFlags.Static); + } + ReflectionAvailable = UtilType != null && EventCountProp != null && EnableMethod != null; + } + catch + { + ReflectionAvailable = false; + } + } + + internal static object Enable(JObject @params) + { + if (!ReflectionAvailable) + { + return new ErrorResponse( + "FrameDebuggerUtility not available via reflection in this Unity version."); + } + + try + { + if (EnableMethod != null) + { + InvokeSetEnabled(true); + } + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to enable Frame Debugger: {ex.Message}"); + } + + int eventCount = 0; + string warning = null; + try + { + eventCount = (int)EventCountProp.GetValue(null); + } + catch (Exception ex) + { + warning = $"Could not read event count: {ex.Message}"; + } + + var data = new Dictionary + { + ["enabled"] = true, + ["event_count"] = eventCount, + }; + if (warning != null) + data["warning"] = warning; + + return new SuccessResponse("Frame Debugger enabled.", data); + } + + internal static object Disable(JObject @params) + { + if (!ReflectionAvailable) + { + return new ErrorResponse( + "FrameDebuggerUtility not available via reflection in this Unity version."); + } + + try + { + if (EnableMethod != null) + { + InvokeSetEnabled(false); + } + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to disable Frame Debugger: {ex.Message}"); + } + + return new SuccessResponse("Frame Debugger disabled.", new { enabled = false }); + } + + internal static object GetEvents(JObject @params) + { + if (!ReflectionAvailable || GetEventDataMethod == null) + { + return new SuccessResponse("Frame Debugger events (reflection unavailable).", new + { + events = new List(), + total_events = 0, + warning = "FrameDebuggerUtility API not available in this Unity version. " + + "Event data cannot be extracted programmatically.", + }); + } + + var p = new ToolParams(@params); + int pageSize = p.GetInt("page_size") ?? 50; + int cursor = p.GetInt("cursor") ?? 0; + + int totalEvents = 0; + try + { + totalEvents = (int)EventCountProp.GetValue(null); + } + catch + { + return new SuccessResponse("Could not read event count.", new + { + events = new List(), + total_events = 0, + warning = "Failed to read event count via reflection.", + }); + } + + if (totalEvents == 0) + { + return new SuccessResponse("Frame Debugger has no events. Is it enabled?", new + { + events = new List(), + total_events = 0, + }); + } + + var events = new List(); + int end = Math.Min(cursor + pageSize, totalEvents); + + for (int i = cursor; i < end; i++) + { + try + { + var eventData = GetEventDataMethod.Invoke(null, new object[] { i }); + if (eventData != null) + { + var eventType = eventData.GetType(); + var entry = new Dictionary { ["index"] = i }; + + TryAddField(eventType, eventData, "shaderName", entry); + TryAddField(eventType, eventData, "passName", entry); + TryAddField(eventType, eventData, "rtName", entry); + TryAddField(eventType, eventData, "rtWidth", entry); + TryAddField(eventType, eventData, "rtHeight", entry); + TryAddField(eventType, eventData, "vertexCount", entry); + TryAddField(eventType, eventData, "indexCount", entry); + TryAddField(eventType, eventData, "instanceCount", entry); + TryAddField(eventType, eventData, "meshName", entry); + + events.Add(entry); + } + } + catch + { + events.Add(new Dictionary + { + ["index"] = i, + ["error"] = "Failed to read event data", + }); + } + } + + var result = new Dictionary + { + ["events"] = events, + ["total_events"] = totalEvents, + ["page_size"] = pageSize, + ["cursor"] = cursor, + }; + if (end < totalEvents) + result["next_cursor"] = end; + + return new SuccessResponse($"Frame Debugger events {cursor}-{end - 1} of {totalEvents}.", result); + } + + private static void InvokeSetEnabled(bool value) + { + int paramCount = EnableMethod.GetParameters().Length; + if (paramCount == 2) + EnableMethod.Invoke(null, new object[] { value, 0 }); + else if (paramCount == 1) + EnableMethod.Invoke(null, new object[] { value }); + else + throw new InvalidOperationException($"SetEnabled has unexpected {paramCount} parameters."); + } + + private static void TryAddField(Type type, object obj, string fieldName, Dictionary dict) + { + try + { + var field = type.GetField(fieldName, BindingFlags.Public | BindingFlags.Instance) + ?? type.GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); + var prop = type.GetProperty(fieldName, BindingFlags.Public | BindingFlags.Instance) + ?? type.GetProperty(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); + if (field != null) + dict[fieldName] = field.GetValue(obj); + else if (prop != null) + dict[fieldName] = prop.GetValue(obj); + } + catch { /* skip unavailable fields */ } + } + } +} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs new file mode 100644 index 000000000..a11ded2e8 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs @@ -0,0 +1,50 @@ +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Profiler +{ + internal static class FrameTimingOps + { + internal static object GetFrameTiming(JObject @params) + { + if (!FrameTimingManager.IsFeatureEnabled()) + { + return new ErrorResponse( + "Frame Timing Stats is not enabled. " + + "Enable it in Project Settings > Player > Other Settings > 'Frame Timing Stats', " + + "or use a Development Build (always enabled)."); + } + + FrameTimingManager.CaptureFrameTimings(); + var timings = new FrameTiming[1]; + uint count = FrameTimingManager.GetLatestTimings(1, timings); + + if (count == 0) + { + return new SuccessResponse("No frame timing data available yet (need a few frames).", new + { + available = false, + }); + } + + var t = timings[0]; + return new SuccessResponse("Frame timing captured.", new + { + available = true, + cpu_frame_time_ms = t.cpuFrameTime, + cpu_main_thread_frame_time_ms = t.cpuMainThreadFrameTime, + cpu_main_thread_present_wait_time_ms = t.cpuMainThreadPresentWaitTime, + cpu_render_thread_frame_time_ms = t.cpuRenderThreadFrameTime, + gpu_frame_time_ms = t.gpuFrameTime, + frame_start_timestamp = t.frameStartTimestamp, + first_submit_timestamp = t.firstSubmitTimestamp, + cpu_time_present_called = t.cpuTimePresentCalled, + cpu_time_frame_complete = t.cpuTimeFrameComplete, + height_scale = t.heightScale, + width_scale = t.widthScale, + sync_interval = t.syncInterval, + }); + } + } +} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs new file mode 100644 index 000000000..898cecf5b --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Profiler +{ + internal static class MemorySnapshotOps + { + private static readonly Type MemoryProfilerType = + Type.GetType("Unity.MemoryProfiler.MemoryProfiler, Unity.MemoryProfiler.Editor"); + + private static bool HasPackage => MemoryProfilerType != null; + + internal static async Task TakeSnapshotAsync(JObject @params) + { + if (!HasPackage) + return PackageMissingError(); + + var p = new ToolParams(@params); + string snapshotPath = p.Get("snapshot_path"); + + if (string.IsNullOrEmpty(snapshotPath)) + { + string dir = Path.Combine(Application.temporaryCachePath, "MemoryCaptures"); + Directory.CreateDirectory(dir); + snapshotPath = Path.Combine(dir, $"snapshot_{DateTime.Now:yyyyMMdd_HHmmss}.snap"); + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + try + { + var takeMethod = MemoryProfilerType.GetMethod("TakeSnapshot", + new[] { typeof(string), typeof(Action), typeof(Action), typeof(uint) }); + + if (takeMethod == null) + { + // Try 2-param overload: TakeSnapshot(string, Action) + takeMethod = MemoryProfilerType.GetMethod("TakeSnapshot", + new[] { typeof(string), typeof(Action) }); + } + + if (takeMethod == null) + return new ErrorResponse("Could not find TakeSnapshot method on MemoryProfiler. API may have changed."); + + Action callback = (path, result) => + { + if (result) + { + var fi = new FileInfo(path); + tcs.TrySetResult(new SuccessResponse("Memory snapshot captured.", new + { + path, + size_bytes = fi.Exists ? fi.Length : 0, + size_mb = fi.Exists ? Math.Round(fi.Length / (1024.0 * 1024.0), 2) : 0, + })); + } + else + { + tcs.TrySetResult(new ErrorResponse($"Snapshot capture failed for path: {path}")); + } + }; + + int paramCount = takeMethod.GetParameters().Length; + if (paramCount == 4) + takeMethod.Invoke(null, new object[] { snapshotPath, callback, null, 0u }); + else if (paramCount == 2) + takeMethod.Invoke(null, new object[] { snapshotPath, callback }); + else + return new ErrorResponse($"TakeSnapshot has unexpected {paramCount} parameters. API may have changed."); + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to take snapshot: {ex.Message}"); + } + + var timeout = Task.Delay(TimeSpan.FromSeconds(30)); + var completed = await Task.WhenAny(tcs.Task, timeout); + if (completed == timeout) + return new ErrorResponse("Snapshot timed out after 30 seconds."); + + return await tcs.Task; + } + + internal static object ListSnapshots(JObject @params) + { + if (!HasPackage) + return PackageMissingError(); + + var p = new ToolParams(@params); + string searchPath = p.Get("search_path"); + + var dirs = new List(); + if (!string.IsNullOrEmpty(searchPath)) + { + dirs.Add(searchPath); + } + else + { + dirs.Add(Path.Combine(Application.temporaryCachePath, "MemoryCaptures")); + dirs.Add(Path.Combine(Application.dataPath, "..", "MemoryCaptures")); + } + + var snapshots = new List(); + foreach (string dir in dirs) + { + if (!Directory.Exists(dir)) continue; + foreach (string file in Directory.GetFiles(dir, "*.snap")) + { + var fi = new FileInfo(file); + snapshots.Add(new + { + path = fi.FullName, + size_bytes = fi.Length, + size_mb = Math.Round(fi.Length / (1024.0 * 1024.0), 2), + created = fi.CreationTimeUtc.ToString("o"), + }); + } + } + + return new SuccessResponse($"Found {snapshots.Count} snapshot(s).", new + { + snapshots, + searched_dirs = dirs, + }); + } + + internal static object CompareSnapshots(JObject @params) + { + if (!HasPackage) + return PackageMissingError(); + + var p = new ToolParams(@params); + var pathAResult = p.GetRequired("snapshot_a"); + if (!pathAResult.IsSuccess) + return new ErrorResponse(pathAResult.ErrorMessage); + + var pathBResult = p.GetRequired("snapshot_b"); + if (!pathBResult.IsSuccess) + return new ErrorResponse(pathBResult.ErrorMessage); + + string pathA = pathAResult.Value; + string pathB = pathBResult.Value; + + if (!File.Exists(pathA)) + return new ErrorResponse($"Snapshot file not found: {pathA}"); + if (!File.Exists(pathB)) + return new ErrorResponse($"Snapshot file not found: {pathB}"); + + var fiA = new FileInfo(pathA); + var fiB = new FileInfo(pathB); + + return new SuccessResponse("Snapshot comparison (file-level metadata).", new + { + snapshot_a = new + { + path = fiA.FullName, + size_bytes = fiA.Length, + size_mb = Math.Round(fiA.Length / (1024.0 * 1024.0), 2), + created = fiA.CreationTimeUtc.ToString("o"), + }, + snapshot_b = new + { + path = fiB.FullName, + size_bytes = fiB.Length, + size_mb = Math.Round(fiB.Length / (1024.0 * 1024.0), 2), + created = fiB.CreationTimeUtc.ToString("o"), + }, + delta = new + { + size_delta_bytes = fiB.Length - fiA.Length, + size_delta_mb = Math.Round((fiB.Length - fiA.Length) / (1024.0 * 1024.0), 2), + time_delta_seconds = (fiB.CreationTimeUtc - fiA.CreationTimeUtc).TotalSeconds, + }, + note = "For detailed object-level comparison, open both snapshots in the Memory Profiler window.", + }); + } + + private static ErrorResponse PackageMissingError() + { + return new ErrorResponse( + "Package com.unity.memoryprofiler is required. " + + "Install via Package Manager or: manage_packages action=add_package package_id=com.unity.memoryprofiler"); + } + } +} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/ObjectMemoryOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/ObjectMemoryOps.cs new file mode 100644 index 000000000..ad4b7185c --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/ObjectMemoryOps.cs @@ -0,0 +1,54 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.Profiling; + +namespace MCPForUnity.Editor.Tools.Profiler +{ + internal static class ObjectMemoryOps + { + internal static object GetObjectMemory(JObject @params) + { + var p = new ToolParams(@params); + var objectPathResult = p.GetRequired("object_path"); + if (!objectPathResult.IsSuccess) + return new ErrorResponse(objectPathResult.ErrorMessage); + + string objectPath = objectPathResult.Value; + + // Try scene hierarchy first + var go = GameObject.Find(objectPath); + if (go != null) + { + long bytes = Profiler.GetRuntimeMemorySizeLong(go); + return new SuccessResponse($"Memory for '{objectPath}'.", new + { + object_name = go.name, + object_type = go.GetType().Name, + size_bytes = bytes, + size_mb = Math.Round(bytes / (1024.0 * 1024.0), 3), + source = "scene_hierarchy", + }); + } + + // Try asset path + var asset = AssetDatabase.LoadAssetAtPath(objectPath); + if (asset != null) + { + long bytes = Profiler.GetRuntimeMemorySizeLong(asset); + return new SuccessResponse($"Memory for '{objectPath}'.", new + { + object_name = asset.name, + object_type = asset.GetType().Name, + size_bytes = bytes, + size_mb = Math.Round(bytes / (1024.0 * 1024.0), 3), + source = "asset_database", + }); + } + + return new ErrorResponse($"Object not found at path '{objectPath}'. Try a scene hierarchy path (e.g. /Player/Mesh) or an asset path (e.g. Assets/Textures/hero.png)."); + } + } +} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs new file mode 100644 index 000000000..4d11faa80 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEngine.Profiling; + +namespace MCPForUnity.Editor.Tools.Profiler +{ + internal static class SessionOps + { + private static readonly string[] AreaNames = Enum.GetNames(typeof(ProfilerArea)); + + internal static object Start(JObject @params) + { + var p = new ToolParams(@params); + string logFile = p.Get("log_file"); + bool enableCallstacks = p.GetBool("enable_callstacks"); + + Profiler.enabled = true; + + bool recording = false; + if (!string.IsNullOrEmpty(logFile)) + { + Profiler.logFile = logFile; + Profiler.enableBinaryLog = true; + recording = true; + } + + if (enableCallstacks) + Profiler.enableAllocationCallstacks = true; + + return new SuccessResponse("Profiler started.", new + { + enabled = Profiler.enabled, + recording = Profiler.enableBinaryLog, + log_file = Profiler.enableBinaryLog ? Profiler.logFile : null, + allocation_callstacks = Profiler.enableAllocationCallstacks, + }); + } + + internal static object Stop(JObject @params) + { + string previousLogFile = Profiler.enableBinaryLog ? Profiler.logFile : null; + + Profiler.enableBinaryLog = false; + Profiler.enableAllocationCallstacks = false; + Profiler.enabled = false; + + return new SuccessResponse("Profiler stopped.", new + { + enabled = false, + previous_log_file = previousLogFile, + }); + } + + internal static object Status(JObject @params) + { + var areas = new Dictionary(); + foreach (string name in AreaNames) + { + if (Enum.TryParse(name, out var area)) + areas[name] = Profiler.GetAreaEnabled(area); + } + + return new SuccessResponse("Profiler status.", new + { + enabled = Profiler.enabled, + recording = Profiler.enableBinaryLog, + log_file = Profiler.enableBinaryLog ? Profiler.logFile : null, + allocation_callstacks = Profiler.enableAllocationCallstacks, + areas, + }); + } + + internal static object SetAreas(JObject @params) + { + var areasToken = @params["areas"] as JObject; + if (areasToken == null) + return new ErrorResponse($"'areas' parameter required. Valid areas: {string.Join(", ", AreaNames)}"); + + var updated = new Dictionary(); + foreach (var prop in areasToken.Properties()) + { + if (!Enum.TryParse(prop.Name, true, out var area)) + return new ErrorResponse($"Unknown area '{prop.Name}'. Valid: {string.Join(", ", AreaNames)}"); + + if (prop.Value.Type != JTokenType.Boolean) + return new ErrorResponse($"Area '{prop.Name}' value must be a boolean (true/false), got: {prop.Value}"); + bool enabled = prop.Value.ToObject(); + Profiler.SetAreaEnabled(area, enabled); + updated[prop.Name] = enabled; + } + + return new SuccessResponse($"Updated {updated.Count} profiler area(s).", new { areas = updated }); + } + } +} diff --git a/README.md b/README.md index 553c8dde2..f16cc4dda 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@
Recent Updates -* **v9.6.2 (beta)** — New `manage_physics` tool (21 actions): physics settings, layer collision matrix, physics materials, joints (5 3D + 9 2D types), queries (raycast, raycast_all, linecast, shapecast, overlap), force application (AddForce/AddTorque/AddExplosionForce), rigidbody configuration, scene-wide validation, and edit-mode simulation. Full 3D and 2D support. +* **v9.6.3 (beta)** — New `manage_profiler` tool (14 actions): Profiler session control (start/stop/status/set areas), frame timing and counter reads, object memory queries, memory snapshots (take/list/compare via com.unity.memoryprofiler), and Frame Debugger (enable/disable/get events). Group: `profiling`. +* **v9.6.2** — New `manage_physics` tool (21 actions): physics settings, layer collision matrix, physics materials, joints (5 3D + 9 2D types), queries (raycast, raycast_all, linecast, shapecast, overlap), force application (AddForce/AddTorque/AddExplosionForce), rigidbody configuration, scene-wide validation, and edit-mode simulation. Full 3D and 2D support. * **v9.6.1** — QoL extensions: `manage_editor` gains undo/redo actions. `manage_scene` gains multi-scene editing (additive load, close, set active, move GO between scenes), scene templates (3d_basic, 2d_basic, etc.), and scene validation with auto-repair. New `manage_build` tool: trigger player builds, switch platforms, configure player settings, manage build scenes and profiles (Unity 6+), run batch builds across multiple platforms, and async job tracking with polling. New `MaxPollSeconds` infrastructure for long-running tool operations. * **v9.5.4** — New `unity_reflect` and `unity_docs` tools for API verification: inspect live C# APIs via reflection and fetch official Unity documentation (ScriptReference, Manual, package docs). New `manage_packages` tool: install, remove, search, and manage Unity packages and scoped registries. Includes input validation, dependency checks on removal, and git URL warnings. * **v9.5.3** — New `manage_graphics` tool (33 actions): volume/post-processing, light baking, rendering stats, pipeline settings, URP renderer features. 3 new resources: `volumes`, `rendering_stats`, `renderer_features`. @@ -96,7 +97,7 @@ openupm add com.coplaydev.unity-mcp * **Extensible** — Works with various MCP Clients ### Available Tools -`apply_text_edits` • `batch_execute` • `create_script` • `debug_request_context` • `delete_script` • `execute_custom_tool` • `execute_menu_item` • `find_gameobjects` • `find_in_file` • `get_sha` • `get_test_job` • `manage_animation` • `manage_asset` • `manage_build` • `manage_camera` • `manage_components` • `manage_editor` • `manage_gameobject` • `manage_graphics` • `manage_material` • `manage_packages` • `manage_physics` • `manage_prefabs` • `manage_probuilder` • `manage_scene` • `manage_script` • `manage_script_capabilities` • `manage_scriptable_object` • `manage_shader` • `manage_texture` • `manage_tools` • `manage_ui` • `manage_vfx` • `read_console` • `refresh_unity` • `run_tests` • `script_apply_edits` • `set_active_instance` • `unity_docs` • `unity_reflect` • `validate_script` +`apply_text_edits` • `batch_execute` • `create_script` • `debug_request_context` • `delete_script` • `execute_custom_tool` • `execute_menu_item` • `find_gameobjects` • `find_in_file` • `get_sha` • `get_test_job` • `manage_animation` • `manage_asset` • `manage_build` • `manage_camera` • `manage_components` • `manage_editor` • `manage_gameobject` • `manage_graphics` • `manage_material` • `manage_packages` • `manage_physics` • `manage_prefabs` • `manage_probuilder` • `manage_profiler` • `manage_scene` • `manage_script` • `manage_script_capabilities` • `manage_scriptable_object` • `manage_shader` • `manage_texture` • `manage_tools` • `manage_ui` • `manage_vfx` • `read_console` • `refresh_unity` • `run_tests` • `script_apply_edits` • `set_active_instance` • `unity_docs` • `unity_reflect` • `validate_script` ### Available Resources `cameras` • `custom_tools` • `renderer_features` • `rendering_stats` • `volumes` • `editor_active_tool` • `editor_prefab_stage` • `editor_selection` • `editor_state` • `editor_windows` • `gameobject` • `gameobject_api` • `gameobject_component` • `gameobject_components` • `get_tests` • `get_tests_for_mode` • `menu_items` • `prefab_api` • `prefab_hierarchy` • `prefab_info` • `project_info` • `project_layers` • `project_tags` • `tool_groups` • `unity_instances` diff --git a/Server/src/cli/commands/profiler.py b/Server/src/cli/commands/profiler.py new file mode 100644 index 000000000..ba95a41cb --- /dev/null +++ b/Server/src/cli/commands/profiler.py @@ -0,0 +1,170 @@ +import click +from cli.utils.connection import handle_unity_errors, run_command, get_config +from cli.utils.output import format_output + + +@click.group("profiler") +def profiler(): + """Unity Profiler session control, counter reads, memory snapshots, and Frame Debugger.""" + pass + + +# --- Session --- + +@profiler.command("start") +@click.option("--log-file", default=None, help="Path to .raw file for recording.") +@click.option("--callstacks", is_flag=True, default=False, help="Enable allocation callstacks.") +@handle_unity_errors +def start(log_file, callstacks): + """Start the Unity Profiler, optionally record to a .raw file.""" + config = get_config() + params = {"action": "profiler_start"} + if log_file: + params["log_file"] = log_file + if callstacks: + params["enable_callstacks"] = True + result = run_command("manage_profiler", params, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("stop") +@handle_unity_errors +def stop(): + """Stop the Unity Profiler and any active recording.""" + config = get_config() + result = run_command("manage_profiler", {"action": "profiler_stop"}, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("status") +@handle_unity_errors +def status(): + """Get Profiler enabled state, active areas, and recording status.""" + config = get_config() + result = run_command("manage_profiler", {"action": "profiler_status"}, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("set-areas") +@click.option("--area", multiple=True, help="Area=bool pairs (e.g. CPU=true Audio=false).") +@handle_unity_errors +def set_areas(area): + """Toggle specific ProfilerAreas on or off.""" + config = get_config() + areas = {} + for a in area: + name, _, val = a.partition("=") + areas[name.strip()] = val.strip().lower() in ("true", "1", "yes") + result = run_command("manage_profiler", {"action": "profiler_set_areas", "areas": areas}, config) + click.echo(format_output(result, config.format)) + + +# --- Counters --- + +@profiler.command("frame-timing") +@handle_unity_errors +def frame_timing(): + """Get frame timing via FrameTimingManager (12 fields, synchronous).""" + config = get_config() + result = run_command("manage_profiler", {"action": "get_frame_timing"}, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("get-counters") +@click.option("--category", required=True, help="Profiler category (e.g. Render, Scripts, Memory).") +@click.option("--counter", multiple=True, help="Specific counter names. Omit to read all in category.") +@handle_unity_errors +def get_counters(category, counter): + """Read profiler counters by category (async, 1-frame wait).""" + config = get_config() + params = {"action": "get_counters", "category": category} + if counter: + params["counters"] = list(counter) + result = run_command("manage_profiler", params, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("object-memory") +@click.option("--path", required=True, help="Scene hierarchy or asset path.") +@handle_unity_errors +def object_memory(path): + """Get native memory size of a specific Unity object.""" + config = get_config() + result = run_command("manage_profiler", {"action": "get_object_memory", "object_path": path}, config) + click.echo(format_output(result, config.format)) + + +# --- Memory Snapshot --- + +@profiler.command("memory-snapshot") +@click.option("--path", default=None, help="Output .snap file path (default: auto-generated).") +@handle_unity_errors +def memory_snapshot(path): + """Take a memory snapshot (requires com.unity.memoryprofiler).""" + config = get_config() + params = {"action": "memory_take_snapshot"} + if path: + params["snapshot_path"] = path + result = run_command("manage_profiler", params, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("memory-list") +@click.option("--search-path", default=None, help="Directory to search for snapshots.") +@handle_unity_errors +def memory_list(search_path): + """List available memory snapshot files.""" + config = get_config() + params = {"action": "memory_list_snapshots"} + if search_path: + params["search_path"] = search_path + result = run_command("manage_profiler", params, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("memory-compare") +@click.option("--a", "snapshot_a", required=True, help="First snapshot path.") +@click.option("--b", "snapshot_b", required=True, help="Second snapshot path.") +@handle_unity_errors +def memory_compare(snapshot_a, snapshot_b): + """Compare two memory snapshots.""" + config = get_config() + result = run_command("manage_profiler", { + "action": "memory_compare_snapshots", + "snapshot_a": snapshot_a, "snapshot_b": snapshot_b, + }, config) + click.echo(format_output(result, config.format)) + + +# --- Frame Debugger --- + +@profiler.command("frame-debugger-enable") +@handle_unity_errors +def frame_debugger_enable(): + """Enable the Frame Debugger and report event count.""" + config = get_config() + result = run_command("manage_profiler", {"action": "frame_debugger_enable"}, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("frame-debugger-disable") +@handle_unity_errors +def frame_debugger_disable(): + """Disable the Frame Debugger.""" + config = get_config() + result = run_command("manage_profiler", {"action": "frame_debugger_disable"}, config) + click.echo(format_output(result, config.format)) + + +@profiler.command("frame-debugger-events") +@click.option("--page-size", default=50, help="Events per page (default 50).") +@click.option("--cursor", default=None, type=int, help="Cursor offset.") +@handle_unity_errors +def frame_debugger_events(page_size, cursor): + """Get Frame Debugger draw call events (paged).""" + config = get_config() + params = {"action": "frame_debugger_get_events", "page_size": page_size} + if cursor is not None: + params["cursor"] = cursor + result = run_command("manage_profiler", params, config) + click.echo(format_output(result, config.format)) diff --git a/Server/src/cli/main.py b/Server/src/cli/main.py index a13c3de86..44afce32c 100644 --- a/Server/src/cli/main.py +++ b/Server/src/cli/main.py @@ -274,6 +274,7 @@ def register_optional_command(module_name: str, command_name: str) -> None: ("cli.commands.reflect", "reflect"), ("cli.commands.docs", "docs"), ("cli.commands.physics", "physics"), + ("cli.commands.profiler", "profiler"), ] for module_name, command_name in optional_commands: diff --git a/Server/src/services/registry/tool_registry.py b/Server/src/services/registry/tool_registry.py index cfe147cde..069b44ab9 100644 --- a/Server/src/services/registry/tool_registry.py +++ b/Server/src/services/registry/tool_registry.py @@ -24,6 +24,7 @@ "scripting_ext": "ScriptableObject management", "testing": "Test runner & async test jobs", "probuilder": "ProBuilder 3D modeling – requires com.unity.probuilder package", + "profiling": "Unity Profiler session control, counters, memory snapshots & Frame Debugger", } DEFAULT_ENABLED_GROUPS: set[str] = {"core"} diff --git a/Server/src/services/tools/manage_profiler.py b/Server/src/services/tools/manage_profiler.py new file mode 100644 index 000000000..321f90309 --- /dev/null +++ b/Server/src/services/tools/manage_profiler.py @@ -0,0 +1,106 @@ +from typing import Annotated, Any, Optional + +from fastmcp import Context +from mcp.types import ToolAnnotations + +from services.registry import mcp_for_unity_tool +from services.tools import get_unity_instance_from_context +from transport.unity_transport import send_with_unity_instance +from transport.legacy.unity_connection import async_send_command_with_retry + +SESSION_ACTIONS = [ + "profiler_start", "profiler_stop", "profiler_status", "profiler_set_areas", +] + +COUNTER_ACTIONS = [ + "get_frame_timing", "get_counters", "get_object_memory", +] + +MEMORY_SNAPSHOT_ACTIONS = [ + "memory_take_snapshot", "memory_list_snapshots", "memory_compare_snapshots", +] + +FRAME_DEBUGGER_ACTIONS = [ + "frame_debugger_enable", "frame_debugger_disable", "frame_debugger_get_events", +] + +UTILITY_ACTIONS = ["ping"] + +ALL_ACTIONS = ( + UTILITY_ACTIONS + SESSION_ACTIONS + COUNTER_ACTIONS + + MEMORY_SNAPSHOT_ACTIONS + FRAME_DEBUGGER_ACTIONS +) + + +@mcp_for_unity_tool( + group="profiling", + description=( + "Unity Profiler session control, counter reads, memory snapshots, and Frame Debugger.\n\n" + "SESSION:\n" + "- profiler_start: Enable profiler, optionally record to .raw file (log_file, enable_callstacks)\n" + "- profiler_stop: Disable profiler, stop recording\n" + "- profiler_status: Get enabled state, active areas, recording path\n" + "- profiler_set_areas: Toggle ProfilerAreas on/off (areas dict)\n\n" + "COUNTERS:\n" + "- get_frame_timing: FrameTimingManager data (12 fields, synchronous)\n" + "- get_counters: Generic counter read by category + optional counter names (async, 1-frame wait)\n" + "- get_object_memory: Memory size of a specific object by path\n\n" + "MEMORY SNAPSHOT (requires com.unity.memoryprofiler):\n" + "- memory_take_snapshot: Capture memory snapshot to file\n" + "- memory_list_snapshots: List available .snap files\n" + "- memory_compare_snapshots: Compare two snapshot files\n\n" + "FRAME DEBUGGER:\n" + "- frame_debugger_enable: Turn on Frame Debugger, report event count\n" + "- frame_debugger_disable: Turn off Frame Debugger\n" + "- frame_debugger_get_events: Get draw call events (paged, best-effort via reflection)" + ), + annotations=ToolAnnotations( + title="Manage Profiler", + destructiveHint=False, + readOnlyHint=False, + ), +) +async def manage_profiler( + ctx: Context, + action: Annotated[str, "The profiler action to perform."], + category: Annotated[Optional[str], "Profiler category name for get_counters (e.g. Render, Scripts, Memory, Physics)."] = None, + counters: Annotated[Optional[list[str]], "Specific counter names for get_counters. Omit to read all in category."] = None, + object_path: Annotated[Optional[str], "Scene hierarchy or asset path for get_object_memory."] = None, + log_file: Annotated[Optional[str], "Path to .raw file for profiler_start recording."] = None, + enable_callstacks: Annotated[Optional[bool], "Enable allocation callstacks for profiler_start."] = None, + areas: Annotated[Optional[dict[str, bool]], "Dict of area name to bool for profiler_set_areas."] = None, + snapshot_path: Annotated[Optional[str], "Output path for memory_take_snapshot."] = None, + search_path: Annotated[Optional[str], "Search directory for memory_list_snapshots."] = None, + snapshot_a: Annotated[Optional[str], "First snapshot path for memory_compare_snapshots."] = None, + snapshot_b: Annotated[Optional[str], "Second snapshot path for memory_compare_snapshots."] = None, + page_size: Annotated[Optional[int], "Page size for frame_debugger_get_events (default 50)."] = None, + cursor: Annotated[Optional[int], "Cursor offset for frame_debugger_get_events."] = None, +) -> dict[str, Any]: + action_lower = action.lower() + if action_lower not in ALL_ACTIONS: + return { + "success": False, + "message": f"Unknown action '{action}'. Valid actions: {', '.join(ALL_ACTIONS)}", + } + + unity_instance = await get_unity_instance_from_context(ctx) + + params_dict: dict[str, Any] = {"action": action_lower} + + param_map = { + "category": category, "counters": counters, + "object_path": object_path, + "log_file": log_file, "enable_callstacks": enable_callstacks, + "areas": areas, + "snapshot_path": snapshot_path, "search_path": search_path, + "snapshot_a": snapshot_a, "snapshot_b": snapshot_b, + "page_size": page_size, "cursor": cursor, + } + for key, val in param_map.items(): + if val is not None: + params_dict[key] = val + + result = await send_with_unity_instance( + async_send_command_with_retry, unity_instance, "manage_profiler", params_dict + ) + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/Server/tests/test_manage_profiler.py b/Server/tests/test_manage_profiler.py new file mode 100644 index 000000000..fcb652d62 --- /dev/null +++ b/Server/tests/test_manage_profiler.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from services.tools.manage_profiler import ( + manage_profiler, + ALL_ACTIONS, + SESSION_ACTIONS, + COUNTER_ACTIONS, + MEMORY_SNAPSHOT_ACTIONS, + FRAME_DEBUGGER_ACTIONS, + UTILITY_ACTIONS, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def mock_unity(monkeypatch): + """Patch Unity transport layer and return captured call dict.""" + captured: dict[str, object] = {} + + async def fake_send(send_fn, unity_instance, tool_name, params): + captured["unity_instance"] = unity_instance + captured["tool_name"] = tool_name + captured["params"] = params + return {"success": True, "message": "ok"} + + monkeypatch.setattr( + "services.tools.manage_profiler.get_unity_instance_from_context", + AsyncMock(return_value="unity-instance-1"), + ) + monkeypatch.setattr( + "services.tools.manage_profiler.send_with_unity_instance", + fake_send, + ) + return captured + + +# --------------------------------------------------------------------------- +# Action list completeness +# --------------------------------------------------------------------------- + +def test_profiler_actions_count(): + assert len(ALL_ACTIONS) == 14 + + +def test_no_duplicate_actions(): + assert len(ALL_ACTIONS) == len(set(ALL_ACTIONS)) + + +def test_session_actions(): + expected = {"profiler_start", "profiler_stop", "profiler_status", "profiler_set_areas"} + assert set(SESSION_ACTIONS) == expected + + +def test_counter_actions(): + expected = {"get_frame_timing", "get_counters", "get_object_memory"} + assert set(COUNTER_ACTIONS) == expected + + +def test_memory_snapshot_actions(): + expected = {"memory_take_snapshot", "memory_list_snapshots", "memory_compare_snapshots"} + assert set(MEMORY_SNAPSHOT_ACTIONS) == expected + + +def test_frame_debugger_actions(): + expected = {"frame_debugger_enable", "frame_debugger_disable", "frame_debugger_get_events"} + assert set(FRAME_DEBUGGER_ACTIONS) == expected + + +def test_utility_actions(): + assert UTILITY_ACTIONS == ["ping"] + + +def test_all_actions_is_union(): + expected = set(UTILITY_ACTIONS + SESSION_ACTIONS + COUNTER_ACTIONS + MEMORY_SNAPSHOT_ACTIONS + FRAME_DEBUGGER_ACTIONS) + assert set(ALL_ACTIONS) == expected + + +# --------------------------------------------------------------------------- +# Invalid / missing action +# --------------------------------------------------------------------------- + +def test_unknown_action_returns_error(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="nonexistent_action") + ) + assert result["success"] is False + assert "Unknown action" in result["message"] + assert "tool_name" not in mock_unity + + +def test_empty_action_returns_error(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="") + ) + assert result["success"] is False + assert "Unknown action" in result["message"] + assert "tool_name" not in mock_unity + + +# --------------------------------------------------------------------------- +# Each action forwards correctly +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("action_name", [ + "ping", + "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", +]) +def test_every_action_forwards_to_unity(mock_unity, action_name): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action=action_name) + ) + assert result["success"] is True + assert mock_unity["tool_name"] == "manage_profiler" + assert mock_unity["params"]["action"] == action_name + + +def test_uses_unity_instance_from_context(mock_unity): + asyncio.run( + manage_profiler(SimpleNamespace(), action="get_frame_timing") + ) + assert mock_unity["unity_instance"] == "unity-instance-1" + + +# --------------------------------------------------------------------------- +# Param forwarding +# --------------------------------------------------------------------------- + +def test_get_counters_forwards_category(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="get_counters", category="Render") + ) + assert result["success"] is True + assert mock_unity["params"]["category"] == "Render" + + +def test_get_counters_forwards_counter_names(mock_unity): + result = asyncio.run( + manage_profiler( + SimpleNamespace(), action="get_counters", + category="Render", counters=["Draw Calls Count", "Batches Count"], + ) + ) + assert result["success"] is True + assert mock_unity["params"]["counters"] == ["Draw Calls Count", "Batches Count"] + + +def test_get_counters_omits_none_counters(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="get_counters", category="Memory") + ) + assert result["success"] is True + assert "counters" not in mock_unity["params"] + + +def test_profiler_start_forwards_log_file(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="profiler_start", log_file="/tmp/profile.raw") + ) + assert result["success"] is True + assert mock_unity["params"]["log_file"] == "/tmp/profile.raw" + + +def test_profiler_start_forwards_callstacks(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="profiler_start", enable_callstacks=True) + ) + assert result["success"] is True + assert mock_unity["params"]["enable_callstacks"] is True + + +def test_profiler_set_areas_forwards_areas(mock_unity): + areas = {"CPU": True, "Audio": False} + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="profiler_set_areas", areas=areas) + ) + assert result["success"] is True + assert mock_unity["params"]["areas"] == areas + + +def test_get_object_memory_forwards_path(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="get_object_memory", object_path="/Player/Mesh") + ) + assert result["success"] is True + assert mock_unity["params"]["object_path"] == "/Player/Mesh" + + +def test_memory_take_snapshot_forwards_path(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="memory_take_snapshot", snapshot_path="/tmp/snap.snap") + ) + assert result["success"] is True + assert mock_unity["params"]["snapshot_path"] == "/tmp/snap.snap" + + +def test_memory_compare_forwards_both_paths(mock_unity): + result = asyncio.run( + manage_profiler( + SimpleNamespace(), action="memory_compare_snapshots", + snapshot_a="/tmp/a.snap", snapshot_b="/tmp/b.snap", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["snapshot_a"] == "/tmp/a.snap" + assert mock_unity["params"]["snapshot_b"] == "/tmp/b.snap" + + +def test_frame_debugger_get_events_forwards_paging(mock_unity): + result = asyncio.run( + manage_profiler( + SimpleNamespace(), action="frame_debugger_get_events", + page_size=25, cursor=50, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["page_size"] == 25 + assert mock_unity["params"]["cursor"] == 50 + + +def test_action_only_params_no_extras(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="profiler_stop") + ) + assert result["success"] is True + assert mock_unity["params"] == {"action": "profiler_stop"} + + +# --------------------------------------------------------------------------- +# Case insensitivity +# --------------------------------------------------------------------------- + +def test_action_case_insensitive(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="Get_Frame_Timing") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "get_frame_timing" + + +def test_action_uppercase(mock_unity): + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="PROFILER_STATUS") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "profiler_status" + + +# --------------------------------------------------------------------------- +# Non-dict response wrapped +# --------------------------------------------------------------------------- + +def test_non_dict_response_wrapped(monkeypatch): + monkeypatch.setattr( + "services.tools.manage_profiler.get_unity_instance_from_context", + AsyncMock(return_value="unity-1"), + ) + + async def fake_send(send_fn, unity_instance, tool_name, params): + return "unexpected string response" + + monkeypatch.setattr( + "services.tools.manage_profiler.send_with_unity_instance", + fake_send, + ) + + result = asyncio.run( + manage_profiler(SimpleNamespace(), action="get_frame_timing") + ) + assert result["success"] is False + assert "unexpected string response" in result["message"] + + +# --------------------------------------------------------------------------- +# Tool registration +# --------------------------------------------------------------------------- + +def test_tool_registered_with_profiling_group(): + from services.registry.tool_registry import _tool_registry + + profiler_tools = [ + t for t in _tool_registry if t.get("name") == "manage_profiler" + ] + assert len(profiler_tools) == 1 + assert profiler_tools[0]["group"] == "profiling" diff --git a/docs/i18n/README-zh.md b/docs/i18n/README-zh.md index 7f1a665fc..7854f29ea 100644 --- a/docs/i18n/README-zh.md +++ b/docs/i18n/README-zh.md @@ -20,7 +20,8 @@
最近更新 -* **v9.6.2 (beta)** — 新增 `manage_physics` 工具(21个操作):物理设置、层碰撞矩阵、物理材质、关节(5种3D + 9种2D类型)、查询(raycast、raycast_all、linecast、shapecast、overlap)、力施加(AddForce/AddTorque/AddExplosionForce)、刚体配置、场景物理验证与编辑器模式模拟。全面支持3D和2D物理。 +* **v9.6.3 (beta)** — 新增 `manage_profiler` 工具(14个操作):Profiler 会话控制(启动/停止/状态/设置区域)、帧时间与计数器读取、对象内存查询、内存快照(通过 com.unity.memoryprofiler 进行拍摄/列表/比较)、帧调试器(启用/禁用/获取事件)。分组:`profiling`。 +* **v9.6.2** — 新增 `manage_physics` 工具(21个操作):物理设置、层碰撞矩阵、物理材质、关节(5种3D + 9种2D类型)、查询(raycast、raycast_all、linecast、shapecast、overlap)、力施加(AddForce/AddTorque/AddExplosionForce)、刚体配置、场景物理验证与编辑器模式模拟。全面支持3D和2D物理。 * **v9.6.1** — QoL 扩展:`manage_editor` 新增撤销/重做操作。`manage_scene` 新增多场景编辑(叠加加载、关闭、设置活动场景、跨场景移动物体)、场景模板(3d_basic、2d_basic 等)、场景验证与自动修复。新增 `manage_build` 工具:触发玩家构建、切换平台、配置玩家设置、管理构建场景和配置文件(Unity 6+)、跨多平台批量构建、异步任务跟踪与轮询。新增 `MaxPollSeconds` 基础设施,支持长时间运行的工具操作。 * **v9.5.4** — 新增 `unity_reflect` 和 `unity_docs` 工具用于 API 验证:通过反射检查实时 C# API,获取官方 Unity 文档(ScriptReference、Manual、包文档)。新增 `manage_packages` 工具:安装、移除、搜索和管理 Unity 包及作用域注册表。包含输入验证、移除时依赖检查和 git URL 警告。 * **v9.5.3** — 新增 `manage_graphics` 工具(33个操作):体积/后处理、光照烘焙、渲染统计、管线设置、URP渲染器特性。3个新资源:`volumes`、`rendering_stats`、`renderer_features`。 @@ -96,7 +97,7 @@ openupm add com.coplaydev.unity-mcp * **可扩展** — 可与多种 MCP Client 配合使用 ### 可用工具 -`apply_text_edits` • `batch_execute` • `create_script` • `debug_request_context` • `delete_script` • `execute_custom_tool` • `execute_menu_item` • `find_gameobjects` • `find_in_file` • `get_sha` • `get_test_job` • `manage_animation` • `manage_asset` • `manage_build` • `manage_camera` • `manage_components` • `manage_editor` • `manage_gameobject` • `manage_graphics` • `manage_material` • `manage_packages` • `manage_physics` • `manage_prefabs` • `manage_probuilder` • `manage_scene` • `manage_script` • `manage_script_capabilities` • `manage_scriptable_object` • `manage_shader` • `manage_texture` • `manage_tools` • `manage_ui` • `manage_vfx` • `read_console` • `refresh_unity` • `run_tests` • `script_apply_edits` • `set_active_instance` • `unity_docs` • `unity_reflect` • `validate_script` +`apply_text_edits` • `batch_execute` • `create_script` • `debug_request_context` • `delete_script` • `execute_custom_tool` • `execute_menu_item` • `find_gameobjects` • `find_in_file` • `get_sha` • `get_test_job` • `manage_animation` • `manage_asset` • `manage_build` • `manage_camera` • `manage_components` • `manage_editor` • `manage_gameobject` • `manage_graphics` • `manage_material` • `manage_packages` • `manage_physics` • `manage_prefabs` • `manage_probuilder` • `manage_profiler` • `manage_scene` • `manage_script` • `manage_script_capabilities` • `manage_scriptable_object` • `manage_shader` • `manage_texture` • `manage_tools` • `manage_ui` • `manage_vfx` • `read_console` • `refresh_unity` • `run_tests` • `script_apply_edits` • `set_active_instance` • `unity_docs` • `unity_reflect` • `validate_script` ### 可用资源 `cameras` • `custom_tools` • `renderer_features` • `rendering_stats` • `volumes` • `editor_active_tool` • `editor_prefab_stage` • `editor_selection` • `editor_state` • `editor_windows` • `gameobject` • `gameobject_api` • `gameobject_component` • `gameobject_components` • `get_tests` • `get_tests_for_mode` • `menu_items` • `prefab_api` • `prefab_hierarchy` • `prefab_info` • `project_info` • `project_layers` • `project_tags` • `tool_groups` • `unity_instances` diff --git a/manifest.json b/manifest.json index 7ea0fbe72..ebc2d7759 100644 --- a/manifest.json +++ b/manifest.json @@ -125,6 +125,10 @@ "name": "manage_probuilder", "description": "Create and edit ProBuilder meshes, shapes, and geometry operations" }, + { + "name": "manage_profiler", + "description": "Unity Profiler session control, counter reads, memory snapshots, and Frame Debugger (group: profiling)" + }, { "name": "manage_scene", "description": "Load, save, query hierarchy, multi-scene editing, templates, validation, and manage Unity scenes" diff --git a/unity-mcp-skill/references/tools-reference.md b/unity-mcp-skill/references/tools-reference.md index afd71e06f..601e533a9 100644 --- a/unity-mcp-skill/references/tools-reference.md +++ b/unity-mcp-skill/references/tools-reference.md @@ -20,6 +20,7 @@ Complete reference for all MCP tools. Each tool includes parameters, types, and - [Package Tools](#package-tools) - [Physics Tools](#physics-tools) - [ProBuilder Tools](#probuilder-tools) +- [Profiler Tools](#profiler-tools) - [Docs Tools](#docs-tools) --- @@ -1423,6 +1424,77 @@ See also: [ProBuilder Workflow Guide](probuilder-guide.md) for detailed patterns --- +## Profiler Tools + +### `manage_profiler` + +Unity Profiler session control, counter reads, memory snapshots, and Frame Debugger. Group: `profiling` (opt-in via `manage_tools`). + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `action` | string | Yes | See action groups below | +| `category` | string | For get_counters | Profiler category name (e.g. `Render`, `Scripts`, `Memory`, `Physics`) | +| `counters` | list[str] | No | Specific counter names for get_counters. Omit to read all in category | +| `object_path` | string | For get_object_memory | Scene hierarchy or asset path | +| `log_file` | string | No | Path to `.raw` file for profiler_start recording | +| `enable_callstacks` | bool | No | Enable allocation callstacks for profiler_start | +| `areas` | dict[str, bool] | For profiler_set_areas | Area name to enabled/disabled mapping | +| `snapshot_path` | string | No | Output path for memory_take_snapshot | +| `search_path` | string | No | Search directory for memory_list_snapshots | +| `snapshot_a` | string | For memory_compare_snapshots | First snapshot file path | +| `snapshot_b` | string | For memory_compare_snapshots | Second snapshot file path | +| `page_size` | int | No | Page size for frame_debugger_get_events (default 50) | +| `cursor` | int | No | Cursor offset for frame_debugger_get_events | + +**Action groups:** + +- **Session:** `profiler_start`, `profiler_stop`, `profiler_status`, `profiler_set_areas` +- **Counters:** `get_frame_timing`, `get_counters`, `get_object_memory` +- **Memory Snapshot:** `memory_take_snapshot`, `memory_list_snapshots`, `memory_compare_snapshots` (requires `com.unity.memoryprofiler`) +- **Frame Debugger:** `frame_debugger_enable`, `frame_debugger_disable`, `frame_debugger_get_events` +- **Utility:** `ping` + +```python +# Check profiler availability +manage_profiler(action="ping") + +# Start profiling (optionally record to file) +manage_profiler(action="profiler_start") +manage_profiler(action="profiler_start", log_file="Assets/profiler.raw", enable_callstacks=True) + +# Check profiler status +manage_profiler(action="profiler_status") + +# Toggle profiler areas +manage_profiler(action="profiler_set_areas", areas={"CPU": True, "GPU": True, "Rendering": True, "Memory": False}) + +# Stop profiling +manage_profiler(action="profiler_stop") + +# Read frame timing data (12 fields from FrameTimingManager) +manage_profiler(action="get_frame_timing") + +# Read counters by category +manage_profiler(action="get_counters", category="Render") +manage_profiler(action="get_counters", category="Memory", counters=["Total Used Memory", "GC Used Memory"]) + +# Get memory size of a specific object +manage_profiler(action="get_object_memory", object_path="Player/Mesh") + +# Memory snapshots (requires com.unity.memoryprofiler) +manage_profiler(action="memory_take_snapshot") +manage_profiler(action="memory_take_snapshot", snapshot_path="Assets/Snapshots/baseline.snap") +manage_profiler(action="memory_list_snapshots") +manage_profiler(action="memory_compare_snapshots", snapshot_a="Assets/Snapshots/before.snap", snapshot_b="Assets/Snapshots/after.snap") + +# Frame Debugger +manage_profiler(action="frame_debugger_enable") +manage_profiler(action="frame_debugger_get_events", page_size=20, cursor=0) +manage_profiler(action="frame_debugger_disable") +``` + +--- + ## Docs Tools Tools for verifying Unity C# APIs and fetching official documentation. Group: `docs`.