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/CLAUDE.md b/CLAUDE.md index b6d9d72c7..11c17935e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ from services.registry import mcp_for_unity_tool @mcp_for_unity_tool( description="Does something in Unity.", - group="core", # core (default), vfx, animation, ui, scripting_ext, testing, probuilder + group="core", # core (default), vfx, animation, ui, scripting_ext, testing, probuilder, profiling, docs ) async def manage_something( ctx: Context, diff --git a/MCPForUnity/Editor/Helpers/McpLogRecord.cs b/MCPForUnity/Editor/Helpers/McpLogRecord.cs index 33002b78e..aeb1bd156 100644 --- a/MCPForUnity/Editor/Helpers/McpLogRecord.cs +++ b/MCPForUnity/Editor/Helpers/McpLogRecord.cs @@ -10,8 +10,9 @@ namespace MCPForUnity.Editor.Helpers { internal static class McpLogRecord { - private static readonly string LogPath = Path.Combine(Application.dataPath, "mcp.log"); - private static readonly string ErrorLogPath = Path.Combine(Application.dataPath, "mcpError.log"); + private static readonly string LogDir = Path.Combine(Application.dataPath, "UnityMCP", "Log"); + private static readonly string LogPath = Path.Combine(LogDir, "mcp.log"); + private static readonly string ErrorLogPath = Path.Combine(LogDir, "mcpError.log"); private const long MaxLogSizeBytes = 1024 * 1024; // 1 MB private static bool _sessionStarted; private static readonly object _logLock = new(); @@ -79,6 +80,7 @@ internal static void Log(string commandType, JObject parameters, string type, st private static void RotateAndAppend(string path, string line) { + Directory.CreateDirectory(LogDir); RotateIfNeeded(path); File.AppendAllText(path, line + Environment.NewLine); } diff --git a/MCPForUnity/Editor/Services/IPackageUpdateService.cs b/MCPForUnity/Editor/Services/IPackageUpdateService.cs index 612adee68..1a8daaf24 100644 --- a/MCPForUnity/Editor/Services/IPackageUpdateService.cs +++ b/MCPForUnity/Editor/Services/IPackageUpdateService.cs @@ -24,6 +24,12 @@ public interface IPackageUpdateService /// UpdateCheckResult FetchAndCompare(string currentVersion); + /// + /// Performs only the network fetch and version comparison using pre-computed installation info. + /// Use this overload when calling from a background thread to avoid main-thread-only API calls. + /// + UpdateCheckResult FetchAndCompare(string currentVersion, bool isGitInstallation, string gitBranch); + /// /// Caches a successful fetch result in EditorPrefs. Must be called from the main thread. /// @@ -43,6 +49,12 @@ public interface IPackageUpdateService /// True if installed via Git, false if Asset Store or unknown bool IsGitInstallation(); + /// + /// Determines the Git branch to check for updates (e.g. "main" or "beta"). + /// Must be called from the main thread (uses Unity PackageManager APIs). + /// + string GetGitUpdateBranch(string currentVersion); + /// /// Clears the cached update check data, forcing a fresh check on next request /// diff --git a/MCPForUnity/Editor/Services/PackageUpdateService.cs b/MCPForUnity/Editor/Services/PackageUpdateService.cs index 66fa923a2..8c441d9b9 100644 --- a/MCPForUnity/Editor/Services/PackageUpdateService.cs +++ b/MCPForUnity/Editor/Services/PackageUpdateService.cs @@ -118,7 +118,12 @@ public UpdateCheckResult FetchAndCompare(string currentVersion) { bool isGitInstallation = IsGitInstallation(); string gitBranch = isGitInstallation ? GetGitUpdateBranch(currentVersion) : "main"; + return FetchAndCompare(currentVersion, isGitInstallation, gitBranch); + } + /// + public UpdateCheckResult FetchAndCompare(string currentVersion, bool isGitInstallation, string gitBranch) + { string latestVersion = isGitInstallation ? FetchLatestVersionFromGitHub(gitBranch) : FetchLatestVersionFromAssetStoreJson(); @@ -279,7 +284,8 @@ private static bool IsPreReleaseVersion(string version) return version.IndexOf('-', StringComparison.Ordinal) >= 0; } - private static string GetGitUpdateBranch(string currentVersion) + /// + public string GetGitUpdateBranch(string currentVersion) { try { diff --git a/MCPForUnity/Editor/Tools/Graphics/RenderingStatsOps.cs b/MCPForUnity/Editor/Tools/Graphics/RenderingStatsOps.cs index fbf018d1e..94f8837fa 100644 --- a/MCPForUnity/Editor/Tools/Graphics/RenderingStatsOps.cs +++ b/MCPForUnity/Editor/Tools/Graphics/RenderingStatsOps.cs @@ -8,6 +8,7 @@ using Unity.Profiling; using Unity.Profiling.LowLevel.Unsafe; using UnityEngine.Profiling; +using UProfiler = UnityEngine.Profiling.Profiler; namespace MCPForUnity.Editor.Tools.Graphics { @@ -126,12 +127,12 @@ internal static object GetMemory(JObject @params) { var data = new Dictionary { - ["totalAllocatedMB"] = Math.Round(Profiler.GetTotalAllocatedMemoryLong() / (1024.0 * 1024.0), 2), - ["totalReservedMB"] = Math.Round(Profiler.GetTotalReservedMemoryLong() / (1024.0 * 1024.0), 2), - ["totalUnusedReservedMB"] = Math.Round(Profiler.GetTotalUnusedReservedMemoryLong() / (1024.0 * 1024.0), 2), - ["monoUsedMB"] = Math.Round(Profiler.GetMonoUsedSizeLong() / (1024.0 * 1024.0), 2), - ["monoHeapMB"] = Math.Round(Profiler.GetMonoHeapSizeLong() / (1024.0 * 1024.0), 2), - ["graphicsDriverMB"] = Math.Round(Profiler.GetAllocatedMemoryForGraphicsDriver() / (1024.0 * 1024.0), 2), + ["totalAllocatedMB"] = Math.Round(UProfiler.GetTotalAllocatedMemoryLong() / (1024.0 * 1024.0), 2), + ["totalReservedMB"] = Math.Round(UProfiler.GetTotalReservedMemoryLong() / (1024.0 * 1024.0), 2), + ["totalUnusedReservedMB"] = Math.Round(UProfiler.GetTotalUnusedReservedMemoryLong() / (1024.0 * 1024.0), 2), + ["monoUsedMB"] = Math.Round(UProfiler.GetMonoUsedSizeLong() / (1024.0 * 1024.0), 2), + ["monoHeapMB"] = Math.Round(UProfiler.GetMonoHeapSizeLong() / (1024.0 * 1024.0), 2), + ["graphicsDriverMB"] = Math.Round(UProfiler.GetAllocatedMemoryForGraphicsDriver() / (1024.0 * 1024.0), 2), }; return new diff --git a/MCPForUnity/Editor/Tools/Profiler.meta b/MCPForUnity/Editor/Tools/Profiler.meta new file mode 100644 index 000000000..82a6285df --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3ac9eeedc58b5d24aaf5c913954e0ec9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: 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/ManageProfiler.cs.meta b/MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs.meta new file mode 100644 index 000000000..8bddd351e --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 369d21351c8c6c744a5775783d4957c9 \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations.meta b/MCPForUnity/Editor/Tools/Profiler/Operations.meta new file mode 100644 index 000000000..88c22d987 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 69357f71e3f052245b353af3c57b521c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs new file mode 100644 index 000000000..160d668ff --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs @@ -0,0 +1,149 @@ +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 — use GetSample(0) for last completed frame data; + // CurrentValue is always 0 for per-frame render counters. + for (int i = 0; i < recorders.Count; i++) + { + var recorder = recorders[i]; + string name = counterNames[i]; + long value = 0; + if (recorder.Valid && recorder.Count > 0) + value = recorder.GetSample(0).Value; + else if (recorder.Valid) + value = recorder.CurrentValue; + data[name] = value; + 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); + int remaining = 2; + + void Tick() + { + if (--remaining > 0) return; + 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/CounterOps.cs.meta b/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs.meta new file mode 100644 index 000000000..edcf0f885 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d082e7102c501e14084295fdd363f2e4 \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs new file mode 100644 index 000000000..0876fdb12 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; + +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 GetFrameEventsMethod; + private static readonly MethodInfo GetEventDataMethod; + private static readonly MethodInfo GetEventInfoNameMethod; + private static readonly Type EventDataType; + private static readonly bool Available; + + static FrameDebuggerOps() + { + try + { + // Unity 6+: moved to FrameDebuggerInternal sub-namespace + UtilType = Type.GetType("UnityEditorInternal.FrameDebuggerInternal.FrameDebuggerUtility, UnityEditor"); + // Unity 2021–2022: original location + UtilType ??= Type.GetType("UnityEditorInternal.FrameDebuggerUtility, UnityEditor"); + + if (UtilType == null) return; + + EventCountProp = UtilType.GetProperty("count", BindingFlags.Public | BindingFlags.Static) + ?? UtilType.GetProperty("eventsCount", 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); + + GetFrameEventsMethod = UtilType.GetMethod("GetFrameEvents", BindingFlags.Public | BindingFlags.Static); + GetEventInfoNameMethod = UtilType.GetMethod("GetFrameEventInfoName", BindingFlags.Public | BindingFlags.Static); + + // Unity 6: GetFrameEventData(int, FrameDebuggerEventData) — 2 params, returns bool + // Older: GetFrameEventData(int) — 1 param, returns event data object + EventDataType = Type.GetType("UnityEditorInternal.FrameDebuggerInternal.FrameDebuggerEventData, UnityEditor") + ?? Type.GetType("UnityEditorInternal.FrameDebuggerEventData, UnityEditor"); + + if (EventDataType != null) + { + GetEventDataMethod = UtilType.GetMethod("GetFrameEventData", BindingFlags.Public | BindingFlags.Static, + null, new[] { typeof(int), EventDataType }, null); + } + GetEventDataMethod ??= UtilType.GetMethod("GetFrameEventData", BindingFlags.Public | BindingFlags.Static); + + Available = EventCountProp != null && EnableMethod != null; + } + catch + { + Available = false; + } + } + + internal static object Enable(JObject @params) + { + if (!Available) + return new ErrorResponse("FrameDebuggerUtility not found via reflection."); + + // Open the Frame Debugger window (required for event capture) + EditorApplication.ExecuteMenuItem("Window/Analysis/Frame Debugger"); + + // Frame Debugger requires game to be paused before enabling to capture events. + if (EditorApplication.isPlaying && !EditorApplication.isPaused) + { + return new ErrorResponse( + "Game must be paused before enabling Frame Debugger. " + + "Call manage_editor action=pause first, then retry frame_debugger_enable."); + } + + try + { + InvokeSetEnabled(true); + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to enable Frame Debugger: {ex.Message}"); + } + + int eventCount = GetEventCount(); + return new SuccessResponse("Frame Debugger enabled.", new + { + enabled = true, + event_count = eventCount, + }); + } + + internal static object Disable(JObject @params) + { + if (!Available) + return new ErrorResponse("FrameDebuggerUtility not found via reflection."); + + try + { + 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 (!Available) + return new ErrorResponse("FrameDebuggerUtility not found via reflection."); + + var p = new ToolParams(@params); + int pageSize = p.GetInt("page_size") ?? 50; + int cursor = p.GetInt("cursor") ?? 0; + + int totalEvents = GetEventCount(); + if (totalEvents == 0) + { + return new SuccessResponse("Frame Debugger has no events. Is it enabled?", new + { + events = new List(), + total_events = 0, + }); + } + + // Try GetFrameEvents() for the event descriptor array (has type/name info) + object[] frameEvents = null; + if (GetFrameEventsMethod != null) + { + try + { + var raw = GetFrameEventsMethod.Invoke(null, null); + if (raw is Array arr) + { + frameEvents = new object[arr.Length]; + arr.CopyTo(frameEvents, 0); + } + } + catch { /* fall through */ } + } + + var events = new List(); + int end = Math.Min(cursor + pageSize, totalEvents); + + for (int i = cursor; i < end; i++) + { + var entry = new Dictionary { ["index"] = i }; + + // Get event name + if (GetEventInfoNameMethod != null) + { + try { entry["name"] = (string)GetEventInfoNameMethod.Invoke(null, new object[] { i }); } + catch { /* skip */ } + } + + // Get fields from FrameDebuggerEvent descriptor + if (frameEvents != null && i < frameEvents.Length) + { + var desc = frameEvents[i]; + var descType = desc.GetType(); + TryAddField(descType, desc, "type", entry, "event_type"); + TryAddField(descType, desc, "gameObjectInstanceID", entry); + } + + // Get detailed event data + if (GetEventDataMethod != null) + { + try + { + var paramInfos = GetEventDataMethod.GetParameters(); + object eventData; + + if (paramInfos.Length == 2 && EventDataType != null) + { + // Unity 6: bool GetFrameEventData(int, FrameDebuggerEventData) + eventData = Activator.CreateInstance(EventDataType); + var args = new object[] { i, eventData }; + var ok = GetEventDataMethod.Invoke(null, args); + eventData = (ok is true) ? args[1] : null; + } + else + { + // Older: FrameDebuggerEventData GetFrameEventData(int) + eventData = GetEventDataMethod.Invoke(null, new object[] { i }); + } + + if (eventData != null) + { + var edType = eventData.GetType(); + TryAddField(edType, eventData, "shaderName", entry); + TryAddField(edType, eventData, "passName", entry); + TryAddField(edType, eventData, "rtName", entry); + TryAddField(edType, eventData, "rtWidth", entry); + TryAddField(edType, eventData, "rtHeight", entry); + TryAddField(edType, eventData, "vertexCount", entry); + TryAddField(edType, eventData, "indexCount", entry); + TryAddField(edType, eventData, "instanceCount", entry); + TryAddField(edType, eventData, "meshName", entry); + } + } + catch { /* skip event data for this index */ } + } + + events.Add(entry); + } + + 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 int GetEventCount() + { + try { return (int)EventCountProp.GetValue(null); } + catch { return 0; } + } + + private static void TryAddField(Type type, object obj, string fieldName, Dictionary dict, string outputKey = null) + { + 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); + object val = field != null ? field.GetValue(obj) + : prop != null ? prop.GetValue(obj) + : null; + if (val != null) + dict[outputKey ?? fieldName] = val.GetType().IsEnum ? val.ToString() : val; + } + catch { /* skip unavailable fields */ } + } + + } +} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs.meta b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs.meta new file mode 100644 index 000000000..1b5df5c73 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6c99fe029dffcb945a39beda3acf304e \ No newline at end of file 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/FrameTimingOps.cs.meta b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs.meta new file mode 100644 index 000000000..ae1cccfda --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: aace6e3da2222164298de24af50cab3b \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs new file mode 100644 index 000000000..5485ecae5 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs @@ -0,0 +1,200 @@ +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 debugScreenCaptureType = Type.GetType( + "Unity.Profiling.Memory.Experimental.DebugScreenCapture, Unity.MemoryProfiler.Editor"); + + System.Reflection.MethodInfo takeMethod = null; + + if (debugScreenCaptureType != null) + { + var screenshotCallbackType = typeof(Action<,,>).MakeGenericType( + typeof(string), typeof(bool), debugScreenCaptureType); + takeMethod = MemoryProfilerType.GetMethod("TakeSnapshot", + new[] { typeof(string), typeof(Action), screenshotCallbackType, 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/MemorySnapshotOps.cs.meta b/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs.meta new file mode 100644 index 000000000..826eb6e63 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cb63d9b6a47327b40883452637eeb075 \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/ObjectMemoryOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/ObjectMemoryOps.cs new file mode 100644 index 000000000..c3bb7f7f5 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/ObjectMemoryOps.cs @@ -0,0 +1,55 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.Profiling; +using UProfiler = UnityEngine.Profiling.Profiler; + +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 = UProfiler.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 = UProfiler.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/ObjectMemoryOps.cs.meta b/MCPForUnity/Editor/Tools/Profiler/Operations/ObjectMemoryOps.cs.meta new file mode 100644 index 000000000..b3bfc58f0 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/ObjectMemoryOps.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d57a224350157834083a7aabc86dc4e1 \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs b/MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs new file mode 100644 index 000000000..a493f5ac0 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEngine; +using UnityEngine.Profiling; +using UProfiler = UnityEngine.Profiling.Profiler; + +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"); + + UProfiler.enabled = true; + + bool recording = false; + if (!string.IsNullOrEmpty(logFile)) + { + string dir = Path.GetDirectoryName(logFile); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + return new ErrorResponse($"Log file directory does not exist: {dir}"); + + UProfiler.logFile = logFile; + UProfiler.enableBinaryLog = true; + recording = true; + } + + if (enableCallstacks) + UProfiler.enableAllocationCallstacks = true; + + return new SuccessResponse("Profiler started.", new + { + enabled = UProfiler.enabled, + recording = UProfiler.enableBinaryLog, + log_file = UProfiler.enableBinaryLog ? UProfiler.logFile : null, + allocation_callstacks = UProfiler.enableAllocationCallstacks, + }); + } + + internal static object Stop(JObject @params) + { + string previousLogFile = UProfiler.enableBinaryLog ? UProfiler.logFile : null; + + UProfiler.enableBinaryLog = false; + UProfiler.enableAllocationCallstacks = false; + UProfiler.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] = UProfiler.GetAreaEnabled(area); + } + + return new SuccessResponse("Profiler status.", new + { + enabled = UProfiler.enabled, + recording = UProfiler.enableBinaryLog, + log_file = UProfiler.enableBinaryLog ? UProfiler.logFile : null, + allocation_callstacks = UProfiler.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(); + UProfiler.SetAreaEnabled(area, enabled); + updated[prop.Name] = enabled; + } + + return new SuccessResponse($"Updated {updated.Count} profiler area(s).", new { areas = updated }); + } + } +} diff --git a/MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs.meta b/MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs.meta new file mode 100644 index 000000000..082dbcbb2 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4470c7a41470fb841b1d7e75d990eafd \ No newline at end of file diff --git a/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs index 6d5de0efb..eec0f9a74 100644 --- a/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs @@ -102,7 +102,7 @@ private void InitializeUI() } if (logRecordToggle != null) { - logRecordToggle.tooltip = "Log every MCP tool execution (tool, action, status, duration) to Assets/mcp.log."; + logRecordToggle.tooltip = "Log every MCP tool execution (tool, action, status, duration) to Assets/UnityMCP/Log/mcp.log."; var logRecordLabel = logRecordToggle?.parent?.Q