diff --git a/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs index 22b1374e9..f97113618 100644 --- a/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs +++ b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs @@ -131,6 +131,73 @@ private static bool IsOrDerivedFrom(Type type, string baseTypeFullName) return false; } + // Type full names that are known to crash the Editor when accessed via reflection. + // Photon Fusion uses IL weaving to inject fields with these types into NetworkBehaviour + // subclasses. They contain native/unmanaged memory and cannot be safely serialized. + private static readonly HashSet _crashingTypeNames = new HashSet + { + "Fusion.NetworkBehaviourBuffer", + "Fusion.NetworkBehaviourCallbackBuffer", + "Fusion.Networked+Internals", + "Fusion.Changed`1", + }; + private static readonly PropertyInfo _isByRefLikeProperty = typeof(Type).GetProperty("IsByRefLike"); + + /// + /// Checks if a type is unsafe to access via reflection or serialize. + /// Returns true for ref structs (Span, ReadOnlySpan), pointer types, + /// by-ref types, and known IL-weaved types that crash the Editor. + /// + private static bool IsUnsafeType(Type type) + { + return IsUnsafeType(type, new HashSet()); + } + + private static bool IsUnsafeType(Type type, HashSet visitedTypes) + { + if (type == null) return false; + if (!visitedTypes.Add(type)) return false; + + // Pointer and by-ref types cannot be serialized + if (type.IsPointer || type.IsByRef) + return true; + + // Ref structs (Span<>, ReadOnlySpan<>, etc.) cannot be boxed. Use reflection + // so Unity versions without Type.IsByRefLike still compile. + if (type.IsValueType && _isByRefLikeProperty != null && (bool)_isByRefLikeProperty.GetValue(type, null)) + return true; + + // Check the type and its generic definition against the blacklist + string fullName = type.FullName; + if (fullName != null && _crashingTypeNames.Contains(fullName)) + return true; + + if (type.IsGenericType) + { + string genericFullName = type.GetGenericTypeDefinition()?.FullName; + if (genericFullName != null && _crashingTypeNames.Contains(genericFullName)) + return true; + } + + // Catch-all for Fusion buffer types injected by IL weaving + if (fullName != null && fullName.StartsWith("Fusion.") && fullName.Contains("Buffer")) + return true; + + // Arrays and generic containers can wrap unsafe Fusion/ref-like types. + // Newtonsoft.Json would still recurse into those values during serialization. + Type elementType = type.GetElementType(); + if (elementType != null && IsUnsafeType(elementType, visitedTypes)) + return true; + + foreach (Type genericArgument in type.GetGenericArguments()) + { + if (IsUnsafeType(genericArgument, visitedTypes)) + return true; + } + + return false; + } + /// /// Serializes a UnityEngine.Object reference to a dictionary with name, instanceID, and assetPath. /// Used for consistent serialization of asset references in special-case component handlers. @@ -352,6 +419,9 @@ public static object GetComponentData(Component c, bool includeNonPublicSerializ { // Basic filtering (readable, not indexer, not transform which is handled elsewhere) if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue; + // Skip properties whose return type would crash when accessed via reflection + // (e.g. Fusion IL-weaved types, Span<>, ReadOnlySpan<>, pointers) + if (IsUnsafeType(propInfo.PropertyType)) continue; // Add if not already added (handles overrides - keep the most derived version) if (!propertiesToCache.Any(p => p.Name == propInfo.Name)) { @@ -367,6 +437,9 @@ public static object GetComponentData(Component c, bool includeNonPublicSerializ foreach (var fieldInfo in declaredFields) { if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields + // Skip fields whose type would crash when accessed via reflection + // (e.g. Fusion IL-weaved types, Span<>, ReadOnlySpan<>, pointers) + if (IsUnsafeType(fieldInfo.FieldType)) continue; // Add if not already added (handles hiding - keep the most derived version) if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue; diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/FusionUnsafeTypeSerializationTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/FusionUnsafeTypeSerializationTests.cs new file mode 100644 index 000000000..99c97931e --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/FusionUnsafeTypeSerializationTests.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using NUnit.Framework; +using UnityEngine; + +namespace Fusion +{ + public struct NetworkBehaviourBuffer + { + public int Value; + } + + public struct Changed + { + public T Value; + } +} + +namespace MCPForUnityTests.Editor.Tools +{ + public class FusionUnsafeTypeSerializationTests + { + [Test] + public void GetComponentData_SkipsFusionUnsafeTypesInsideContainers() + { + var testObject = new GameObject("FusionUnsafeTypeTestObject"); + + try + { + var component = testObject.AddComponent(); + component.bufferList.Add(new Fusion.NetworkBehaviourBuffer { Value = 1 }); + component.nestedChangedLookup["changed"] = new List> + { + new Fusion.Changed { Value = 2 } + }; + component.ChangedListProperty.Add(new Fusion.Changed { Value = 3 }); + + var result = GameObjectSerializer.GetComponentData(component) as Dictionary; + + Assert.IsNotNull(result, "GetComponentData should return dictionary data."); + Assert.IsTrue(result.TryGetValue("properties", out object propertiesObject), "Serialized data should contain properties."); + + var properties = propertiesObject as Dictionary; + Assert.IsNotNull(properties, "Serialized properties should be a dictionary."); + + Assert.IsTrue(properties.ContainsKey(nameof(FusionUnsafeTypeComponent.safeValue)), "Safe fields should still serialize."); + Assert.IsFalse(properties.ContainsKey(nameof(FusionUnsafeTypeComponent.directBuffer)), "Direct Fusion buffer fields should be skipped."); + Assert.IsFalse(properties.ContainsKey(nameof(FusionUnsafeTypeComponent.bufferList)), "Collections containing Fusion buffer types should be skipped."); + Assert.IsFalse(properties.ContainsKey(nameof(FusionUnsafeTypeComponent.nestedChangedLookup)), "Nested generic containers containing Fusion Changed should be skipped."); + Assert.IsFalse(properties.ContainsKey(nameof(FusionUnsafeTypeComponent.ChangedListProperty)), "Properties returning collections of Fusion Changed should be skipped."); + } + finally + { + UnityEngine.Object.DestroyImmediate(testObject); + } + } + } + + public sealed class FusionUnsafeTypeComponent : MonoBehaviour + { + public string safeValue = "kept"; + public Fusion.NetworkBehaviourBuffer directBuffer; + public List bufferList = new List(); + public Dictionary>> nestedChangedLookup = new Dictionary>>(); + + public List> ChangedListProperty { get; } = new List>(); + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/FusionUnsafeTypeSerializationTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/FusionUnsafeTypeSerializationTests.cs.meta new file mode 100644 index 000000000..fe351ce18 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/FusionUnsafeTypeSerializationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0a2e39a143bd4d46a03d27cc30468d2e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: