using System; using System.Collections.Generic; using System.Linq; using System.Text; using Newtonsoft.Json.Linq; using Unity.Pipeline.Commands; using Unity.Pipeline.Editor.Authoring; using Unity.Pipeline.Editor.Commands.GameObjects; using Unity.Pipeline.Models; using UnityEditor; using UnityEngine; using Object = UnityEngine.Object; namespace Unity.Pipeline.Editor.Commands.Scripts { /// /// Read and write serialized ([SerializeField] / public) fields on a component or asset through /// Unity's model (CLI-195). /// /// Why SerializedObject and not plain reflection: it honours Unity's serialization rules (private /// [SerializeField] fields, property drawers, prefab overrides), marks the object dirty correctly, /// and integrates with Undo. Writes are wrapped in an so an /// agent's change reverts as one step. /// /// Field addressing uses Unity's native SerializedProperty path syntax, so array elements are /// reachable directly: "myArray.Array.data[2]" sets the third element; "myArray.Array.size" sets /// the array length. Nested fields use dotted paths ("settings.speed"). /// public static class SerializedFieldCommands { [CliCommand("set_serialized_field", "Set a serialized field on a component/asset. Supports primitives, enums, Vector/Color/Rect/Bounds, " + "object references (value = an ObjectRef: asset by guid/fileId/path or scene object by instanceId/hierarchyPath), " + "and array elements via 'name.Array.data[i]' (or 'name.Array.size' to resize).")] public static AuthoringResult SetSerializedField( [CliArg("target", "Reference to the component or asset to modify (globalId/path/guid/instanceId/hierarchyPath). May be a GameObject when 'component' is given.", Required = true)] ObjectRef target, [CliArg("field", "SerializedProperty path, e.g. 'speed', 'settings.speed', or 'waypoints.Array.data[0]'.", Required = true)] string field, [CliArg("value", "JSON value to assign. For object references pass an ObjectRef object (or null to clear). For enums pass the value name.", Required = true)] JToken value, [CliArg("component", "Component type name on the target GameObject (e.g. 'Rigidbody'). Use when 'target' is a GameObject; omit when 'target' is already a component handle.")] string component = null) { if (target == null || target.IsEmpty) throw new ArgumentException("set_serialized_field 'target' is required."); if (string.IsNullOrWhiteSpace(field)) throw new ArgumentException("set_serialized_field 'field' is required."); var obj = ResolveSerializable(target, component); using (new AuthoringUndoScope($"Set {field}")) { // RegisterCompleteObjectUndo captures the pre-change state for revert; the // SerializedObject below then records the change for prefab/asset dirtying. Undo.RegisterCompleteObjectUndo(obj, $"Set {field}"); var so = new SerializedObject(obj); var prop = so.FindProperty(field); if (prop == null) throw new ArgumentException( $"Field '{field}' was not found on '{obj.GetType().Name}'. " + "Use a SerializedProperty path (e.g. 'speed', 'settings.speed', 'items.Array.data[0]')."); SerializedPropertyConverter.SetValue(prop, value); so.ApplyModifiedProperties(); EditorUtility.SetDirty(obj); } var result = ObjectResolver.Describe(obj) ?? new AuthoringResult { Type = obj.GetType().Name }; return result; } [CliCommand("get_serialized_fields", "Read serialized fields of a component/asset. Returns each top-level field's name, type and value " + "(object references are returned as re-usable handles). Pass 'field' to read a single SerializedProperty path.")] public static object GetSerializedFields( [CliArg("target", "Reference to the component or asset to read (globalId/path/guid/instanceId/hierarchyPath). May be a GameObject when 'component' is given.", Required = true)] ObjectRef target, [CliArg("field", "Optional single SerializedProperty path to read (e.g. 'speed' or 'items.Array.data[0]'). Omit to read all top-level fields.")] string field = null, [CliArg("component", "Component type name on the target GameObject (e.g. 'Rigidbody'). Use when 'target' is a GameObject; omit when 'target' is already a component handle.")] string component = null) { if (target == null || target.IsEmpty) throw new ArgumentException("get_serialized_fields 'target' is required."); var obj = ResolveSerializable(target, component); var so = new SerializedObject(obj); if (!string.IsNullOrWhiteSpace(field)) { var prop = so.FindProperty(field); if (prop == null) throw new ArgumentException($"Field '{field}' was not found on '{obj.GetType().Name}'."); return new { type = obj.GetType().Name, fields = new[] { DescribeProperty(prop) } }; } var fields = new List(); var iterator = so.GetIterator(); // enterChildren=true on the first MoveNext to step into the top level; then false to stay // at the top level and skip nested children (callers drill in via an explicit 'field'). var enterChildren = true; while (iterator.NextVisible(enterChildren)) { enterChildren = false; // m_Script is Unity's hidden back-reference to the MonoScript; not a user field. if (iterator.propertyPath == "m_Script") continue; fields.Add(DescribeProperty(iterator)); } return new { type = obj.GetType().Name, fields }; } /// /// Resolve a handle to an object that a SerializedObject can wrap: a Component or an asset. /// /// Two addressing forms (CLI-225): /// * The handle already points at a Component (or asset) → use it directly. An optional /// type name is validated against it as a guard. /// * The handle points at a GameObject AND is given → look up /// the matching component(s) on that GameObject. Exactly one match is used; MULTIPLE /// same-type components are an error that lists each instanceId so the agent can re-address /// by instanceId; zero matches is a clear error. /// /// A GameObject with no is rejected (a GameObject's "fields" are /// its components), with a hint to pass --component or a specific component handle. /// /// This mirrors 's GO-path+type pattern, but /// deliberately errors on MULTIPLE matches (that helper returns the first) so a set/get never /// silently targets the wrong instance. /// private static Object ResolveSerializable(ObjectRef target, string component = null) { if (!ObjectResolver.TryResolve(target, out var obj, out var error)) throw new ArgumentException($"Could not resolve target: {error}"); // The handle already points at a specific Component: honour it directly. A 'component' here // is only a constraint to validate against, never a reason to re-fetch (which could pick a // different same-type instance and ignore the handle). if (obj is Component directComponent) { if (!string.IsNullOrWhiteSpace(component)) { var requestedType = TypeResolver.ResolveComponentType(component); if (requestedType == null) throw new ArgumentException($"Could not resolve component type '{component}'."); if (!requestedType.IsInstanceOfType(directComponent)) throw new ArgumentException( $"Target resolves to a {directComponent.GetType().Name}, which is not a '{requestedType.Name}'."); } return directComponent; } if (obj is GameObject go) { if (string.IsNullOrWhiteSpace(component)) throw new ArgumentException( $"Target '{target}' is a GameObject. Pass --component to pick a component on it, " + "or target a specific component directly (use its instanceId/globalId), " + "to read or set serialized fields."); var componentType = TypeResolver.ResolveComponentType(component); if (componentType == null) throw new ArgumentException($"Could not resolve component type '{component}'."); var matches = go.GetComponents(componentType).Where(c => c != null).ToArray(); if (matches.Length == 0) throw new ArgumentException( $"GameObject '{go.name}' has no component of type '{componentType.Name}'."); if (matches.Length > 1) { var sb = new StringBuilder(); sb.Append($"GameObject '{go.name}' has {matches.Length} components of type '{componentType.Name}'. ") .Append("Re-target a specific one by its instanceId: "); for (int i = 0; i < matches.Length; i++) { if (i > 0) sb.Append(", "); sb.Append($"instanceId {PipelineUtils.GetObjectId(matches[i])}"); } sb.Append('.'); throw new ArgumentException(sb.ToString()); } return matches[0]; } // Not a Component, not a GameObject — an asset (e.g. ScriptableObject) handled directly. // A 'component' filter is meaningless for an asset; reject it rather than silently ignore it // (a supplied component here signals a misrouted or misspelled request). if (!string.IsNullOrWhiteSpace(component)) throw new ArgumentException( $"Target '{target}' resolved to a {obj.GetType().Name} (an asset), which has no components. " + "Omit --component when targeting an asset directly."); return obj; } private static object DescribeProperty(SerializedProperty prop) { return new { name = prop.name, path = prop.propertyPath, propertyType = prop.propertyType.ToString(), isArray = prop.isArray && prop.propertyType != SerializedPropertyType.String, arrayLength = (prop.isArray && prop.propertyType != SerializedPropertyType.String) ? prop.arraySize : (int?)null, value = SerializedPropertyConverter.GetValue(prop) }; } } }