Add Unity Pipeline package support

This commit is contained in:
ud18010
2026-07-31 17:34:04 +08:00
parent d1a526094e
commit 786b7ffff9
590 changed files with 51995 additions and 2 deletions
@@ -0,0 +1,138 @@
using System;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Editor.Commands.Scripts
{
/// <summary>
/// Authoring command that adds a MonoBehaviour component to a GameObject, addressing the script
/// either by its (compiled) type name OR by its source asset path (CLI-195, CLI-224).
///
/// COMPILE-AWARE: the type must already be compiled. A script created via create_script does not
/// have a compiled <see cref="Type"/> until a domain reload runs, so attaching it before that
/// fails with a CLEAR, RECOVERABLE error (no crash, no silent no-op) telling the agent to
/// recompile and retry. The full flow is:
/// create_script -> recompile -> poll recompile_status (completed/up_to_date) -> attach_script.
///
/// Addressing by asset path resolves the backing class through
/// <see cref="MonoScript.GetClass"/>, which correctly handles a class whose name differs from
/// the filename. A path whose MonoScript has no class yet (not compiled, or no single matching
/// top-level type) surfaces the same recoverable "not yet compiled / ambiguous" error.
///
/// The component add is wrapped in an <see cref="AuthoringUndoScope"/> and registered with the
/// Undo system so it reverts as one step and the owning object/prefab is marked dirty.
/// </summary>
public static class AttachScriptCommand
{
[CliCommand("attach_script",
"Add a MonoBehaviour to a GameObject by its (compiled) type name OR by its script asset path. " +
"Provide exactly one of 'type' or 'script'. " +
"If the type isn't compiled yet, returns a recoverable error: recompile, poll recompile_status, then retry.")]
public static AuthoringResult AttachScript(
[CliArg("target", "Reference to the GameObject to add the component to (globalId/path/guid/instanceId/hierarchyPath).", Required = true)] ObjectRef target,
[CliArg("type", "Component type name to add, e.g. PlayerController or Game.Player.PlayerController. Must already be compiled. Mutually exclusive with 'script'.")] string type = null,
[CliArg("script", "Script asset path, e.g. 'Assets/Pool/Scripts/CueShooter.cs'. The backing class is resolved via MonoScript.GetClass(), so the class name may differ from the filename. Mutually exclusive with 'type'.")] string script = null)
{
if (target == null || target.IsEmpty)
throw new ArgumentException("attach_script 'target' is required.");
// Exactly one addressing form must be supplied. Both is ambiguous; neither is incomplete.
var hasType = !string.IsNullOrWhiteSpace(type);
var hasScript = !string.IsNullOrWhiteSpace(script);
if (hasType && hasScript)
throw new ArgumentException(
"Provide either 'type' (class name) or 'script' (asset path), not both.");
if (!hasType && !hasScript)
throw new ArgumentException(
"Provide either 'type' (class name) or 'script' (asset path).");
if (!ObjectResolver.TryResolve(target, out var obj, out var resolveError))
throw new ArgumentException($"Could not resolve target: {resolveError}");
var go = obj as GameObject ?? (obj as Component)?.gameObject;
if (go == null)
throw new ArgumentException(
$"Target '{target}' resolved to a {obj.GetType().Name}, which is not a GameObject. " +
"attach_script needs a GameObject.");
// Resolve the component Type from whichever addressing form was given. A type that exists but
// isn't compiled yet (or is the wrong kind) surfaces as InvalidOperationException so the
// server returns a 400 "Command Execution Failed" with the recovery instructions intact.
// ArgumentException is reserved for caller input mistakes — both/neither args, or a script
// path that points at no asset — which the server reports as "Parameter Validation Failed".
var componentType = hasScript
? ResolveTypeFromScriptPath(script)
: ResolveTypeFromName(type);
using (new AuthoringUndoScope($"Attach {componentType.Name}"))
{
// Undo.AddComponent registers the add for undo and returns the new component.
var component = Undo.AddComponent(go, componentType);
if (component == null)
throw new InvalidOperationException(
$"Failed to add component '{componentType.FullName}' to '{go.name}'. " +
"It may conflict with [DisallowMultipleComponent] or a required-component rule.");
EditorUtility.SetDirty(go);
var result = ObjectResolver.Describe(component) ?? new AuthoringResult { Type = componentType.Name };
return result;
}
}
/// <summary>
/// Resolve a component type by its compiled type name. A missing type is the expected,
/// recoverable "created but not yet compiled" case.
/// </summary>
private static Type ResolveTypeFromName(string type)
{
if (!ScriptTypeResolver.TryResolveComponentType(type, out var componentType, out var typeError))
throw new InvalidOperationException(typeError);
return componentType;
}
/// <summary>
/// Resolve a component type from a script asset path. Loads the <see cref="MonoScript"/> at the
/// path and resolves its backing class via <see cref="MonoScript.GetClass"/> — which correctly
/// handles a class whose name differs from the filename. Validates the class is a concrete
/// MonoBehaviour. A null class is the recoverable "not yet compiled / ambiguous" case and
/// mirrors the type-not-found message style. A path that resolves to no MonoScript asset is a
/// caller input mistake (ArgumentException → "Parameter Validation Failed"), not a recompile-
/// recoverable failure.
/// </summary>
private static Type ResolveTypeFromScriptPath(string path)
{
var trimmed = path.Trim();
var mono = AssetDatabase.LoadAssetAtPath<MonoScript>(trimmed);
if (mono == null)
throw new ArgumentException(
$"No MonoScript at '{trimmed}'. Check the asset path (it should be a project-relative " +
"path to a .cs file, e.g. 'Assets/Scripts/PlayerController.cs').");
var cls = mono.GetClass();
if (cls == null)
throw new InvalidOperationException(
$"Script at '{trimmed}' has no resolvable class. " +
"If you just created or edited this script it is not compiled yet: run 'recompile', poll " +
"'recompile_status' until it reports completed/up_to_date, then retry attach_script. " +
"If it should already exist, ensure the file has a single top-level type matching the " +
"script and that it compiled without errors.");
if (!typeof(MonoBehaviour).IsAssignableFrom(cls))
throw new InvalidOperationException(
$"Type '{cls.FullName}' (from '{trimmed}') does not derive from MonoBehaviour and cannot be added as a component.");
if (cls.IsAbstract)
throw new InvalidOperationException(
$"Type '{cls.FullName}' (from '{trimmed}') is abstract and cannot be instantiated as a component.");
return cls;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 488a53ecbf5c4984bdc009bf1f2f9a45
@@ -0,0 +1,163 @@
using System;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
namespace Unity.Pipeline.Editor.Commands.Scripts
{
/// <summary>
/// Authoring command that writes a new C# MonoBehaviour (or other base-class) script from a
/// template into the project under the authoring root (CLI-195).
///
/// IMPORTANT — the compile/domain-reload boundary:
/// Writing a .cs file does NOT make the type available. Unity must import and compile the new
/// file, which triggers a domain reload, before the type exists and can be attached. The agent
/// workflow is therefore:
/// 1. create_script (this command — writes the file)
/// 2. recompile (triggers compilation; see <see cref="RecompileCommand"/>)
/// 3. poll recompile_status (wait until "completed" / "up_to_date")
/// 4. attach_script (now the type exists; see <see cref="AttachScriptCommand"/>)
/// This command intentionally does its part only: it creates the file and returns the asset
/// identity. It does not trigger a recompile itself — the agent owns that step so it can batch
/// multiple authoring writes before paying the domain-reload cost once.
/// </summary>
public static class CreateScriptCommand
{
/// <summary>
/// Default class body (the Start/Update stubs) written inside the generated class, matching
/// Unity's own new-MonoBehaviour template so the output reads like a hand-authored script.
/// <see cref="BuildSource"/> wraps it with the using/class declaration and an optional namespace.
/// </summary>
private const string DefaultBody =
" // Use this for initialization\n" +
" void Start()\n" +
" {\n\n" +
" }\n\n" +
" // Update is called once per frame\n" +
" void Update()\n" +
" {\n\n" +
" }\n";
[CliCommand("create_script",
"Create a new C# script (default base class MonoBehaviour) from a template under the authoring root. " +
"NOTE: the type does not exist until a recompile completes — to attach it, call recompile, poll recompile_status, then attach_script.")]
public static AuthoringResult CreateScript(
[CliArg("name", "Class/file name without extension, e.g. PlayerController. Must be a valid C# identifier.", Required = true)] string name,
[CliArg("path", "Folder (relative to the authoring root; the Assets/ prefix is optional) to write the .cs into. Defaults to the authoring root.")] string path = null,
[CliArg("namespace", "Optional namespace to wrap the class in. Omit for the global namespace.")] string @namespace = null,
[CliArg("base_class", "Base class to derive from. Defaults to MonoBehaviour.")] string baseClass = "MonoBehaviour",
[CliArg("overwrite", "Overwrite the file if it already exists. Defaults to false (an existing file is an error).")] bool overwrite = false)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Script 'name' is required.");
var className = name.Trim();
if (className.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
className = className.Substring(0, className.Length - 3);
if (!IsValidIdentifier(className))
throw new ArgumentException($"Script name '{className}' is not a valid C# class identifier.");
if (string.IsNullOrWhiteSpace(baseClass))
baseClass = "MonoBehaviour";
// Resolve & sandbox the destination folder through the project-path policy. A null/empty
// path means "the authoring root itself" — Resolve treats an empty path as an error, so
// fall back to the configured root in that case (it is already a confined, valid path).
string folder;
if (string.IsNullOrWhiteSpace(path))
{
folder = ProjectPaths.AuthoringRoot;
}
else
{
folder = ProjectPaths.Resolve(path, out var error);
if (folder == null)
throw new ArgumentException(error);
}
if (!AssetDatabase.IsValidFolder(folder))
throw new ArgumentException(
$"Destination folder '{folder}' does not exist. Create it first with create_folder.");
var assetPath = $"{folder}/{className}.cs";
if (File.Exists(ToAbsolute(assetPath)) && !overwrite)
throw new ArgumentException(
$"A script already exists at '{assetPath}'. Pass overwrite=true to replace it.");
var source = BuildSource(className, @namespace, baseClass);
// Write through the filesystem then import: AssetDatabase has no "create text asset" API,
// and CreateAsset is for UnityEngine.Object instances, not source files.
File.WriteAllText(ToAbsolute(assetPath), source, new UTF8Encoding(false));
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
// The MonoScript may already be loadable (its asset entry exists), but its compiled Type
// will not exist until the next domain reload — see the class doc.
var script = AssetDatabase.LoadAssetAtPath<MonoScript>(assetPath);
var result = ObjectResolver.Describe(script) ?? new AuthoringResult { Type = "MonoScript" };
result.AssetPath = assetPath;
return result;
}
/// <summary>
/// Build the script source from the template, substituting the class name, optional namespace
/// and base class. When a namespace is supplied the class body is indented one extra level.
/// </summary>
private static string BuildSource(string className, string @namespace, string baseClass)
{
var usings = "using UnityEngine;\n\n";
var classDecl = $"public class {className} : {baseClass}\n";
var sb = new StringBuilder();
sb.Append(usings);
if (!string.IsNullOrWhiteSpace(@namespace))
{
sb.Append($"namespace {@namespace}\n{{\n");
sb.Append(Indent(classDecl, " "));
sb.Append(" {\n");
sb.Append(Indent(DefaultBody, " "));
sb.Append(" }\n");
sb.Append("}\n");
}
else
{
sb.Append(classDecl);
sb.Append("{\n");
sb.Append(DefaultBody);
sb.Append("}\n");
}
return sb.ToString();
}
private static string Indent(string text, string prefix)
{
var sb = new StringBuilder();
foreach (var line in text.Split('\n'))
sb.Append(line.Length == 0 ? "\n" : prefix + line + "\n");
// Split adds a trailing empty element for a trailing newline; trim the extra newline we
// appended for it.
if (text.EndsWith("\n") && sb.Length > 0)
sb.Length -= 1;
return sb.ToString();
}
private static bool IsValidIdentifier(string value)
{
// A C# identifier: starts with a letter or underscore, then letters/digits/underscores.
return Regex.IsMatch(value, @"^[A-Za-z_][A-Za-z0-9_]*$");
}
private static string ToAbsolute(string assetPath)
{
// assetPath is project-relative ("Assets/..."); ProjectRoot is the folder containing Assets/.
return Path.Combine(ProjectPaths.ProjectRoot, assetPath).Replace('\\', '/');
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 34d285de0757475796fcecc889996421
@@ -0,0 +1,161 @@
using System;
using System.Linq;
using System.Reflection;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Editor.Commands.Scripts
{
/// <summary>
/// Resolves an agent-supplied type name (or a <see cref="MonoScript"/> reference) to a compiled
/// <see cref="Type"/>, searching the currently-loaded assemblies.
///
/// This is the heart of the compile-aware behaviour for CLI-195: a script that was just created
/// has no compiled type until a domain reload runs, so resolution here will fail until the agent
/// recompiles. Callers translate that failure into a clear, recoverable error rather than a crash.
/// </summary>
public static class ScriptTypeResolver
{
/// <summary>
/// Try to resolve a MonoBehaviour-derived type by its name. Accepts either a bare class name
/// ("PlayerController") or a fully-qualified name ("Game.Player.PlayerController"). When a
/// bare name is ambiguous across namespaces the first match wins; callers should prefer the
/// fully-qualified name. Returns false with a human-readable <paramref name="error"/> when no
/// loaded type matches (typically: not yet compiled).
/// </summary>
public static bool TryResolveComponentType(string typeName, out Type type, out string error)
{
type = null;
error = null;
if (string.IsNullOrWhiteSpace(typeName))
{
error = "Type name is required.";
return false;
}
var name = typeName.Trim();
var match = FindType(name);
if (match == null)
{
error =
$"Type '{name}' was not found in any loaded assembly. " +
"If you just created this script it is not compiled yet: run 'recompile', poll " +
"'recompile_status' until it reports completed/up_to_date, then retry attach_script. " +
"If it should already exist, check the class name (and namespace) and that the file compiled without errors.";
return false;
}
if (!typeof(MonoBehaviour).IsAssignableFrom(match))
{
error = $"Type '{match.FullName}' does not derive from MonoBehaviour and cannot be added as a component.";
return false;
}
if (match.IsAbstract)
{
error = $"Type '{match.FullName}' is abstract and cannot be instantiated as a component.";
return false;
}
type = match;
return true;
}
/// <summary>
/// Resolve the compiled <see cref="Type"/> backing a <see cref="MonoScript"/> asset reference
/// (e.g. one returned by create_script). Returns false when the script has no class yet
/// (uncompiled) or the asset is not a MonoScript.
/// </summary>
public static bool TryResolveFromMonoScript(MonoScript script, out Type type, out string error)
{
type = null;
error = null;
if (script == null)
{
error = "MonoScript reference is null.";
return false;
}
var scriptClass = script.GetClass();
if (scriptClass == null)
{
error =
$"Script '{script.name}' has no compiled class. It is likely not compiled yet: run 'recompile', " +
"poll 'recompile_status', then retry.";
return false;
}
type = scriptClass;
return true;
}
/// <summary>
/// Search all loaded assemblies for a type by full name first, then by bare class name. Done
/// over <see cref="AppDomain.CurrentDomain"/> rather than TypeCache so it also finds types in
/// non-Unity-managed assemblies and matches what Activator/AddComponent can actually load.
/// </summary>
private static Type FindType(string name)
{
var assemblies = PipelineUtils.GetLoadedAssemblies();
// 1. Fully-qualified name (exact).
foreach (var asm in assemblies)
{
var t = asm.GetType(name, throwOnError: false, ignoreCase: false);
if (t != null)
return t;
}
// 2. Bare class name — match on Name across loaded types. GetTypes() is expensive, so skip
// dynamic and system/Unity assemblies (which never hold user MonoBehaviours). This mirrors
// CommandRegistry.IsSystemAssembly's predicate (replicated locally rather than shared so we
// don't widen that internal helper's surface).
foreach (var asm in assemblies)
{
if (IsSkippableAssembly(asm))
continue;
Type[] types;
try
{
types = asm.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
types = ex.Types.Where(t => t != null).ToArray();
}
var hit = types.FirstOrDefault(t => t != null && t.Name == name);
if (hit != null)
return hit;
}
return null;
}
/// <summary>
/// Assemblies we never need to scan for a user MonoBehaviour: dynamic (in-memory, can't be
/// reflected for types reliably) and core system/Unity assemblies. Mirrors the predicate in
/// <c>CommandRegistry.IsSystemAssembly</c>.
/// </summary>
private static bool IsSkippableAssembly(Assembly assembly)
{
if (assembly.IsDynamic)
return true;
var name = assembly.GetName().Name;
if (string.IsNullOrEmpty(name))
return false;
return name.StartsWith("System.", StringComparison.Ordinal) ||
name.StartsWith("Microsoft.", StringComparison.Ordinal) ||
name.StartsWith("mscorlib", StringComparison.Ordinal) ||
name.StartsWith("netstandard", StringComparison.Ordinal) ||
name.Equals("UnityEngine", StringComparison.Ordinal) ||
name.Equals("UnityEditor", StringComparison.Ordinal);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b1131a6a27648b1829e78c8c0bd69a7
@@ -0,0 +1,215 @@
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
{
/// <summary>
/// Read and write serialized ([SerializeField] / public) fields on a component or asset through
/// Unity's <see cref="SerializedObject"/> 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 <see cref="AuthoringUndoScope"/> 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").
/// </summary>
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<object>();
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 };
}
/// <summary>
/// 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
/// <paramref name="component"/> type name is validated against it as a guard.
/// * The handle points at a GameObject AND <paramref name="component"/> 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 <paramref name="component"/> is rejected (a GameObject's "fields" are
/// its components), with a hint to pass --component or a specific component handle.
///
/// This mirrors <see cref="ComponentCommands.ResolveComponent"/>'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.
/// </summary>
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 <TypeName> 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)
};
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 974b8c0dcec74d388bbf2061fc3aa3dc
@@ -0,0 +1,237 @@
using System;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Editor.Commands.Scripts
{
/// <summary>
/// Bridges agent-supplied JSON values and Unity's <see cref="SerializedProperty"/> model for the
/// set_serialized_field / get_serialized_fields commands (CLI-195).
///
/// Supported property kinds: integers, floats, bools, strings, enums, Vector2/3/4, Color,
/// Quaternion, Rect, Bounds, and object references. Object references accept an <see cref="ObjectRef"/>
/// handle (resolved via <see cref="ObjectResolver"/>) — assets by GUID/fileId or path, and scene
/// objects by instanceId/hierarchyPath. Arrays are addressed element-by-element via a path like
/// "myArray.Array.data[2]" (Unity's native SerializedProperty path syntax) — see the commands.
/// </summary>
public static class SerializedPropertyConverter
{
/// <summary>
/// Assign a JSON value to a serialized property, converting by the property's type. Throws
/// <see cref="ArgumentException"/> with a clear message when the value can't be coerced or the
/// property type isn't supported. The caller is responsible for ApplyModifiedProperties.
/// </summary>
public static void SetValue(SerializedProperty prop, JToken value)
{
// Whole-array assignment: an array/list field (Generic + isArray) set from a JSON array —
// resize, then recurse per element (handles primitive and object-reference arrays alike).
// Element-by-element addressing via "field.Array.data[i]" / "field.Array.size" still works.
if (prop.isArray && prop.propertyType != SerializedPropertyType.String)
{
if (!(value is JArray array))
throw new ArgumentException(
$"Field '{prop.propertyPath}' is an array; provide a JSON array of elements.");
prop.arraySize = array.Count;
for (int i = 0; i < array.Count; i++)
SetValue(prop.GetArrayElementAtIndex(i), array[i]);
return;
}
switch (prop.propertyType)
{
case SerializedPropertyType.Integer:
prop.longValue = value.ToObject<long>();
break;
case SerializedPropertyType.Boolean:
prop.boolValue = value.ToObject<bool>();
break;
case SerializedPropertyType.Float:
prop.doubleValue = value.ToObject<double>();
break;
case SerializedPropertyType.String:
prop.stringValue = value.ToObject<string>();
break;
case SerializedPropertyType.Enum:
SetEnum(prop, value);
break;
case SerializedPropertyType.Vector2:
prop.vector2Value = ToVector2(value);
break;
case SerializedPropertyType.Vector3:
prop.vector3Value = ToVector3(value);
break;
case SerializedPropertyType.Vector4:
prop.vector4Value = ToVector4(value);
break;
case SerializedPropertyType.Quaternion:
var v4 = ToVector4(value);
prop.quaternionValue = new Quaternion(v4.x, v4.y, v4.z, v4.w);
break;
case SerializedPropertyType.Color:
prop.colorValue = ToColor(value);
break;
case SerializedPropertyType.Rect:
prop.rectValue = ToRect(value);
break;
case SerializedPropertyType.Bounds:
prop.boundsValue = ToBounds(value);
break;
case SerializedPropertyType.ObjectReference:
prop.objectReferenceValue = ResolveObjectReference(value);
break;
case SerializedPropertyType.ArraySize:
// Resizing an array via "<field>.Array.size" — the property is an int under the hood.
prop.intValue = value.ToObject<int>();
break;
default:
throw new ArgumentException(
$"Field '{prop.propertyPath}' has unsupported type '{prop.propertyType}'. " +
"Supported: int, float, bool, string, enum, Vector2/3/4, Quaternion, Color, Rect, Bounds, object reference, array size.");
}
}
/// <summary>
/// Read a serialized property into a plain object suitable for JSON serialization. Object
/// references are described via <see cref="ObjectResolver.Describe"/> so the agent gets a
/// re-usable handle; arrays are returned as a length plus per-element values.
/// </summary>
public static object GetValue(SerializedProperty prop)
{
// Array/list fields are returned as a JSON array of their elements so they round-trip with
// SetValue. (String also reports isArray, hence the exclusion.)
if (prop.isArray && prop.propertyType != SerializedPropertyType.String)
{
var elements = new JArray();
for (int i = 0; i < prop.arraySize; i++)
{
var v = GetValue(prop.GetArrayElementAtIndex(i));
elements.Add(v == null ? JValue.CreateNull() : JToken.FromObject(v));
}
return elements;
}
switch (prop.propertyType)
{
case SerializedPropertyType.Integer:
return prop.longValue;
case SerializedPropertyType.Boolean:
return prop.boolValue;
case SerializedPropertyType.Float:
return prop.doubleValue;
case SerializedPropertyType.String:
return prop.stringValue;
case SerializedPropertyType.Enum:
return prop.enumValueIndex >= 0 && prop.enumValueIndex < prop.enumNames.Length
? prop.enumNames[prop.enumValueIndex]
: (object)prop.intValue;
case SerializedPropertyType.Vector2:
return new { x = prop.vector2Value.x, y = prop.vector2Value.y };
case SerializedPropertyType.Vector3:
return new { x = prop.vector3Value.x, y = prop.vector3Value.y, z = prop.vector3Value.z };
case SerializedPropertyType.Vector4:
return new { x = prop.vector4Value.x, y = prop.vector4Value.y, z = prop.vector4Value.z, w = prop.vector4Value.w };
case SerializedPropertyType.Quaternion:
return new { x = prop.quaternionValue.x, y = prop.quaternionValue.y, z = prop.quaternionValue.z, w = prop.quaternionValue.w };
case SerializedPropertyType.Color:
return new { r = prop.colorValue.r, g = prop.colorValue.g, b = prop.colorValue.b, a = prop.colorValue.a };
case SerializedPropertyType.Rect:
return new { x = prop.rectValue.x, y = prop.rectValue.y, width = prop.rectValue.width, height = prop.rectValue.height };
case SerializedPropertyType.Bounds:
var b = prop.boundsValue;
return new { center = new { x = b.center.x, y = b.center.y, z = b.center.z }, size = new { x = b.size.x, y = b.size.y, z = b.size.z } };
case SerializedPropertyType.ObjectReference:
return ObjectResolver.Describe(prop.objectReferenceValue);
case SerializedPropertyType.ArraySize:
return prop.intValue;
default:
// Unknown/unsupported types are reported by kind rather than failing a whole read.
return new { unsupported = prop.propertyType.ToString() };
}
}
private static void SetEnum(SerializedProperty prop, JToken value)
{
// Accept either the enum name (preferred, stable across reordering) or the numeric index.
if (value.Type == JTokenType.String)
{
var name = value.ToObject<string>();
var idx = Array.IndexOf(prop.enumNames, name);
if (idx < 0)
idx = Array.IndexOf(prop.enumDisplayNames, name);
if (idx < 0)
throw new ArgumentException(
$"Enum '{name}' is not a valid value for '{prop.propertyPath}'. Valid: {string.Join(", ", prop.enumNames)}.");
prop.enumValueIndex = idx;
}
else
{
var idx = value.ToObject<int>();
if (idx < 0 || idx >= prop.enumNames.Length)
throw new ArgumentException(
$"Enum index {idx} is out of range for '{prop.propertyPath}' (valid 0..{prop.enumNames.Length - 1}). " +
$"Valid values: {string.Join(", ", prop.enumNames)}.");
prop.enumValueIndex = idx;
}
}
private static Object ResolveObjectReference(JToken value)
{
// An explicit null clears the reference; anything else MUST resolve — never silently no-op
// (an unresolved handle used to be dropped, so the command reported success while assigning
// nothing).
if (value == null || value.Type == JTokenType.Null)
return null;
var handle = value.ToObject<ObjectRef>();
if (handle == null || handle.IsEmpty)
throw new ArgumentException(
$"'{value}' is not a recognized object reference handle. " +
"Provide a path, guid, globalId, or instanceId (or null to clear).");
if (!ObjectResolver.TryResolve(handle, out var obj, out var error))
throw new ArgumentException($"Could not resolve object reference: {error}");
return obj;
}
private static float F(JToken t, string key, float fallback = 0f)
{
// Indexing a non-object token (e.g. the agent passed a number or array for a Vector3/Color)
// with a string key throws an opaque InvalidOperationException — validate first so the
// caller gets a clear, actionable ArgumentException instead.
if (!(t is JObject obj))
throw new ArgumentException(
$"Expected a JSON object with named components (e.g. {{ \"{key}\": 0 }}) " +
$"but received a '{t?.Type.ToString() ?? "null"}'.");
var token = obj[key];
return token != null ? token.ToObject<float>() : fallback;
}
private static Vector2 ToVector2(JToken t) => new Vector2(F(t, "x"), F(t, "y"));
private static Vector3 ToVector3(JToken t) => new Vector3(F(t, "x"), F(t, "y"), F(t, "z"));
private static Vector4 ToVector4(JToken t) => new Vector4(F(t, "x"), F(t, "y"), F(t, "z"), F(t, "w"));
private static Color ToColor(JToken t) => new Color(F(t, "r"), F(t, "g"), F(t, "b"), F(t, "a", 1f));
private static Rect ToRect(JToken t) => new Rect(F(t, "x"), F(t, "y"), F(t, "width"), F(t, "height"));
private static Bounds ToBounds(JToken t)
{
if (!(t is JObject obj))
throw new ArgumentException(
$"Expected a JSON object with 'center' and 'size' components but received a " +
$"'{t?.Type.ToString() ?? "null"}'.");
var center = obj["center"] != null ? ToVector3(obj["center"]) : Vector3.zero;
var size = obj["size"] != null ? ToVector3(obj["size"]) : Vector3.zero;
return new Bounds(center, size);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ce4000d573a64d76a63e42b2b40c60a5