diff --git a/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs index f97113618..4907b03b7 100644 --- a/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs +++ b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs @@ -714,6 +714,7 @@ private static object ConvertJTokenToPlainObject(JToken token) new RectConverter(), new BoundsConverter(), new Matrix4x4Converter(), // Fix #478: Safe Matrix4x4 serialization for Cinemachine + new TransformHandleConverter(), // Unity 6 TransformHandle: enumerating a destroyed handle's children throws NRE new UnityEngineObjectConverter() // Handles serialization of references }, ReferenceLoopHandling = ReferenceLoopHandling.Ignore, diff --git a/MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs b/MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs index a96e9ad02..95522f748 100644 --- a/MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs +++ b/MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs @@ -266,6 +266,31 @@ public override Matrix4x4 ReadJson(JsonReader reader, Type objectType, Matrix4x4 } } + /// + /// Safe pass-by converter for UnityEngine.TransformHandle (Unity 6+). Reflection-based + /// serialization of a handle enumerates its direct children, which throws + /// NullReferenceException the moment the underlying object is destroyed (a common race + /// when reading component data during play mode while scripts Destroy() objects), and on + /// live objects would walk entire child hierarchies of handles. A handle carries no + /// serializable identity of its own, so it is written as null. The type is matched by + /// name because it does not exist on all Unity versions this package supports. + /// Intentionally internal for the same converter-scanner reason as + /// UnityEngineObjectConverter below (see issue #1138). + /// + internal class TransformHandleConverter : JsonConverter + { + public override bool CanRead => false; + + public override bool CanConvert(Type objectType) + => objectType?.FullName == "UnityEngine.TransformHandle"; + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + => writer.WriteNull(); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + => throw new NotSupportedException("TransformHandleConverter is write-only."); + } + // Converter for UnityEngine.Object references (GameObjects, Components, Materials, Textures, etc.) // Intentionally internal: this converter is meant for MCP-internal serialization only. // Leaving it public lets third-party Newtonsoft converter scanners (e.g. jillejr's diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/TransformHandleConverterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/TransformHandleConverterTests.cs new file mode 100644 index 000000000..092e1f5a6 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/TransformHandleConverterTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Linq; +using System.Reflection; +using MCPForUnity.Runtime.Serialization; +using Newtonsoft.Json; +using NUnit.Framework; +using UnityEngine; + +namespace MCPForUnityTests.Editor.Helpers +{ + /// + /// Tests for TransformHandleConverter, which writes UnityEngine.TransformHandle + /// (Unity 6+) as null instead of letting reflection-based serialization enumerate + /// the handle's children - that enumeration throws NullReferenceException once the + /// underlying object is destroyed (a live race when component data is read during + /// play mode while scripts Destroy() objects). + /// The type is resolved by reflection so this file compiles on every Unity version + /// the package supports; handle-specific tests self-ignore where the type is absent. + /// + public class TransformHandleConverterTests + { + private JsonSerializerSettings _settings; + + [SetUp] + public void SetUp() + { + _settings = new JsonSerializerSettings + { + Converters = { new TransformHandleConverter() } + }; + } + + private static Type TransformHandleType => + typeof(Transform).Assembly.GetType("UnityEngine.TransformHandle"); + + // Finds a public instance property on Transform that returns a TransformHandle, + // which is how the GameObjectSerializer's reflection walk encounters one. + private static object GetHandleFor(Transform transform) + { + Type handleType = TransformHandleType; + if (handleType == null) return null; + + PropertyInfo handleProperty = typeof(Transform) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(p => p.PropertyType == handleType); + return handleProperty?.GetValue(transform); + } + + [Test] + public void CanConvert_RejectsUnrelatedTypes() + { + var converter = new TransformHandleConverter(); + + Assert.That(converter.CanConvert(typeof(Vector3)), Is.False); + Assert.That(converter.CanConvert(typeof(Transform)), Is.False); + Assert.That(converter.CanConvert(typeof(Matrix4x4)), Is.False); + } + + [Test] + public void CanConvert_MatchesTransformHandleByName() + { + Type handleType = TransformHandleType; + if (handleType == null) + Assert.Ignore("UnityEngine.TransformHandle does not exist on this Unity version."); + + Assert.That(new TransformHandleConverter().CanConvert(handleType), Is.True); + } + + [Test] + public void Serialize_LiveHandle_WritesNull() + { + var go = new GameObject("TransformHandleConverterTests_Live"); + try + { + object handle = GetHandleFor(go.transform); + if (handle == null) + Assert.Ignore("No TransformHandle-returning property on this Unity version."); + + string json = JsonConvert.SerializeObject(handle, _settings); + Assert.That(json, Is.EqualTo("null")); + } + finally + { + UnityEngine.Object.DestroyImmediate(go); + } + } + + [Test] + public void Serialize_HandleOfDestroyedObject_DoesNotThrow() + { + // The regression: a handle obtained before its object is destroyed. Without + // the converter, serializing it enumerates its children and throws + // NullReferenceException from TransformHandle.AssertHandleIsValid. + var go = new GameObject("TransformHandleConverterTests_Destroyed"); + object handle = GetHandleFor(go.transform); + UnityEngine.Object.DestroyImmediate(go); + + if (handle == null) + Assert.Ignore("No TransformHandle-returning property on this Unity version."); + + string json = null; + Assert.DoesNotThrow(() => json = JsonConvert.SerializeObject(handle, _settings)); + Assert.That(json, Is.EqualTo("null")); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/TransformHandleConverterTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/TransformHandleConverterTests.cs.meta new file mode 100644 index 000000000..177aca79a --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/TransformHandleConverterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: be5e4cb07a0b45b5a39740c520e258b2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: