using System;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditorInternal;
using UnityEditor.SceneManagement;
using UnityEngine;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Maintains a cached readiness snapshot (v2) so status reads remain fast even when Unity is busy.
/// Updated on the main thread via Editor callbacks and periodic update ticks.
/// </summary>
[InitializeOnLoad]
internal static class EditorStateCache
{
private static readonly object LockObj = new();
private static long _sequence;
private static long _observedUnixMs;
private static bool _lastIsCompiling;
private static long? _lastCompileStartedUnixMs;
private static long? _lastCompileFinishedUnixMs;
private static bool _domainReloadPending;
private static long? _domainReloadBeforeUnixMs;
private static long? _domainReloadAfterUnixMs;
private static double _lastUpdateTimeSinceStartup;
private const double MinUpdateIntervalSeconds = 1.0; // Reduced frequency: 1s instead of 0.25s
// State tracking to detect when snapshot actually changes (checked BEFORE building)
private static string _lastTrackedScenePath;
private static string _lastTrackedSceneName;
private static bool _lastTrackedIsFocused;
private static bool _lastTrackedIsPlaying;
private static bool _lastTrackedIsPaused;
private static bool _lastTrackedIsUpdating;
private static bool _lastTrackedTestsRunning;
private static string _lastTrackedActivityPhase;
private static JObject _cached;
private sealed class EditorStateSnapshot
{
[JsonProperty("schema_version")]
public string SchemaVersion { get; set; }
[JsonProperty("observed_at_unix_ms")]
public long ObservedAtUnixMs { get; set; }
[JsonProperty("sequence")]
public long Sequence { get; set; }
[JsonProperty("unity")]
public EditorStateUnity Unity { get; set; }
[JsonProperty("editor")]
public EditorStateEditor Editor { get; set; }
[JsonProperty("activity")]
public EditorStateActivity Activity { get; set; }
[JsonProperty("compilation")]
public EditorStateCompilation Compilation { get; set; }
[JsonProperty("assets")]
public EditorStateAssets Assets { get; set; }
[JsonProperty("tests")]
public EditorStateTests Tests { get; set; }
[JsonProperty("transport")]
public EditorStateTransport Transport { get; set; }
}
private sealed class EditorStateUnity
{
[JsonProperty("instance_id")]
public string InstanceId { get; set; }
[JsonProperty("unity_version")]
public string UnityVersion { get; set; }
[JsonProperty("project_id")]
public string ProjectId { get; set; }
[JsonProperty("platform")]
public string Platform { get; set; }
[JsonProperty("is_batch_mode")]
public bool? IsBatchMode { get; set; }
}
private sealed class EditorStateEditor
{
[JsonProperty("is_focused")]
public bool? IsFocused { get; set; }
[JsonProperty("play_mode")]
public EditorStatePlayMode PlayMode { get; set; }
[JsonProperty("active_scene")]
public EditorStateActiveScene ActiveScene { get; set; }
}
private sealed class EditorStatePlayMode
{
[JsonProperty("is_playing")]
public bool? IsPlaying { get; set; }
[JsonProperty("is_paused")]
public bool? IsPaused { get; set; }
[JsonProperty("is_changing")]
public bool? IsChanging { get; set; }
}
private sealed class EditorStateActiveScene
{
[JsonProperty("path")]
public string Path { get; set; }
[JsonProperty("guid")]
public string Guid { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
}
private sealed class EditorStateActivity
{
[JsonProperty("phase")]
public string Phase { get; set; }
[JsonProperty("since_unix_ms")]
public long SinceUnixMs { get; set; }
[JsonProperty("reasons")]
public string[] Reasons { get; set; }
}
private sealed class EditorStateCompilation
{
[JsonProperty("is_compiling")]
public bool? IsCompiling { get; set; }
[JsonProperty("is_domain_reload_pending")]
public bool? IsDomainReloadPending { get; set; }
[JsonProperty("last_compile_started_unix_ms")]
public long? LastCompileStartedUnixMs { get; set; }
[JsonProperty("last_compile_finished_unix_ms")]
public long? LastCompileFinishedUnixMs { get; set; }
[JsonProperty("last_domain_reload_before_unix_ms")]
public long? LastDomainReloadBeforeUnixMs { get; set; }
[JsonProperty("last_domain_reload_after_unix_ms")]
public long? LastDomainReloadAfterUnixMs { get; set; }
}
private sealed class EditorStateAssets
{
[JsonProperty("is_updating")]
public bool? IsUpdating { get; set; }
[JsonProperty("external_changes_dirty")]
public bool? ExternalChangesDirty { get; set; }
[JsonProperty("external_changes_last_seen_unix_ms")]
public long? ExternalChangesLastSeenUnixMs { get; set; }
[JsonProperty("external_changes_dirty_since_unix_ms")]
public long? ExternalChangesDirtySinceUnixMs { get; set; }
[JsonProperty("external_changes_last_cleared_unix_ms")]
public long? ExternalChangesLastClearedUnixMs { get; set; }
[JsonProperty("refresh")]
public EditorStateRefresh Refresh { get; set; }
}
private sealed class EditorStateRefresh
{
[JsonProperty("is_refresh_in_progress")]
public bool? IsRefreshInProgress { get; set; }
[JsonProperty("last_refresh_requested_unix_ms")]
public long? LastRefreshRequestedUnixMs { get; set; }
[JsonProperty("last_refresh_finished_unix_ms")]
public long? LastRefreshFinishedUnixMs { get; set; }
}
private sealed class EditorStateTests
{
[JsonProperty("is_running")]
public bool? IsRunning { get; set; }
[JsonProperty("mode")]
public string Mode { get; set; }
[JsonProperty("current_job_id")]
public string CurrentJobId { get; set; }
[JsonProperty("started_unix_ms")]
public long? StartedUnixMs { get; set; }
[JsonProperty("started_by")]
public string StartedBy { get; set; }
[JsonProperty("last_run")]
public EditorStateLastRun LastRun { get; set; }
}
private sealed class EditorStateLastRun
{
[JsonProperty("finished_unix_ms")]
public long? FinishedUnixMs { get; set; }
[JsonProperty("result")]
public string Result { get; set; }
[JsonProperty("counts")]
public object Counts { get; set; }
}
private sealed class EditorStateTransport
{
[JsonProperty("unity_bridge_connected")]
public bool? UnityBridgeConnected { get; set; }
[JsonProperty("last_message_unix_ms")]
public long? LastMessageUnixMs { get; set; }
}
static EditorStateCache()
{
try
{
_sequence = 0;
_observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
_cached = BuildSnapshot("init");
EditorApplication.update += OnUpdate;
EditorApplication.playModeStateChanged += _ => ForceUpdate("playmode");
AssemblyReloadEvents.beforeAssemblyReload += () =>
{
_domainReloadPending = true;
_domainReloadBeforeUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
ForceUpdate("before_domain_reload");
};
AssemblyReloadEvents.afterAssemblyReload += () =>
{
_domainReloadPending = false;
_domainReloadAfterUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
ForceUpdate("after_domain_reload");
};
}
catch (Exception ex)
{
McpLog.Error($"[EditorStateCache] Failed to initialise: {ex.Message}\n{ex.StackTrace}");
}
}
private static void OnUpdate()
{
// Throttle to reduce overhead while keeping the snapshot fresh enough for polling clients.
double now = EditorApplication.timeSinceStartup;
// Use GetActualIsCompiling() to avoid Play mode false positives (issue #582)
bool isCompiling = GetActualIsCompiling();
// Check for compilation edge transitions (always update on these)
bool compilationEdge = isCompiling != _lastIsCompiling;
if (!compilationEdge && now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds)
{
return;
}
// Fast state-change detection BEFORE building snapshot.
// This avoids the expensive BuildSnapshot() call entirely when nothing changed.
// These checks are much cheaper than building a full JSON snapshot.
var scene = EditorSceneManager.GetActiveScene();
string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path;
string sceneName = scene.name ?? string.Empty;
bool isFocused = InternalEditorUtility.isApplicationActive;
bool isPlaying = EditorApplication.isPlaying;
bool isPaused = EditorApplication.isPaused;
bool isUpdating = EditorApplication.isUpdating;
bool testsRunning = TestRunStatus.IsRunning;
var activityPhase = "idle";
if (testsRunning)
{
activityPhase = "running_tests";
}
else if (isCompiling)
{
activityPhase = "compiling";
}
else if (_domainReloadPending)
{
activityPhase = "domain_reload";
}
else if (isUpdating)
{
activityPhase = "asset_import";
}
else if (EditorApplication.isPlayingOrWillChangePlaymode)
{
activityPhase = "playmode_transition";
}
bool hasChanges = compilationEdge
|| _lastTrackedScenePath != scenePath
|| _lastTrackedSceneName != sceneName
|| _lastTrackedIsFocused != isFocused
|| _lastTrackedIsPlaying != isPlaying
|| _lastTrackedIsPaused != isPaused
|| _lastTrackedIsUpdating != isUpdating
|| _lastTrackedTestsRunning != testsRunning
|| _lastTrackedActivityPhase != activityPhase;
if (!hasChanges)
{
// No state change - skip the expensive BuildSnapshot entirely.
// This is the key optimization that prevents the 28ms GC spikes.
return;
}
// Update tracked state
_lastTrackedScenePath = scenePath;
_lastTrackedSceneName = sceneName;
_lastTrackedIsFocused = isFocused;
_lastTrackedIsPlaying = isPlaying;
_lastTrackedIsPaused = isPaused;
_lastTrackedIsUpdating = isUpdating;
_lastTrackedTestsRunning = testsRunning;
_lastTrackedActivityPhase = activityPhase;
_lastUpdateTimeSinceStartup = now;
ForceUpdate("tick");
}
private static void ForceUpdate(string reason)
{
lock (LockObj)
{
_cached = BuildSnapshot(reason);
}
}
private static JObject BuildSnapshot(string reason)
{
_sequence++;
_observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
bool isCompiling = GetActualIsCompiling();
if (isCompiling && !_lastIsCompiling)
{
_lastCompileStartedUnixMs = _observedUnixMs;
}
else if (!isCompiling && _lastIsCompiling)
{
_lastCompileFinishedUnixMs = _observedUnixMs;
}
_lastIsCompiling = isCompiling;
var scene = EditorSceneManager.GetActiveScene();
string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path;
string sceneGuid = !string.IsNullOrEmpty(scenePath) ? AssetDatabase.AssetPathToGUID(scenePath) : null;
bool testsRunning = TestRunStatus.IsRunning;
var testsMode = TestRunStatus.Mode?.ToString();
string currentJobId = TestJobManager.CurrentJobId;
bool isFocused = InternalEditorUtility.isApplicationActive;
var activityPhase = "idle";
if (testsRunning)
{
activityPhase = "running_tests";
}
else if (isCompiling)
{
activityPhase = "compiling";
}
else if (_domainReloadPending)
{
activityPhase = "domain_reload";
}
else if (EditorApplication.isUpdating)
{
activityPhase = "asset_import";
}
else if (EditorApplication.isPlayingOrWillChangePlaymode)
{
activityPhase = "playmode_transition";
}
var snapshot = new EditorStateSnapshot
{
SchemaVersion = "unity-mcp/editor_state@2",
ObservedAtUnixMs = _observedUnixMs,
Sequence = _sequence,
Unity = new EditorStateUnity
{
InstanceId = null,
UnityVersion = Application.unityVersion,
ProjectId = null,
Platform = Application.platform.ToString(),
IsBatchMode = Application.isBatchMode
},
Editor = new EditorStateEditor
{
IsFocused = isFocused,
PlayMode = new EditorStatePlayMode
{
IsPlaying = EditorApplication.isPlaying,
IsPaused = EditorApplication.isPaused,
IsChanging = EditorApplication.isPlayingOrWillChangePlaymode
},
ActiveScene = new EditorStateActiveScene
{
Path = scenePath,
Guid = sceneGuid,
Name = scene.name ?? string.Empty
}
},
Activity = new EditorStateActivity
{
Phase = activityPhase,
SinceUnixMs = _observedUnixMs,
Reasons = new[] { reason }
},
Compilation = new EditorStateCompilation
{
IsCompiling = isCompiling,
IsDomainReloadPending = _domainReloadPending,
LastCompileStartedUnixMs = _lastCompileStartedUnixMs,
LastCompileFinishedUnixMs = _lastCompileFinishedUnixMs,
LastDomainReloadBeforeUnixMs = _domainReloadBeforeUnixMs,
LastDomainReloadAfterUnixMs = _domainReloadAfterUnixMs
},
Assets = new EditorStateAssets
{
IsUpdating = EditorApplication.isUpdating,
ExternalChangesDirty = false,
ExternalChangesLastSeenUnixMs = null,
ExternalChangesDirtySinceUnixMs = null,
ExternalChangesLastClearedUnixMs = null,
Refresh = new EditorStateRefresh
{
IsRefreshInProgress = false,
LastRefreshRequestedUnixMs = null,
LastRefreshFinishedUnixMs = null
}
},
Tests = new EditorStateTests
{
IsRunning = testsRunning,
Mode = testsMode,
CurrentJobId = string.IsNullOrEmpty(currentJobId) ? null : currentJobId,
StartedUnixMs = TestRunStatus.StartedUnixMs,
StartedBy = "unknown",
LastRun = TestRunStatus.FinishedUnixMs.HasValue
? new EditorStateLastRun
{
FinishedUnixMs = TestRunStatus.FinishedUnixMs,
Result = "unknown",
Counts = null
}
: null
},
Transport = new EditorStateTransport
{
UnityBridgeConnected = null,
LastMessageUnixMs = null
}
};
return JObject.FromObject(snapshot);
}
public static JObject GetSnapshot()
{
lock (LockObj)
{
// Defensive: if something went wrong early, rebuild once.
if (_cached == null)
{
_cached = BuildSnapshot("rebuild");
}
// Always return a fresh clone to prevent mutation bugs.
// The main GC optimization comes from state-change detection (OnUpdate)
// which prevents unnecessary _cached rebuilds, not from caching the clone.
return (JObject)_cached.DeepClone();
}
}
/// <summary>
/// Returns the actual compilation state, working around a known Unity quirk where
/// EditorApplication.isCompiling can return false positives in Play mode.
/// See: https://github.com/CoplayDev/unity-mcp/issues/549
/// </summary>
private static bool GetActualIsCompiling()
{
// If EditorApplication.isCompiling is false, Unity is definitely not compiling
if (!EditorApplication.isCompiling)
{
return false;
}
// In Play mode, EditorApplication.isCompiling can have false positives.
// Double-check with CompilationPipeline.isCompiling via reflection.
if (EditorApplication.isPlaying)
{
try
{
Type pipeline = Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor");
var prop = pipeline?.GetProperty("isCompiling", BindingFlags.Public | BindingFlags.Static);
if (prop != null)
{
return (bool)prop.GetValue(null);
}
}
catch
{
// If reflection fails, fall back to EditorApplication.isCompiling
}
}
// Outside Play mode or if reflection failed, trust EditorApplication.isCompiling
return true;
}
}
}