chore(unity): 固定Unity CLI Loop本地包并移除UnitySkills依赖

This commit is contained in:
Liubing\LB
2026-09-28 18:14:59 +08:00
parent 0eb43b2b40
commit c4ae02def9
952 changed files with 70137 additions and 11 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1511443bffd3044d8a38e727e688c81f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,102 @@
using System;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Response schema for ClearConsole command
/// Provides type-safe response structure for console clearing operation
/// </summary>
public class ClearConsoleResponse : BaseToolResponse
{
/// <summary>
/// Whether the console clear operation was successful
/// </summary>
public bool Success { get; set; }
/// <summary>
/// Number of logs that were cleared from the console
/// </summary>
public int ClearedLogCount { get; set; }
/// <summary>
/// Breakdown of cleared logs by type
/// </summary>
public ClearedLogCounts ClearedCounts { get; set; }
/// <summary>
/// Message describing the clear operation result
/// </summary>
public string Message { get; set; }
/// <summary>
/// Error message if the operation failed
/// </summary>
public string ErrorMessage { get; set; }
/// <summary>
/// Create a new ClearConsoleResponse for successful operation
/// </summary>
public ClearConsoleResponse(bool success, int clearedLogCount, ClearedLogCounts clearedCounts, string message)
{
Success = success;
ClearedLogCount = clearedLogCount;
ClearedCounts = clearedCounts ?? new ClearedLogCounts();
Message = message ?? string.Empty;
ErrorMessage = string.Empty;
}
/// <summary>
/// Create a new ClearConsoleResponse for failed operation
/// </summary>
public ClearConsoleResponse(string errorMessage)
{
Success = false;
ClearedLogCount = 0;
ClearedCounts = new ClearedLogCounts();
Message = string.Empty;
ErrorMessage = errorMessage ?? string.Empty;
}
/// <summary>
/// Parameterless constructor for JSON deserialization
/// </summary>
public ClearConsoleResponse()
{
ClearedCounts = new ClearedLogCounts();
Message = string.Empty;
ErrorMessage = string.Empty;
}
}
/// <summary>
/// Breakdown of cleared logs by type
/// </summary>
public class ClearedLogCounts
{
/// <summary>
/// Number of error logs that were cleared
/// </summary>
public int ErrorCount { get; set; }
/// <summary>
/// Number of warning logs that were cleared
/// </summary>
public int WarningCount { get; set; }
/// <summary>
/// Number of info logs that were cleared
/// </summary>
public int LogCount { get; set; }
public ClearedLogCounts()
{
}
public ClearedLogCounts(int errorCount, int warningCount, int logCount)
{
ErrorCount = errorCount;
WarningCount = warningCount;
LogCount = logCount;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dbbf8d96e00c54171b03c9c0d42822dc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,21 @@
using System.ComponentModel;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Schema for ClearConsole command parameters
/// Provides type-safe parameter access for clearing Unity Console
/// Related classes:
/// - ConsoleUtility: Service layer for console operations
/// - ClearConsoleResponse: Type-safe response structure
/// - ClearConsoleCommand: Command implementation
/// </summary>
public class ClearConsoleSchema : BaseToolSchema
{
/// <summary>
/// Whether to add a confirmation log message after clearing
/// </summary>
[Description("Whether to add a confirmation log message after clearing")]
public bool AddConfirmationMessage { get; set; } = false;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 94a230c702f0e4d75b03dc706886b935
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,29 @@
using System.Threading;
using System.Threading.Tasks;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// ClearConsole tool handler - Type-safe implementation using Schema and Response
/// Clears Unity console logs for clean development workflow
/// Related classes: ClearConsoleUseCase, ConsoleUtility, ClearConsoleSchema, ClearConsoleResponse
/// </summary>
[McpTool(Description = "Clear Unity console logs")]
public class ClearConsoleTool : AbstractUnityTool<ClearConsoleSchema, ClearConsoleResponse>
{
public override string ToolName => "clear-console";
/// <summary>
/// Execute console clear tool
/// </summary>
/// <param name="parameters">Type-safe parameters</param>
/// <param name="cancellationToken">Cancellation token for timeout control</param>
/// <returns>Clear operation result</returns>
protected override async Task<ClearConsoleResponse> ExecuteAsync(ClearConsoleSchema parameters, CancellationToken cancellationToken)
{
// Create and execute ClearConsoleUseCase instance
ClearConsoleUseCase useCase = new();
return await useCase.ExecuteAsync(parameters, cancellationToken);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b72387c853bad490fb5cb20f551f0781
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fc3fddf04059a4303be8a7f27aa53052
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,48 @@
---
name: uloop-clear-console
description: "Clear Unity Console entries. Use before compile, tests, or debugging when stale logs would hide the current result."
---
# uloop clear-console
Clear Unity console logs.
## Usage
```bash
uloop clear-console [--add-confirmation-message]
```
## Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `--add-confirmation-message` | boolean | `false` | Add confirmation message after clearing |
## Global Options
| Option | Description |
|--------|-------------|
| `--project-path <path>` | Optional. Use only when the target Unity project is not the current directory. |
## Examples
```bash
# Clear console
uloop clear-console
# Clear with confirmation
uloop clear-console --add-confirmation-message
```
## Output
Returns JSON with:
- `Success` (boolean): Whether the clear operation succeeded
- `ClearedLogCount` (number): Total number of log entries that were cleared
- `ClearedCounts` (object): Breakdown by log type
- `ErrorCount` (number): Errors cleared
- `WarningCount` (number): Warnings cleared
- `LogCount` (number): Info logs cleared
- `Message` (string): Description of the result; carries the failure summary when the operation fails (e.g. `"Failed to clear console: ..."`)
- `ErrorMessage` (string): Currently always empty for this tool — read `Message` for failure details

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9f1d3cbc9cba645b398b9f4bbe9537c4
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 90328140d69f84f1e925caf7f710c0eb
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,22 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Represents information about a Unity component
/// </summary>
public class ComponentInfo
{
public string type { get; set; }
public string fullTypeName { get; set; }
public ComponentPropertyInfo[] properties { get; set; }
}
/// <summary>
/// Represents a property of a Unity component
/// </summary>
public class ComponentPropertyInfo
{
public string name { get; set; }
public string type { get; set; }
public object value { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b8f54071c76614440b5775885e181d95
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a8a3c04e7ca654807956364da02c4e85
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,208 @@
#nullable enable
#if ULOOPMCP_HAS_INPUT_SYSTEM
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.LowLevel;
namespace io.github.hatayama.uLoopMCP
{
// Shared helper for applying Input System state changes at the correct update phase.
// Both keyboard and mouse simulation need frame-precise timing so that
// wasPressedThisFrame / wasReleasedThisFrame detect the injected state.
internal static class InputSystemUpdateHelper
{
public static Task ApplyOnNextConfiguredUpdate(Action apply, CancellationToken ct)
{
InputUpdateType targetUpdateType = InputUpdateTypeResolver.Resolve();
if (InputUpdateTypeResolver.RequiresExplicitUpdate())
{
return ApplyOnExplicitUpdate(apply, targetUpdateType, ct);
}
TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
CancellationTokenRegistration registration = default;
Action? callback = null;
callback = () =>
{
InputUpdateType currentUpdateType = InputState.currentUpdateType;
if (!InputUpdateTypeResolver.IsMatch(currentUpdateType, targetUpdateType))
{
return;
}
Debug.Assert(callback != null, "callback must be assigned before subscription");
InputSystem.onBeforeUpdate -= callback;
registration.Dispose();
apply();
tcs.TrySetResult(true);
};
InputSystem.onBeforeUpdate += callback;
if (ct.CanBeCanceled)
{
registration = ct.Register(() =>
{
Debug.Assert(callback != null, "callback must be assigned before cancellation");
InputSystem.onBeforeUpdate -= callback;
tcs.TrySetCanceled(ct);
});
}
return tcs.Task;
}
public static int GetMinimumObservationFrameCount()
{
if (!InputUpdateTypeResolver.RequiresExplicitUpdate())
{
return 1;
}
InputUpdateType targetUpdateType = InputUpdateTypeResolver.Resolve();
if (targetUpdateType != InputUpdateType.Manual)
{
return 2;
}
// Manual-mode projects often call InputSystem.Update from their own Update loop,
// so zero-duration taps need one extra frame to remain visible to gameplay code.
return 3;
}
public static async Task WaitForObservationFrames(CancellationToken ct)
{
await WaitForRuntimeFrames(GetMinimumObservationFrameCount(), ct);
}
public static async Task WaitForPressLifetime(float duration, CancellationToken ct)
{
int minimumObservationFrames = GetMinimumObservationFrameCount();
int startFrameCount = Time.frameCount;
float startTime = Time.realtimeSinceStartup;
float elapsed = 0f;
int observedFrames = 0;
while (observedFrames < minimumObservationFrames || elapsed < duration)
{
await EditorDelay.DelayFrame(1, ct);
observedFrames = Time.frameCount - startFrameCount;
elapsed = Time.realtimeSinceStartup - startTime;
}
}
public static void RunExplicitUpdate(InputUpdateType targetUpdateType)
{
InputSettings? settings = InputSystem.settings;
if (settings == null)
{
InputSystem.Update();
return;
}
InputSettings.UpdateMode originalUpdateMode = settings.updateMode;
InputSettings.UpdateMode targetUpdateMode = GetExplicitUpdateMode(targetUpdateType, originalUpdateMode);
if (targetUpdateMode == originalUpdateMode)
{
InputSystem.Update();
return;
}
settings.updateMode = targetUpdateMode;
try
{
InputSystem.Update();
}
finally
{
settings.updateMode = originalUpdateMode;
}
}
private static Task ApplyOnExplicitUpdate(Action apply, InputUpdateType targetUpdateType, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
CancellationTokenRegistration registration = default;
Action? callback = null;
callback = () =>
{
InputUpdateType currentUpdateType = InputState.currentUpdateType;
if (!InputUpdateTypeResolver.IsMatch(currentUpdateType, targetUpdateType))
{
return;
}
Debug.Assert(callback != null, "callback must be assigned before subscription");
InputSystem.onBeforeUpdate -= callback;
registration.Dispose();
apply();
tcs.TrySetResult(true);
};
InputSystem.onBeforeUpdate += callback;
if (ct.CanBeCanceled)
{
registration = ct.Register(() =>
{
Debug.Assert(callback != null, "callback must be assigned before cancellation");
InputSystem.onBeforeUpdate -= callback;
tcs.TrySetCanceled(ct);
});
}
RunExplicitUpdate(targetUpdateType);
if (!tcs.Task.IsCompleted)
{
Debug.Assert(callback != null, "callback must be assigned before explicit update fallback");
InputSystem.onBeforeUpdate -= callback;
registration.Dispose();
apply();
RunExplicitUpdate(targetUpdateType);
tcs.TrySetResult(true);
}
return tcs.Task;
}
private static InputSettings.UpdateMode GetExplicitUpdateMode(
InputUpdateType targetUpdateType,
InputSettings.UpdateMode fallbackUpdateMode)
{
if (targetUpdateType == InputUpdateType.Dynamic)
{
return InputSettings.UpdateMode.ProcessEventsInDynamicUpdate;
}
if (targetUpdateType == InputUpdateType.Fixed)
{
return InputSettings.UpdateMode.ProcessEventsInFixedUpdate;
}
if (targetUpdateType == InputUpdateType.Manual)
{
return InputSettings.UpdateMode.ProcessEventsManually;
}
return fallbackUpdateMode;
}
private static async Task WaitForRuntimeFrames(int frameCount, CancellationToken ct)
{
int startFrameCount = Time.frameCount;
int observedFrames = 0;
while (observedFrames < frameCount)
{
await EditorDelay.DelayFrame(1, ct);
observedFrames = Time.frameCount - startFrameCount;
}
}
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5d2cdea3342ed49e2a971e1766cbafa7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,64 @@
#nullable enable
#if ULOOPMCP_HAS_INPUT_SYSTEM
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.LowLevel;
namespace io.github.hatayama.uLoopMCP
{
// Projects can process Input System events in dynamic, fixed, or manual mode.
// Input simulation must follow that configured loop to avoid frame mismatches.
internal static class InputUpdateTypeResolver
{
public static InputUpdateType Resolve()
{
InputSettings? settings = InputSystem.settings;
if (settings == null)
{
return InputUpdateType.Dynamic;
}
InputSettings.UpdateMode updateMode = settings.updateMode;
switch (updateMode)
{
case InputSettings.UpdateMode.ProcessEventsInFixedUpdate:
// Paused screens commonly set timeScale to 0, which stops fixed ticks entirely.
// Falling back to Dynamic keeps input simulation responsive for those menus.
return IsPausedFixedUpdate(settings) ? InputUpdateType.Dynamic : InputUpdateType.Fixed;
case InputSettings.UpdateMode.ProcessEventsManually:
return InputUpdateType.Manual;
default:
return InputUpdateType.Dynamic;
}
}
public static bool IsMatch(InputUpdateType current, InputUpdateType expected)
{
return (current & expected) == expected;
}
public static bool RequiresExplicitUpdate()
{
InputSettings? settings = InputSystem.settings;
if (settings == null)
{
return false;
}
if (settings.updateMode == InputSettings.UpdateMode.ProcessEventsManually)
{
return true;
}
return IsPausedFixedUpdate(settings);
}
private static bool IsPausedFixedUpdate(InputSettings settings)
{
return settings.updateMode == InputSettings.UpdateMode.ProcessEventsInFixedUpdate && Time.timeScale <= 0f;
}
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 67072e4564d6345d58ceff0440cbfeda
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: acb202afcf62f40b88d846c3f1e0c373
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,102 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Compile error or warning information
/// </summary>
public class CompileIssue
{
public string Message { get; set; }
public string File { get; set; }
public int Line { get; set; }
public CompileIssue(string message, string file, int line)
{
Message = message;
File = file;
Line = line;
}
public CompileIssue() { }
}
/// <summary>
/// Response schema for Compile command
/// Provides type-safe response structure
/// </summary>
public class CompileResponse : BaseToolResponse
{
/// <summary>
/// Whether compilation was successful.
/// Null is used when the tool cannot reliably determine the result yet (e.g., forced recompile/domain reload),
/// to avoid misleading clients into treating "0 errors" as a confirmed success.
/// </summary>
public bool? Success { get; set; }
/// <summary>
/// Number of compilation errors.
/// Null is used when the tool intentionally does not provide details (e.g., forced recompile),
/// because Unity reports errors/warnings after domain reload and clients should fetch logs later.
/// </summary>
public int? ErrorCount { get; set; }
/// <summary>
/// Number of compilation warnings.
/// Null is used when the tool intentionally does not provide details (e.g., forced recompile),
/// because Unity reports errors/warnings after domain reload and clients should fetch logs later.
/// </summary>
public int? WarningCount { get; set; }
/// <summary>
/// Compilation errors.
/// Null is used when the tool intentionally does not provide details (e.g., forced recompile),
/// because Unity reports errors/warnings after domain reload and clients should fetch logs later.
/// </summary>
public CompileIssue[] Errors { get; set; }
/// <summary>
/// Compilation warnings.
/// Null is used when the tool intentionally does not provide details (e.g., forced recompile),
/// because Unity reports errors/warnings after domain reload and clients should fetch logs later.
/// </summary>
public CompileIssue[] Warnings { get; set; }
/// <summary>
/// Optional message for additional information
/// </summary>
public string Message { get; set; }
/// <summary>
/// Unity project root path (from Application.dataPath).
/// Set only when WaitForDomainReload=true so that TS/CLI can locate lock files
/// in the correct project even when connected to a remote Unity instance.
/// </summary>
public string ProjectRoot { get; set; }
/// <summary>
/// Create a new CompileResponse
/// </summary>
public CompileResponse(
bool? success,
int? errorCount,
int? warningCount,
CompileIssue[] errors,
CompileIssue[] warnings,
string message = null
)
{
Success = success;
ErrorCount = errorCount;
WarningCount = warningCount;
Errors = errors;
Warnings = warnings;
Message = message;
}
/// <summary>
/// Parameterless constructor for JSON deserialization
/// </summary>
public CompileResponse()
{
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 56090f2379ae3428bbe733a4db41e344
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,29 @@
using System.ComponentModel;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Schema for Compile command parameters
/// Provides type-safe parameter access with default values
/// </summary>
public class CompileSchema : BaseToolSchema
{
/// <summary>
/// Whether to perform forced recompilation
/// </summary>
[Description("Whether to perform forced recompilation. Note: Force compilation results do not include error/warning messages in the response. Use get-logs tool after execution to retrieve compilation messages.")]
public bool ForceRecompile { get; set; } = false;
/// <summary>
/// Whether to wait for domain reload completion before the caller returns.
/// </summary>
[Description("Whether to wait for domain reload completion before returning.")]
public bool WaitForDomainReload { get; set; } = false;
/// <summary>
/// Internal request identifier used for delayed result recovery across domain reload.
/// </summary>
[Browsable(false)]
public string RequestId { get; set; } = "";
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2581c0d32a90546fd9978a2f938e2f6f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,29 @@
using System.Threading;
using System.Threading.Tasks;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Compile tool handler - Type-safe implementation using Schema and Response
/// Handles Unity project compilation with optional force recompile
/// Related classes: CompileUseCase, CompilationStateValidationService, CompilationExecutionService
/// </summary>
[McpTool(Description = "Execute Unity project compilation")]
public class CompileTool : AbstractUnityTool<CompileSchema, CompileResponse>
{
public override string ToolName => "compile";
/// <summary>
/// Execute compile tool - delegates to CompileUseCase
/// </summary>
/// <param name="parameters">Type-safe parameters</param>
/// <param name="cancellationToken">Cancellation token for timeout control</param>
/// <returns>Compile result</returns>
protected override async Task<CompileResponse> ExecuteAsync(CompileSchema parameters, CancellationToken cancellationToken)
{
// Create and execute CompileUseCase instance
CompileUseCase useCase = new();
return await useCase.ExecuteAsync(parameters, cancellationToken);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 60e9cabfcc7d047cbab5dfd6e9b06904
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1186b025586e04dd794dace399e9ce35
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,70 @@
---
name: uloop-compile
description: "Compile the Unity project and report errors/warnings. Use after C# edits or when a full Domain Reload compile is needed."
---
# uloop compile
Execute Unity project compilation.
## Usage
```bash
uloop compile [--force-recompile] [--wait-for-domain-reload]
```
## Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `--force-recompile` | boolean | `false` | Force full recompilation (triggers Domain Reload) |
| `--wait-for-domain-reload` | boolean | `false` | Wait until Domain Reload completes before returning |
## Global Options
| Option | Description |
|--------|-------------|
| `--project-path <path>` | Optional. Use only when the target Unity project is not the current directory. |
## Examples
```bash
# Check compilation
uloop compile
# Force full recompilation
uloop compile --force-recompile
# Force recompilation and wait for Domain Reload completion
uloop compile --force-recompile true --wait-for-domain-reload true
# Wait for Domain Reload completion even without force recompilation
uloop compile --force-recompile false --wait-for-domain-reload true
```
## Output
Returns JSON:
- `Success`: boolean
- `ErrorCount`: number
- `WarningCount`: number
## Troubleshooting
Diagnose the failure mode before retrying.
**Stale lock files** (CLI hangs or shows "Unity is busy" while Unity Editor *is* running):
```bash
uloop fix
```
This removes any leftover lock files (`compiling.lock`, `domainreload.lock`, `serverstarting.lock`) from the Unity project's Temp directory. Then retry `uloop compile`.
**Unity Editor not running** (CLI returns a connection failure and no Unity process is alive):
```bash
uloop launch
```
`uloop launch` auto-detects the project at the current working directory and opens it in the matching Unity Editor version. After Unity finishes launching, retry `uloop compile`.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3e7fbf71d98ab425486f6bd68b7a6612
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a76e166c9de484f20b07f88305dd4b6b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,10 @@
namespace io.github.hatayama.uLoopMCP
{
public class ControlPlayModeResponse : BaseToolResponse
{
public bool IsPlaying { get; set; }
public bool IsPaused { get; set; }
public string Message { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 752e3f7c18a5649d8b6a555add78f1d7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,18 @@
using System.ComponentModel;
namespace io.github.hatayama.uLoopMCP
{
public enum PlayModeAction
{
Play = 0,
Stop = 1,
Pause = 2
}
public class ControlPlayModeSchema : BaseToolSchema
{
[Description("Action to perform: Play(0) - Start play mode, Stop(1) - Stop play mode, Pause(2) - Pause play mode")]
public PlayModeAction Action { get; set; } = PlayModeAction.Play;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5634451b383ae4ff8b0a1cd00500f342
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,18 @@
using System.Threading;
using System.Threading.Tasks;
namespace io.github.hatayama.uLoopMCP
{
[McpTool(Description = "Control Unity Editor play mode (play/stop/pause)")]
public class ControlPlayModeTool : AbstractUnityTool<ControlPlayModeSchema, ControlPlayModeResponse>
{
public override string ToolName => "control-play-mode";
protected override async Task<ControlPlayModeResponse> ExecuteAsync(ControlPlayModeSchema parameters, CancellationToken cancellationToken)
{
ControlPlayModeUseCase useCase = new();
return await useCase.ExecuteAsync(parameters, cancellationToken);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bdce84d8140ea44e9a5fbc8b1e1cf5d6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8809b197b1dbb42fbbabc04674f5baa9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,55 @@
---
name: uloop-control-play-mode
description: "Control Unity Editor Play Mode. Use to start, stop, or pause Play Mode for runtime behavior checks and frame inspection."
---
# uloop control-play-mode
Control Unity Editor play mode (play/stop/pause).
## Usage
```bash
uloop control-play-mode [options]
```
## Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause` |
## Global Options
| Option | Description |
|--------|-------------|
| `--project-path <path>` | Optional. Use only when the target Unity project is not the current directory. |
## Examples
```bash
# Start play mode
uloop control-play-mode --action Play
# Stop play mode
uloop control-play-mode --action Stop
# Pause play mode
uloop control-play-mode --action Pause
```
## Output
Returns JSON with the current play mode state:
- `IsPlaying`: Whether Unity is currently in play mode
- `IsPaused`: Whether play mode is paused
- `Message`: Description of the action performed
## Notes
- Play action starts the game in the Unity Editor (also resumes from pause)
- Stop action exits play mode and returns to edit mode
- Pause action pauses the game while remaining in play mode
- Useful for automated testing workflows
- PlayMode entry may complete on the next editor frame. If a PlayMode-dependent command reports "PlayMode is not active" immediately after `--action Play`, wait briefly and retry.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 1e1425714444f43d1aab052bf7101893
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a4d88782b067f473a8c1bf230936d8f0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,122 @@
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json;
using System.Threading;
namespace io.github.hatayama.uLoopMCP
{
// Related classes:
// - IUnityTool: The interface that this class implements.
// - UnityToolRegistry: Registers and manages instances of tool implementations.
// - ToolParameterSchemaGenerator: Generates the JSON schema for tool parameters.
/// <summary>
/// Abstract base class for type-safe Unity tools using Schema and Response types
/// </summary>
/// <typeparam name="TSchema">Schema type for tool parameters</typeparam>
/// <typeparam name="TResponse">Response type for tool results</typeparam>
public abstract class AbstractUnityTool<TSchema, TResponse> : IUnityTool
where TSchema : BaseToolSchema, new()
where TResponse : BaseToolResponse
{
public abstract string ToolName { get; }
/// <summary>
/// Automatically generates parameter schema from TSchema type
/// </summary>
public virtual ToolParameterSchema ParameterSchema =>
ToolParameterSchemaGenerator.FromDto<TSchema>();
/// <summary>
/// Execute tool with type-safe Schema parameters
/// Note: This method is called from the main thread context.
/// MainThreadSwitcher.SwitchToMainThread() is already handled by the upper layer (JsonRpcProcessor),
/// so individual tools do not need to call it again.
/// </summary>
/// <param name="parameters">Strongly typed parameters</param>
/// <param name="cancellationToken">Cancellation token for timeout control</param>
/// <returns>Strongly typed tool execution result</returns>
protected abstract Task<TResponse> ExecuteAsync(TSchema parameters, CancellationToken cancellationToken);
/// <summary>
/// IUnityTool implementation - converts JToken to Schema and returns BaseToolResponse
/// </summary>
public async Task<BaseToolResponse> ExecuteAsync(JToken paramsToken)
{
// Convert JToken to strongly typed Schema
TSchema parameters = ConvertToSchema(paramsToken);
// Execute with type-safe parameters
TResponse response = await ExecuteAsync(parameters, CancellationToken.None);
// Return as BaseToolResponse for IUnityTool interface compatibility
return response;
}
/// <summary>
/// Convert JToken to strongly typed Schema with default value fallback
/// </summary>
private TSchema ConvertToSchema(JToken paramsToken)
{
if (paramsToken == null || paramsToken.Type == JTokenType.Null)
{
// Return default instance if no parameters provided
return new TSchema();
}
// Create JsonSerializerSettings with CamelCasePropertyNamesContractResolver
// This allows TypeScript side to use camelCase while C# uses PascalCase
JsonSerializerSettings settings = new JsonSerializerSettings
{
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
};
// Create JsonSerializer with custom settings
JsonSerializer serializer = JsonSerializer.Create(settings);
// Try to deserialize from JToken with custom serializer
TSchema schema;
try
{
schema = paramsToken.ToObject<TSchema>(serializer);
}
catch (JsonSerializationException ex)
{
// Create detailed error message for type mismatches
string errorMessage = $"Parameter type mismatch for tool '{ToolName}': {ex.Message}";
// Check for specific Dictionary<string, object> conversion errors
if (ex.Message.Contains("Dictionary") && ex.Message.Contains("Error converting value"))
{
string received = paramsToken["parameters"]?.ToString() ?? paramsToken["Parameters"]?.ToString() ?? "null";
errorMessage =
"Parameter 'Parameters' must be an object, not a string. " +
"Do: omit 'Parameters' or use {}. " +
"Don't: \"{}\" (string). " +
$"Received: {received}";
}
throw new ParameterValidationException(errorMessage, ex);
}
// If deserialization returns null, create default instance
if (schema == null)
{
schema = new TSchema();
}
// Apply default values for null properties
return ApplyDefaultValues(schema);
}
/// <summary>
/// Apply default values to Schema properties if they are null
/// Override this method to provide custom default value logic
/// </summary>
protected virtual TSchema ApplyDefaultValues(TSchema schema)
{
// Default implementation - return as is
// Subclasses can override to apply specific default values
return schema;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8d4b53392372a454e954428caf82258a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,26 @@
using System.Threading.Tasks;
using System.Threading;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// UseCase base class - Responsible for temporal cohesion
/// New instances are created each time and disposed after Execute completion
/// Related classes: AbstractUnityTool, BaseToolSchema, BaseToolResponse
/// </summary>
/// <typeparam name="TSchema">Schema type for tool parameters</typeparam>
/// <typeparam name="TResponse">Response type for tool results</typeparam>
public abstract class AbstractUseCase<TSchema, TResponse>
where TSchema : BaseToolSchema
where TResponse : BaseToolResponse
{
/// <summary>
/// UseCase execution method - The only public method
/// Responsible for temporal cohesion (executing multiple operations in sequence)
/// </summary>
/// <param name="parameters">Type-safe parameters</param>
/// <param name="cancellationToken">Cancellation control token</param>
/// <returns>Type-safe execution result</returns>
public abstract Task<TResponse> ExecuteAsync(TSchema parameters, CancellationToken cancellationToken);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9a74e8d5c037f4277a284f8f686edc05
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,13 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Base response class for all Unity MCP tool responses
/// </summary>
public abstract class BaseToolResponse
{
/// <summary>
/// uLoopMCP server version for CLI version compatibility check
/// </summary>
public string Ver => McpVersion.VERSION;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a54be1e158eda4064aa08ab9da92d980
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,9 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Base schema class for all Unity MCP tool parameters
/// </summary>
public abstract class BaseToolSchema
{
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c705d28d63c1b4fb5aa6d7797c95631d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,114 @@
using UnityEditor;
using System;
using Newtonsoft.Json;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// SessionState management class for forced recompilation
/// Preserves request information even when assembly reload occurs
/// </summary>
public static class CompileSessionState
{
// Note: Compile requests are now managed via McpSessionManager
/// <summary>
/// Forced recompilation request information
/// </summary>
[Serializable]
public class CompileRequestInfo
{
public string requestId;
public bool forceRecompile;
public string clientEndpoint;
public DateTime requestTime;
public bool isCompleted;
public CompileRequestInfo(string requestId, bool forceRecompile, string clientEndpoint)
{
this.requestId = requestId;
this.forceRecompile = forceRecompile;
this.clientEndpoint = clientEndpoint;
this.requestTime = DateTime.Now;
this.isCompleted = false;
}
}
/// <summary>
/// Save forced recompilation request
/// </summary>
public static void SaveCompileRequest(string requestId, bool forceRecompile, string clientEndpoint = "unknown")
{
if (!forceRecompile) return; // Don't save normal compilation
CompileRequestInfo requestInfo = new CompileRequestInfo(requestId, forceRecompile, clientEndpoint);
string json = JsonConvert.SerializeObject(requestInfo);
McpEditorSettings.SetCompileRequestJson(requestId, json);
McpEditorSettings.AddPendingCompileRequest(requestId);
}
/// <summary>
/// Get pending request IDs
/// </summary>
public static string[] GetPendingRequestIds()
{
return McpEditorSettings.GetPendingCompileRequestIds();
}
/// <summary>
/// Get saved request information
/// </summary>
public static CompileRequestInfo GetCompileRequest(string requestId)
{
string json = McpEditorSettings.GetCompileRequestJson(requestId);
if (string.IsNullOrEmpty(json)) return null;
try
{
return JsonConvert.DeserializeObject<CompileRequestInfo>(json);
}
catch
{
return null;
}
}
/// <summary>
/// Mark request as completed
/// </summary>
public static void MarkRequestCompleted(string requestId)
{
CompileRequestInfo requestInfo = GetCompileRequest(requestId);
if (requestInfo == null) return;
requestInfo.isCompleted = true;
string json = JsonConvert.SerializeObject(requestInfo);
McpEditorSettings.SetCompileRequestJson(requestId, json);
McpEditorSettings.RemovePendingCompileRequest(requestId);
}
// Note: RemoveFromPendingRequests is now handled by McpSessionManager
/// <summary>
/// Start forced recompilation
/// </summary>
public static void StartForceRecompile()
{
UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(
UnityEditor.Compilation.RequestScriptCompilationOptions.CleanBuildCache
);
}
/// <summary>
/// Clear SessionState
/// </summary>
public static void ClearAll()
{
McpEditorSettings.ClearAllCompileRequests();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 43fb73081d49d4a309e18955e541493f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Static class for managing custom tools
/// Allows users to register and manage their own tools
/// </summary>
public static class CustomToolManager
{
private static UnityToolRegistry _sharedRegistry;
internal static event Action OnToolsChanged;
/// <summary>
/// Get shared registry (lazy initialization)
/// </summary>
private static UnityToolRegistry SharedRegistry
{
get
{
if (_sharedRegistry == null)
{
_sharedRegistry = new UnityToolRegistry();
// Standard tools are automatically registered in UnityToolRegistry constructor
}
return _sharedRegistry;
}
}
/// <summary>
/// Register custom tool
/// </summary>
/// <param name="tool">Tool to register</param>
public static void RegisterCustomTool(IUnityTool tool)
{
SharedRegistry.RegisterTool(tool);
// Notify tool changes for manual registration
NotifyToolChanges();
}
/// <summary>
/// Unregister custom tool
/// </summary>
/// <param name="toolName">Name of tool to unregister</param>
public static void UnregisterCustomTool(string toolName)
{
SharedRegistry.UnregisterTool(toolName);
// Notify tool changes for manual unregistration
NotifyToolChanges();
}
/// <summary>
/// Get list of all registered tools (standard + custom)
/// </summary>
/// <returns>Array of tool information</returns>
public static ToolInfo[] GetRegisteredCustomTools()
{
return SharedRegistry.GetRegisteredTools();
}
/// <summary>
/// Check if specified tool is registered
/// </summary>
/// <param name="toolName">Tool name</param>
/// <returns>True if registered</returns>
public static bool IsCustomToolRegistered(string toolName)
{
return SharedRegistry.IsToolRegistered(toolName);
}
/// <summary>
/// Get internal registry (for MCP server)
/// </summary>
/// <returns>UnityToolRegistry instance</returns>
internal static UnityToolRegistry GetRegistry()
{
return SharedRegistry;
}
internal static UnityToolRegistry TryGetRegistry()
{
return _sharedRegistry;
}
internal static void WarmupRegistry()
{
_ = SharedRegistry;
}
/// <summary>
/// Debug: Get detailed registry information
/// </summary>
/// <returns>Debug information</returns>
public static string GetDebugInfo()
{
ToolInfo[] tools = SharedRegistry.GetRegisteredTools();
string[] toolNames = new string[tools.Length];
for (int i = 0; i < tools.Length; i++)
{
toolNames[i] = tools[i].Name;
}
return $"Registry instance: {SharedRegistry.GetHashCode()}, Tools: [{string.Join(", ", toolNames)}]";
}
/// <summary>
/// Manually notify tool changes to MCP clients
/// Public API for users to trigger notifications when needed
/// </summary>
public static void NotifyToolChanges()
{
UnityToolRegistry.TriggerToolsChangedNotification();
OnToolsChanged?.Invoke();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dd9b6252fa10a4edc9a5f2ee0140365d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,79 @@
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Constants for parameter schema property names
/// Ensures consistency between Unity and TypeScript sides
/// </summary>
public static class ParameterSchemaConstants
{
public const string TYPE_PROPERTY = "Type";
public const string DESCRIPTION_PROPERTY = "Description";
public const string DEFAULT_VALUE_PROPERTY = "DefaultValue";
public const string ENUM_PROPERTY = "Enum";
public const string PROPERTIES_PROPERTY = "Properties";
public const string REQUIRED_PROPERTY = "Required";
}
/// <summary>
/// Base interface for Unity MCP tool handlers
/// Following the Open-Closed principle, when adding new tools,
/// create a new class that implements this interface
/// </summary>
public interface IUnityTool
{
/// <summary>
/// Get tool name
/// </summary>
string ToolName { get; }
/// <summary>
/// Get parameter schema information for TypeScript side
/// </summary>
ToolParameterSchema ParameterSchema { get; }
/// <summary>
/// Execute tool
/// </summary>
/// <param name="paramsToken">JSON token for parameters</param>
/// <returns>Execution result</returns>
Task<BaseToolResponse> ExecuteAsync(JToken paramsToken);
}
/// <summary>
/// Parameter schema definition for tools (immutable)
/// </summary>
public class ToolParameterSchema
{
public readonly Dictionary<string, ParameterInfo> Properties;
public readonly string[] Required;
public ToolParameterSchema(Dictionary<string, ParameterInfo> properties = null, string[] required = null)
{
Properties = properties ?? new Dictionary<string, ParameterInfo>();
Required = required ?? new string[0];
}
}
/// <summary>
/// Individual parameter information (immutable)
/// </summary>
public class ParameterInfo
{
public readonly string Type;
public readonly string Description;
public readonly object DefaultValue;
public readonly string[] Enum;
public ParameterInfo(string type, string description, object defaultValue = null, string[] enumValues = null)
{
Type = type;
Description = description;
DefaultValue = defaultValue;
Enum = enumValues;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4f9cf1a64741648cb8533b76ec6f3a87
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,34 @@
using System;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Attribute to mark classes for automatic MCP tool registration
/// Classes marked with this attribute will be automatically registered as MCP commands
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public class McpToolAttribute : Attribute
{
/// <summary>
/// Gets whether this tool should only be displayed in development mode
/// </summary>
public bool DisplayDevelopmentOnly { get; set; } = false;
/// <summary>
/// Gets the specific security setting required to execute this command
/// </summary>
public SecuritySettings RequiredSecuritySetting { get; set; } = SecuritySettings.None;
/// <summary>
/// Gets or sets the description of this tool
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// Initialize McpTool attribute
/// </summary>
public McpToolAttribute()
{
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f9f72309eee6a49528d19b182ff51ad6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,64 @@
using UnityEngine;
using UnityEditor;
namespace io.github.hatayama.uLoopMCP
{
// Instantiates the InputVisualizationCanvas prefab and manages its lifecycle.
internal static class OverlayCanvasFactory
{
private const string CANVAS_PREFAB_PATH = "Packages/io.github.hatayama.uloopmcp/Runtime/Common/InputVisualizationCanvas.prefab";
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStaticFields()
{
_instance = null;
}
private static InputVisualizationCanvas _instance;
public static InputVisualizationCanvas VisualizationCanvas
{
get
{
EnsureExists();
Debug.Assert(_instance != null, "InputVisualizationCanvas instance must exist after EnsureExists");
return _instance!;
}
}
public static void EnsureExists()
{
if (_instance != null)
{
return;
}
// Domain Reload resets _instance but DontDestroyOnLoad objects survive; reclaim one and destroy duplicates
InputVisualizationCanvas[] existing =
Object.FindObjectsByType<InputVisualizationCanvas>(FindObjectsSortMode.None);
for (int i = 0; i < existing.Length; i++)
{
if (_instance == null)
{
_instance = existing[i];
}
else
{
Object.DestroyImmediate(existing[i].gameObject);
}
}
if (_instance != null)
{
return;
}
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(CANVAS_PREFAB_PATH);
Debug.Assert(prefab != null, $"InputVisualizationCanvas prefab not found at {CANVAS_PREFAB_PATH}");
GameObject go = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
Object.DontDestroyOnLoad(go);
_instance = go.GetComponent<InputVisualizationCanvas>();
Debug.Assert(_instance != null, "InputVisualizationCanvas component not found on prefab");
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 76e5da6abcd484cbcbdbbf462a6fdd4b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,52 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Generic data model representing service execution result
/// Used in Application Service Layer
/// </summary>
/// <typeparam name="T">Type of result data</typeparam>
public class ServiceResult<T>
{
/// <summary>
/// Whether execution was successful
/// </summary>
public bool Success { get; }
/// <summary>
/// Data of execution result
/// </summary>
public T Data { get; }
/// <summary>
/// Error message when execution fails
/// </summary>
public string ErrorMessage { get; }
/// <summary>
/// Create ServiceResult
/// </summary>
/// <param name="success">Execution result</param>
/// <param name="data">Result data</param>
/// <param name="errorMessage">Error message (null on success)</param>
public ServiceResult(bool success, T data = default, string errorMessage = null)
{
Success = success;
Data = data;
ErrorMessage = errorMessage;
}
/// <summary>
/// Create success result
/// </summary>
/// <param name="data">Result data</param>
/// <returns>ServiceResult representing success</returns>
public static ServiceResult<T> SuccessResult(T data) => new(true, data);
/// <summary>
/// Create failure result
/// </summary>
/// <param name="errorMessage">Error message</param>
/// <returns>ServiceResult representing failure</returns>
public static ServiceResult<T> FailureResult(string errorMessage) => new(false, default, errorMessage);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: de2101885e97d4499889ddbb6bb6e358
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,54 @@
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
internal static class ToolExecutionAvailability
{
internal static bool ShouldReportDependencyUnavailableBeforeDisabled(string toolName)
{
return ShouldReportDependencyUnavailableBeforeDisabled(
toolName,
isTestFrameworkAvailable: IsTestFrameworkAvailable);
}
internal static bool ShouldReportDependencyUnavailableBeforeDisabled(
string toolName,
bool isTestFrameworkAvailable)
{
Debug.Assert(!string.IsNullOrEmpty(toolName), "toolName must not be null or empty");
return toolName == McpConstants.TOOL_NAME_RUN_TESTS && !isTestFrameworkAvailable;
}
internal static bool ShouldExposeInRegisteredTools(string toolName)
{
return ShouldExposeInRegisteredTools(
toolName,
isToolEnabled: ToolSettings.IsToolEnabled(toolName),
isTestFrameworkAvailable: IsTestFrameworkAvailable);
}
internal static bool ShouldExposeInRegisteredTools(
string toolName,
bool isToolEnabled,
bool isTestFrameworkAvailable)
{
Debug.Assert(!string.IsNullOrEmpty(toolName), "toolName must not be null or empty");
return isToolEnabled ||
ShouldReportDependencyUnavailableBeforeDisabled(toolName, isTestFrameworkAvailable);
}
internal static bool IsTestFrameworkAvailable
{
get
{
#if ULOOPMCP_HAS_TEST_FRAMEWORK
return true;
#else
return false;
#endif
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 12a3cd3bb7213f14ab18fa845fe17f13
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
using System.ComponentModel;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Utility class to generate ToolParameterSchema from DTO classes
/// Eliminates duplication between DTO default values and schema definitions
/// </summary>
public static class ToolParameterSchemaGenerator
{
/// <summary>
/// Generate ToolParameterSchema from DTO type using reflection
/// </summary>
/// <typeparam name="TDto">DTO type</typeparam>
/// <returns>Generated parameter schema</returns>
public static ToolParameterSchema FromDto<TDto>() where TDto : class, new()
{
Type dtoType = typeof(TDto);
Dictionary<string, ParameterInfo> properties = new Dictionary<string, ParameterInfo>();
List<string> required = new List<string>();
// Create instance to get default values
TDto defaultInstance = new TDto();
foreach (PropertyInfo property in dtoType.GetProperties())
{
// Skip properties without public setters
if (!property.CanWrite || property.SetMethod?.IsPublic != true)
continue;
BrowsableAttribute browsableAttribute = property.GetCustomAttribute<BrowsableAttribute>();
if (browsableAttribute != null && !browsableAttribute.Browsable)
continue;
// Get JSON property name (fallback to property name)
string parameterName = GetJsonPropertyName(property);
// Get property type information
string parameterType = GetParameterType(property.PropertyType);
// Get description from attributes
string description = GetDescription(property);
// Get default value from instance
object defaultValue = property.GetValue(defaultInstance);
// Get enum values if applicable
string[] enumValues = GetEnumValues(property.PropertyType);
// Check if property is required
bool isRequired = IsRequired(property);
if (isRequired)
{
required.Add(parameterName);
}
// Create parameter info
ParameterInfo paramInfo = new ParameterInfo(
parameterType,
description,
defaultValue,
enumValues
);
properties[parameterName] = paramInfo;
}
return new ToolParameterSchema(properties, required.ToArray());
}
/// <summary>
/// Get JSON property name from JsonProperty attribute or property name
/// </summary>
private static string GetJsonPropertyName(PropertyInfo property)
{
JsonPropertyAttribute jsonAttr = property.GetCustomAttribute<JsonPropertyAttribute>();
return jsonAttr?.PropertyName ?? property.Name;
}
/// <summary>
/// Convert .NET type to parameter type string
/// </summary>
private static string GetParameterType(Type type)
{
// Handle nullable types
Type underlyingType = Nullable.GetUnderlyingType(type) ?? type;
if (underlyingType == typeof(string))
return "string";
if (underlyingType == typeof(bool))
return "boolean";
if (underlyingType == typeof(int) || underlyingType == typeof(long) ||
underlyingType == typeof(float) || underlyingType == typeof(double) ||
underlyingType == typeof(decimal))
return "number";
if (underlyingType.IsArray || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)))
return "array";
if (underlyingType.IsEnum)
return "string"; // Enums are treated as strings with enum constraints
if (IsDictionaryType(underlyingType))
return "object";
return "string"; // Default fallback
}
/// <summary>
/// Check if type is a dictionary type (Dictionary, IDictionary, IReadOnlyDictionary)
/// </summary>
private static bool IsDictionaryType(Type type)
{
if (type.IsGenericType)
{
Type genericDef = type.GetGenericTypeDefinition();
if (genericDef == typeof(Dictionary<,>) ||
genericDef == typeof(IDictionary<,>) ||
genericDef == typeof(IReadOnlyDictionary<,>))
return true;
}
return type.GetInterfaces().Any(i =>
i.IsGenericType &&
(i.GetGenericTypeDefinition() == typeof(IDictionary<,>) ||
i.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>)));
}
/// <summary>
/// Get description from Description attribute or generate default
/// </summary>
private static string GetDescription(PropertyInfo property)
{
DescriptionAttribute descAttr = property.GetCustomAttribute<DescriptionAttribute>();
if (descAttr != null)
return descAttr.Description;
// Generate default description
return $"Parameter: {property.Name}";
}
/// <summary>
/// Get enum values if property type is enum
/// </summary>
private static string[] GetEnumValues(Type type)
{
Type underlyingType = Nullable.GetUnderlyingType(type) ?? type;
if (underlyingType.IsEnum)
{
return Enum.GetNames(underlyingType);
}
return null;
}
/// <summary>
/// Check if property is required using custom Required attribute
/// For now, returns false as Unity doesn't support DataAnnotations
/// Can be extended with custom attributes if needed
/// </summary>
private static bool IsRequired(PropertyInfo property)
{
// TODO: Implement custom Required attribute if needed
return false;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b9d06a624dd5d4129b95d39ea144a412
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,424 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Stopwatch = System.Diagnostics.Stopwatch;
namespace io.github.hatayama.uLoopMCP
{
// Related classes:
// - UnityToolExecutor: Uses this registry to execute _tools.
// - IUnityTool: The interface for all _tools stored in this registry.
// - AbstractUnityTool: The base class for most tool implementations.
// - McpToolAttribute: Attribute used to discover and register _tools automatically.
/// <summary>
/// Unity MCP tool registry class
/// Supports dynamic tool registration, allowing users to add their own _tools
/// </summary>
public class UnityToolRegistry
{
private readonly Dictionary<string, IUnityTool> _tools = new();
/// <summary>
/// Singleton instance for global access
/// </summary>
public static UnityToolRegistry Instance { get; private set; }
/// <summary>
/// Default constructor
/// Auto-registers standard _tools
/// </summary>
public UnityToolRegistry()
{
Instance = this;
RegisterDefaultTools();
}
/// <summary>
/// Register standard _tools
/// </summary>
private void RegisterDefaultTools()
{
// Register _tools with attribute-based discovery
RegisterToolsWithAttributes();
// Manual registration for _tools without attributes (for backward compatibility)
RegisterManualTools();
}
/// <summary>
/// Register _tools using attribute-based discovery
/// </summary>
private void RegisterToolsWithAttributes()
{
try
{
// Get all assemblies in the current domain
Assembly[] assemblies = System.AppDomain.CurrentDomain.GetAssemblies();
List<Type> toolTypes = new List<Type>();
foreach (Assembly assembly in assemblies)
{
// Find all types with McpTool attribute that implement IUnityTool
Type[] types = assembly.GetTypes()
.Where(type => type.GetCustomAttribute<McpToolAttribute>() != null)
.Where(type => typeof(IUnityTool).IsAssignableFrom(type))
.Where(type => !type.IsAbstract && !type.IsInterface)
.ToArray();
toolTypes.AddRange(types);
}
// Register all _tools - filtering will be handled by TypeScript side
foreach (Type type in toolTypes)
{
// Security: Validate type before creating instance
if (!IsValidToolType(type))
{
UnityEngine.Debug.LogWarning($"{McpConstants.SECURITY_LOG_PREFIX} Skipping invalid tool type: {type.FullName}");
continue;
}
IUnityTool tool = (IUnityTool)Activator.CreateInstance(type);
RegisterTool(tool);
}
}
catch (Exception)
{
throw;
}
}
/// <summary>
/// Register _tools manually (for backward compatibility)
/// </summary>
private void RegisterManualTools()
{
// Only register _tools that don't have the McpTool attribute
// This prevents double registration
if (!IsToolTypeRegistered<PingTool>())
{
RegisterTool(new PingTool());
}
if (!IsToolTypeRegistered<CompileTool>())
{
RegisterTool(new CompileTool());
}
if (!IsToolTypeRegistered<GetLogsTool>())
{
RegisterTool(new GetLogsTool());
}
if (!IsToolTypeRegistered<RunTestsTool>())
{
RegisterTool(new RunTestsTool());
}
}
/// <summary>
/// Check if a tool type is already registered
/// </summary>
private bool IsToolTypeRegistered<T>() where T : IUnityTool
{
return _tools.Values.Any(tool => tool.GetType() == typeof(T));
}
/// <summary>
/// Security: Validate if the type is safe to instantiate
/// </summary>
private bool IsValidToolType(Type type)
{
try
{
// Must implement IUnityTool
if (!typeof(IUnityTool).IsAssignableFrom(type))
{
return false;
}
// Must not be abstract or interface
if (type.IsAbstract || type.IsInterface)
{
return false;
}
// Must have McpTool attribute
if (type.GetCustomAttribute<McpToolAttribute>() == null)
{
return false;
}
// Must have parameterless constructor
if (type.GetConstructor(Type.EmptyTypes) == null)
{
return false;
}
return true;
}
catch (Exception ex)
{
UnityEngine.Debug.LogError($"{McpConstants.SECURITY_LOG_PREFIX} Error validating tool type {type?.FullName}: {ex.Message}");
return false;
}
}
/// <summary>
/// Register tool
/// </summary>
/// <param name="tool">Tool to register</param>
public void RegisterTool(IUnityTool tool)
{
if (tool == null)
{
throw new ArgumentNullException(nameof(tool));
}
if (string.IsNullOrWhiteSpace(tool.ToolName))
{
throw new ArgumentException("Tool name cannot be null or empty", nameof(tool));
}
_tools[tool.ToolName] = tool;
}
/// <summary>
/// Unregister tool
/// </summary>
/// <param name="toolName">Name of tool to unregister</param>
public void UnregisterTool(string toolName)
{
_tools.Remove(toolName);
}
/// <summary>
/// Execute tool
/// </summary>
/// <param name="toolName">Tool name</param>
/// <param name="paramsToken">Parameters</param>
/// <returns>Execution result</returns>
/// <exception cref="ArgumentException">When tool is unknown</exception>
/// <exception cref="McpSecurityException">When tool is blocked by security settings</exception>
public async Task<BaseToolResponse> ExecuteToolAsync(string toolName, JToken paramsToken)
{
if (!_tools.TryGetValue(toolName, out IUnityTool tool))
{
throw new ArgumentException($"Unknown tool: {toolName}");
}
if (!ToolSettings.IsToolEnabled(toolName)
&& !ToolExecutionAvailability.ShouldReportDependencyUnavailableBeforeDisabled(toolName))
{
throw new ToolDisabledException(toolName);
}
// Security check - validate tool before execution
if (!McpSecurityChecker.IsToolAllowed(toolName))
{
throw new McpSecurityException(toolName, "Tool is blocked by security settings");
}
Stopwatch mainThreadHopStopwatch = Stopwatch.StartNew();
await MainThreadSwitcher.SwitchToMainThread();
mainThreadHopStopwatch.Stop();
Stopwatch toolBodyStopwatch = Stopwatch.StartNew();
BaseToolResponse response = await tool.ExecuteAsync(paramsToken);
toolBodyStopwatch.Stop();
AppendExecuteDynamicCodeTimingsIfSupported(
response,
$"[Perf] RegistryMainThreadHop: {mainThreadHopStopwatch.Elapsed.TotalMilliseconds:F1}ms",
$"[Perf] ToolBody: {toolBodyStopwatch.Elapsed.TotalMilliseconds:F1}ms");
return response;
}
private static void AppendExecuteDynamicCodeTimingsIfSupported(
BaseToolResponse response,
params string[] timingEntries)
{
if (response is not ExecuteDynamicCodeResponse executeDynamicCodeResponse)
{
return;
}
if (executeDynamicCodeResponse.Timings == null)
{
executeDynamicCodeResponse.Timings = new List<string>();
}
executeDynamicCodeResponse.Timings.AddRange(timingEntries);
}
/// <summary>
/// Get detailed information of registered _tools
/// </summary>
/// <returns>Array of tool information</returns>
public ToolInfo[] GetRegisteredTools()
{
return _tools.Values
.Where(tool => ToolExecutionAvailability.ShouldExposeInRegisteredTools(tool.ToolName))
.Select(tool =>
{
// Check if tool has McpTool attribute with DisplayDevelopmentOnly
bool displayDevelopmentOnly = false;
McpToolAttribute attribute = tool.GetType().GetCustomAttribute<McpToolAttribute>();
if (attribute != null)
{
displayDevelopmentOnly = attribute.DisplayDevelopmentOnly;
}
// Check security settings
bool isAllowed = McpSecurityChecker.IsToolAllowed(tool.ToolName);
// Get description from attribute
string description = attribute?.Description ?? "";
// Modify description for blocked _tools
if (!isAllowed)
{
description = $"[BLOCKED] {description} - Blocked by security settings";
}
return new ToolInfo(tool.ToolName, description, tool.ParameterSchema, displayDevelopmentOnly);
}).ToArray();
}
/// <summary>
/// Get all tools including disabled ones. Used by Tool Settings UI.
/// </summary>
public ToolInfo[] GetAllRegisteredToolInfos()
{
return _tools.Values.Select(tool =>
{
McpToolAttribute attribute = tool.GetType().GetCustomAttribute<McpToolAttribute>();
string description = attribute?.Description ?? "";
bool displayDevelopmentOnly = attribute?.DisplayDevelopmentOnly ?? false;
return new ToolInfo(tool.ToolName, description, tool.ParameterSchema, displayDevelopmentOnly);
}).ToArray();
}
public ToolSettingsCatalogItem[] GetToolSettingsCatalog()
{
return _tools.Values.Select(tool =>
{
Type toolType = tool.GetType();
McpToolAttribute attribute = toolType.GetCustomAttribute<McpToolAttribute>();
string description = attribute?.Description ?? "";
bool displayDevelopmentOnly = attribute?.DisplayDevelopmentOnly ?? false;
bool isThirdParty = IsThirdPartyAssembly(toolType.Assembly.GetName().Name);
return new ToolSettingsCatalogItem(
tool.ToolName,
description,
displayDevelopmentOnly,
isThirdParty);
}).ToArray();
}
/// <summary>
/// Check if a tool belongs to a third-party assembly.
/// </summary>
public bool IsThirdPartyTool(string toolName)
{
if (!_tools.TryGetValue(toolName, out IUnityTool tool))
{
return true;
}
string assemblyName = tool.GetType().Assembly.GetName().Name;
return IsThirdPartyAssembly(assemblyName);
}
private static bool IsThirdPartyAssembly(string assemblyName)
{
return assemblyName != "uLoopMCP.Editor";
}
/// <summary>
/// Get tool type by name for security checking
/// </summary>
/// <param name="toolName">Tool name</param>
/// <returns>Tool type or null if not found</returns>
public Type GetToolType(string toolName)
{
if (_tools.TryGetValue(toolName, out IUnityTool tool))
{
return tool.GetType();
}
return null;
}
/// <summary>
/// Check if specified tool is registered
/// </summary>
/// <param name="toolName">Tool name</param>
/// <returns>True if registered</returns>
public bool IsToolRegistered(string toolName)
{
return _tools.ContainsKey(toolName);
}
/// <summary>
/// Manually trigger _tools changed notification
/// Used for manual notifications and post-compilation notifications
/// </summary>
public static void TriggerToolsChangedNotification()
{
// Call the public method in McpServerController
McpServerController.TriggerToolChangeNotification();
}
}
/// <summary>
/// Class representing tool information
/// </summary>
public class ToolInfo
{
[JsonProperty("name")] public string Name { get; }
[JsonProperty("description")] public string Description { get; }
[JsonProperty("parameterSchema")] public ToolParameterSchema ParameterSchema { get; }
[JsonProperty("displayDevelopmentOnly")] public bool DisplayDevelopmentOnly { get; }
public ToolInfo(string name, string description, ToolParameterSchema parameterSchema, bool displayDevelopmentOnly = false)
{
Name = name;
Description = description;
ParameterSchema = parameterSchema;
DisplayDevelopmentOnly = displayDevelopmentOnly;
}
}
/// <summary>
/// Lightweight registry metadata used by Tool Settings UI without generating parameter schemas.
/// </summary>
public class ToolSettingsCatalogItem
{
public readonly string Name;
public readonly string Description;
public readonly bool DisplayDevelopmentOnly;
public readonly bool IsThirdParty;
public ToolSettingsCatalogItem(
string name,
string description,
bool displayDevelopmentOnly,
bool isThirdParty)
{
Name = name;
Description = description;
DisplayDevelopmentOnly = displayDevelopmentOnly;
IsThirdParty = isThirdParty;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b13155ca2a4cd42b2b0c2997a7925511
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,43 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Data model representing validation result
/// Used in Application Service Layer
/// </summary>
public class ValidationResult
{
/// <summary>
/// Whether validation was successful
/// </summary>
public bool IsValid { get; }
/// <summary>
/// Error message when validation fails
/// </summary>
public string ErrorMessage { get; }
/// <summary>
/// Create ValidationResult
/// </summary>
/// <param name="isValid">Validation result</param>
/// <param name="errorMessage">Error message (null on success)</param>
public ValidationResult(bool isValid, string errorMessage = null)
{
IsValid = isValid;
ErrorMessage = errorMessage;
}
/// <summary>
/// Create success result
/// </summary>
/// <returns>ValidationResult representing success</returns>
public static ValidationResult Success() => new(true);
/// <summary>
/// Create failure result
/// </summary>
/// <param name="errorMessage">Error message</param>
/// <returns>ValidationResult representing failure</returns>
public static ValidationResult Failure(string errorMessage) => new(false, errorMessage);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: de99bb9f98ad74025bd8be4e41a953af
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: dbe57138c4a024b33bfe815d180e9068
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,27 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Response for debug sleep tool execution
/// Related classes:
/// - SleepTool: Implementation that generates this response
/// - SleepSchema: Input parameters for the sleep tool
/// - BaseToolResponse: Base class providing timing information
/// </summary>
public class SleepResponse : BaseToolResponse
{
/// <summary>
/// Message indicating the result of the sleep operation
/// </summary>
public string Message { get; set; }
/// <summary>
/// Actual number of seconds the tool slept before completion or timeout
/// </summary>
public int ActualSleepSeconds { get; set; }
/// <summary>
/// Indicates whether the operation completed successfully or was cancelled
/// </summary>
public bool WasCancelled { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 28eaf066ee0a94a999dd67f9f4baabcd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,19 @@
using System.ComponentModel;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Schema for debug sleep tool parameters
/// Related classes:
/// - SleepTool: Implementation of the debug sleep tool
/// - SleepResponse: Response structure for the sleep tool
/// </summary>
public class SleepSchema : BaseToolSchema
{
/// <summary>
/// Number of seconds to sleep for testing purposes (default: 15)
/// </summary>
[Description("Number of seconds to sleep for testing purposes (default: 15)")]
public int SleepSeconds { get; set; } = 15;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 65adc65e8b27d41e889d4ccb67be267b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,45 @@
using System.Threading.Tasks;
using System.Threading;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Debug tool that sleeps for specified duration to test timeout functionality
/// Related classes:
/// - SleepSchema: Input parameters including sleep duration and timeout
/// - SleepResponse: Output containing results and timing information
/// - AbstractUnityTool: Base class providing timeout infrastructure
/// </summary>
[McpTool(
DisplayDevelopmentOnly = true,
Description = "Debug tool that sleeps for specified duration to test timeout functionality"
)]
public class SleepTool : AbstractUnityTool<SleepSchema, SleepResponse>
{
public override string ToolName => "debug-sleep";
protected override async Task<SleepResponse> ExecuteAsync(SleepSchema parameters, CancellationToken cancellationToken)
{
int actualSleepSeconds = 0;
// Sleep 1 second at a time, checking for cancellation between each sleep
for (int i = 0; i < parameters.SleepSeconds; i++)
{
// Check for cancellation before each sleep iteration
cancellationToken.ThrowIfCancellationRequested();
// Sleep for 1 second with cancellation support
await Task.Delay(1000, cancellationToken);
actualSleepSeconds = i + 1;
}
return new SleepResponse
{
Message = $"Successfully slept for {actualSleepSeconds} seconds",
ActualSleepSeconds = actualSleepSeconds,
WasCancelled = false
};
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3819fd5fb05cb4309a2229a93f79f74a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ae802852e2dfd47dc979543d41bb00d2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,31 @@
# execute-dynamic-code design notes
This directory owns the user-facing `execute-dynamic-code` tool contract.
The public contract must stay stable even when the internal compiler strategy changes.
## Fast path intent
- The preferred compilation path is Unity-bundled Roslyn.
- `SharedRoslynCompilerWorkerHost` keeps a warm compiler process so structural cache misses stay fast.
- The shared worker is an optimization only. It must not own response shaping, security validation, or tool-specific policy.
## Prewarm intent
- Auto prewarm exists to hide first-use compiler startup cost after server startup or recovery.
- Prewarm must go through the same runtime facade used by normal requests.
- Prewarm must be single-flight. It should never create a parallel execution lane or bypass runtime idleness checks.
- Prewarm is allowed to improve latency only. It must not change observable tool behavior.
## Fallback intent
- Preferred order is: shared Roslyn worker -> one-shot Roslyn -> AssemblyBuilder fallback.
- Fallback changes performance, not semantics.
- Any fallback from the ideal fast path is a maintainer-visible error condition and must be logged through `DynamicCompilationHealthMonitor`.
## Invariants
- Security validation always runs in Unity after compilation and before execution.
- In restricted mode, metadata validation and IL validation must both complete before `Assembly.Load`.
- Tool entry points and use cases depend on runtime-facing facades, not compiler backend details.
- Worker state is disposable. Domain reload or worker protocol failure must be handled by rebuilding state, not by preserving it.
- The compile backend may change, but `ExecuteDynamicCodeResponse` shape and user-facing behavior must remain stable.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3a32019de73d24a92b23228d40898acf
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,98 @@
using System.Collections.Generic;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Response for dynamic code execution tool
/// Related classes: ExecuteDynamicCodeTool, ExecuteDynamicCodeSchema
/// </summary>
public class ExecuteDynamicCodeResponse : BaseToolResponse
{
private const bool EmitTimingsInJsonResponses = false;
/// <summary>Execution success flag</summary>
public bool Success { get; set; }
/// <summary>Execution result</summary>
public string Result { get; set; }
/// <summary>Log messages</summary>
public List<string> Logs { get; set; } = new();
/// <summary>Compilation errors</summary>
public List<CompilationErrorDto> CompilationErrors { get; set; } = new();
/// <summary>Error message (on failure)</summary>
public string ErrorMessage { get; set; }
/// <summary>Current security level</summary>
public string SecurityLevel { get; set; }
/// <summary>Error message (alias for ErrorMessage)</summary>
public string Error
{
get => ErrorMessage;
set => ErrorMessage = value;
}
/// <summary>
/// Code formatted for compilation
/// (After extracting/moving using statements and applying class/method wrapping)
/// Allows checking the actual compiled code during debugging
/// </summary>
public string UpdatedCode { get; set; }
/// <summary>
/// Summary of diagnostics (unique count, total count, first error brief)
/// </summary>
public string DiagnosticsSummary { get; set; }
/// <summary>
/// Structured diagnostics for rich clients (line/column/code/message/hint/suggestions)
/// </summary>
public List<CompilationErrorDto> Diagnostics { get; set; } = new();
/// <summary>
/// Lightweight internal timings for benchmark comparison.
/// </summary>
public List<string> Timings { get; set; } = new();
// Keep timings available in memory for diagnostics and readiness decisions
// while avoiding noisy default payloads for normal execute-dynamic-code users.
public bool ShouldSerializeTimings()
{
return EmitTimingsInJsonResponses;
}
}
/// <summary>
/// DTO for compilation error information
/// </summary>
public class CompilationErrorDto
{
/// <summary>Error message</summary>
public string Message { get; set; }
/// <summary>Line number</summary>
public int Line { get; set; }
/// <summary>Column number</summary>
public int Column { get; set; }
/// <summary>Compiler error code (e.g., CS0103)</summary>
public string ErrorCode { get; set; }
/// <summary>Optional hint for resolving the error</summary>
public string Hint { get; set; }
/// <summary>Suggested fixes (e.g., add using or qualify)</summary>
public List<string> Suggestions { get; set; } = new();
/// <summary>Context lines around the error with a caret pointer</summary>
public string Context { get; set; }
/// <summary>Pointer column for caret rendering (1-based)</summary>
public int PointerColumn { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 88dbc381725914ce488e21b9b27a2cb8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,29 @@
using System.Collections.Generic;
namespace io.github.hatayama.uLoopMCP
{
internal static class ExecuteDynamicCodeResponseTimingAugmenter
{
public static void AppendTimingEntries(
ExecuteDynamicCodeResponse response,
double mainThreadWaitMilliseconds,
double toolTotalMilliseconds,
double requestTotalMilliseconds)
{
if (response == null)
{
return;
}
if (response.Timings == null)
{
response.Timings = new List<string>();
}
response.Timings.Add($"[Perf] MainThreadWait: {mainThreadWaitMilliseconds:F1}ms");
response.Timings.Add($"[Perf] ToolTotal: {toolTotalMilliseconds:F1}ms");
response.Timings.Add($"[Perf] RequestTotal: {requestTotalMilliseconds:F1}ms");
response.Timings.AddRange(DynamicCodeStartupTelemetry.CreateTimingEntries());
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8683636c860b448cf93c98208c36946b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,65 @@
using System.Collections.Generic;
using System.ComponentModel;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Parameter schema for dynamic code execution tool
/// Related classes: ExecuteDynamicCodeTool, ExecuteDynamicCodeResponse
/// </summary>
public class ExecuteDynamicCodeSchema : BaseToolSchema
{
/// <summary>C# code to execute</summary>
[Description(@"Editor automation only — no file I/O, no script authoring.
Direct statements only (no classes/namespaces/methods); return is optional (auto 'return null;' if omitted).
You may include using directives at the top; they are hoisted above the wrapper.
Example:
using UnityEngine;
var x = Mathf.PI;
return x;
Do:
- Prefab/material wiring (PrefabUtility)
- AddComponent + reference wiring (SerializedObject)
- Scene/hierarchy edits
Don’t:
- System.IO.* (File/Directory/Path)
- AssetDatabase.CreateFolder / file writes
- Create/edit .cs/.asmdef (use Terminal/IDE instead)
Need files/dirs? Run terminal commands.")]
public string Code { get; set; } = "";
/// <summary>Runtime parameters (advanced; usually unnecessary)</summary>
[EditorBrowsable(EditorBrowsableState.Advanced)]
[Description(@"Advanced, optional parameters passed to the snippet.
- For throwaway snippets, prefer defining locals directly in code
- Keys are not preserved; values map to parameters[""param0""], parameters[""param1""], ...
- Use when injecting UnityEngine.Object or sensitive values, or reusing a snippet with varying data
- Use object values supported by the Editor; prefer fully-qualified type names for ambiguous refs (e.g., UnityEngine.Object)
- Input type must be an OBJECT. Strings like ""{}"" are invalid.
- Do: omit 'Parameters' entirely, or use {}
- Don't: ""{}"" (string), ""{""a"":1}"" (string)
Examples:
OK: ""Parameters"": {}
OK: (omit the 'Parameters' property)
NG: ""Parameters"": ""{}""")]
public Dictionary<string, object> Parameters { get; set; } = new();
/// <summary>Compile only (do not execute)</summary>
[Description(@"Compile only (no execution).
- Uses Roslyn validation to surface diagnostics
- For new MonoBehaviours: create .cs → compile (mcp__uLoopMCP__compile with ForceRecompile=false) → ensure ErrorCount=0 → AddComponent")]
public bool CompileOnly { get; set; } = false;
[EditorBrowsable(EditorBrowsableState.Advanced)]
public bool YieldToForegroundRequests { get; set; } = false;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 96f1941ea53e64fbf9dca3d1b273ae72
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,47 @@
using System.Threading;
using System.Threading.Tasks;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// MCP Dynamic C# Code Execution Tool.
/// Delegates workflow orchestration to the dedicated execute-dynamic-code use case.
/// </summary>
[McpTool(Description = @"Execute C# code dynamically in Unity Editor for editor automation.
Use find-game-objects first for basic selected GameObject discovery or property inspection. Use this after built-in inspection tools are not enough or when you need to modify Unity state.
Direct statements only (no classes/namespaces/methods); return is optional (auto 'return null;' if omitted).
You may include using directives at the top; they are hoisted above the wrapper.
Example:
using UnityEngine;
var x = Mathf.PI;
return x;
Do:
- Prefab/material wiring (PrefabUtility)
- AddComponent + reference wiring (SerializedObject)
- Scene/hierarchy edits
Don't:
- System.IO.* (File/Directory/Path)
- AssetDatabase.CreateFolder / file writes
- Create/edit .cs/.asmdef (use Terminal/IDE instead)
Need files/dirs? Run terminal commands.
See examples at {project_root}/.claude/skills/unity-cli-loop/uloop-execute-dynamic-code/examples/")]
public class ExecuteDynamicCodeTool : AbstractUnityTool<ExecuteDynamicCodeSchema, ExecuteDynamicCodeResponse>
{
public override string ToolName => "execute-dynamic-code";
protected override async Task<ExecuteDynamicCodeResponse> ExecuteAsync(
ExecuteDynamicCodeSchema parameters,
CancellationToken cancellationToken)
{
IExecuteDynamicCodeUseCase useCase = await DynamicCodeServices.GetExecuteDynamicCodeUseCaseAsync();
return await useCase.ExecuteAsync(parameters, cancellationToken);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c0a0005c984014fe9ae1227c1e458432
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e4545560316c242f08f6034dc0c57177
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,87 @@
---
name: uloop-execute-dynamic-code
description: "Execute C# with Unity APIs when existing uloop tools cannot inspect or edit enough. Use for scene, prefab, SerializedObject, AssetDatabase refresh/.meta generation, menu, or PlayMode automation."
context: fork
---
# Task
Execute the following request using `uloop execute-dynamic-code`: $ARGUMENTS
For basic selected GameObject discovery or property inspection, use `find-game-objects --search-mode Selected` before this tool. Use this tool after the built-in inspection tools are not enough or when you need to modify Unity state.
## Workflow
1. Read the relevant reference file(s) from the Code Examples section below
2. Construct C# code based on the reference examples
3. For multiline snippets, write the C# statements to a temporary `.csx` file and execute `uloop execute-dynamic-code --code-file <path>`
4. Use `uloop execute-dynamic-code --code '<code>'` only for short one-line snippets
5. If execution fails, adjust code and retry
6. Report the execution result
## Parameters
- `--code '<code>'`: Inline C# statements to execute. Use this for one-line snippets only.
- `--code-file <path>`: Read C# statements from a UTF-8 file. Prefer this for multiline snippets, especially on PowerShell, because Windows `.cmd` shims can lose lines from multiline inline arguments before `uloop` receives them.
- **Shell quoting**: bash/zsh uses single quotes, for example `uloop execute-dynamic-code --code 'using UnityEngine; return Mathf.PI;'`. PowerShell single-quoted strings can contain normal double quotes, for example `uloop execute-dynamic-code --code 'Debug.Log("Hello!");'`.
- `--parameters {}` (advanced, optional): Pass an object when reusing a snippet with varying data or when keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit this flag for most snippets, and pass an object instead of a JSON string.
- `--compile-only true` (optional): Compile the snippet without executing it. Use this when you want Roslyn diagnostics before running new code.
## Code Rules
Write direct statements only — no class/namespace/method wrappers. Return is optional.
```csharp
using UnityEngine;
float x = Mathf.PI;
return x;
```
**Forbidden** — these will be rejected at compile time: `System.IO.*`, `AssetDatabase.CreateFolder`, creating/editing `.cs`/`.asmdef` files. Use terminal commands for file operations instead.
## Output
Returns JSON:
- `Success`: boolean — overall execution success
- `Result`: string — value of the snippet's `return` statement (empty when omitted)
- `Logs`: string[] — execution diagnostics from the dynamic-code runner, not Unity Console entries
- `CompilationErrors`: object[] — Roslyn diagnostics with `Message`, `Line`, `Column`, `ErrorCode`, optional `Hint` and `Suggestions`
- `ErrorMessage`: string — top-level failure summary (empty on success)
- `Error`: string — alias of `ErrorMessage`
- `SecurityLevel`: string — dynamic-code security level active for the request
- `UpdatedCode`: string|null — the wrapped form actually compiled (handy when debugging using-statement reordering)
- `DiagnosticsSummary`: string|null — compact summary when diagnostics are available
- `Diagnostics`: object[] — structured diagnostics; same shape as `CompilationErrors`, usually populated together with it
Use `uloop get-logs` to retrieve `Debug.Log`, `Debug.LogWarning`, and `Debug.LogError` messages emitted by the snippet. On `Success: false`, inspect `CompilationErrors` first. If empty, read `ErrorMessage` (and `Logs` for extra context) — the failure may be a runtime exception, security violation, cancellation, or an "execution in progress" rejection, all of which return empty `CompilationErrors`. Both EditMode and PlayMode are supported targets — the snippet runs in whichever mode the Editor is currently in.
## Code Examples by Category
For detailed code examples, refer to these files:
- **Prefab operations**: See [references/prefab-operations.md](references/prefab-operations.md)
- Create prefabs, instantiate, add components, modify properties
- **Material operations**: See [references/material-operations.md](references/material-operations.md)
- Create materials, set shaders/textures, modify properties
- **Asset operations**: See [references/asset-operations.md](references/asset-operations.md)
- Find/search assets, duplicate, move, rename, load
- **ScriptableObject**: See [references/scriptableobject.md](references/scriptableobject.md)
- Create ScriptableObjects, modify with SerializedObject
- **Scene operations**: See [references/scene-operations.md](references/scene-operations.md)
- Create/modify GameObjects, set parents, wire references, load scenes
- **Batch operations**: See [references/batch-operations.md](references/batch-operations.md)
- Bulk modify objects, batch add/remove components, rename, layer/tag/material replacement
- **Cleanup operations**: See [references/cleanup-operations.md](references/cleanup-operations.md)
- Detect broken scripts, missing references, unused materials, empty GameObjects
- **Undo operations**: See [references/undo-operations.md](references/undo-operations.md)
- Undo-aware operations: RecordObject, AddComponent, SetParent, grouping
- **Selection operations**: See [references/selection-operations.md](references/selection-operations.md)
- Get/set selection, multi-select, filter by type/editability
- **PlayMode automation (zsh)**: See [references/playmode-automation-zsh.md](references/playmode-automation-zsh.md)
- Click UI buttons, invoke methods, set fields, tool combination workflows for zsh users
- **PlayMode automation (PowerShell)**: See [references/playmode-automation-powershell.md](references/playmode-automation-powershell.md)
- Click UI buttons, invoke methods, set fields, tool combination workflows for PowerShell users
- **PlayMode UI controls**: See [references/playmode-ui-controls.md](references/playmode-ui-controls.md)
- InputField, Slider, Toggle, Dropdown, drag & drop simulation, list all UI controls
- **PlayMode inspection**: See [references/playmode-inspection.md](references/playmode-inspection.md)
- Scene info, game state via reflection, physics state, raycast checks, GameObject search, position/rotation

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 2445f075f1af349fc8bf73259f1df493
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c5155f736569e440ea4679a66a5ee36b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,194 @@
# Asset Operations
Code examples for AssetDatabase operations using `execute-dynamic-code`.
## Find Assets by Type
```csharp
using UnityEditor;
using System.Collections.Generic;
string[] prefabGuids = AssetDatabase.FindAssets("t:Prefab");
List<string> paths = new List<string>();
foreach (string guid in prefabGuids)
{
paths.Add(AssetDatabase.GUIDToAssetPath(guid));
}
return $"Found {paths.Count} prefabs";
```
## Find Assets by Name
```csharp
using UnityEditor;
using System.Collections.Generic;
string searchName = "Player";
string[] guids = AssetDatabase.FindAssets(searchName);
List<string> paths = new List<string>();
foreach (string guid in guids)
{
paths.Add(AssetDatabase.GUIDToAssetPath(guid));
}
return $"Found {paths.Count} assets matching '{searchName}'";
```
## Find Assets in Folder
```csharp
using UnityEditor;
using System.Collections.Generic;
string folder = "Assets/Prefabs";
string[] guids = AssetDatabase.FindAssets("t:Prefab", new[] { folder });
List<string> paths = new List<string>();
foreach (string guid in guids)
{
paths.Add(AssetDatabase.GUIDToAssetPath(guid));
}
return $"Found {paths.Count} prefabs in {folder}";
```
## Duplicate Asset
```csharp
using UnityEditor;
string sourcePath = "Assets/Materials/MyMaterial.mat";
string destPath = "Assets/Materials/MyMaterial_Backup.mat";
bool success = AssetDatabase.CopyAsset(sourcePath, destPath);
return success ? $"Copied to {destPath}" : "Copy failed";
```
## Move Asset
```csharp
using UnityEditor;
string sourcePath = "Assets/OldFolder/MyAsset.asset";
string destPath = "Assets/NewFolder/MyAsset.asset";
string error = AssetDatabase.MoveAsset(sourcePath, destPath);
return string.IsNullOrEmpty(error) ? $"Moved to {destPath}" : $"Error: {error}";
```
## Rename Asset
```csharp
using UnityEditor;
string assetPath = "Assets/Materials/OldName.mat";
string newName = "NewName";
string error = AssetDatabase.RenameAsset(assetPath, newName);
return string.IsNullOrEmpty(error) ? $"Renamed to {newName}" : $"Error: {error}";
```
## Rename Asset (Undo-supported)
```csharp
using UnityEditor;
// ObjectNames.SetNameSmart() supports Undo (AssetDatabase.RenameAsset() does NOT)
Object selected = Selection.activeObject;
if (selected == null)
{
return "No asset selected";
}
string oldName = selected.name;
ObjectNames.SetNameSmart(selected, "NewName");
AssetDatabase.SaveAssets();
return $"Renamed {oldName} to {selected.name}";
```
## Get Asset Path from Object
```csharp
using UnityEditor;
GameObject selected = Selection.activeGameObject;
if (selected == null)
{
return "No object selected";
}
string path = AssetDatabase.GetAssetPath(selected);
if (string.IsNullOrEmpty(path))
{
return "Selected object is not an asset (scene object)";
}
return $"Asset path: {path}";
```
## Load Asset at Path
```csharp
using UnityEditor;
string path = "Assets/Prefabs/Player.prefab";
GameObject asset = AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (asset == null)
{
return $"Asset not found at {path}";
}
return $"Loaded: {asset.name}";
```
## Get All Assets of Type
```csharp
using UnityEditor;
string[] scriptGuids = AssetDatabase.FindAssets("t:MonoScript");
int count = 0;
foreach (string guid in scriptGuids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
if (path.StartsWith("Assets/"))
{
count++;
}
}
return $"Found {count} scripts in Assets folder";
```
## Check if Asset Exists
```csharp
using UnityEditor;
string path = "Assets/Prefabs/Player.prefab";
string guid = AssetDatabase.AssetPathToGUID(path);
bool exists = !string.IsNullOrEmpty(guid);
return exists ? $"Asset exists: {path}" : $"Asset not found: {path}";
```
## Get Asset Dependencies
```csharp
using UnityEditor;
string assetPath = "Assets/Prefabs/Player.prefab";
string[] dependencies = AssetDatabase.GetDependencies(assetPath, true);
return $"Asset has {dependencies.Length} dependencies";
```
## Refresh AssetDatabase
Use this after terminal-based asset file changes when Unity needs to import them and generate `.meta` files.
```csharp
using UnityEditor;
AssetDatabase.Refresh();
return "AssetDatabase refreshed";
```

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 39fd76f51419343c1bbc8844b9e1f45f
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,399 @@
# Batch Operations
Code examples for batch processing using `execute-dynamic-code`.
## Batch Modify Selected Objects
```csharp
using UnityEditor;
using System.Collections.Generic;
GameObject[] selected = Selection.gameObjects;
if (selected.Length == 0)
{
return "No GameObjects selected";
}
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Batch Modify");
foreach (GameObject obj in selected)
{
Undo.RecordObject(obj.transform, "");
obj.transform.localScale = Vector3.one * 2;
}
Undo.CollapseUndoOperations(undoGroup);
return $"Scaled {selected.Length} objects (Single undo step)";
```
## Edit Multiple Objects with SerializedObject
```csharp
using UnityEditor;
GameObject[] selected = Selection.gameObjects;
if (selected.Length == 0)
{
return "No GameObjects selected";
}
List<Transform> transforms = new List<Transform>();
foreach (GameObject obj in selected)
{
transforms.Add(obj.transform);
}
SerializedObject serializedObj = new SerializedObject(transforms.ToArray());
SerializedProperty positionProp = serializedObj.FindProperty("m_LocalPosition");
positionProp.vector3Value = Vector3.zero;
serializedObj.ApplyModifiedProperties();
return $"Reset position of {selected.Length} objects";
```
## Batch Add Component
```csharp
using UnityEditor;
GameObject[] selected = Selection.gameObjects;
if (selected.Length == 0)
{
return "No GameObjects selected";
}
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Batch Add Rigidbody");
int addedCount = 0;
foreach (GameObject obj in selected)
{
if (obj.GetComponent<Rigidbody>() == null)
{
Undo.AddComponent<Rigidbody>(obj);
addedCount++;
}
}
Undo.CollapseUndoOperations(undoGroup);
return $"Added Rigidbody to {addedCount} objects";
```
## Batch Process Assets with StartAssetEditing
```csharp
using UnityEditor;
string[] guids = AssetDatabase.FindAssets("t:Material", new[] { "Assets/Materials" });
if (guids.Length == 0)
{
return "No materials found";
}
AssetDatabase.StartAssetEditing();
try
{
int modified = 0;
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
Material mat = AssetDatabase.LoadAssetAtPath<Material>(path);
if (mat != null)
{
mat.color = Color.white;
EditorUtility.SetDirty(mat);
modified++;
}
}
AssetDatabase.SaveAssets();
return $"Reset color of {modified} materials";
}
finally
{
AssetDatabase.StopAssetEditing();
}
```
## Batch Rename GameObjects
```csharp
using UnityEditor;
GameObject[] selected = Selection.gameObjects;
if (selected.Length == 0)
{
return "No GameObjects selected";
}
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Batch Rename");
for (int i = 0; i < selected.Length; i++)
{
Undo.RecordObject(selected[i], "");
selected[i].name = $"Item_{i:D3}";
}
Undo.CollapseUndoOperations(undoGroup);
return $"Renamed {selected.Length} objects";
```
## Batch Set Layer
```csharp
using UnityEditor;
GameObject[] selected = Selection.gameObjects;
if (selected.Length == 0)
{
return "No GameObjects selected";
}
int layer = LayerMask.NameToLayer("Default");
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Batch Set Layer");
foreach (GameObject obj in selected)
{
Undo.RecordObject(obj, "");
obj.layer = layer;
}
Undo.CollapseUndoOperations(undoGroup);
return $"Set layer of {selected.Length} objects to Default";
```
## Batch Set Tag
```csharp
using UnityEditor;
GameObject[] selected = Selection.gameObjects;
if (selected.Length == 0)
{
return "No GameObjects selected";
}
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Batch Set Tag");
foreach (GameObject obj in selected)
{
Undo.RecordObject(obj, "");
obj.tag = "Enemy";
}
Undo.CollapseUndoOperations(undoGroup);
return $"Tagged {selected.Length} objects as Enemy";
```
## Batch Modify ScriptableObjects
```csharp
using UnityEditor;
string[] guids = AssetDatabase.FindAssets("t:ScriptableObject", new[] { "Assets/Data" });
if (guids.Length == 0)
{
return "No ScriptableObjects found";
}
int modified = 0;
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
ScriptableObject so = AssetDatabase.LoadAssetAtPath<ScriptableObject>(path);
if (so == null) continue;
SerializedObject serializedObj = new SerializedObject(so);
SerializedProperty prop = serializedObj.FindProperty("isEnabled");
if (prop != null)
{
prop.boolValue = true;
serializedObj.ApplyModifiedProperties();
EditorUtility.SetDirty(so);
modified++;
}
}
AssetDatabase.SaveAssets();
return $"Enabled {modified} ScriptableObjects";
```
## Batch Remove Component
```csharp
using UnityEditor;
GameObject[] selected = Selection.gameObjects;
if (selected.Length == 0)
{
return "No GameObjects selected";
}
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Batch Remove Rigidbody");
int removedCount = 0;
foreach (GameObject obj in selected)
{
Rigidbody rb = obj.GetComponent<Rigidbody>();
if (rb != null)
{
Undo.DestroyObjectImmediate(rb);
removedCount++;
}
}
Undo.CollapseUndoOperations(undoGroup);
return $"Removed Rigidbody from {removedCount} objects";
```
## Batch Set Static Flags
```csharp
using UnityEditor;
GameObject[] selected = Selection.gameObjects;
if (selected.Length == 0)
{
return "No GameObjects selected";
}
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Batch Set Static");
foreach (GameObject obj in selected)
{
Undo.RecordObject(obj, "");
GameObjectUtility.SetStaticEditorFlags(obj, StaticEditorFlags.BatchingStatic | StaticEditorFlags.OccludeeStatic);
}
Undo.CollapseUndoOperations(undoGroup);
return $"Set static flags on {selected.Length} objects";
```
## Batch Process with Progress Bar
```csharp
using UnityEditor;
string[] guids = AssetDatabase.FindAssets("t:Texture2D");
if (guids.Length == 0)
{
return "No textures found";
}
int processed = 0;
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter;
if (importer != null && importer.maxTextureSize > 1024)
{
importer.maxTextureSize = 1024;
importer.SaveAndReimport();
processed++;
}
if (processed % 10 == 0)
{
EditorUtility.DisplayProgressBar("Processing Textures", path, (float)processed / guids.Length);
}
}
EditorUtility.ClearProgressBar();
return $"Resized {processed} textures to max 1024";
```
## Batch Align Objects
```csharp
using UnityEditor;
GameObject[] selected = Selection.gameObjects;
if (selected.Length < 2)
{
return "Select at least 2 objects";
}
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Align Objects");
float startX = selected[0].transform.position.x;
float spacing = 2f;
for (int i = 0; i < selected.Length; i++)
{
Undo.RecordObject(selected[i].transform, "");
Vector3 pos = selected[i].transform.position;
pos.x = startX + (i * spacing);
selected[i].transform.position = pos;
}
Undo.CollapseUndoOperations(undoGroup);
return $"Aligned {selected.Length} objects with {spacing}m spacing";
```
## Batch Rename Assets (Undo-supported)
```csharp
using UnityEditor;
// ObjectNames.SetNameSmart() supports Undo (AssetDatabase.RenameAsset() does NOT)
Object[] selected = Selection.objects;
if (selected.Length == 0)
{
return "No assets selected";
}
for (int i = 0; i < selected.Length; i++)
{
string newName = $"{i:D3}_{selected[i].name}";
ObjectNames.SetNameSmart(selected[i], newName);
}
AssetDatabase.SaveAssets();
return $"Renamed {selected.Length} assets";
```
## Batch Replace Material
```csharp
using UnityEditor;
GameObject[] selected = Selection.gameObjects;
if (selected.Length == 0)
{
return "No GameObjects selected";
}
string materialPath = "Assets/Materials/NewMaterial.mat";
Material newMat = AssetDatabase.LoadAssetAtPath<Material>(materialPath);
if (newMat == null)
{
return $"Material not found at {materialPath}";
}
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Batch Replace Material");
int replaced = 0;
foreach (GameObject obj in selected)
{
MeshRenderer renderer = obj.GetComponent<MeshRenderer>();
if (renderer != null)
{
Undo.RecordObject(renderer, "");
renderer.sharedMaterial = newMat;
replaced++;
}
}
Undo.CollapseUndoOperations(undoGroup);
return $"Replaced material on {replaced} objects";
```

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: cd5beae8d98da49f590990720e8d86be
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,403 @@
# Cleanup Operations
Code examples for project cleanup operations using `execute-dynamic-code`.
## Detect Missing Scripts on GameObject
```csharp
using UnityEditor;
GameObject selected = Selection.activeGameObject;
if (selected == null)
{
return "No GameObject selected";
}
int missingCount = GameObjectUtility.GetMonoBehavioursWithMissingScriptCount(selected);
return $"{selected.name} has {missingCount} missing script(s)";
```
## Remove Missing Scripts from GameObject
```csharp
using UnityEditor;
GameObject selected = Selection.activeGameObject;
if (selected == null)
{
return "No GameObject selected";
}
int removedCount = GameObjectUtility.RemoveMonoBehavioursWithMissingScript(selected);
return $"Removed {removedCount} missing script(s) from {selected.name}";
```
## Scan Scene for Missing Scripts
```csharp
using UnityEditor;
GameObject[] allObjects = Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None);
List<string> objectsWithMissing = new List<string>();
foreach (GameObject obj in allObjects)
{
int count = GameObjectUtility.GetMonoBehavioursWithMissingScriptCount(obj);
if (count > 0)
{
objectsWithMissing.Add($"{obj.name} ({count})");
}
}
if (objectsWithMissing.Count == 0)
{
return "No missing scripts found in scene";
}
return $"Objects with missing scripts: {string.Join(", ", objectsWithMissing)}";
```
## Remove All Missing Scripts from Scene
```csharp
using UnityEditor;
GameObject[] allObjects = Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None);
int totalRemoved = 0;
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Remove All Missing Scripts");
foreach (GameObject obj in allObjects)
{
int removed = GameObjectUtility.RemoveMonoBehavioursWithMissingScript(obj);
totalRemoved += removed;
}
Undo.CollapseUndoOperations(undoGroup);
return $"Removed {totalRemoved} missing scripts from scene";
```
## Detect Missing References in Component
```csharp
using UnityEditor;
GameObject selected = Selection.activeGameObject;
if (selected == null)
{
return "No GameObject selected";
}
List<string> missingRefs = new List<string>();
Component[] components = selected.GetComponents<Component>();
foreach (Component comp in components)
{
if (comp == null) continue;
SerializedObject so = new SerializedObject(comp);
SerializedProperty prop = so.GetIterator();
while (prop.NextVisible(true))
{
if (prop.propertyType == SerializedPropertyType.ObjectReference)
{
if (prop.objectReferenceValue == null && prop.objectReferenceInstanceIDValue != 0)
{
missingRefs.Add($"{comp.GetType().Name}.{prop.name}");
}
}
}
}
if (missingRefs.Count == 0)
{
return "No missing references found";
}
return $"Missing references: {string.Join(", ", missingRefs)}";
```
## Scan Scene for Missing References
```csharp
using UnityEditor;
GameObject[] allObjects = Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None);
List<string> results = new List<string>();
foreach (GameObject obj in allObjects)
{
Component[] components = obj.GetComponents<Component>();
foreach (Component comp in components)
{
if (comp == null) continue;
SerializedObject so = new SerializedObject(comp);
SerializedProperty prop = so.GetIterator();
while (prop.NextVisible(true))
{
if (prop.propertyType == SerializedPropertyType.ObjectReference)
{
if (prop.objectReferenceValue == null && prop.objectReferenceInstanceIDValue != 0)
{
results.Add($"{obj.name}/{comp.GetType().Name}.{prop.name}");
}
}
}
}
}
if (results.Count == 0)
{
return "No missing references found in scene";
}
return $"Missing references ({results.Count}): {string.Join(", ", results.Take(10))}...";
```
## Find Unused Materials in Project
```csharp
using UnityEditor;
using System.Collections.Generic;
string[] materialGuids = AssetDatabase.FindAssets("t:Material");
HashSet<string> usedMaterials = new HashSet<string>();
string[] prefabGuids = AssetDatabase.FindAssets("t:Prefab");
foreach (string guid in prefabGuids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
string[] deps = AssetDatabase.GetDependencies(path, true);
foreach (string dep in deps)
{
if (dep.EndsWith(".mat"))
{
usedMaterials.Add(dep);
}
}
}
// This scan only checks prefab dependencies. Verify scene and other asset
// references manually before deleting any reported materials.
List<string> unusedMaterials = new List<string>();
foreach (string guid in materialGuids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
if (!usedMaterials.Contains(path))
{
unusedMaterials.Add(path);
}
}
return $"Found {unusedMaterials.Count} materials not referenced by prefabs. Verify scene and other asset references manually before deleting.";
```
## Find Empty GameObjects
```csharp
using UnityEditor;
GameObject[] allObjects = Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None);
List<string> emptyObjects = new List<string>();
foreach (GameObject obj in allObjects)
{
Component[] components = obj.GetComponents<Component>();
if (components.Length == 1 && obj.transform.childCount == 0)
{
emptyObjects.Add(obj.name);
}
}
if (emptyObjects.Count == 0)
{
return "No empty GameObjects found";
}
return $"Empty objects ({emptyObjects.Count}): {string.Join(", ", emptyObjects.Take(20))}";
```
## Find Duplicate Names in Hierarchy
```csharp
using UnityEditor;
GameObject[] allObjects = Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None);
Dictionary<string, int> nameCounts = new Dictionary<string, int>();
foreach (GameObject obj in allObjects)
{
if (nameCounts.ContainsKey(obj.name))
{
nameCounts[obj.name]++;
}
else
{
nameCounts[obj.name] = 1;
}
}
List<string> duplicates = new List<string>();
foreach (KeyValuePair<string, int> kvp in nameCounts)
{
if (kvp.Value > 1)
{
duplicates.Add($"{kvp.Key} ({kvp.Value})");
}
}
if (duplicates.Count == 0)
{
return "No duplicate names found";
}
return $"Duplicate names: {string.Join(", ", duplicates.Take(15))}";
```
## Check for Broken Prefab Instances
```csharp
using UnityEditor;
GameObject[] allObjects = Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None);
List<string> brokenPrefabs = new List<string>();
foreach (GameObject obj in allObjects)
{
if (PrefabUtility.IsPartOfPrefabInstance(obj))
{
GameObject prefabAsset = PrefabUtility.GetCorrespondingObjectFromSource(obj);
if (prefabAsset == null)
{
brokenPrefabs.Add(obj.name);
}
}
}
if (brokenPrefabs.Count == 0)
{
return "No broken prefab instances found";
}
return $"Broken prefab instances: {string.Join(", ", brokenPrefabs)}";
```
## Find Objects with Negative Scale
```csharp
using UnityEditor;
GameObject[] allObjects = Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None);
List<string> negativeScale = new List<string>();
foreach (GameObject obj in allObjects)
{
Vector3 scale = obj.transform.localScale;
if (scale.x < 0 || scale.y < 0 || scale.z < 0)
{
negativeScale.Add($"{obj.name} ({scale})");
}
}
if (negativeScale.Count == 0)
{
return "No objects with negative scale found";
}
return $"Negative scale objects: {string.Join(", ", negativeScale.Take(10))}";
```
## Remove Empty Leaf GameObjects
```csharp
using UnityEditor;
GameObject[] allObjects = Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None);
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Remove Empty Leaves");
int removedCount = 0;
foreach (GameObject obj in allObjects)
{
if (obj == null) continue;
Component[] components = obj.GetComponents<Component>();
if (components.Length == 1 && obj.transform.childCount == 0)
{
Undo.DestroyObjectImmediate(obj);
removedCount++;
}
}
Undo.CollapseUndoOperations(undoGroup);
return $"Removed {removedCount} empty leaf GameObjects";
```
## Find Large Meshes
```csharp
using UnityEditor;
string[] meshGuids = AssetDatabase.FindAssets("t:Mesh");
List<string> largeMeshes = new List<string>();
int threshold = 10000;
foreach (string guid in meshGuids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
Mesh mesh = AssetDatabase.LoadAssetAtPath<Mesh>(path);
if (mesh != null && mesh.vertexCount > threshold)
{
largeMeshes.Add($"{path} ({mesh.vertexCount} verts)");
}
}
if (largeMeshes.Count == 0)
{
return $"No meshes with more than {threshold} vertices found";
}
return $"Large meshes: {string.Join(", ", largeMeshes.Take(10))}";
```
## Validate Asset References
```csharp
using UnityEditor;
string[] guids = AssetDatabase.FindAssets("t:ScriptableObject", new[] { "Assets/Data" });
List<string> invalidRefs = new List<string>();
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
ScriptableObject so = AssetDatabase.LoadAssetAtPath<ScriptableObject>(path);
if (so == null) continue;
SerializedObject serializedObj = new SerializedObject(so);
SerializedProperty prop = serializedObj.GetIterator();
while (prop.NextVisible(true))
{
if (prop.propertyType == SerializedPropertyType.ObjectReference)
{
if (prop.objectReferenceValue == null && prop.objectReferenceInstanceIDValue != 0)
{
invalidRefs.Add($"{path}: {prop.name}");
}
}
}
}
if (invalidRefs.Count == 0)
{
return "All asset references are valid";
}
return $"Invalid references ({invalidRefs.Count}): {string.Join(", ", invalidRefs.Take(10))}";
```

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 13f19aa1f6ea44f119e9e253e14d731c
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,160 @@
# Material Operations
Code examples for Material operations using `execute-dynamic-code`.
## Create a New Material
```csharp
using UnityEditor;
Shader shader = Shader.Find("Standard");
Material mat = new Material(shader);
mat.name = "MyMaterial";
string path = "Assets/Materials/MyMaterial.mat";
AssetDatabase.CreateAsset(mat, path);
AssetDatabase.SaveAssets();
return $"Material created at {path}";
```
## Set Material Color
```csharp
using UnityEditor;
string matPath = "Assets/Materials/MyMaterial.mat";
Material mat = AssetDatabase.LoadAssetAtPath<Material>(matPath);
if (mat == null)
{
return $"Material not found at {matPath}";
}
mat.SetColor("_Color", new Color(1f, 0.5f, 0f, 1f));
EditorUtility.SetDirty(mat);
AssetDatabase.SaveAssets();
return "Material color set to orange";
```
## Set Material Properties (Float, Vector)
```csharp
using UnityEditor;
string matPath = "Assets/Materials/MyMaterial.mat";
Material mat = AssetDatabase.LoadAssetAtPath<Material>(matPath);
mat.SetFloat("_Metallic", 0.8f);
mat.SetFloat("_Glossiness", 0.6f);
mat.SetVector("_EmissionColor", new Vector4(1, 1, 0, 1));
EditorUtility.SetDirty(mat);
AssetDatabase.SaveAssets();
return "Material properties updated";
```
## Assign Texture to Material
```csharp
using UnityEditor;
string matPath = "Assets/Materials/MyMaterial.mat";
string texPath = "Assets/Textures/MyTexture.png";
Material mat = AssetDatabase.LoadAssetAtPath<Material>(matPath);
Texture2D tex = AssetDatabase.LoadAssetAtPath<Texture2D>(texPath);
if (mat == null)
{
return $"Material not found at {matPath}";
}
if (tex == null)
{
return $"Texture not found at {texPath}";
}
mat.SetTexture("_MainTex", tex);
EditorUtility.SetDirty(mat);
AssetDatabase.SaveAssets();
return $"Assigned {tex.name} to material";
```
## Assign Material to GameObject
```csharp
using UnityEditor;
string matPath = "Assets/Materials/MyMaterial.mat";
Material mat = AssetDatabase.LoadAssetAtPath<Material>(matPath);
GameObject selected = Selection.activeGameObject;
if (selected == null)
{
return "No GameObject selected";
}
Renderer renderer = selected.GetComponent<Renderer>();
if (renderer == null)
{
return "Selected object has no Renderer";
}
renderer.sharedMaterial = mat;
EditorUtility.SetDirty(selected);
return $"Assigned {mat.name} to {selected.name}";
```
## Enable/Disable Material Keywords
```csharp
using UnityEditor;
string matPath = "Assets/Materials/MyMaterial.mat";
Material mat = AssetDatabase.LoadAssetAtPath<Material>(matPath);
mat.EnableKeyword("_EMISSION");
mat.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive;
EditorUtility.SetDirty(mat);
AssetDatabase.SaveAssets();
return "Emission enabled on material";
```
## Find All Materials Using a Shader
```csharp
using UnityEditor;
using System.Collections.Generic;
string shaderName = "Standard";
string[] guids = AssetDatabase.FindAssets("t:Material");
List<string> matchingMaterials = new List<string>();
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
Material mat = AssetDatabase.LoadAssetAtPath<Material>(path);
if (mat != null && mat.shader != null && mat.shader.name == shaderName)
{
matchingMaterials.Add(path);
}
}
return $"Found {matchingMaterials.Count} materials using {shaderName}";
```
## Duplicate Material
```csharp
using UnityEditor;
string sourcePath = "Assets/Materials/MyMaterial.mat";
string destPath = "Assets/Materials/MyMaterial_Copy.mat";
Material source = AssetDatabase.LoadAssetAtPath<Material>(sourcePath);
if (source == null)
{
return $"Material not found at {sourcePath}";
}
Material copy = new Material(source);
AssetDatabase.CreateAsset(copy, destPath);
AssetDatabase.SaveAssets();
return $"Material duplicated to {destPath}";
```

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 6b5ad08d159fb4390911b484746791d1
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,330 @@
# PlayMode Automation (PowerShell)
Code examples for runtime automation during Play mode using `execute-dynamic-code`.
These examples manipulate live scene objects while the game is running.
Shell command examples in this file target `PowerShell`.
## When to use dedicated mouse simulation tools instead
The examples in this file call UI handlers or runtime methods from C#.
That remains the better choice when you want targeted automation, direct state control, or a quick diagnostic path.
Use dedicated mouse tools only when the input route itself is part of what you need to verify.
Choose the tool based on what you are trying to validate:
| Scenario | Recommended tool | Why |
|----------|------------------|-----|
| Verify that a uGUI element responds through the real EventSystem pointer path | `simulate-mouse-ui` | Fires `PointerDown` / `PointerUp` / `PointerClick` / drag events through EventSystem raycasts instead of bypassing the UI input route. |
| Test gameplay that reads `Mouse.current`, button state, delta, or scroll | `simulate-mouse-input` | Injects Input System mouse state into `Mouse.current`, so game code can observe `wasPressedThisFrame`, movement delta, and scroll like player input. This assumes the project uses the New Input System (`Input System Package (New)` or `Both`). If that is not available in the target project, prefer `execute-dynamic-code` for a project-specific workaround instead of changing project settings just to use this tool. |
| Jump straight to a known button callback, invoke a method, inspect state, or set up a test precondition | `execute-dynamic-code` | Best when you intentionally want direct automation without reproducing the full input pipeline. |
| Drive custom runtime behavior that does not map cleanly to the built-in mouse tools | `execute-dynamic-code` | Lets you call project-specific methods, inspect scene objects, and prototype one-off flows immediately. |
In short: do not default everything to mouse simulation.
Use `execute-dynamic-code` for direct automation and diagnostics, and switch to `simulate-mouse-ui` or `simulate-mouse-input` when reproducing the real input path is the thing you need to test.
## PowerShell Quoting Notes
Use these patterns when you need shell-safe inline code:
### Double quotes inside C# strings
Single-quote the whole snippet and keep C# string literals unchanged.
```powershell
uloop execute-dynamic-code --code 'return "Hello from PowerShell";'
```
### Single quotes inside inline C# code
If the C# snippet itself contains a single quote, double it inside the PowerShell single-quoted string.
```powershell
uloop execute-dynamic-code --code 'char initial = ''A''; return initial.ToString();'
```
### JSON-like values passed via `--parameters`
Wrap the whole expression in single quotes so PowerShell passes the inner double quotes through unchanged.
```powershell
uloop execute-dynamic-code --code 'return parameters["param0"];' --parameters '{"param0":"Hello from PowerShell"}'
```
### Multi-line C# snippets
Use a here-string when the snippet spans multiple lines.
```powershell
$code = @'
using UnityEngine;
GameObject obj = GameObject.Find("Player");
if (obj == null) return "Player not found";
return obj.name;
'@
uloop execute-dynamic-code --code $code
```
You can combine multi-line code with multi-line parameters the same way.
```powershell
$code = @'
return parameters["param0"];
'@
$parameters = @'
{"param0":"Hello from PowerShell"}
'@
uloop execute-dynamic-code --code $code --parameters $parameters
```
## Click UI Button by Path
```csharp
using UnityEngine.UI;
Button btn = GameObject.Find("Canvas/StartButton")?.GetComponent<Button>();
if (btn == null) return "Button not found";
btn.onClick.Invoke();
return $"Clicked {btn.gameObject.name}";
```
## Click UI Button by Search
```csharp
using UnityEngine.UI;
using System.Linq;
Button[] buttons = Object.FindObjectsByType<Button>(FindObjectsSortMode.None);
Button target = buttons.FirstOrDefault(b => b.gameObject.name == "PlayButton");
if (target == null) return $"PlayButton not found. Available: {string.Join(", ", buttons.Select(b => b.gameObject.name))}";
target.onClick.Invoke();
return $"Clicked {target.gameObject.name}";
```
## Toggle GameObject Active State
```csharp
GameObject obj = GameObject.Find("Enemy");
if (obj == null) return "Enemy not found";
obj.SetActive(!obj.activeSelf);
return $"{obj.name} is now {(obj.activeSelf ? "active" : "inactive")}";
```
## Invoke Method on MonoBehaviour
```csharp
using System.Reflection;
GameObject player = GameObject.Find("Player");
if (player == null) return "Player not found";
MonoBehaviour script = player.GetComponent("PlayerController") as MonoBehaviour;
if (script == null) return "PlayerController not found";
MethodInfo method = script.GetType().GetMethod("TakeDamage");
if (method == null) return "TakeDamage method not found";
method.Invoke(script, new object[] { 10f });
return $"Invoked TakeDamage(10) on {player.name}";
```
## Set Field Value at Runtime
```csharp
using System.Reflection;
GameObject player = GameObject.Find("Player");
if (player == null) return "Player not found";
MonoBehaviour script = player.GetComponent("PlayerController") as MonoBehaviour;
if (script == null) return "PlayerController not found";
FieldInfo field = script.GetType().GetField("moveSpeed", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (field == null) return "moveSpeed field not found";
field.SetValue(script, 20f);
return $"Set moveSpeed to 20 on {player.name}";
```
## Move Player to Position
```csharp
GameObject player = GameObject.Find("Player");
if (player == null) return "Player not found";
Vector3 targetPos = new Vector3(5f, 0f, 10f);
player.transform.position = targetPos;
return $"Moved {player.name} to {targetPos}";
```
---
# Tool Combination Workflows
Examples of combining `execute-dynamic-code` with other uloop tools for multi-step PlayMode automation.
## find-game-objects → Click Button
Use `find-game-objects` to discover buttons with their hierarchy paths, then click one via `execute-dynamic-code`.
**Step 1**: Find all GameObjects with Button component
```powershell
uloop find-game-objects --required-components UnityEngine.UI.Button
```
**Step 2**: Click the target button using the path from Step 1
```csharp
using UnityEngine.UI;
// Use the path returned by find-game-objects (e.g. "Canvas/MainMenu/StartButton")
GameObject btnObj = GameObject.Find("Canvas/MainMenu/StartButton");
if (btnObj == null) return "Button not found at path";
Button btn = btnObj.GetComponent<Button>();
if (btn == null) return "No Button component";
btn.onClick.Invoke();
return $"Clicked {btnObj.name}";
```
## get-hierarchy → Navigate UI and Click
Use `get-hierarchy` to explore the UI tree structure, then target the right element.
**Step 1**: Get Canvas hierarchy to understand UI structure
```powershell
uloop get-hierarchy --root-path 'Canvas' --max-depth 3
```
**Step 2**: Based on the hierarchy JSON, click the desired button
```csharp
using UnityEngine.UI;
// Path identified from hierarchy output
GameObject btnObj = GameObject.Find("Canvas/SettingsPanel/AudioTab/MuteToggle");
if (btnObj == null) return "MuteToggle not found";
Toggle toggle = btnObj.GetComponent<Toggle>();
if (toggle != null)
{
toggle.isOn = !toggle.isOn;
return $"Toggled {btnObj.name} to {toggle.isOn}";
}
return "No Toggle component found";
```
## Execute Action → Screenshot to Verify
Run an action then capture a screenshot to visually confirm the result.
**Step 1**: Perform the action
```csharp
using UnityEngine.UI;
Button btn = GameObject.Find("Canvas/PlayButton")?.GetComponent<Button>();
if (btn == null) return "PlayButton not found";
btn.onClick.Invoke();
return "Clicked PlayButton";
```
**Step 2**: Capture Game View to verify the result
```powershell
uloop screenshot --window-name Game
```
## Execute Action → Check Logs for Side Effects
Run an action then inspect Unity Console logs to verify expected behavior.
**Step 1**: Clear console before the action
```powershell
uloop clear-console
```
**Step 2**: Perform the action
```csharp
using System.Reflection;
GameObject player = GameObject.Find("Player");
if (player == null) return "Player not found";
MonoBehaviour script = player.GetComponent("PlayerController") as MonoBehaviour;
if (script == null) return "PlayerController not found";
MethodInfo method = script.GetType().GetMethod("TakeDamage");
if (method == null) return "TakeDamage method not found";
method.Invoke(script, new object[] { 50f });
return "Invoked TakeDamage(50)";
```
**Step 3**: Check logs for expected output
```powershell
uloop get-logs --log-type Log --search-text 'damage'
```
## Full Automation: Play → Act → Capture → Stop
End-to-end test flow: start Play mode, perform actions, capture evidence, stop.
**Step 0**: Clear console to isolate this run
```powershell
uloop clear-console
```
**Step 1**: Start Play mode
```powershell
uloop control-play-mode --action Play
```
**Step 2**: Wait for scene initialization, then find and click a button
```csharp
using UnityEngine.UI;
using System.Linq;
// Scene may need a moment to initialize after Play starts
Button[] buttons = Object.FindObjectsByType<Button>(FindObjectsSortMode.None);
Button startBtn = buttons.FirstOrDefault(b => b.gameObject.name.Contains("Start"));
if (startBtn == null) return $"Start button not found. Available: {string.Join(", ", buttons.Select(b => b.gameObject.name))}";
startBtn.onClick.Invoke();
return $"Clicked {startBtn.gameObject.name}";
```
**Step 3**: Capture screenshot as evidence
```powershell
uloop screenshot --window-name Game
```
**Step 4**: Check logs for errors
```powershell
uloop get-logs --log-type Error
```
**Step 5**: Stop Play mode
```powershell
uloop control-play-mode --action Stop
```

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 86022f911600881458e40f35e562a5db
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,299 @@
# PlayMode Automation (zsh)
Code examples for runtime automation during Play mode using `execute-dynamic-code`.
These examples manipulate live scene objects while the game is running.
Shell command examples in this file target `zsh`-style usage.
## When to use dedicated mouse simulation tools instead
The examples in this file call UI handlers or runtime methods from C#.
That remains the better choice when you want targeted automation, direct state control, or a quick diagnostic path.
Use dedicated mouse tools only when the input route itself is part of what you need to verify.
Choose the tool based on what you are trying to validate:
| Scenario | Recommended tool | Why |
|----------|------------------|-----|
| Verify that a uGUI element responds through the real EventSystem pointer path | `simulate-mouse-ui` | Fires `PointerDown` / `PointerUp` / `PointerClick` / drag events through EventSystem raycasts instead of bypassing the UI input route. |
| Test gameplay that reads `Mouse.current`, button state, delta, or scroll | `simulate-mouse-input` | Injects Input System mouse state into `Mouse.current`, so game code can observe `wasPressedThisFrame`, movement delta, and scroll like player input. This assumes the project uses the New Input System (`Input System Package (New)` or `Both`). If that is not available in the target project, prefer `execute-dynamic-code` for a project-specific workaround instead of changing project settings just to use this tool. |
| Jump straight to a known button callback, invoke a method, inspect state, or set up a test precondition | `execute-dynamic-code` | Best when you intentionally want direct automation without reproducing the full input pipeline. |
| Drive custom runtime behavior that does not map cleanly to the built-in mouse tools | `execute-dynamic-code` | Lets you call project-specific methods, inspect scene objects, and prototype one-off flows immediately. |
In short: do not default everything to mouse simulation.
Use `execute-dynamic-code` for direct automation and diagnostics, and switch to `simulate-mouse-ui` or `simulate-mouse-input` when reproducing the real input path is the thing you need to test.
## zsh Quoting Notes
Use these patterns when you need shell-safe inline code:
### Double quotes inside C# strings
Single-quote the whole snippet and keep C# string literals unchanged.
```zsh
uloop execute-dynamic-code --code 'return "Hello from zsh";'
```
### Single quotes inside inline C# code
If the C# snippet itself contains a single quote, close and reopen the shell string with `'\''`.
```zsh
uloop execute-dynamic-code --code 'char initial = '\''A'\''; return initial.ToString();'
```
### JSON-like values passed via `--parameters`
Wrap the whole expression in single quotes so the shell does not interpret double quotes.
```zsh
uloop execute-dynamic-code --code 'return parameters["param0"];' --parameters '{"param0":"Hello from zsh"}'
```
## Click UI Button by Path
```csharp
using UnityEngine.UI;
Button btn = GameObject.Find("Canvas/StartButton")?.GetComponent<Button>();
if (btn == null) return "Button not found";
btn.onClick.Invoke();
return $"Clicked {btn.gameObject.name}";
```
## Click UI Button by Search
```csharp
using UnityEngine.UI;
using System.Linq;
Button[] buttons = Object.FindObjectsByType<Button>(FindObjectsSortMode.None);
Button target = buttons.FirstOrDefault(b => b.gameObject.name == "PlayButton");
if (target == null) return $"PlayButton not found. Available: {string.Join(", ", buttons.Select(b => b.gameObject.name))}";
target.onClick.Invoke();
return $"Clicked {target.gameObject.name}";
```
## Toggle GameObject Active State
```csharp
GameObject obj = GameObject.Find("Enemy");
if (obj == null) return "Enemy not found";
obj.SetActive(!obj.activeSelf);
return $"{obj.name} is now {(obj.activeSelf ? "active" : "inactive")}";
```
## Invoke Method on MonoBehaviour
```csharp
using System.Reflection;
GameObject player = GameObject.Find("Player");
if (player == null) return "Player not found";
MonoBehaviour script = player.GetComponent("PlayerController") as MonoBehaviour;
if (script == null) return "PlayerController not found";
MethodInfo method = script.GetType().GetMethod("TakeDamage");
if (method == null) return "TakeDamage method not found";
method.Invoke(script, new object[] { 10f });
return $"Invoked TakeDamage(10) on {player.name}";
```
## Set Field Value at Runtime
```csharp
using System.Reflection;
GameObject player = GameObject.Find("Player");
if (player == null) return "Player not found";
MonoBehaviour script = player.GetComponent("PlayerController") as MonoBehaviour;
if (script == null) return "PlayerController not found";
FieldInfo field = script.GetType().GetField("moveSpeed", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (field == null) return "moveSpeed field not found";
field.SetValue(script, 20f);
return $"Set moveSpeed to 20 on {player.name}";
```
## Move Player to Position
```csharp
GameObject player = GameObject.Find("Player");
if (player == null) return "Player not found";
Vector3 targetPos = new Vector3(5f, 0f, 10f);
player.transform.position = targetPos;
return $"Moved {player.name} to {targetPos}";
```
---
# Tool Combination Workflows
Examples of combining `execute-dynamic-code` with other uloop tools for multi-step PlayMode automation.
## find-game-objects → Click Button
Use `find-game-objects` to discover buttons with their hierarchy paths, then click one via `execute-dynamic-code`.
**Step 1**: Find all GameObjects with Button component
```zsh
uloop find-game-objects --required-components UnityEngine.UI.Button
```
**Step 2**: Click the target button using the path from Step 1
```csharp
using UnityEngine.UI;
// Use the path returned by find-game-objects (e.g. "Canvas/MainMenu/StartButton")
GameObject btnObj = GameObject.Find("Canvas/MainMenu/StartButton");
if (btnObj == null) return "Button not found at path";
Button btn = btnObj.GetComponent<Button>();
if (btn == null) return "No Button component";
btn.onClick.Invoke();
return $"Clicked {btnObj.name}";
```
## get-hierarchy → Navigate UI and Click
Use `get-hierarchy` to explore the UI tree structure, then target the right element.
**Step 1**: Get Canvas hierarchy to understand UI structure
```zsh
uloop get-hierarchy --root-path "Canvas" --max-depth 3
```
**Step 2**: Based on the hierarchy JSON, click the desired button
```csharp
using UnityEngine.UI;
// Path identified from hierarchy output
GameObject btnObj = GameObject.Find("Canvas/SettingsPanel/AudioTab/MuteToggle");
if (btnObj == null) return "MuteToggle not found";
Toggle toggle = btnObj.GetComponent<Toggle>();
if (toggle != null)
{
toggle.isOn = !toggle.isOn;
return $"Toggled {btnObj.name} to {toggle.isOn}";
}
return "No Toggle component found";
```
## Execute Action → Screenshot to Verify
Run an action then capture a screenshot to visually confirm the result.
**Step 1**: Perform the action
```csharp
using UnityEngine.UI;
Button btn = GameObject.Find("Canvas/PlayButton")?.GetComponent<Button>();
if (btn == null) return "PlayButton not found";
btn.onClick.Invoke();
return "Clicked PlayButton";
```
**Step 2**: Capture Game View to verify the result
```zsh
uloop screenshot --window-name Game
```
## Execute Action → Check Logs for Side Effects
Run an action then inspect Unity Console logs to verify expected behavior.
**Step 1**: Clear console before the action
```zsh
uloop clear-console
```
**Step 2**: Perform the action
```csharp
using System.Reflection;
GameObject player = GameObject.Find("Player");
if (player == null) return "Player not found";
MonoBehaviour script = player.GetComponent("PlayerController") as MonoBehaviour;
if (script == null) return "PlayerController not found";
MethodInfo method = script.GetType().GetMethod("TakeDamage");
if (method == null) return "TakeDamage method not found";
method.Invoke(script, new object[] { 50f });
return "Invoked TakeDamage(50)";
```
**Step 3**: Check logs for expected output
```zsh
uloop get-logs --log-type Log --search-text "damage"
```
## Full Automation: Play → Act → Capture → Stop
End-to-end test flow: start Play mode, perform actions, capture evidence, stop.
**Step 0**: Clear console to isolate this run
```zsh
uloop clear-console
```
**Step 1**: Start Play mode
```zsh
uloop control-play-mode --action Play
```
**Step 2**: Wait for scene initialization, then find and click a button
```csharp
using UnityEngine.UI;
using System.Linq;
// Scene may need a moment to initialize after Play starts
Button[] buttons = Object.FindObjectsByType<Button>(FindObjectsSortMode.None);
Button startBtn = buttons.FirstOrDefault(b => b.gameObject.name.Contains("Start"));
if (startBtn == null) return $"Start button not found. Available: {string.Join(", ", buttons.Select(b => b.gameObject.name))}";
startBtn.onClick.Invoke();
return $"Clicked {startBtn.gameObject.name}";
```
**Step 3**: Capture screenshot as evidence
```zsh
uloop screenshot --window-name Game
```
**Step 4**: Check logs for errors
```zsh
uloop get-logs --log-type Error
```
**Step 5**: Stop Play mode
```zsh
uloop control-play-mode --action Stop
```

Some files were not shown because too many files have changed in this diff Show More