Skip to content
Open
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
1 change: 1 addition & 0 deletions MCPForUnity/Editor/Helpers/GameObjectSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,31 @@ public override Matrix4x4 ReadJson(JsonReader reader, Type objectType, Matrix4x4
}
}

/// <summary>
/// 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).
/// </summary>
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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"));
}
}
}

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