Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Editor/Bridge/UnityToolBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NativeMcp.Editor.Helpers;
using NativeMcp.Editor.Services;
using NativeMcp.Editor.Tools;
using NativeMcp.Editor.Protocol;
Expand Down Expand Up @@ -251,7 +252,8 @@ private static McpToolCallResult WrapResult(object rawResult)
// Serialize the result object to JSON
try
{
text = JsonConvert.SerializeObject(rawResult, Formatting.Indented);
text = JsonConvert.SerializeObject(rawResult, Formatting.Indented,
UnityJsonSerializer.Settings);

// Check if the result contains an error status
if (rawResult is JObject jObj)
Expand Down
8 changes: 5 additions & 3 deletions Editor/Helpers/GameObjectLookup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ public static SearchMethod ParseSearchMethod(string method)
"by_component" => SearchMethod.ByComponent,
"by_path" => SearchMethod.ByPath,
"by_id" => SearchMethod.ById,
_ => SearchMethod.ByName
_ => throw new ArgumentException(
$"Unknown search method '{method}'. " +
"Valid: by_name, by_tag, by_layer, by_component, by_path, by_id.")
};
}

Expand Down Expand Up @@ -335,7 +337,7 @@ public static IEnumerable<GameObject> GetAllSceneObjects(bool includeInactive)
/// Gets all GameObjects in the DontDestroyOnLoad scene.
/// Uses a temporary helper object to discover the hidden scene.
/// </summary>
private static IEnumerable<GameObject> GetDontDestroyOnLoadObjects(bool includeInactive)
internal static IEnumerable<GameObject> GetDontDestroyOnLoadObjects(bool includeInactive)
{
if (!TryGetDontDestroyOnLoadScene(out var ddolScene))
yield break;
Expand All @@ -353,7 +355,7 @@ private static IEnumerable<GameObject> GetDontDestroyOnLoadObjects(bool includeI
}
}

private static bool TryGetDontDestroyOnLoadScene(out Scene scene)
internal static bool TryGetDontDestroyOnLoadScene(out Scene scene)
{
scene = default;

Expand Down
1 change: 1 addition & 0 deletions Editor/Helpers/NativeMcpKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ internal static class NativeMcpKeys
{
// SessionState keys (per-session, lost on editor restart)
public const string PendingTestRun = "NativeMcp_PendingTestRun";
public const string PendingPlayForFrames = "NativeMcp_PendingPlayForFrames";
public const string LastPort = "NativeMcp_LastPort";

// EditorPrefs keys (persistent across sessions)
Expand Down
2 changes: 2 additions & 0 deletions Editor/Helpers/NativeMcpKeys.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 11 additions & 4 deletions Editor/Helpers/UnityJsonSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ namespace NativeMcp.Editor.Helpers
public static class UnityJsonSerializer
{
/// <summary>
/// Shared JsonSerializer instance with converters for Unity types.
/// Use this for all JToken-to-Unity-type conversions.
/// Shared serializer settings with converters for Unity types.
/// Use with JsonConvert.SerializeObject for consistent Unity type serialization.
/// </summary>
public static readonly JsonSerializer Instance = JsonSerializer.Create(new JsonSerializerSettings
public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
{
Converters = new List<JsonConverter>
{
Expand All @@ -25,9 +25,16 @@ public static class UnityJsonSerializer
new ColorConverter(),
new RectConverter(),
new BoundsConverter(),
new Matrix4x4Converter(),
new UnityEngineObjectConverter()
}
});
};

/// <summary>
/// Shared JsonSerializer instance with converters for Unity types.
/// Use this for all JToken-to-Unity-type conversions.
/// </summary>
public static readonly JsonSerializer Instance = JsonSerializer.Create(Settings);
}
}

8 changes: 7 additions & 1 deletion Editor/NativeMcp.Editor.asmdef
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"versionDefines": [
{
"name": "com.unity.inputsystem",
"expression": "1.0.0",
"define": "NATIVE_MCP_HAS_INPUT_SYSTEM"
}
],
"noEngineReferences": false
}
259 changes: 259 additions & 0 deletions Editor/Tools/EditorControl/EditorPlayForFrames.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
using System;
using System.Threading.Tasks;
using NativeMcp.Editor.Bridge;
using NativeMcp.Editor.Helpers;
using NativeMcp.Editor.Tools;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;

namespace NativeMcp.Editor.Tools.EditorControl
{
[McpForUnityTool("editor_play_for_frames", Internal = true,
Description = "Play the game for a specified number of frames, then pause. Supports recovery across domain reloads.")]
public static class EditorPlayForFrames
{
private const int DefaultTimeoutSeconds = 30;
private const string PendingKey = NativeMcpKeys.PendingPlayForFrames;

public class Parameters
{
[ToolParameter("Number of frames to advance (>= 1).")]
public int frames { get; set; }

[ToolParameter("Timeout in seconds. Default 30.", Required = false)]
public int? timeout { get; set; }
}

// Active operation state (in-memory, lost on domain reload)
private static TaskCompletionSource<object> _activeTcs;
private static EditorApplication.CallbackFunction _activeTick;
private static int _framesRemaining;
private static int _framesRequested;
private static int _startFrame;
private static DateTime _deadlineUtc;
private static int _timeoutSeconds;
private static string _activeOperationId;

// Domain reload recovery flag (set by [InitializeOnLoad] after reload)
private static bool _hasPendingRecovery;

[Serializable]
private class PendingState
{
public string operationId;
public int framesRemaining;
public int framesRequested;
public string deadlineUtc;
}

[InitializeOnLoad]
private static class ReloadWatcher
{
static ReloadWatcher()
{
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeReload;
AssemblyReloadEvents.afterAssemblyReload += OnAfterReload;
}
}

private static void OnBeforeReload()
{
if (_activeTcs == null || _activeTcs.Task.IsCompleted)
return;

// Snapshot current state to SessionState
var state = new PendingState
{
operationId = _activeOperationId,
framesRemaining = _framesRemaining,
framesRequested = _framesRequested,
deadlineUtc = _deadlineUtc.ToString("O")
};
SessionState.SetString(PendingKey, JsonUtility.ToJson(state));
Debug.Log($"[NativeMcp] PlayForFrames: saved pending state before domain reload ({_framesRemaining} frames remaining)");

// Clean up in-memory state (will be lost anyway)
CancelActive(silent: true);
}

private static void OnAfterReload()
{
string json = SessionState.GetString(PendingKey, "");
if (!string.IsNullOrEmpty(json))
{
_hasPendingRecovery = true;
Debug.Log("[NativeMcp] PlayForFrames: pending operation detected after domain reload, awaiting bridge replay.");
}
}

public static async Task<object> HandleCommand(JObject @params)
{
try
{
int frames = @params["frames"]?.Value<int>() ?? 0;
int timeout = @params["timeout"]?.Value<int>() ?? DefaultTimeoutSeconds;

if (frames < 1)
return new ErrorResponse("frames must be >= 1.");

if (!EditorApplication.isPlaying)
{
if (EditorApplication.isPlayingOrWillChangePlaymode)
return new ErrorResponse("Play mode is transitioning. Wait and try again.");
return new ErrorResponse("Not in play mode. Call 'play' first.");
}

// Check for domain reload recovery
if (_hasPendingRecovery)
{
_hasPendingRecovery = false;
string json = SessionState.GetString(PendingKey, "");
if (!string.IsNullOrEmpty(json))
{
var recovered = JsonUtility.FromJson<PendingState>(json);
SessionState.EraseString(PendingKey);

if (recovered.framesRemaining > 0)
{
Debug.Log($"[NativeMcp] PlayForFrames: recovering from domain reload, {recovered.framesRemaining} frames remaining.");
frames = recovered.framesRemaining;
var recoveredDeadline = DateTime.Parse(recovered.deadlineUtc).ToUniversalTime();
if (DateTime.UtcNow >= recoveredDeadline)
{
EditorApplication.isPaused = true;
return new ErrorResponse("Timed out during domain reload.", new
{
frames_requested = recovered.framesRequested,
frames_elapsed = recovered.framesRequested - recovered.framesRemaining
});
}
timeout = (int)Math.Max(1, (recoveredDeadline - DateTime.UtcNow).TotalSeconds);
}
}
}

// Cancel any previous active operation
CancelActive(silent: false);

// Set up new operation
_activeOperationId = Guid.NewGuid().ToString("N");
_framesRequested = frames;
_framesRemaining = frames;
_startFrame = Time.frameCount;
_timeoutSeconds = timeout;
_deadlineUtc = DateTime.UtcNow.AddSeconds(timeout);
_activeTcs = new TaskCompletionSource<object>(
TaskCreationOptions.RunContinuationsAsynchronously);

// Unpause if paused
EditorApplication.isPaused = false;

// Start tick loop
EditorNudge.BeginNudge();

_activeTick = Tick;
EditorApplication.update += _activeTick;

return await _activeTcs.Task;
}
catch (Exception e)
{
return new ErrorResponse($"Error in play_for_frames: {e.Message}");
}
}

private static void Tick()
{
if (_activeTcs == null || _activeTcs.Task.IsCompleted)
{
Cleanup();
return;
}

// Detect exit from play mode
if (!EditorApplication.isPlaying)
{
int elapsed = _framesRequested - _framesRemaining;
Cleanup();
_activeTcs.TrySetResult(new ErrorResponse("Play mode exited during play_for_frames.", new
{
frames_requested = _framesRequested,
frames_elapsed = elapsed
}));
return;
}

// Check timeout
if (DateTime.UtcNow >= _deadlineUtc)
{
int elapsed = _framesRequested - _framesRemaining;
EditorApplication.isPaused = true;
Cleanup();
_activeTcs.TrySetResult(new ErrorResponse($"Timed out after {_timeoutSeconds}s.", new
{
frames_requested = _framesRequested,
frames_elapsed = elapsed
}));
return;
}

// Count frame
_framesRemaining--;

if (_framesRemaining <= 0)
{
int endFrame = Time.frameCount;
int elapsed = _framesRequested;
EditorApplication.isPaused = true;
Cleanup();
_activeTcs.TrySetResult(new SuccessResponse($"Advanced {elapsed} frames and paused.", new
{
frames_requested = elapsed,
frames_elapsed = elapsed,
start_frame = _startFrame,
end_frame = endFrame
}));
}
}

private static void CancelActive(bool silent)
{
if (_activeTick != null)
{
EditorApplication.update -= _activeTick;
_activeTick = null;
}

EditorNudge.EndNudge();
SessionState.EraseString(PendingKey);

if (_activeTcs != null && !_activeTcs.Task.IsCompleted)
{
if (!silent)
{
_activeTcs.TrySetResult(
new ErrorResponse("Cancelled by new play_for_frames call."));
}
else
{
_activeTcs.TrySetCanceled();
}
}

_activeTcs = null;
_activeOperationId = null;
}

private static void Cleanup()
{
if (_activeTick != null)
{
EditorApplication.update -= _activeTick;
_activeTick = null;
}
EditorNudge.EndNudge();
SessionState.EraseString(PendingKey);
}
}
}
2 changes: 2 additions & 0 deletions Editor/Tools/EditorControl/EditorPlayForFrames.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading