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,8 @@
fileFormatVersion: 2
guid: 881bcacc155b44229761a2e2b2917fae
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,423 @@
using System;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
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;
namespace Unity.Pipeline.Editor.Commands.Animation
{
/// <summary>
/// Group A of CLI-214: author <see cref="AnimationClip"/> assets and their float curves.
///
/// These sit on the CLI-190 authoring foundation, so:
/// - every agent-supplied asset path is funnelled through <see cref="ProjectPaths.Resolve"/>
/// (sandboxed to the authoring root),
/// - an existing clip is addressed with an <see cref="ObjectRef"/> resolved by
/// <see cref="ObjectResolver"/> and re-confined to the authoring root,
/// - destructive/overwriting operations require an explicit <c>confirm</c> argument and every
/// command supports <c>dry_run</c>.
///
/// NOTE: AnimationClip sub-object edits (curves, clip settings) are NOT covered by Unity's Undo,
/// so changes are persisted with <see cref="EditorUtility.SetDirty"/> + <see cref="AssetDatabase.SaveAssets"/>
/// rather than wrapped in an <see cref="AuthoringUndoScope"/>. Only float curves are supported in
/// v1 (object-reference / PPtr curves are out of scope).
/// </summary>
public static class AnimationClipCommands
{
[CliCommand("create_animation_clip",
"Create an empty .anim AnimationClip asset under the authoring root, with an optional frame rate and loop flag.",
MainThreadRequired = true)]
public static AuthoringResult CreateAnimationClip(
[CliArg("path", "Asset path ending in .anim, relative to the authoring root. The Assets/ prefix is optional.", Required = true)] string path,
[CliArg("frameRate", "Sampling frame rate of the clip (default 60).")] float frameRate = 60f,
[CliArg("loop", "If true, set the clip's loop-time flag in its AnimationClipSettings (default false).")] bool loop = false,
[CliArg("confirm", "Required (true) only when overwriting an existing asset at the path.")] bool confirm = false,
[CliArg("dry_run", "If true, validate inputs and report what would be created without writing anything.")] bool dryRun = false)
{
var normalized = ProjectPaths.Resolve(path, out var error);
if (normalized == null)
throw new ArgumentException(error);
if (!string.Equals(Path.GetExtension(normalized), ".anim", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException($"Animation clip path '{normalized}' must end in .anim.");
if (frameRate <= 0f)
throw new ArgumentException($"frameRate must be positive (got {frameRate}).");
var exists = AssetDatabase.LoadMainAssetAtPath(normalized) != null;
if (exists && !confirm)
throw new ArgumentException($"An asset already exists at '{normalized}'. Pass confirm=true to overwrite it.");
if (dryRun)
return new AuthoringResult { AssetPath = normalized, Type = nameof(AnimationClip) };
EnsureParentFolder(normalized);
var clip = new AnimationClip { frameRate = frameRate };
var settings = AnimationUtility.GetAnimationClipSettings(clip);
settings.loopTime = loop;
AnimationUtility.SetAnimationClipSettings(clip, settings);
// CreateAsset does not reliably overwrite, so remove an existing asset first (the confirm
// guard above gates that one already exists).
if (exists)
AssetDatabase.DeleteAsset(normalized);
AssetDatabase.CreateAsset(clip, normalized);
EditorUtility.SetDirty(clip);
AssetDatabase.SaveAssets();
AssetDatabase.ImportAsset(normalized);
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = nameof(AnimationClip) };
result.AssetPath = normalized;
return result;
}
[CliCommand("set_animation_curve",
"Add or replace a single float curve binding on an AnimationClip (via AnimationUtility.SetEditorCurve). " +
"Replacing an existing binding overwrites it rather than duplicating.",
MainThreadRequired = true)]
public static SetAnimationCurveResult SetAnimationCurve(
[CliArg("clip", "Reference to the AnimationClip to edit (path / guid / globalId).", Required = true)] ObjectRef clip,
[CliArg("path", "GameObject path relative to the animated root the property lives on. Empty string (default) targets the root.")] string path = "",
[CliArg("type", "Component type the property lives on, e.g. \"Transform\", \"UnityEngine.Light\". Resolved via the component TypeResolver.", Required = true)] string type = null,
[CliArg("property", "Curve property name, e.g. \"m_LocalPosition.x\", \"m_LocalScale.y\", \"localEulerAnglesRaw.z\".", Required = true)] string property = null,
[CliArg("keys", "Keyframes: [{ time, value, inTangent?, outTangent?, weightedMode?: \"None\"|\"In\"|\"Out\"|\"Both\" }]. Omitted tangents default to 0 (flat); this is NOT Unity's Auto tangent mode.", Required = true)] JArray keys = null,
[CliArg("dry_run", "If true, validate type/property/keys without writing the curve.")] bool dryRun = false)
{
var (clipAsset, clipPath) = ResolveClip(clip);
var componentType = ResolveCurveType(type);
if (string.IsNullOrWhiteSpace(property))
throw new ArgumentException("property is required.");
var bindingPath = path ?? string.Empty;
var curve = BuildCurve(keys, out var keyCount);
var result = new SetAnimationCurveResult
{
AssetPath = clipPath,
Type = nameof(AnimationClip),
Binding = new CurveBinding { Path = bindingPath, Type = componentType.Name, Property = property },
KeyCount = keyCount
};
if (dryRun)
return result;
var binding = EditorCurveBinding.FloatCurve(bindingPath, componentType, property);
AnimationUtility.SetEditorCurve(clipAsset, binding, curve);
EditorUtility.SetDirty(clipAsset);
AssetDatabase.SaveAssets();
var described = ObjectResolver.Describe(clipAsset);
if (described != null)
{
result.Guid = described.Guid;
result.FileId = described.FileId;
result.GlobalId = described.GlobalId;
}
return result;
}
[CliCommand("get_animation_clip",
"Read an AnimationClip's metadata and all float curve bindings (optionally with keyframes).",
MainThreadRequired = true)]
public static AnimationClipInfo GetAnimationClip(
[CliArg("clip", "Reference to the AnimationClip to read (path / guid / globalId).", Required = true)] ObjectRef clip,
[CliArg("includeKeys", "If true, include each binding's keyframes (default false).")] bool includeKeys = false)
{
var (clipAsset, clipPath) = ResolveClip(clip);
var settings = AnimationUtility.GetAnimationClipSettings(clipAsset);
var info = new AnimationClipInfo
{
AssetPath = clipPath,
FrameRate = clipAsset.frameRate,
Length = clipAsset.length,
Loop = settings.loopTime
};
foreach (var binding in AnimationUtility.GetCurveBindings(clipAsset))
{
var curve = AnimationUtility.GetEditorCurve(clipAsset, binding);
var keyCount = curve?.length ?? 0;
var entry = new CurveBindingInfo
{
Path = binding.path,
Type = binding.type != null ? binding.type.Name : null,
Property = binding.propertyName,
KeyCount = keyCount
};
if (includeKeys && curve != null)
{
entry.Keys = new List<KeyframeInfo>(keyCount);
foreach (var key in curve.keys)
{
entry.Keys.Add(new KeyframeInfo
{
Time = key.time,
Value = key.value,
InTangent = key.inTangent,
OutTangent = key.outTangent
});
}
}
info.Bindings.Add(entry);
}
return info;
}
[CliCommand("remove_animation_curve",
"Remove a float curve binding from an AnimationClip (SetEditorCurve(clip, binding, null)). Destructive: requires confirm=true.",
MainThreadRequired = true)]
public static SetAnimationCurveResult RemoveAnimationCurve(
[CliArg("clip", "Reference to the AnimationClip to edit (path / guid / globalId).", Required = true)] ObjectRef clip,
[CliArg("path", "GameObject path relative to the animated root the binding lives on. Empty string (default) targets the root.")] string path = "",
[CliArg("type", "Component type of the binding to remove, e.g. \"Transform\". Resolved via the component TypeResolver.", Required = true)] string type = null,
[CliArg("property", "Curve property name to remove, e.g. \"m_LocalPosition.x\".", Required = true)] string property = null,
[CliArg("confirm", "Must be true to actually remove the binding (destructive guard).")] bool confirm = false,
[CliArg("dry_run", "If true, report the binding that would be removed without removing it.")] bool dryRun = false)
{
var (clipAsset, clipPath) = ResolveClip(clip);
var componentType = ResolveCurveType(type);
if (string.IsNullOrWhiteSpace(property))
throw new ArgumentException("property is required.");
var bindingPath = path ?? string.Empty;
var binding = EditorCurveBinding.FloatCurve(bindingPath, componentType, property);
var existing = AnimationUtility.GetEditorCurve(clipAsset, binding);
if (existing == null)
throw new ArgumentException(
$"No curve binding for path='{bindingPath}', type='{componentType.Name}', property='{property}' on '{clipPath}'.");
var keyCount = existing.length;
var result = new SetAnimationCurveResult
{
AssetPath = clipPath,
Type = nameof(AnimationClip),
Binding = new CurveBinding { Path = bindingPath, Type = componentType.Name, Property = property },
KeyCount = keyCount
};
if (dryRun)
return result;
if (!confirm)
throw new ArgumentException(
$"Refusing to remove curve '{property}' from '{clipPath}'. Pass confirm=true (destructive, not undoable via Unity's Undo).");
AnimationUtility.SetEditorCurve(clipAsset, binding, null);
EditorUtility.SetDirty(clipAsset);
AssetDatabase.SaveAssets();
return result;
}
/// <summary>
/// Resolve a clip handle to a loaded <see cref="AnimationClip"/> and its sandbox-confined asset
/// path, rejecting handles that don't point at an on-disk AnimationClip or that escape the root.
/// </summary>
private static (AnimationClip clip, string path) ResolveClip(ObjectRef clip)
{
if (clip == null || clip.IsEmpty)
throw new ArgumentException("clip is required.");
if (!ObjectResolver.TryResolve(clip, out var obj, out var error))
throw new ArgumentException(error);
if (!(obj is AnimationClip clipAsset))
throw new ArgumentException($"Reference '{clip}' resolved to a {obj.GetType().Name}, not an AnimationClip.");
var assetPath = AssetDatabase.GetAssetPath(clipAsset);
if (string.IsNullOrEmpty(assetPath))
throw new ArgumentException($"Reference '{clip}' does not point at an on-disk AnimationClip.");
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
if (confined == null)
throw new ArgumentException(
$"Clip '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
return (clipAsset, confined);
}
/// <summary>
/// Resolve a curve-binding component type. Reuses the component <see cref="TypeResolver"/>,
/// which only accepts <see cref="Component"/> subclasses (curves bind to component properties).
/// </summary>
private static Type ResolveCurveType(string type)
{
if (string.IsNullOrWhiteSpace(type))
throw new ArgumentException("type is required.");
var resolved = TypeResolver.ResolveComponentType(type);
if (resolved == null)
throw new ArgumentException(
$"Could not resolve component type '{type}'. Use a short name (e.g. Transform) or a fully-qualified name (e.g. UnityEngine.Light).");
return resolved;
}
/// <summary>Build an <see cref="AnimationCurve"/> from the agent-supplied keyframe array.</summary>
private static AnimationCurve BuildCurve(JArray keys, out int keyCount)
{
if (keys == null || keys.Count == 0)
throw new ArgumentException("keys must be a non-empty array of { time, value, ... }.");
var keyframes = new List<Keyframe>(keys.Count);
foreach (var token in keys)
{
if (!(token is JObject keyObj))
throw new ArgumentException("Each key must be an object: { time, value, inTangent?, outTangent?, weightedMode? }.");
if (keyObj["time"] == null || keyObj["value"] == null)
throw new ArgumentException("Each key requires a 'time' and a 'value'.");
var time = keyObj["time"].ToObject<float>();
var value = keyObj["value"].ToObject<float>();
var inTangent = keyObj["inTangent"]?.ToObject<float>() ?? 0f;
var outTangent = keyObj["outTangent"]?.ToObject<float>() ?? 0f;
var keyframe = new Keyframe(time, value, inTangent, outTangent);
var weightedToken = keyObj["weightedMode"];
if (weightedToken != null && weightedToken.Type != JTokenType.Null)
{
var name = weightedToken.ToObject<string>();
if (!Enum.TryParse<WeightedMode>(name, ignoreCase: true, out var weightedMode))
throw new ArgumentException($"Unknown weightedMode '{name}'. Use None | In | Out | Both.");
keyframe.weightedMode = weightedMode;
}
keyframes.Add(keyframe);
}
keyCount = keyframes.Count;
return new AnimationCurve(keyframes.ToArray());
}
/// <summary>Create the asset's parent folder chain if it does not yet exist.</summary>
private static void EnsureParentFolder(string assetPath)
{
var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/');
if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent))
return;
CreateFolderRecursive(parent);
}
private static void CreateFolderRecursive(string assetsPath)
{
if (AssetDatabase.IsValidFolder(assetsPath))
return;
var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/');
var name = Path.GetFileName(assetsPath);
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
throw new ArgumentException($"Invalid folder path '{assetsPath}'.");
if (!AssetDatabase.IsValidFolder(parent))
CreateFolderRecursive(parent);
AssetDatabase.CreateFolder(parent, name);
}
}
/// <summary>
/// Result of <c>set_animation_curve</c> / <c>remove_animation_curve</c>: the clip identity extended
/// with the affected binding and its key count.
/// </summary>
[Serializable]
public class SetAnimationCurveResult : AuthoringResult
{
[JsonProperty("binding")]
public CurveBinding Binding { get; set; }
[JsonProperty("keyCount")]
public int KeyCount { get; set; }
}
/// <summary>A curve binding's address: GameObject path, component type, and property name.</summary>
[Serializable]
public class CurveBinding
{
[JsonProperty("path")]
public string Path { get; set; }
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("property")]
public string Property { get; set; }
}
/// <summary>Result of <c>get_animation_clip</c>: clip metadata and its curve bindings.</summary>
[Serializable]
public class AnimationClipInfo
{
[JsonProperty("assetPath")]
public string AssetPath { get; set; }
[JsonProperty("frameRate")]
public float FrameRate { get; set; }
[JsonProperty("length")]
public float Length { get; set; }
[JsonProperty("loop")]
public bool Loop { get; set; }
[JsonProperty("bindings")]
public List<CurveBindingInfo> Bindings { get; set; } = new List<CurveBindingInfo>();
}
/// <summary>A single curve binding in <see cref="AnimationClipInfo"/>, optionally with its keys.</summary>
[Serializable]
public class CurveBindingInfo
{
[JsonProperty("path")]
public string Path { get; set; }
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("property")]
public string Property { get; set; }
[JsonProperty("keyCount")]
public int KeyCount { get; set; }
[JsonProperty("keys", NullValueHandling = NullValueHandling.Ignore)]
public List<KeyframeInfo> Keys { get; set; }
}
/// <summary>A single keyframe in a curve binding.</summary>
[Serializable]
public class KeyframeInfo
{
[JsonProperty("time")]
public float Time { get; set; }
[JsonProperty("value")]
public float Value { get; set; }
[JsonProperty("inTangent")]
public float InTangent { get; set; }
[JsonProperty("outTangent")]
public float OutTangent { get; set; }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 764503eaf79a41668748014af111eaf2
@@ -0,0 +1,885 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
namespace Unity.Pipeline.Editor.Commands.Animation
{
/// <summary>
/// Group B of CLI-214: author <see cref="AnimatorController"/> assets — layers, parameters, states
/// and transitions. Built on <c>UnityEditor.Animations</c>, which is part of the Editor (no extra
/// package), so the types are referenced directly.
///
/// Like the animation-clip commands, paths/handles are sandbox-confined via
/// <see cref="ProjectPaths"/> / <see cref="ObjectResolver"/>, and every command supports
/// <c>dry_run</c>. Some AnimatorController API calls register Undo internally, but sub-object edits
/// are not reliably undoable, so changes are persisted with <see cref="EditorUtility.SetDirty"/> +
/// <see cref="AssetDatabase.SaveAssets"/>.
///
/// Out of scope (v1): BlendTree authoring (assigning an EXISTING BlendTree as a state motion is
/// allowed), StateMachineBehaviours, sub-state machines, and humanoid/avatar configuration.
/// </summary>
public static class AnimatorControllerCommands
{
[CliCommand("create_animator_controller",
"Create an .controller AnimatorController asset (with a default Base Layer) under the authoring root.",
MainThreadRequired = true)]
public static AuthoringResult CreateAnimatorController(
[CliArg("path", "Asset path ending in .controller, relative to the authoring root. The Assets/ prefix is optional.", Required = true)] string path,
[CliArg("confirm", "Required (true) only when overwriting an existing asset at the path.")] bool confirm = false,
[CliArg("dry_run", "If true, validate inputs and report what would be created without writing anything.")] bool dryRun = false)
{
var normalized = ProjectPaths.Resolve(path, out var error);
if (normalized == null)
throw new ArgumentException(error);
if (!string.Equals(Path.GetExtension(normalized), ".controller", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException($"Animator controller path '{normalized}' must end in .controller.");
var exists = AssetDatabase.LoadMainAssetAtPath(normalized) != null;
if (exists && !confirm)
throw new ArgumentException($"An asset already exists at '{normalized}'. Pass confirm=true to overwrite it.");
if (dryRun)
return new AuthoringResult { AssetPath = normalized, Type = nameof(AnimatorController) };
EnsureParentFolder(normalized);
if (exists)
AssetDatabase.DeleteAsset(normalized);
// CreateAnimatorControllerAtPath creates the asset on disk WITH a default "Base Layer".
var controller = AnimatorController.CreateAnimatorControllerAtPath(normalized);
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = nameof(AnimatorController) };
result.AssetPath = normalized;
return result;
}
[CliCommand("add_animator_parameter",
"Add a parameter (Float | Int | Bool | Trigger) to an AnimatorController. A duplicate name returns code 'duplicate_parameter'.",
MainThreadRequired = true)]
public static object AddAnimatorParameter(
[CliArg("controller", "Reference to the AnimatorController to edit (path / guid / globalId).", Required = true)] ObjectRef controller,
[CliArg("name", "Parameter name.", Required = true)] string name,
[CliArg("type", "Parameter type: Float | Int | Bool | Trigger.", Required = true)] string type = null,
[CliArg("defaultValue", "Default value for Float/Int/Bool (ignored for Trigger).")] JToken defaultValue = null,
[CliArg("dry_run", "If true, validate inputs without writing the parameter.")] bool dryRun = false)
{
var (ctrl, ctrlPath) = ResolveController(controller);
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("name is required.");
var paramType = ParseParameterType(type);
if (ctrl.parameters.Any(p => p.name == name))
return ErrorResult.PackageStyle("duplicate_parameter", $"A parameter named '{name}' already exists on '{ctrlPath}'.");
// Resolve (and validate) the default value up front so dry_run also fails on a bad value.
float defFloat = 0f;
int defInt = 0;
bool defBool = false;
switch (paramType)
{
case AnimatorControllerParameterType.Float:
if (defaultValue != null && defaultValue.Type != JTokenType.Null) defFloat = defaultValue.ToObject<float>();
break;
case AnimatorControllerParameterType.Int:
if (defaultValue != null && defaultValue.Type != JTokenType.Null) defInt = defaultValue.ToObject<int>();
break;
case AnimatorControllerParameterType.Bool:
if (defaultValue != null && defaultValue.Type != JTokenType.Null) defBool = defaultValue.ToObject<bool>();
break;
}
var result = new AddParameterResult
{
AssetPath = ctrlPath,
Type = nameof(AnimatorController),
Parameter = new ParameterInfo
{
Name = name,
ParamType = paramType.ToString(),
DefaultValue = DefaultValueToken(paramType, defFloat, defInt, defBool)
}
};
if (dryRun)
return result;
var parameter = new AnimatorControllerParameter
{
name = name,
type = paramType,
defaultFloat = defFloat,
defaultInt = defInt,
defaultBool = defBool
};
ctrl.AddParameter(parameter);
Persist(ctrl);
FillIdentity(result, ctrl);
return result;
}
[CliCommand("add_animator_layer",
"Add a layer to an AnimatorController.",
MainThreadRequired = true)]
public static object AddAnimatorLayer(
[CliArg("controller", "Reference to the AnimatorController to edit (path / guid / globalId).", Required = true)] ObjectRef controller,
[CliArg("name", "Layer name.", Required = true)] string name,
[CliArg("weight", "Layer weight (default 1).")] float weight = 1f,
[CliArg("blendingMode", "Blending mode: Override | Additive (default Override).")] string blendingMode = "Override",
[CliArg("dry_run", "If true, validate inputs without writing the layer.")] bool dryRun = false)
{
var (ctrl, ctrlPath) = ResolveController(controller);
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("name is required.");
if (!Enum.TryParse<AnimatorLayerBlendingMode>(blendingMode, ignoreCase: true, out var mode))
throw new ArgumentException($"Unknown blendingMode '{blendingMode}'. Use Override | Additive.");
var index = ctrl.layers.Length;
var result = new AddLayerResult
{
AssetPath = ctrlPath,
Type = nameof(AnimatorController),
Layer = new LayerSummary { Name = name, Index = index, Weight = weight }
};
if (dryRun)
return result;
// AddLayer(name) appends a layer with a fresh state machine. We then set its weight/blending
// by writing the layer array back (AnimatorControllerLayer is a value-like wrapper, so the
// edited copy must be reassigned).
ctrl.AddLayer(name);
var layers = ctrl.layers;
layers[index].defaultWeight = weight;
layers[index].blendingMode = mode;
ctrl.layers = layers;
Persist(ctrl);
FillIdentity(result, ctrl);
return result;
}
[CliCommand("add_animator_state",
"Add a state to a layer, optionally with a motion (AnimationClip or BlendTree) and as the layer default. " +
"A layer name with no match returns code 'layer_not_found'.",
MainThreadRequired = true)]
public static object AddAnimatorState(
[CliArg("controller", "Reference to the AnimatorController to edit (path / guid / globalId).", Required = true)] ObjectRef controller,
[CliArg("layer", "Layer index (int) or name (string). Default 0 (Base Layer).")] JToken layer = null,
[CliArg("name", "State name.", Required = true)] string name = null,
[CliArg("motion", "Optional AnimationClip or BlendTree asset to assign as the state's motion.")] ObjectRef motion = null,
[CliArg("isDefault", "If true, set this state as the layer's default state.")] bool isDefault = false,
[CliArg("position", "Optional [x, y] node position in the graph (cosmetic).")] JArray position = null,
[CliArg("dry_run", "If true, validate inputs without writing the state.")] bool dryRun = false)
{
var (ctrl, ctrlPath) = ResolveController(controller);
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("name is required.");
if (!TryResolveLayer(ctrl, layer, out var layerIndex, out var layerError))
return ErrorResult.PackageStyle("layer_not_found", layerError);
// Resolve an optional motion. Only AnimationClip / Motion (BlendTree derives from Motion) are
// valid. A handle that doesn't resolve to a Motion is a clear error.
UnityEngine.Motion resolvedMotion = null;
if (motion != null && !motion.IsEmpty)
{
if (!ObjectResolver.TryResolve(motion, out var motionObj, out var motionError))
throw new ArgumentException($"Could not resolve motion: {motionError}");
resolvedMotion = motionObj as UnityEngine.Motion;
if (resolvedMotion == null)
throw new ArgumentException($"Motion reference '{motion}' resolved to a {motionObj.GetType().Name}, not an AnimationClip or BlendTree.");
// Re-confine the motion to the authoring root: a restricted root must not be bypassed by
// referencing an AnimationClip/BlendTree that lives outside the sandbox.
ConfineToAuthoringRoot(resolvedMotion, "Motion", motion);
}
Vector3? nodePosition = null;
if (position != null && position.Count > 0)
{
if (position.Count < 2)
throw new ArgumentException("position must be [x, y].");
nodePosition = new Vector3(position[0].ToObject<float>(), position[1].ToObject<float>(), 0f);
}
var result = new AddStateResult
{
AssetPath = ctrlPath,
Type = nameof(AnimatorController),
State = new StateSummary
{
Name = name,
Layer = layerIndex,
HasMotion = resolvedMotion != null,
IsDefault = isDefault
}
};
if (dryRun)
return result;
var stateMachine = ctrl.layers[layerIndex].stateMachine;
var state = nodePosition.HasValue
? stateMachine.AddState(name, nodePosition.Value)
: stateMachine.AddState(name);
if (resolvedMotion != null)
state.motion = resolvedMotion;
if (isDefault)
stateMachine.defaultState = state;
Persist(ctrl);
FillIdentity(result, ctrl);
return result;
}
[CliCommand("add_animator_transition",
"Add a transition between two states (or from AnyState/Entry, to Exit) on a layer, with optional conditions. " +
"Validates that the states exist and each condition's parameter exists and its mode matches the parameter type.",
MainThreadRequired = true)]
public static object AddAnimatorTransition(
[CliArg("controller", "Reference to the AnimatorController to edit (path / guid / globalId).", Required = true)] ObjectRef controller,
[CliArg("layer", "Layer index (int) or name (string). Default 0 (Base Layer).")] JToken layer = null,
[CliArg("fromState", "Source state name, or the special \"AnyState\" / \"Entry\".", Required = true)] string fromState = null,
[CliArg("toState", "Destination state name, or the special \"Exit\".", Required = true)] string toState = null,
[CliArg("conditions", "Optional conditions: [{ parameter, mode: \"If\"|\"IfNot\"|\"Greater\"|\"Less\"|\"Equals\"|\"NotEqual\", threshold? }].")] JArray conditions = null,
[CliArg("hasExitTime", "If true, the transition uses exit time (default false).")] bool hasExitTime = false,
[CliArg("exitTime", "Normalized exit time (0..1) when hasExitTime is set.")] float exitTime = 0f,
[CliArg("duration", "Transition duration in seconds (default 0.25).")] float duration = 0.25f,
[CliArg("hasFixedDuration", "If true, duration is in seconds; otherwise normalized (default true).")] bool hasFixedDuration = true,
[CliArg("dry_run", "If true, validate everything (states, parameters, mode/type) without writing the transition.")] bool dryRun = false)
{
var (ctrl, ctrlPath) = ResolveController(controller);
if (string.IsNullOrWhiteSpace(fromState))
throw new ArgumentException("fromState is required.");
if (string.IsNullOrWhiteSpace(toState))
throw new ArgumentException("toState is required.");
// exitTime is normalized (0..1) and only meaningful with hasExitTime; duration must be
// non-negative. Reject out-of-range values up front (writing nothing) rather than letting
// Unity clamp them unpredictably.
if (hasExitTime && (exitTime < 0f || exitTime > 1f))
throw new ArgumentException($"exitTime must be in [0, 1] when hasExitTime is set (got {exitTime}).");
if (duration < 0f)
throw new ArgumentException($"duration must be >= 0 (got {duration}).");
if (!TryResolveLayer(ctrl, layer, out var layerIndex, out var layerError))
return ErrorResult.PackageStyle("layer_not_found", layerError);
var stateMachine = ctrl.layers[layerIndex].stateMachine;
const string AnyState = "AnyState";
const string Entry = "Entry";
const string Exit = "Exit";
var fromIsAny = string.Equals(fromState, AnyState, StringComparison.OrdinalIgnoreCase);
var fromIsEntry = string.Equals(fromState, Entry, StringComparison.OrdinalIgnoreCase);
var toIsExit = string.Equals(toState, Exit, StringComparison.OrdinalIgnoreCase);
// Resolve the source state (unless it's AnyState/Entry, which are nodes on the machine).
AnimatorState fromAnimState = null;
if (!fromIsAny && !fromIsEntry)
{
fromAnimState = FindState(stateMachine, fromState);
if (fromAnimState == null)
throw new ArgumentException($"Source state '{fromState}' not found in layer {layerIndex}.");
}
// Resolve the destination state (unless it's Exit).
AnimatorState toAnimState = null;
if (!toIsExit)
{
toAnimState = FindState(stateMachine, toState);
if (toAnimState == null)
throw new ArgumentException($"Destination state '{toState}' not found in layer {layerIndex}.");
}
// Entry transitions cannot carry conditions/exit-time the same way; restrict combinations the
// engine forbids so an agent gets a clear error rather than a malformed transition.
if (fromIsEntry && toIsExit)
throw new ArgumentException("A transition from Entry directly to Exit is not supported.");
// Validate conditions BEFORE creating anything (so a bad condition writes nothing).
var parsedConditions = ParseConditions(conditions, ctrl);
var result = new AddTransitionResult
{
AssetPath = ctrlPath,
Type = nameof(AnimatorController),
Transition = new TransitionSummary
{
From = fromState,
To = toState,
ConditionCount = parsedConditions.Count
}
};
if (dryRun)
return result;
// Create the transition node matching the from/to kinds.
AnimatorStateTransition stateTransition = null;
AnimatorTransition entryTransition = null;
if (fromIsEntry)
{
// Entry -> state. Entry transitions are plain AnimatorTransition (no exit time/duration).
entryTransition = stateMachine.AddEntryTransition(toAnimState);
}
else if (fromIsAny)
{
stateTransition = toIsExit ? null : stateMachine.AddAnyStateTransition(toAnimState);
if (stateTransition == null)
throw new ArgumentException("An AnyState transition to Exit is not supported.");
}
else
{
stateTransition = toIsExit
? fromAnimState.AddExitTransition()
: fromAnimState.AddTransition(toAnimState);
}
if (stateTransition != null)
{
stateTransition.hasExitTime = hasExitTime;
stateTransition.exitTime = exitTime;
stateTransition.hasFixedDuration = hasFixedDuration;
stateTransition.duration = duration;
foreach (var c in parsedConditions)
stateTransition.AddCondition(c.Mode, c.Threshold, c.Parameter);
}
else if (entryTransition != null)
{
foreach (var c in parsedConditions)
entryTransition.AddCondition(c.Mode, c.Threshold, c.Parameter);
}
Persist(ctrl);
FillIdentity(result, ctrl);
return result;
}
[CliCommand("get_animator_controller",
"Read an AnimatorController's full structure: parameters, layers, states (with motion / default), and transitions (with conditions).",
MainThreadRequired = true)]
public static AnimatorControllerInfo GetAnimatorController(
[CliArg("controller", "Reference to the AnimatorController to read (path / guid / globalId).", Required = true)] ObjectRef controller)
{
var (ctrl, ctrlPath) = ResolveController(controller);
var info = new AnimatorControllerInfo { AssetPath = ctrlPath };
foreach (var p in ctrl.parameters)
{
info.Parameters.Add(new ParameterInfo
{
Name = p.name,
ParamType = p.type.ToString(),
DefaultValue = DefaultValueToken(p.type, p.defaultFloat, p.defaultInt, p.defaultBool)
});
}
for (int i = 0; i < ctrl.layers.Length; i++)
{
var layer = ctrl.layers[i];
var sm = layer.stateMachine;
var layerInfo = new LayerInfo
{
Name = layer.name,
Index = i,
DefaultState = sm != null && sm.defaultState != null ? sm.defaultState.name : null
};
if (sm != null)
{
foreach (var childState in sm.states)
{
var state = childState.state;
layerInfo.States.Add(new StateInfo
{
Name = state.name,
Motion = state.motion != null ? state.motion.name : null,
IsDefault = sm.defaultState == state
});
foreach (var t in state.transitions)
layerInfo.Transitions.Add(DescribeTransition(state.name, t));
}
foreach (var t in sm.anyStateTransitions)
layerInfo.Transitions.Add(DescribeTransition("AnyState", t));
}
info.Layers.Add(layerInfo);
}
return info;
}
// ---- helpers ----
private static TransitionInfo DescribeTransition(string from, AnimatorStateTransition t)
{
var to = t.isExit ? "Exit" : (t.destinationState != null ? t.destinationState.name : null);
var ti = new TransitionInfo
{
From = from,
To = to,
HasExitTime = t.hasExitTime,
Duration = t.duration
};
foreach (var c in t.conditions)
{
ti.Conditions.Add(new ConditionInfo
{
Parameter = c.parameter,
Mode = c.mode.ToString(),
Threshold = c.threshold
});
}
return ti;
}
private static AnimatorState FindState(AnimatorStateMachine sm, string name)
{
if (sm == null)
return null;
foreach (var childState in sm.states)
{
if (childState.state != null && childState.state.name == name)
return childState.state;
}
return null;
}
private struct ParsedCondition
{
public string Parameter;
public AnimatorConditionMode Mode;
public float Threshold;
}
/// <summary>
/// Parse and validate the conditions array against the controller's parameters: each parameter
/// must exist and its type must be compatible with the condition mode (If/IfNot for Bool/Trigger;
/// numeric Greater/Less/Equals/NotEqual for Float/Int). Throws (writing nothing) on any mismatch.
/// </summary>
private static List<ParsedCondition> ParseConditions(JArray conditions, AnimatorController ctrl)
{
var parsed = new List<ParsedCondition>();
if (conditions == null)
return parsed;
foreach (var token in conditions)
{
if (!(token is JObject obj))
throw new ArgumentException("Each condition must be an object: { parameter, mode, threshold? }.");
var parameterName = obj["parameter"]?.ToObject<string>();
var modeName = obj["mode"]?.ToObject<string>();
if (string.IsNullOrWhiteSpace(parameterName))
throw new ArgumentException("Each condition requires a 'parameter'.");
if (string.IsNullOrWhiteSpace(modeName))
throw new ArgumentException($"Condition for parameter '{parameterName}' requires a 'mode'.");
var param = ctrl.parameters.FirstOrDefault(p => p.name == parameterName);
if (param == null)
throw new ArgumentException($"Condition references parameter '{parameterName}', which does not exist on the controller.");
if (!Enum.TryParse<AnimatorConditionMode>(modeName, ignoreCase: true, out var mode))
throw new ArgumentException($"Unknown condition mode '{modeName}' for parameter '{parameterName}'. Use If | IfNot | Greater | Less | Equals | NotEqual.");
ValidateModeForType(parameterName, param.type, mode);
var threshold = obj["threshold"]?.ToObject<float>() ?? 0f;
parsed.Add(new ParsedCondition { Parameter = parameterName, Mode = mode, Threshold = threshold });
}
return parsed;
}
/// <summary>
/// Enforce that a condition mode matches its parameter type: Bool/Trigger only accept If/IfNot;
/// Float/Int only accept the numeric modes (Greater/Less/Equals/NotEqual). Unity's editor follows
/// the same rule; we reject mismatches up front with an actionable message.
/// </summary>
private static void ValidateModeForType(string parameterName, AnimatorControllerParameterType type, AnimatorConditionMode mode)
{
var isBoolLike = type == AnimatorControllerParameterType.Bool || type == AnimatorControllerParameterType.Trigger;
var isNumeric = type == AnimatorControllerParameterType.Float || type == AnimatorControllerParameterType.Int;
var boolMode = mode == AnimatorConditionMode.If || mode == AnimatorConditionMode.IfNot;
if (isBoolLike && !boolMode)
throw new ArgumentException(
$"Condition mode '{mode}' is not valid for {type} parameter '{parameterName}'. Bool/Trigger parameters use If or IfNot.");
if (isNumeric && boolMode)
throw new ArgumentException(
$"Condition mode '{mode}' is not valid for {type} parameter '{parameterName}'. Float/Int parameters use Greater, Less, Equals or NotEqual.");
}
private static AnimatorControllerParameterType ParseParameterType(string type)
{
if (string.IsNullOrWhiteSpace(type))
throw new ArgumentException("type is required.");
if (!Enum.TryParse<AnimatorControllerParameterType>(type, ignoreCase: true, out var parsed))
throw new ArgumentException($"Unknown parameter type '{type}'. Use Float | Int | Bool | Trigger.");
return parsed;
}
private static JToken DefaultValueToken(AnimatorControllerParameterType type, float f, int i, bool b)
{
switch (type)
{
case AnimatorControllerParameterType.Float: return new JValue(f);
case AnimatorControllerParameterType.Int: return new JValue(i);
case AnimatorControllerParameterType.Bool: return new JValue(b);
default: return JValue.CreateNull(); // Trigger has no default value
}
}
/// <summary>
/// Resolve a layer selector (int index or string name; null/absent => 0). Returns false with an
/// error when a name doesn't match any layer (the caller surfaces 'layer_not_found').
/// </summary>
private static bool TryResolveLayer(AnimatorController ctrl, JToken layer, out int index, out string error)
{
index = 0;
error = null;
if (layer == null || layer.Type == JTokenType.Null)
return true; // default Base Layer
if (layer.Type == JTokenType.Integer || layer.Type == JTokenType.Float)
{
// The layer index is an int. A float is accepted only when it is integer-valued
// (e.g. 1.0); a fractional value like 0.9 is rejected rather than silently rounded.
if (layer.Type == JTokenType.Float)
{
var asDouble = layer.ToObject<double>();
if (asDouble != Math.Floor(asDouble))
{
error = $"Layer index must be an integer; got {asDouble}.";
return false;
}
index = (int)asDouble;
}
else
{
index = layer.ToObject<int>();
}
if (index < 0 || index >= ctrl.layers.Length)
{
error = $"Layer index {index} is out of range (controller has {ctrl.layers.Length} layer(s)).";
return false;
}
return true;
}
var name = layer.ToObject<string>();
if (string.IsNullOrWhiteSpace(name))
return true;
// A numeric string is treated as an index.
if (int.TryParse(name, out var asIndex))
{
if (asIndex < 0 || asIndex >= ctrl.layers.Length)
{
error = $"Layer index {asIndex} is out of range (controller has {ctrl.layers.Length} layer(s)).";
return false;
}
index = asIndex;
return true;
}
for (int i = 0; i < ctrl.layers.Length; i++)
{
if (ctrl.layers[i].name == name)
{
index = i;
return true;
}
}
error = $"No layer named '{name}' on the controller. Add it first with add_animator_layer.";
return false;
}
/// <summary>
/// Reject an on-disk asset reference that resolves outside the authoring root. Mirrors the
/// confinement applied to the controller itself so a restricted root can't be sidestepped by
/// pointing at (e.g.) an AnimationClip/BlendTree elsewhere in the project.
/// </summary>
private static void ConfineToAuthoringRoot(UnityEngine.Object asset, string label, ObjectRef reference)
{
var assetPath = AssetDatabase.GetAssetPath(asset);
if (string.IsNullOrEmpty(assetPath))
throw new ArgumentException($"{label} reference '{reference}' does not point at an on-disk asset.");
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
if (confined == null)
throw new ArgumentException(
$"{label} '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
}
private static (AnimatorController controller, string path) ResolveController(ObjectRef controller)
{
if (controller == null || controller.IsEmpty)
throw new ArgumentException("controller is required.");
if (!ObjectResolver.TryResolve(controller, out var obj, out var error))
throw new ArgumentException(error);
if (!(obj is AnimatorController ctrl))
throw new ArgumentException($"Reference '{controller}' resolved to a {obj.GetType().Name}, not an AnimatorController.");
var assetPath = AssetDatabase.GetAssetPath(ctrl);
if (string.IsNullOrEmpty(assetPath))
throw new ArgumentException($"Reference '{controller}' does not point at an on-disk AnimatorController.");
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
if (confined == null)
throw new ArgumentException(
$"Controller '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
return (ctrl, confined);
}
private static void Persist(AnimatorController ctrl)
{
EditorUtility.SetDirty(ctrl);
AssetDatabase.SaveAssets();
}
private static void FillIdentity(AuthoringResult result, AnimatorController ctrl)
{
var described = ObjectResolver.Describe(ctrl);
if (described == null)
return;
result.Guid = described.Guid;
result.FileId = described.FileId;
result.GlobalId = described.GlobalId;
}
private static void EnsureParentFolder(string assetPath)
{
var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/');
if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent))
return;
CreateFolderRecursive(parent);
}
private static void CreateFolderRecursive(string assetsPath)
{
if (AssetDatabase.IsValidFolder(assetsPath))
return;
var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/');
var name = Path.GetFileName(assetsPath);
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
throw new ArgumentException($"Invalid folder path '{assetsPath}'.");
if (!AssetDatabase.IsValidFolder(parent))
CreateFolderRecursive(parent);
AssetDatabase.CreateFolder(parent, name);
}
}
// ---- result models ----
/// <summary>A structured, non-throwing error envelope ({ error, code }) returned at HTTP 200.</summary>
[Serializable]
public class ErrorResult
{
[JsonProperty("error")]
public string Error { get; set; }
[JsonProperty("code")]
public string Code { get; set; }
public static ErrorResult PackageStyle(string code, string error) => new ErrorResult { Code = code, Error = error };
}
[Serializable]
public class ParameterInfo
{
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("type")]
public string ParamType { get; set; }
[JsonProperty("defaultValue")]
public JToken DefaultValue { get; set; }
}
[Serializable]
public class AddParameterResult : AuthoringResult
{
[JsonProperty("parameter")]
public ParameterInfo Parameter { get; set; }
}
[Serializable]
public class LayerSummary
{
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("index")]
public int Index { get; set; }
[JsonProperty("weight")]
public float Weight { get; set; }
}
[Serializable]
public class AddLayerResult : AuthoringResult
{
[JsonProperty("layer")]
public LayerSummary Layer { get; set; }
}
[Serializable]
public class StateSummary
{
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("layer")]
public int Layer { get; set; }
[JsonProperty("hasMotion")]
public bool HasMotion { get; set; }
[JsonProperty("isDefault")]
public bool IsDefault { get; set; }
}
[Serializable]
public class AddStateResult : AuthoringResult
{
[JsonProperty("state")]
public StateSummary State { get; set; }
}
[Serializable]
public class TransitionSummary
{
[JsonProperty("from")]
public string From { get; set; }
[JsonProperty("to")]
public string To { get; set; }
[JsonProperty("conditionCount")]
public int ConditionCount { get; set; }
}
[Serializable]
public class AddTransitionResult : AuthoringResult
{
[JsonProperty("transition")]
public TransitionSummary Transition { get; set; }
}
[Serializable]
public class AnimatorControllerInfo
{
[JsonProperty("assetPath")]
public string AssetPath { get; set; }
[JsonProperty("parameters")]
public List<ParameterInfo> Parameters { get; set; } = new List<ParameterInfo>();
[JsonProperty("layers")]
public List<LayerInfo> Layers { get; set; } = new List<LayerInfo>();
}
[Serializable]
public class LayerInfo
{
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("index")]
public int Index { get; set; }
[JsonProperty("defaultState")]
public string DefaultState { get; set; }
[JsonProperty("states")]
public List<StateInfo> States { get; set; } = new List<StateInfo>();
[JsonProperty("transitions")]
public List<TransitionInfo> Transitions { get; set; } = new List<TransitionInfo>();
}
[Serializable]
public class StateInfo
{
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("motion")]
public string Motion { get; set; }
[JsonProperty("isDefault")]
public bool IsDefault { get; set; }
}
[Serializable]
public class TransitionInfo
{
[JsonProperty("from")]
public string From { get; set; }
[JsonProperty("to")]
public string To { get; set; }
[JsonProperty("conditions")]
public List<ConditionInfo> Conditions { get; set; } = new List<ConditionInfo>();
[JsonProperty("hasExitTime")]
public bool HasExitTime { get; set; }
[JsonProperty("duration")]
public float Duration { get; set; }
}
[Serializable]
public class ConditionInfo
{
[JsonProperty("parameter")]
public string Parameter { get; set; }
[JsonProperty("mode")]
public string Mode { get; set; }
[JsonProperty("threshold")]
public float Threshold { get; set; }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 426c006ffb43461e94ab3dde2caed7be
@@ -0,0 +1,637 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
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.Animation
{
/// <summary>
/// Group C of CLI-214: author <c>TimelineAsset</c>s (tracks + clips). Timeline ships in the OPTIONAL
/// <c>com.unity.timeline</c> package, so the Editor assembly does NOT reference <c>Unity.Timeline</c>
/// (that would break compilation when the package is absent). Every command:
///
/// 1. runs <see cref="TimelineGuard.IsInstalled"/>; if Timeline is absent it returns a structured
/// <c>{ error, code: "package_not_found" }</c> at HTTP 200 (no exception thrown), and
/// 2. reaches the Timeline API entirely through reflection.
///
/// Type names used (all in <c>Unity.Timeline</c>): <c>UnityEngine.Timeline.TimelineAsset</c>,
/// <c>TrackAsset</c>, <c>TimelineClip</c>, and the concrete track types
/// (<c>AnimationTrack</c>, <c>AudioTrack</c>, <c>ActivationTrack</c>, <c>ControlTrack</c>,
/// <c>PlayableTrack</c>, <c>SignalTrack</c>, <c>MarkerTrack</c>).
///
/// Out of scope (v1): markers, signal emitters/receivers, custom playable tracks, and track bindings
/// to scene objects.
/// </summary>
public static class TimelineCommands
{
private const string Asm = "Unity.Timeline";
// Friendly track-type name => concrete TrackAsset subclass full name.
private static readonly Dictionary<string, string> TrackTypeNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "Animation", "UnityEngine.Timeline.AnimationTrack" },
{ "Audio", "UnityEngine.Timeline.AudioTrack" },
{ "Activation", "UnityEngine.Timeline.ActivationTrack" },
{ "Control", "UnityEngine.Timeline.ControlTrack" },
{ "Playable", "UnityEngine.Timeline.PlayableTrack" },
{ "Signal", "UnityEngine.Timeline.SignalTrack" },
{ "Marker", "UnityEngine.Timeline.MarkerTrack" },
};
[CliCommand("create_timeline",
"Create a .playable TimelineAsset under the authoring root (optional frame rate). Requires the com.unity.timeline package.",
MainThreadRequired = true)]
public static object CreateTimeline(
[CliArg("path", "Asset path ending in .playable, relative to the authoring root. The Assets/ prefix is optional.", Required = true)] string path,
[CliArg("frameRate", "Timeline frame rate (default 60).")] float frameRate = 60f,
[CliArg("confirm", "Required (true) only when overwriting an existing asset at the path.")] bool confirm = false,
[CliArg("dry_run", "If true, validate inputs and report what would be created without writing anything.")] bool dryRun = false)
{
if (!TimelineGuard.IsInstalled())
return TimelineGuard.NotInstalledError();
var normalized = ProjectPaths.Resolve(path, out var error);
if (normalized == null)
throw new ArgumentException(error);
if (!string.Equals(Path.GetExtension(normalized), ".playable", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException($"Timeline path '{normalized}' must end in .playable.");
if (frameRate <= 0f)
throw new ArgumentException($"frameRate must be positive (got {frameRate}).");
var exists = AssetDatabase.LoadMainAssetAtPath(normalized) != null;
if (exists && !confirm)
throw new ArgumentException($"An asset already exists at '{normalized}'. Pass confirm=true to overwrite it.");
if (dryRun)
return new AuthoringResult { AssetPath = normalized, Type = "TimelineAsset" };
EnsureParentFolder(normalized);
var timelineType = TimelineGuard.ResolveTimelineAssetType();
var timeline = ScriptableObject.CreateInstance(timelineType);
SetFrameRate(timeline, timelineType, frameRate);
if (exists)
AssetDatabase.DeleteAsset(normalized);
AssetDatabase.CreateAsset(timeline, normalized);
EditorUtility.SetDirty(timeline);
AssetDatabase.SaveAssets();
AssetDatabase.ImportAsset(normalized);
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = "TimelineAsset" };
result.AssetPath = normalized;
return result;
}
[CliCommand("add_timeline_track",
"Add a track (Animation | Audio | Activation | Control | Playable | Signal | Marker) to a TimelineAsset, optionally nested under a parent group/track. Requires the com.unity.timeline package.",
MainThreadRequired = true)]
public static object AddTimelineTrack(
[CliArg("timeline", "Reference to the TimelineAsset to edit (path / guid / globalId).", Required = true)] ObjectRef timeline,
[CliArg("trackType", "Track type: Animation | Audio | Activation | Control | Playable | Signal | Marker.", Required = true)] string trackType = null,
[CliArg("name", "Optional track display name.")] string name = null,
[CliArg("parentTrack", "Optional name of an existing group/track to nest the new track under.")] string parentTrack = null,
[CliArg("dry_run", "If true, validate inputs without writing the track.")] bool dryRun = false)
{
if (!TimelineGuard.IsInstalled())
return TimelineGuard.NotInstalledError();
var (asset, assetPath, timelineType) = ResolveTimeline(timeline);
if (string.IsNullOrWhiteSpace(trackType) || !TrackTypeNames.TryGetValue(trackType, out var trackTypeFullName))
throw new ArgumentException($"Unknown trackType '{trackType}'. Use Animation | Audio | Activation | Control | Playable | Signal | Marker.");
var concreteTrackType = Type.GetType($"{trackTypeFullName}, {Asm}", throwOnError: false);
if (concreteTrackType == null)
throw new ArgumentException($"Track type '{trackTypeFullName}' is not available in this version of {TimelineGuard.PackageId}.");
var trackName = string.IsNullOrEmpty(name) ? trackType : name;
// Resolve an optional parent track by name BEFORE any write so a bad parent fails clean.
object parent = null;
if (!string.IsNullOrEmpty(parentTrack))
{
parent = FindTrackByName(asset, timelineType, parentTrack);
if (parent == null)
throw new ArgumentException($"Parent track '{parentTrack}' not found on the timeline.");
}
var result = new AddTimelineTrackResult
{
AssetPath = assetPath,
Type = "TimelineAsset",
Track = new TimelineTrackSummary { Name = trackName, TrackType = trackType }
};
if (dryRun)
return result;
// TrackAsset CreateTrack(Type type, TrackAsset parent, string name)
var createTrack = timelineType.GetMethod("CreateTrack", new[] { typeof(Type), GetTrackAssetType(), typeof(string) });
if (createTrack == null)
throw new InvalidOperationException("TimelineAsset.CreateTrack(Type, TrackAsset, string) not found via reflection.");
createTrack.Invoke(asset, new object[] { concreteTrackType, parent, trackName });
EditorUtility.SetDirty(asset);
AssetDatabase.SaveAssets();
FillIdentity(result, asset);
return result;
}
[CliCommand("add_timeline_clip",
"Add a clip to a named track on a TimelineAsset. For Animation tracks pass an AnimationClip asset; for Audio tracks an AudioClip. Requires the com.unity.timeline package.",
MainThreadRequired = true)]
public static object AddTimelineClip(
[CliArg("timeline", "Reference to the TimelineAsset to edit (path / guid / globalId).", Required = true)] ObjectRef timeline,
[CliArg("track", "Target track name.", Required = true)] string track = null,
[CliArg("start", "Clip start time in seconds.", Required = true)] float start = 0f,
[CliArg("duration", "Clip duration in seconds.", Required = true)] float duration = 0f,
[CliArg("asset", "Source asset: for Animation tracks an AnimationClip; for Audio tracks an AudioClip. Required for those track types.")] ObjectRef asset = null,
[CliArg("dry_run", "If true, validate inputs without writing the clip.")] bool dryRun = false)
{
if (!TimelineGuard.IsInstalled())
return TimelineGuard.NotInstalledError();
var (timelineAsset, assetPath, timelineType) = ResolveTimeline(timeline);
if (string.IsNullOrWhiteSpace(track))
throw new ArgumentException("track is required.");
if (duration <= 0f)
throw new ArgumentException($"duration must be positive (got {duration}).");
if (start < 0f)
throw new ArgumentException($"start must be >= 0 (got {start}).");
var trackObj = FindTrackByName(timelineAsset, timelineType, track);
if (trackObj == null)
throw new ArgumentException($"Track '{track}' not found on the timeline.");
// Resolve the optional clip-source asset BEFORE any write.
Object sourceAsset = null;
if (asset != null && !asset.IsEmpty)
{
if (!ObjectResolver.TryResolve(asset, out var srcObj, out var srcError))
throw new ArgumentException($"Could not resolve clip asset: {srcError}");
sourceAsset = srcObj;
// Re-confine the clip source to the authoring root: a restricted root must not be
// bypassed by referencing an AnimationClip/AudioClip that lives outside the sandbox.
var sourcePath = AssetDatabase.GetAssetPath(sourceAsset);
if (string.IsNullOrEmpty(sourcePath))
throw new ArgumentException($"Clip asset reference '{asset}' does not point at an on-disk asset.");
var confinedSource = ProjectPaths.Resolve(sourcePath, out var sourceConfineError);
if (confinedSource == null)
throw new ArgumentException(
$"Clip asset '{sourcePath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {sourceConfineError}");
}
var result = new AddTimelineClipResult
{
AssetPath = assetPath,
Type = "TimelineAsset",
Clip = new TimelineClipSummary { Track = track, Start = start, Duration = duration }
};
if (dryRun)
{
// Even in dry_run, validate that a clip can be created for this track/asset combination so
// a missing-required-asset case fails fast and writes nothing.
ValidateClipCreatable(trackObj, sourceAsset);
return result;
}
var clip = CreateClip(trackObj, sourceAsset);
if (clip == null)
throw new InvalidOperationException($"Failed to create a clip on track '{track}'.");
SetClipTiming(clip, start, duration);
EditorUtility.SetDirty(timelineAsset);
AssetDatabase.SaveAssets();
FillIdentity(result, timelineAsset);
return result;
}
[CliCommand("get_timeline",
"Read a TimelineAsset's structure: frame rate, duration, and its tracks with their clips. Requires the com.unity.timeline package.",
MainThreadRequired = true)]
public static object GetTimeline(
[CliArg("timeline", "Reference to the TimelineAsset to read (path / guid / globalId).", Required = true)] ObjectRef timeline)
{
if (!TimelineGuard.IsInstalled())
return TimelineGuard.NotInstalledError();
var (asset, assetPath, timelineType) = ResolveTimeline(timeline);
var info = new TimelineInfo
{
AssetPath = assetPath,
FrameRate = GetFrameRate(asset, timelineType),
Duration = GetDouble(asset, timelineType, "duration")
};
foreach (var trackObj in GetOutputTracks(asset, timelineType))
{
if (trackObj == null)
continue;
var trackType = trackObj.GetType();
var trackInfo = new TimelineTrackInfo
{
Name = GetName(trackObj),
TrackType = FriendlyTrackType(trackType)
};
foreach (var clipObj in GetClips(trackObj))
{
if (clipObj == null)
continue;
var clipType = clipObj.GetType();
var clipAsset = GetMember(clipObj, clipType, "asset") as Object;
trackInfo.Clips.Add(new TimelineClipInfo
{
Start = GetDoubleMember(clipObj, clipType, "start"),
Duration = GetDoubleMember(clipObj, clipType, "duration"),
Asset = clipAsset != null ? AssetDatabase.GetAssetPath(clipAsset) : null
});
}
info.Tracks.Add(trackInfo);
}
return info;
}
// ---- reflection helpers ----
private static Type GetTrackAssetType()
{
var t = Type.GetType($"UnityEngine.Timeline.TrackAsset, {Asm}", throwOnError: false);
if (t == null)
throw new InvalidOperationException("UnityEngine.Timeline.TrackAsset type not found.");
return t;
}
private static (Object asset, string path, Type timelineType) ResolveTimeline(ObjectRef timeline)
{
if (timeline == null || timeline.IsEmpty)
throw new ArgumentException("timeline is required.");
if (!ObjectResolver.TryResolve(timeline, out var obj, out var error))
throw new ArgumentException(error);
var timelineType = TimelineGuard.ResolveTimelineAssetType();
if (timelineType == null || !timelineType.IsInstanceOfType(obj))
throw new ArgumentException($"Reference '{timeline}' resolved to a {obj.GetType().Name}, not a TimelineAsset.");
var assetPath = AssetDatabase.GetAssetPath(obj);
if (string.IsNullOrEmpty(assetPath))
throw new ArgumentException($"Reference '{timeline}' does not point at an on-disk TimelineAsset.");
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
if (confined == null)
throw new ArgumentException(
$"Timeline '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
return (obj, confined, timelineType);
}
/// <summary>
/// Set the timeline frame rate, preferring the current <c>editorSettings.frameRate</c> (double)
/// and falling back to the obsolete <c>editorSettings.fps</c> (float) on older package versions.
/// </summary>
private static void SetFrameRate(object timeline, Type timelineType, float frameRate)
{
var editorSettings = GetMember(timeline, timelineType, "editorSettings");
if (editorSettings == null)
return;
var settingsType = editorSettings.GetType();
var frameRateProp = settingsType.GetProperty("frameRate", BindingFlags.Public | BindingFlags.Instance);
if (frameRateProp != null && frameRateProp.CanWrite)
{
frameRateProp.SetValue(editorSettings, (double)frameRate);
return;
}
var fpsProp = settingsType.GetProperty("fps", BindingFlags.Public | BindingFlags.Instance);
if (fpsProp != null && fpsProp.CanWrite)
fpsProp.SetValue(editorSettings, frameRate);
}
private static double GetFrameRate(object timeline, Type timelineType)
{
var editorSettings = GetMember(timeline, timelineType, "editorSettings");
if (editorSettings == null)
return 0d;
var settingsType = editorSettings.GetType();
var frameRateProp = settingsType.GetProperty("frameRate", BindingFlags.Public | BindingFlags.Instance);
if (frameRateProp != null && frameRateProp.CanRead)
return Convert.ToDouble(frameRateProp.GetValue(editorSettings));
var fpsProp = settingsType.GetProperty("fps", BindingFlags.Public | BindingFlags.Instance);
if (fpsProp != null && fpsProp.CanRead)
return Convert.ToDouble(fpsProp.GetValue(editorSettings));
return 0d;
}
private static IEnumerable<object> GetOutputTracks(object timeline, Type timelineType)
{
var method = timelineType.GetMethod("GetOutputTracks", Type.EmptyTypes);
if (method == null)
yield break;
var enumerable = method.Invoke(timeline, null) as IEnumerable;
if (enumerable == null)
yield break;
foreach (var item in enumerable)
yield return item;
}
private static object FindTrackByName(object timeline, Type timelineType, string name)
{
foreach (var track in GetOutputTracks(timeline, timelineType))
{
if (track != null && GetName(track) == name)
return track;
}
return null;
}
private static IEnumerable<object> GetClips(object track)
{
var method = track.GetType().GetMethod("GetClips", Type.EmptyTypes);
if (method == null)
yield break;
var enumerable = method.Invoke(track, null) as IEnumerable;
if (enumerable == null)
yield break;
foreach (var item in enumerable)
yield return item;
}
/// <summary>
/// Find a <c>CreateClip</c> overload on the track that accepts the given source asset's type
/// (e.g. AnimationTrack.CreateClip(AnimationClip), AudioTrack.CreateClip(AudioClip)). Returns
/// null when no asset is given (caller then uses CreateDefaultClip).
/// </summary>
private static MethodInfo FindCreateClipForAsset(Type trackType, Object sourceAsset)
{
if (sourceAsset == null)
return null;
var assetType = sourceAsset.GetType();
MethodInfo best = null;
foreach (var m in trackType.GetMethods(BindingFlags.Public | BindingFlags.Instance))
{
if (m.Name != "CreateClip")
continue;
if (m.IsGenericMethodDefinition)
continue;
var ps = m.GetParameters();
if (ps.Length != 1)
continue;
if (ps[0].ParameterType.IsAssignableFrom(assetType))
{
// Prefer an exact match over a UnityEngine.Object-typed overload.
if (ps[0].ParameterType == assetType)
return m;
best = best ?? m;
}
}
return best;
}
private static void ValidateClipCreatable(object track, Object sourceAsset)
{
var trackType = track.GetType();
var isAnimation = trackType.FullName == "UnityEngine.Timeline.AnimationTrack";
var isAudio = trackType.FullName == "UnityEngine.Timeline.AudioTrack";
if ((isAnimation || isAudio) && sourceAsset == null)
throw new ArgumentException($"Track type '{FriendlyTrackType(trackType)}' requires an 'asset' (AnimationClip / AudioClip) to create a clip.");
if (sourceAsset != null && FindCreateClipForAsset(trackType, sourceAsset) == null)
throw new ArgumentException(
$"Track type '{FriendlyTrackType(trackType)}' has no CreateClip overload accepting a {sourceAsset.GetType().Name}.");
}
private static object CreateClip(object track, Object sourceAsset)
{
var trackType = track.GetType();
if (sourceAsset != null)
{
var createClip = FindCreateClipForAsset(trackType, sourceAsset);
if (createClip == null)
throw new ArgumentException(
$"Track type '{FriendlyTrackType(trackType)}' has no CreateClip overload accepting a {sourceAsset.GetType().Name}.");
return createClip.Invoke(track, new object[] { sourceAsset });
}
var isAnimation = trackType.FullName == "UnityEngine.Timeline.AnimationTrack";
var isAudio = trackType.FullName == "UnityEngine.Timeline.AudioTrack";
if (isAnimation || isAudio)
throw new ArgumentException($"Track type '{FriendlyTrackType(trackType)}' requires an 'asset' (AnimationClip / AudioClip) to create a clip.");
// For tracks that don't take a source asset, create a default (empty) clip.
var createDefault = trackType.GetMethod("CreateDefaultClip", Type.EmptyTypes);
if (createDefault != null)
return createDefault.Invoke(track, null);
throw new InvalidOperationException($"No CreateClip / CreateDefaultClip available on '{trackType.Name}'.");
}
private static void SetClipTiming(object clip, double start, double duration)
{
var clipType = clip.GetType();
SetDoubleMember(clip, clipType, "start", start);
SetDoubleMember(clip, clipType, "duration", duration);
}
private static string FriendlyTrackType(Type trackType)
{
foreach (var pair in TrackTypeNames)
{
if (pair.Value == trackType.FullName)
return pair.Key;
}
// Strip the trailing "Track" suffix for any unmapped subclass.
var n = trackType.Name;
return n.EndsWith("Track", StringComparison.Ordinal) ? n.Substring(0, n.Length - "Track".Length) : n;
}
private static string GetName(object obj)
{
var prop = obj.GetType().GetProperty("name", BindingFlags.Public | BindingFlags.Instance);
return prop?.GetValue(obj) as string;
}
private static object GetMember(object obj, Type type, string name)
{
var prop = type.GetProperty(name, BindingFlags.Public | BindingFlags.Instance);
if (prop != null)
return prop.GetValue(obj);
var field = type.GetField(name, BindingFlags.Public | BindingFlags.Instance);
return field?.GetValue(obj);
}
private static double GetDouble(object obj, Type type, string name)
{
var value = GetMember(obj, type, name);
return value == null ? 0d : Convert.ToDouble(value);
}
private static double GetDoubleMember(object obj, Type type, string name) => GetDouble(obj, type, name);
private static void SetDoubleMember(object obj, Type type, string name, double value)
{
var prop = type.GetProperty(name, BindingFlags.Public | BindingFlags.Instance);
if (prop != null && prop.CanWrite)
{
prop.SetValue(obj, value);
return;
}
var field = type.GetField(name, BindingFlags.Public | BindingFlags.Instance);
field?.SetValue(obj, value);
}
private static void FillIdentity(AuthoringResult result, Object asset)
{
var described = ObjectResolver.Describe(asset);
if (described == null)
return;
result.Guid = described.Guid;
result.FileId = described.FileId;
result.GlobalId = described.GlobalId;
}
private static void EnsureParentFolder(string assetPath)
{
var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/');
if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent))
return;
CreateFolderRecursive(parent);
}
private static void CreateFolderRecursive(string assetsPath)
{
if (AssetDatabase.IsValidFolder(assetsPath))
return;
var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/');
var name = Path.GetFileName(assetsPath);
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
throw new ArgumentException($"Invalid folder path '{assetsPath}'.");
if (!AssetDatabase.IsValidFolder(parent))
CreateFolderRecursive(parent);
AssetDatabase.CreateFolder(parent, name);
}
}
// ---- result models ----
[Serializable]
public class TimelineTrackSummary
{
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("type")]
public string TrackType { get; set; }
}
[Serializable]
public class AddTimelineTrackResult : AuthoringResult
{
[JsonProperty("track")]
public TimelineTrackSummary Track { get; set; }
}
[Serializable]
public class TimelineClipSummary
{
[JsonProperty("track")]
public string Track { get; set; }
[JsonProperty("start")]
public float Start { get; set; }
[JsonProperty("duration")]
public float Duration { get; set; }
}
[Serializable]
public class AddTimelineClipResult : AuthoringResult
{
[JsonProperty("clip")]
public TimelineClipSummary Clip { get; set; }
}
[Serializable]
public class TimelineInfo
{
[JsonProperty("assetPath")]
public string AssetPath { get; set; }
[JsonProperty("frameRate")]
public double FrameRate { get; set; }
[JsonProperty("duration")]
public double Duration { get; set; }
[JsonProperty("tracks")]
public List<TimelineTrackInfo> Tracks { get; set; } = new List<TimelineTrackInfo>();
}
[Serializable]
public class TimelineTrackInfo
{
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("type")]
public string TrackType { get; set; }
[JsonProperty("clips")]
public List<TimelineClipInfo> Clips { get; set; } = new List<TimelineClipInfo>();
}
[Serializable]
public class TimelineClipInfo
{
[JsonProperty("start")]
public double Start { get; set; }
[JsonProperty("duration")]
public double Duration { get; set; }
[JsonProperty("asset")]
public string Asset { get; set; }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dcf5399fb1544ee3a5b069c535bcd002
@@ -0,0 +1,52 @@
using System;
namespace Unity.Pipeline.Editor.Commands.Animation
{
/// <summary>
/// Presence guard for the optional <c>com.unity.timeline</c> package (CLI-214, Group C).
///
/// The Editor assembly deliberately does NOT reference <c>Unity.Timeline</c> (an asmdef reference
/// would break compilation in any project that doesn't have the package installed). Instead, the
/// Timeline commands reach the package via reflection, and this guard reports whether the package's
/// core type is loadable — the optional-package guard pattern for commands backed by a package that
/// may not be present.
///
/// The guard caches its result for the lifetime of the domain; a package add/remove triggers a
/// domain reload, which resets the cache.
/// </summary>
public static class TimelineGuard
{
/// <summary>The package id, surfaced in the not-installed error message.</summary>
public const string PackageId = "com.unity.timeline";
/// <summary>Assembly-qualified name of the package's root asset type.</summary>
public const string TimelineAssetTypeName = "UnityEngine.Timeline.TimelineAsset, Unity.Timeline";
private static bool? s_Installed;
/// <summary>
/// True when the Timeline package is installed (its <c>TimelineAsset</c> type resolves).
/// </summary>
public static bool IsInstalled()
{
if (s_Installed.HasValue)
return s_Installed.Value;
s_Installed = ResolveTimelineAssetType() != null;
return s_Installed.Value;
}
/// <summary>
/// The <c>UnityEngine.Timeline.TimelineAsset</c> <see cref="Type"/>, or null if the package is
/// absent. Callers use this as the entry point for further reflection.
/// </summary>
public static Type ResolveTimelineAssetType() => Type.GetType(TimelineAssetTypeName, throwOnError: false);
/// <summary>
/// The structured "package not installed" error (HTTP 200, no exception) that every Timeline
/// command returns when <see cref="IsInstalled"/> is false.
/// </summary>
public static ErrorResult NotInstalledError() =>
ErrorResult.PackageStyle("package_not_found", $"{PackageId} is not installed");
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f3f8120d06814b2c86d46503ac132022
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 39163f849a3aebd47a3f718fdc0224f9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,602 @@
using System;
using System.IO;
using System.Reflection;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
#if UNITY_6000_0_OR_NEWER
using PipelinePhysicsMaterial = UnityEngine.PhysicsMaterial;
#else
using PipelinePhysicsMaterial = UnityEngine.PhysicMaterial;
#endif
namespace Unity.Pipeline.Editor.Commands.Assets
{
/// <summary>
/// Asset lifecycle authoring commands (CLI-191): create / import / move / copy / rename / delete
/// project assets. They sit on top of the CLI-190 authoring foundation, so:
///
/// - every agent-supplied path is funnelled through <see cref="ProjectPaths.Resolve"/> (sandboxed
/// to the authoring root; rejects "../" and out-of-project writes),
/// - results are returned as the canonical <see cref="AuthoringResult"/> envelope so an agent can
/// reference the asset in a follow-up call,
/// - existing assets are addressed with an <see cref="ObjectRef"/> resolved by
/// <see cref="ObjectResolver"/>.
///
/// NOTE: AssetDatabase create/move/copy/rename/delete are NOT part of Unity's Undo system, so
/// <see cref="AuthoringUndoScope"/> would have no effect here — these operations are intentionally
/// not wrapped in an undo scope, and destructive/overwriting operations instead require an explicit
/// <c>confirm</c> argument (and support <c>dry_run</c>) so an agent cannot silently lose data.
/// </summary>
public static class AssetCommands
{
[CliCommand("create_asset", "Create a new ScriptableObject (or other UnityEngine.Object) asset of the given type at a path under the authoring root.")]
public static AuthoringResult CreateAsset(
[CliArg("path", "Asset path relative to the authoring root, including extension (e.g. Data/Config.asset or Materials/Wall.mat). The Assets/ prefix is optional.", Required = true)] string path,
[CliArg("type", "Fully-qualified or short type name to instantiate (e.g. UnityEngine.Material, MyGame.GameConfig). Must derive from UnityEngine.Object and be creatable.", Required = true)] string type,
[CliArg("shader", "Material-only (ignored otherwise): shader name to assign (e.g. Standard, \"Universal Render Pipeline/Lit\"). When omitted, defaults to \"Universal Render Pipeline/Lit\" if a Scriptable Render Pipeline is active, otherwise the built-in \"Standard\" shader (falling back to \"Standard\" if URP/Lit is unavailable).")] string shader = null,
[CliArg("confirm", "Required (true) only when overwriting an existing asset at the path. Ignored when the path is empty.")] bool confirm = false,
[CliArg("dry_run", "If true, validate inputs and report what would be created without writing anything.")] bool dryRun = false)
{
var normalized = ProjectPaths.Resolve(path, out var error);
if (normalized == null)
throw new ArgumentException(error);
if (string.IsNullOrEmpty(Path.GetExtension(normalized)))
throw new ArgumentException($"Asset path '{normalized}' must include a file extension (e.g. .asset, .mat).");
var resolvedType = ResolveType(type);
if (resolvedType == null)
throw new ArgumentException($"Could not resolve type '{type}'. Use a fully-qualified name (e.g. UnityEngine.Material).");
if (!typeof(Object).IsAssignableFrom(resolvedType))
throw new ArgumentException($"Type '{resolvedType.FullName}' does not derive from UnityEngine.Object.");
// CLI-222: Unity 6 renamed PhysicMaterial -> PhysicsMaterial, but the on-disk asset
// extension is still ".physicMaterial". AssetDatabase.CreateAsset rejects a
// ".physicsMaterial" file ("should not be used to create a file of type 'physicsMaterial'"),
// so accept either spelling from the agent and normalize to the extension Unity writes.
if (typeof(PipelinePhysicsMaterial).IsAssignableFrom(resolvedType) &&
normalized.EndsWith(".physicsMaterial", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized.Substring(0, normalized.Length - ".physicsMaterial".Length) + ".physicMaterial";
}
var exists = AssetDatabase.LoadMainAssetAtPath(normalized) != null;
if (exists && !confirm)
throw new ArgumentException($"An asset already exists at '{normalized}'. Pass confirm=true to overwrite it.");
// CLI-221: for a Material, resolve (and thereby validate) the shader up front so dry_run
// also fails fast on an unknown shader — dry_run is documented as "validate inputs".
Shader materialShader = null;
if (typeof(Material).IsAssignableFrom(resolvedType))
materialShader = ResolveShader(shader);
if (dryRun)
return new AuthoringResult { AssetPath = normalized, Type = resolvedType.Name };
EnsureParentFolder(normalized);
Object asset;
if (typeof(ScriptableObject).IsAssignableFrom(resolvedType))
{
asset = ScriptableObject.CreateInstance(resolvedType);
}
else if (typeof(Material).IsAssignableFrom(resolvedType))
{
// CLI-221: Material has no public parameterless ctor (Activator.CreateInstance produces
// an invalid Material with a null shader), so construct it explicitly from the shader
// resolved (and validated) above. The `shader` arg is Material-specific and ignored for
// every other type.
asset = new Material(materialShader);
}
else if (typeof(PipelinePhysicsMaterial).IsAssignableFrom(resolvedType))
{
// CLI-222: PhysicsMaterial has a public parameterless ctor, but create it explicitly so
// we can stamp the documented sensible defaults rather than relying on engine defaults.
asset = new PipelinePhysicsMaterial
{
dynamicFriction = 0.6f,
staticFriction = 0.6f,
bounciness = 0f,
};
}
else
{
// Other UnityEngine.Object subclasses with a public parameterless ctor.
// Activator.CreateInstance throws for abstract types or types without an accessible
// parameterless ctor — surface that as an actionable ArgumentException.
try
{
asset = Activator.CreateInstance(resolvedType) as Object;
}
catch (Exception ex)
{
throw new ArgumentException(
$"Type '{resolvedType.FullName}' could not be instantiated (it may be abstract or lack a public parameterless constructor): {ex.Message}");
}
}
if (asset == null)
throw new ArgumentException($"Type '{resolvedType.FullName}' could not be instantiated as a creatable asset.");
// AssetDatabase.CreateAsset does not reliably overwrite an existing asset at the path, so
// explicitly delete it first. The confirm guard above gates that an asset already exists.
if (exists)
AssetDatabase.DeleteAsset(normalized);
AssetDatabase.CreateAsset(asset, normalized);
AssetDatabase.SaveAssets();
AssetDatabase.ImportAsset(normalized);
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = resolvedType.Name };
result.AssetPath = normalized;
return result;
}
[CliCommand("import_asset", "Import an external file (e.g. a texture, model, audio clip) into the project by copying it to a path under the authoring root, then importing it.")]
public static AuthoringResult ImportAsset(
[CliArg("source", "Absolute filesystem path to the external file to import.", Required = true)] string source,
[CliArg("path", "Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional.", Required = true)] string path,
[CliArg("confirm", "Required (true) only when overwriting an existing asset at the destination path.")] bool confirm = false,
[CliArg("dry_run", "If true, validate inputs and report what would be imported without writing anything.")] bool dryRun = false)
{
if (string.IsNullOrWhiteSpace(source))
throw new ArgumentException("source is required.");
if (!File.Exists(source))
throw new ArgumentException($"Source file '{source}' does not exist.");
var normalized = ProjectPaths.Resolve(path, out var error);
if (normalized == null)
throw new ArgumentException(error);
var exists = AssetDatabase.LoadMainAssetAtPath(normalized) != null || File.Exists(ToAbsolute(normalized));
if (exists && !confirm)
throw new ArgumentException($"An asset already exists at '{normalized}'. Pass confirm=true to overwrite it.");
if (dryRun)
return new AuthoringResult { AssetPath = normalized, Type = "ImportedAsset" };
EnsureParentFolder(normalized);
File.Copy(source, ToAbsolute(normalized), overwrite: true);
AssetDatabase.ImportAsset(normalized, ImportAssetOptions.ForceUpdate);
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult();
result.AssetPath = normalized;
return result;
}
[CliCommand("move_asset", "Move (or rename via a new path) an asset to a new location under the authoring root. Preserves the asset's GUID.")]
public static AuthoringResult MoveAsset(
[CliArg("asset", "Reference to the asset to move (path / guid / globalId).", Required = true)] ObjectRef asset,
[CliArg("destination", "Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional.", Required = true)] string destination,
[CliArg("dry_run", "If true, validate the move (via AssetDatabase.ValidateMoveAsset) without performing it.")] bool dryRun = false)
{
var sourcePath = ResolveAssetPath(asset);
var dest = ProjectPaths.Resolve(destination, out var error);
if (dest == null)
throw new ArgumentException(error);
// For a real move, create the destination's parent folder *before* validating: Unity's
// ValidateMoveAsset reports failure ("move as a subdirectory") when the target folder
// doesn't exist yet. Dry-run must not create folders, so it validates against the current
// project state only.
if (!dryRun)
EnsureParentFolder(dest);
var validation = AssetDatabase.ValidateMoveAsset(sourcePath, dest);
if (!string.IsNullOrEmpty(validation))
throw new ArgumentException($"Cannot move '{sourcePath}' to '{dest}': {validation}");
if (dryRun)
return new AuthoringResult { AssetPath = dest, Type = AssetDatabase.GetMainAssetTypeAtPath(sourcePath)?.Name };
var moveError = AssetDatabase.MoveAsset(sourcePath, dest);
if (!string.IsNullOrEmpty(moveError))
throw new ArgumentException($"Failed to move '{sourcePath}' to '{dest}': {moveError}");
var loaded = AssetDatabase.LoadMainAssetAtPath(dest);
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult();
result.AssetPath = dest;
return result;
}
[CliCommand("copy_asset", "Copy an asset to a new path under the authoring root. The copy gets a fresh GUID.")]
public static AuthoringResult CopyAsset(
[CliArg("asset", "Reference to the asset to copy (path / guid / globalId).", Required = true)] ObjectRef asset,
[CliArg("destination", "Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional.", Required = true)] string destination,
[CliArg("confirm", "Required (true) only when overwriting an existing asset at the destination path.")] bool confirm = false,
[CliArg("dry_run", "If true, validate inputs and report what would be copied without writing anything.")] bool dryRun = false)
{
var sourcePath = ResolveAssetPath(asset);
var dest = ProjectPaths.Resolve(destination, out var error);
if (dest == null)
throw new ArgumentException(error);
var exists = AssetDatabase.LoadMainAssetAtPath(dest) != null;
if (exists && !confirm)
throw new ArgumentException($"An asset already exists at '{dest}'. Pass confirm=true to overwrite it.");
if (dryRun)
return new AuthoringResult { AssetPath = dest, Type = AssetDatabase.GetMainAssetTypeAtPath(sourcePath)?.Name };
EnsureParentFolder(dest);
// AssetDatabase.CopyAsset fails if the destination already exists, so delete it first when
// the caller has confirmed an overwrite (the confirm guard above gates that).
if (exists)
AssetDatabase.DeleteAsset(dest);
if (!AssetDatabase.CopyAsset(sourcePath, dest))
throw new ArgumentException($"Failed to copy '{sourcePath}' to '{dest}'.");
var loaded = AssetDatabase.LoadMainAssetAtPath(dest);
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult();
result.AssetPath = dest;
return result;
}
[CliCommand("rename_asset", "Rename an asset in place (keeps it in the same folder, keeps its GUID).")]
public static AuthoringResult RenameAsset(
[CliArg("asset", "Reference to the asset to rename (path / guid / globalId).", Required = true)] ObjectRef asset,
[CliArg("new_name", "New file name WITHOUT a folder path. The extension is preserved if omitted.", Required = true)] string newName,
[CliArg("dry_run", "If true, validate the rename without performing it.")] bool dryRun = false)
{
if (string.IsNullOrWhiteSpace(newName))
throw new ArgumentException("new_name is required.");
if (newName.Contains("/") || newName.Contains("\\"))
throw new ArgumentException("new_name must be a file name only, not a path. Use move_asset to relocate an asset.");
var sourcePath = ResolveAssetPath(asset);
// Preserve the original extension when the agent omits one (AssetDatabase.RenameAsset expects no extension).
var bareName = Path.GetFileNameWithoutExtension(newName);
var extension = Path.GetExtension(sourcePath);
var folder = Path.GetDirectoryName(sourcePath)?.Replace('\\', '/');
var destPath = $"{folder}/{bareName}{extension}";
if (AssetDatabase.LoadMainAssetAtPath(destPath) != null)
throw new ArgumentException($"An asset already exists at '{destPath}'.");
if (dryRun)
return new AuthoringResult { AssetPath = destPath, Type = AssetDatabase.GetMainAssetTypeAtPath(sourcePath)?.Name };
var renameError = AssetDatabase.RenameAsset(sourcePath, bareName);
if (!string.IsNullOrEmpty(renameError))
throw new ArgumentException($"Failed to rename '{sourcePath}': {renameError}");
var loaded = AssetDatabase.LoadMainAssetAtPath(destPath);
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult();
result.AssetPath = destPath;
return result;
}
[CliCommand("delete_asset", "Delete an asset from the project. Destructive: requires confirm=true.")]
public static AuthoringResult DeleteAsset(
[CliArg("asset", "Reference to the asset to delete (path / guid / globalId).", Required = true)] ObjectRef asset,
[CliArg("confirm", "Must be true to actually delete. Without it the command refuses (destructive guard).")] bool confirm = false,
[CliArg("dry_run", "If true, report the asset that would be deleted without deleting it.")] bool dryRun = false)
{
var sourcePath = ResolveAssetPath(asset);
// Capture the identity before deletion so the agent gets a record of what was removed.
var loaded = AssetDatabase.LoadMainAssetAtPath(sourcePath);
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = AssetDatabase.GetMainAssetTypeAtPath(sourcePath)?.Name };
result.AssetPath = sourcePath;
if (dryRun)
return result;
if (!confirm)
throw new ArgumentException($"Refusing to delete '{sourcePath}'. Pass confirm=true to delete it (destructive, not undoable via Unity's Undo).");
if (!AssetDatabase.DeleteAsset(sourcePath))
throw new ArgumentException($"Failed to delete '{sourcePath}'.");
return result;
}
[CliCommand("find_assets", "Find assets by type and/or name and/or label, returning their path, GUID and type. At least one filter is required.")]
public static FindAssetsResult FindAssets(
[CliArg("type", "Type name to filter by (e.g. Material, GameObject, ScriptableObject, MyGame.GameConfig). Resolved to a System.Type and matched against each asset's actual main type.")] string type = null,
[CliArg("name", "Name substring to filter by (AssetDatabase name filter).")] string name = null,
[CliArg("label", "Asset label to filter by (AssetDatabase 'l:' filter).")] string label = null,
[CliArg("search_in", "Folder to scope the search to, relative to the authoring root (default: the authoring root).")] string searchIn = null,
[CliArg("limit", "Maximum number of results to return (default 200).")] int limit = 200)
{
if (string.IsNullOrWhiteSpace(type) && string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(label))
throw new ArgumentException("At least one of type, name, or label is required.");
// The type filter is applied by post-filtering on each asset's actual loaded type rather
// than via AssetDatabase's "t:" token: "t:" does NOT reliably match freshly-created/custom
// ScriptableObject assets (it can return 0 even for "t:ScriptableObject"), though it works
// for built-in types. Name/label searches do work, so build the query from those only.
var filterParts = new System.Collections.Generic.List<string>();
if (!string.IsNullOrWhiteSpace(name))
filterParts.Add(name.Trim());
if (!string.IsNullOrWhiteSpace(label))
filterParts.Add($"l:{label.Trim()}");
var filter = string.Join(" ", filterParts);
// Default the search scope to the authoring root (not the whole project) so an agent only
// ever discovers assets within its sandbox unless an explicit scope is given.
var scopeError = (string)null;
var scope = !string.IsNullOrWhiteSpace(searchIn)
? ProjectPaths.Resolve(searchIn, out scopeError)
: ProjectPaths.AuthoringRoot;
if (scope == null)
throw new ArgumentException(scopeError);
var searchFolders = new[] { scope };
// Resolve the type filter up front so an unresolvable name fails fast with a clear message.
Type typeFilter = null;
if (!string.IsNullOrWhiteSpace(type))
{
typeFilter = ResolveTypeName(type.Trim());
if (typeFilter == null)
throw new ArgumentException(
$"Could not resolve type '{type}'. Use a short name (e.g. Material) or a fully-qualified name (e.g. MyGame.GameConfig).");
}
// An empty name/label filter enumerates every asset under the search folders.
var guids = AssetDatabase.FindAssets(filter ?? string.Empty, searchFolders);
var result = new FindAssetsResult { Filter = filter };
foreach (var guid in guids)
{
var assetPath = AssetDatabase.GUIDToAssetPath(guid);
if (string.IsNullOrEmpty(assetPath))
continue;
var mainType = AssetDatabase.GetMainAssetTypeAtPath(assetPath);
if (typeFilter != null && (mainType == null || !typeFilter.IsAssignableFrom(mainType)))
continue;
if (result.Assets.Count >= limit)
{
result.Truncated = true;
break;
}
result.Assets.Add(new AuthoringResult
{
AssetPath = assetPath,
Guid = guid,
Type = mainType?.Name
});
}
result.Count = result.Assets.Count;
return result;
}
/// <summary>
/// Resolve a user-supplied type name (short or fully-qualified) to a <see cref="Type"/> by
/// scanning non-dynamic loaded assemblies. Returns null when no match is found.
/// </summary>
private static Type ResolveTypeName(string typeName)
{
var direct = Type.GetType(typeName, throwOnError: false);
if (direct != null)
return direct;
var physicsMaterialType = ResolvePhysicsMaterialTypeName(typeName);
if (physicsMaterialType != null)
return physicsMaterialType;
foreach (var assembly in PipelineUtils.GetLoadedAssemblies())
{
if (assembly.IsDynamic)
continue;
var byFullName = assembly.GetType(typeName, throwOnError: false);
if (byFullName != null)
return byFullName;
}
foreach (var assembly in PipelineUtils.GetLoadedAssemblies())
{
if (assembly.IsDynamic)
continue;
Type[] types;
try
{
types = assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
types = ex.Types;
}
foreach (var candidate in types)
{
if (candidate != null && candidate.Name == typeName)
return candidate;
}
}
return null;
}
/// <summary>
/// Resolve an <see cref="ObjectRef"/> to a project-relative asset path, rejecting handles that
/// do not resolve to an on-disk asset (scene objects have no asset path). The resolved path is
/// confined to the authoring root so a GUID/globalId handle cannot make a destructive command
/// (delete/move/copy/rename) operate on an asset outside the sandbox.
/// </summary>
private static string ResolveAssetPath(ObjectRef asset)
{
var assetPath = ObjectResolver.TryResolve(asset, out var obj, out var error)
? AssetDatabase.GetAssetPath(obj)
: null;
// Fallback: a path reference that points at an asset which exists on disk (a GUID is
// registered) but whose object cannot be loaded — e.g. a just-moved asset whose object
// isn't reloadable this frame, or an asset with an unresolved/broken script. We can still
// operate on it by path, so honor the path rather than failing the whole command.
if (string.IsNullOrEmpty(assetPath) && !string.IsNullOrEmpty(asset?.Path))
{
var candidate = ProjectPaths.Resolve(asset.Path, out _);
if (candidate != null && !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(candidate)))
assetPath = candidate;
}
if (string.IsNullOrEmpty(assetPath))
throw new ArgumentException(error ?? $"Reference '{asset}' does not point at an on-disk asset.");
// Confine to the authoring root. ProjectPaths.Resolve treats explicit "Assets/..." paths as
// project-relative and rejects anything outside the configured root.
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
if (confined == null)
throw new ArgumentException(
$"Asset '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
return confined;
}
/// <summary>Create the asset's parent folder chain if it does not yet exist.</summary>
private static void EnsureParentFolder(string assetPath)
{
var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/');
if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent))
return;
CreateFolderRecursive(parent);
}
private static void CreateFolderRecursive(string assetsPath)
{
if (AssetDatabase.IsValidFolder(assetsPath))
return;
var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/');
var name = Path.GetFileName(assetsPath);
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
throw new ArgumentException($"Invalid folder path '{assetsPath}'.");
if (!AssetDatabase.IsValidFolder(parent))
CreateFolderRecursive(parent);
AssetDatabase.CreateFolder(parent, name);
}
/// <summary>Convert a project-relative asset path to an absolute filesystem path.</summary>
private static string ToAbsolute(string projectRelative)
{
return $"{ProjectPaths.ProjectRoot.Replace('\\', '/')}/{projectRelative}";
}
/// <summary>
/// CLI-221: resolve the shader to assign to a newly-created <see cref="Material"/>.
/// - An explicit <paramref name="shaderName"/> must resolve via <see cref="Shader.Find"/>;
/// a miss is a clear, actionable error (no null Material / null-ref downstream).
/// - When omitted, default to the active render pipeline's lit shader: "Universal Render
/// Pipeline/Lit" when an SRP asset is active, else built-in "Standard". If that default
/// can't be found, fall back to "Standard"; if even that is missing, error clearly.
/// </summary>
private static Shader ResolveShader(string shaderName)
{
if (!string.IsNullOrWhiteSpace(shaderName))
{
var requested = Shader.Find(shaderName.Trim());
if (requested == null)
throw new ArgumentException($"Shader '{shaderName}' not found.");
return requested;
}
var usingSrp = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline != null;
var defaultName = usingSrp ? "Universal Render Pipeline/Lit" : "Standard";
var defaultShader = Shader.Find(defaultName);
if (defaultShader != null)
return defaultShader;
// Fall back to the built-in Standard shader (always present in the Built-in RP, and a sane
// last resort even under an SRP project that lacks the URP/Lit shader).
var standard = Shader.Find("Standard");
if (standard != null)
return standard;
throw new ArgumentException(
$"Could not resolve a default shader ('{defaultName}' or 'Standard'). Pass an explicit shader= argument.");
}
/// <summary>
/// Resolve a user-supplied type name to a <see cref="Type"/>. Tries the name as-is (covers
/// fully-qualified names), then scans loaded assemblies for an exact full-name or short-name
/// match (covers "Material" or a user type given without its namespace).
/// </summary>
private static Type ResolveType(string typeName)
{
if (string.IsNullOrWhiteSpace(typeName))
return null;
// Trim once up front so every resolution path below (special-case + assembly/type scans)
// sees the same normalized name — leading/trailing whitespace must not be accepted for one
// type and rejected for another.
typeName = typeName.Trim();
// CLI-222: in Unity 6 the type is UnityEngine.PhysicsMaterial (renamed from PhysicMaterial).
// A short name "PhysicsMaterial" resolves fine, but the legacy short name "PhysicMaterial"
// matches the obsolete forwarder/alias inconsistently across versions — normalize BOTH the
// current and legacy names (short or fully-qualified) to the real type up front so callers
// can use either spelling.
if (typeName == "PhysicsMaterial" || typeName == "PhysicMaterial"
|| typeName == "UnityEngine.PhysicsMaterial" || typeName == "UnityEngine.PhysicMaterial")
{
return typeof(PipelinePhysicsMaterial);
}
var direct = Type.GetType(typeName, throwOnError: false);
if (direct != null)
return direct;
foreach (var assembly in PipelineUtils.GetLoadedAssemblies())
{
var byFullName = assembly.GetType(typeName, throwOnError: false);
if (byFullName != null)
return byFullName;
}
// Fall back to a short-name match across all loaded types.
foreach (var assembly in PipelineUtils.GetLoadedAssemblies())
{
Type[] types;
try
{
types = assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
types = ex.Types;
}
foreach (var candidate in types)
{
if (candidate != null && candidate.Name == typeName)
return candidate;
}
}
return null;
}
private static Type ResolvePhysicsMaterialTypeName(string typeName)
{
if (typeName == "PhysicsMaterial" || typeName == "PhysicMaterial"
|| typeName == "UnityEngine.PhysicsMaterial" || typeName == "UnityEngine.PhysicMaterial")
{
return typeof(PipelinePhysicsMaterial);
}
return null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 47b49bf20465486fb6ffdf6c03775b16
@@ -0,0 +1,654 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
namespace Unity.Pipeline.Editor.Commands.Assets
{
/// <summary>
/// <para>
/// Import-settings authoring commands (CLI-191 + CLI-212). Reads and edits the
/// <see cref="AssetImporter"/> for an asset.
/// </para>
///
/// <para>
/// <c>set_import_settings</c> sets named members on the importer from a JSON object and re-imports.
/// For the <c>Default</c> platform it sets top-level public properties/fields via reflection (works
/// across every importer kind). For a real platform on a <see cref="TextureImporter"/> /
/// <see cref="AudioImporter"/>, keys are applied onto the platform-override struct
/// (<see cref="TextureImporterPlatformSettings"/> / <see cref="AudioImporterSampleSettings"/>) which
/// is unreachable by top-level reflection. <see cref="ModelImporter"/> has no per-platform overrides.
/// </para>
///
/// <para>
/// <c>get_import_settings</c> reads the importer state structured by importer type (texture / model /
/// audio), including the default-platform fields and, for textures/audio, one platform override block.
/// </para>
///
/// <para>
/// Unknown keys are reported back rather than silently ignored, so an agent gets actionable feedback.
/// </para>
///
/// <para>
/// NOTE: import settings live in the asset's .meta file via the importer; this is an AssetDatabase
/// operation and is not part of Unity's Undo system. SaveAndReimport is not destructive to the
/// source file, so no <c>confirm</c> is required.
/// </para>
/// </summary>
public static class AssetImportCommands
{
// Caller-facing platform names accepted by both commands. "Default" means the default platform
// (top-level importer properties / default sample settings); the rest are real build platforms.
private static readonly HashSet<string> s_SupportedPlatforms = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Default", "Standalone", "iOS", "Android", "WebGL", "tvOS"
};
[CliCommand("set_import_settings", "Set import settings on an asset's AssetImporter (default platform top-level properties, or a texture/audio per-platform override) and re-import it.")]
public static SetImportSettingsResult SetImportSettings(
[CliArg("asset", "Reference to the asset whose importer to edit (path / guid / globalId).", Required = true)] ObjectRef asset,
[CliArg("settings", "JSON object of importer property/field names to values, e.g. {\"isReadable\": true, \"textureType\": \"NormalMap\"}. For platform != Default on a texture/audio importer, keys map onto the platform-settings struct (e.g. maxTextureSize, format, compressionFormat, quality, and overridden).", Required = true)] JObject settings,
[CliArg("platform", "Target platform: Default | Standalone | iOS | Android | WebGL | tvOS. Defaults to Default (top-level importer properties). A real platform writes a per-platform override (textures/audio only).")] string platform = "Default",
[CliArg("dry_run", "If true, validate which settings would apply (and which are unknown) without writing or re-importing.")] bool dryRun = false)
{
var importer = ResolveImporter(asset, out var assetPath);
if (settings == null || settings.Count == 0)
throw new ArgumentException("settings must be a non-empty JSON object.");
if (!s_SupportedPlatforms.Contains(platform))
throw new ArgumentException(
Serialize(new ErrorResult { Code = "unknown_platform", Message = $"Unknown platform '{platform}'. Supported: {string.Join(", ", s_SupportedPlatforms)}." }));
var canonicalPlatform = Canonical(platform);
var isDefault = string.Equals(canonicalPlatform, "Default", StringComparison.OrdinalIgnoreCase);
var result = new SetImportSettingsResult
{
AssetPath = assetPath,
ImporterType = importer.GetType().Name,
Platform = canonicalPlatform
};
// Strip the server-injected token before iterating: it is never an importer setting.
var keys = new List<string>();
foreach (var pair in settings)
{
if (pair.Key == "token")
continue;
keys.Add(pair.Key);
}
if (keys.Count == 0)
throw new ArgumentException("settings must be a non-empty JSON object.");
if (isDefault)
{
ApplyDefaultPlatform(importer, settings, keys, result, write: !dryRun);
}
else
{
switch (importer)
{
case TextureImporter texture:
ApplyTexturePlatform(texture, settings, keys, canonicalPlatform, result, write: !dryRun);
break;
case AudioImporter audio:
ApplyAudioPlatform(audio, settings, keys, canonicalPlatform, result, write: !dryRun);
break;
case ModelImporter _:
throw new ArgumentException(
Serialize(new ErrorResult { Code = "no_platform_overrides", Message = "ModelImporter has no per-platform overrides; use platform=Default." }));
default:
throw new ArgumentException(
Serialize(new ErrorResult { Code = "no_platform_overrides", Message = $"Importer '{result.ImporterType}' has no per-platform overrides; use platform=Default." }));
}
}
if (result.Unknown.Count > 0 && result.Applied.Count == 0)
throw new ArgumentException($"None of the supplied settings could be applied to '{result.ImporterType}' (unknown key or invalid value): {string.Join(", ", result.Unknown)}.");
if (dryRun)
return result;
importer.SaveAndReimport();
return result;
}
[CliCommand("get_import_settings", "Read an asset's import settings, structured by importer type (texture/model/audio), including the default-platform fields and (for textures/audio) one platform override block.")]
public static GetImportSettingsResult GetImportSettings(
[CliArg("asset", "Reference to the asset whose importer to read (path / guid / globalId).", Required = true)] ObjectRef asset,
[CliArg("platform", "Platform whose override to read: Default | Standalone | iOS | Android | WebGL | tvOS. Defaults to Default.")] string platform = "Default")
{
var importer = ResolveImporter(asset, out var assetPath);
if (!s_SupportedPlatforms.Contains(platform))
throw new ArgumentException(
Serialize(new ErrorResult { Code = "unknown_platform", Message = $"Unknown platform '{platform}'. Supported: {string.Join(", ", s_SupportedPlatforms)}." }));
var canonicalPlatform = Canonical(platform);
var isDefault = string.Equals(canonicalPlatform, "Default", StringComparison.OrdinalIgnoreCase);
var result = new GetImportSettingsResult
{
AssetPath = assetPath,
ImporterType = importer.GetType().Name,
Platform = canonicalPlatform
};
switch (importer)
{
case TextureImporter texture:
result.Settings = ReadTextureDefaults(texture);
result.PlatformOverride = ReadTexturePlatformOverride(texture, canonicalPlatform, isDefault);
break;
case ModelImporter model:
result.Settings = ReadModelDefaults(model);
result.PlatformOverride = null; // ModelImporter has no per-platform overrides.
break;
case AudioImporter audio:
result.Settings = ReadAudioDefaults(audio);
result.PlatformOverride = ReadAudioPlatformOverride(audio, canonicalPlatform, isDefault);
break;
default:
// Non-import asset (e.g. a ScriptableObject via NativeFormatImporter): return a
// minimal settings block rather than throwing.
result.Settings = new Dictionary<string, object>
{
["userData"] = importer.userData,
["assetBundleName"] = importer.assetBundleName
};
result.PlatformOverride = null;
break;
}
return result;
}
// ------------------------------------------------------------------ resolution / helpers
/// <summary>
/// Resolve an <see cref="ObjectRef"/> to its <see cref="AssetImporter"/>, confined to the
/// authoring root. Throws an actionable <see cref="ArgumentException"/> on any failure.
/// </summary>
private static AssetImporter ResolveImporter(ObjectRef asset, out string assetPath)
{
if (!ObjectResolver.TryResolve(asset, out var obj, out var error))
throw new ArgumentException(error);
assetPath = AssetDatabase.GetAssetPath(obj);
if (string.IsNullOrEmpty(assetPath))
throw new ArgumentException($"Reference '{asset}' does not point at an on-disk asset.");
// Confine to the authoring root so a GUID/globalId handle cannot reach an asset outside the
// sandbox.
assetPath = ProjectPaths.Resolve(assetPath, out var confineError);
if (assetPath == null)
throw new ArgumentException(
$"Asset '{asset}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
var importer = AssetImporter.GetAtPath(assetPath);
if (importer == null)
throw new ArgumentException($"No AssetImporter found for '{assetPath}'.");
return importer;
}
/// <summary>Normalize a caller platform name to its canonical capitalization (e.g. "ios" -> "iOS").</summary>
private static string Canonical(string platform)
{
foreach (var supported in s_SupportedPlatforms)
{
if (string.Equals(supported, platform, StringComparison.OrdinalIgnoreCase))
return supported;
}
return platform;
}
/// <summary>
/// Map a caller-facing platform name to the build-platform string Unity's importer APIs expect.
/// Most match 1:1; iOS is "iPhone" to those APIs.
/// </summary>
private static string UnityPlatformName(string canonicalPlatform)
{
return string.Equals(canonicalPlatform, "iOS", StringComparison.OrdinalIgnoreCase) ? "iPhone" : canonicalPlatform;
}
// ------------------------------------------------------------------ default-platform reflection
private static void ApplyDefaultPlatform(AssetImporter importer, JObject settings, List<string> keys, SetImportSettingsResult result, bool write)
{
var importerType = importer.GetType();
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
foreach (var key in keys)
{
// In dry_run we only validate the member exists and the value converts — never mutate the
// (possibly cached) importer instance, so a dry run leaves no trace.
var applied = TryApplyMember(importer, importerType, flags, key, settings[key], write);
if (applied)
result.Applied.Add(key);
else
result.Unknown.Add(key);
}
}
/// <summary>
/// Set a single named property or field on the target from a JSON token. Returns false when no
/// writable member with that name exists or the value cannot be converted to the member type.
/// Enum members accept the value name (case-insensitive) as well as numeric values.
/// </summary>
private static bool TryApplyMember(object target, Type type, BindingFlags flags, string memberName, JToken value, bool write)
{
var property = type.GetProperty(memberName, flags);
if (property != null && property.CanWrite && property.GetIndexParameters().Length == 0)
{
return TryConvertAndSet(property.PropertyType, value,
converted => { if (write) property.SetValue(target, converted); });
}
var field = type.GetField(memberName, flags);
if (field != null)
{
return TryConvertAndSet(field.FieldType, value,
converted => { if (write) field.SetValue(target, converted); });
}
return false;
}
/// <summary>
/// Convert a JSON token to <paramref name="targetType"/> (with case-insensitive enum-name support)
/// and, on success, invoke <paramref name="set"/>. Returns false when the value cannot convert.
/// </summary>
private static bool TryConvertAndSet(Type targetType, JToken value, Action<object> set)
{
try
{
if (!TryConvert(targetType, value, out var converted))
return false;
set(converted);
return true;
}
catch
{
return false;
}
}
/// <summary>
/// Convert a JSON token to a CLR value. Enums accept their value name (case-insensitive) or an
/// integral value; everything else defers to Newtonsoft's <see cref="JToken.ToObject(Type)"/>.
/// </summary>
private static bool TryConvert(Type targetType, JToken value, out object converted)
{
converted = null;
var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType;
if (underlying.IsEnum)
{
if (value.Type == JTokenType.String)
{
var name = value.Value<string>();
if (string.IsNullOrEmpty(name))
return false;
try
{
converted = Enum.Parse(underlying, name, ignoreCase: true);
return true;
}
catch
{
return false;
}
}
if (value.Type == JTokenType.Integer)
{
converted = Enum.ToObject(underlying, value.Value<long>());
return true;
}
return false;
}
converted = value.ToObject(targetType);
return true;
}
// ------------------------------------------------------------------ texture platform overrides
private static void ApplyTexturePlatform(TextureImporter texture, JObject settings, List<string> keys, string canonicalPlatform, SetImportSettingsResult result, bool write)
{
var unityPlatform = UnityPlatformName(canonicalPlatform);
// Boxed struct so reflection SetValue mutates the local copy; written back via SetPlatformTextureSettings.
object boxed = texture.GetPlatformTextureSettings(unityPlatform);
var type = typeof(TextureImporterPlatformSettings);
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
// Only treat "overridden" as explicitly provided when the caller passes a valid boolean.
// A non-boolean value is caught by TrySetBoolMember (reported to unknown[]), so we must
// not suppress the default overridden=true in that case or the override is silently skipped.
var explicitOverridden = settings.TryGetValue("overridden", StringComparison.OrdinalIgnoreCase, out var overriddenTok)
&& overriddenTok.Type == JTokenType.Boolean;
foreach (var key in keys)
{
if (string.Equals(key, "overridden", StringComparison.OrdinalIgnoreCase))
{
// Handled explicitly below; still report it as applied.
if (TrySetBoolMember(type, flags, boxed, "overridden", settings[key], write))
result.Applied.Add(key);
else
result.Unknown.Add(key);
continue;
}
if (TryApplyMember(boxed, type, flags, key, settings[key], write))
result.Applied.Add(key);
else
result.Unknown.Add(key);
}
// Default the override on (the caller intends to set platform-specific values) unless they
// explicitly passed overridden:false (in which case the value they passed is already in the
// boxed copy from the loop above).
if (!explicitOverridden)
SetBool(type, flags, boxed, "overridden", true);
if (write && result.Applied.Count > 0)
{
var settingsStruct = (TextureImporterPlatformSettings)boxed;
settingsStruct.name = unityPlatform;
texture.SetPlatformTextureSettings(settingsStruct);
}
}
/// <summary>
/// Set a named boolean member from a JSON token. Returns false when the token is not a boolean or
/// no settable bool member with that name exists, so a non-existent member is reported as unknown
/// rather than falsely applied. In <paramref name="write"/>=false (dry-run) mode the member is only
/// probed for existence, never mutated.
/// </summary>
private static bool TrySetBoolMember(Type type, BindingFlags flags, object boxed, string member, JToken token, bool write)
{
if (token.Type != JTokenType.Boolean)
return false;
if (!write)
return BoolMemberExists(type, flags, member);
return SetBool(type, flags, boxed, member, token.Value<bool>());
}
/// <summary>True when a settable boolean property or field with that name exists on the type.</summary>
private static bool BoolMemberExists(Type type, BindingFlags flags, string member)
{
var property = type.GetProperty(member, flags);
if (property != null && property.CanWrite && property.PropertyType == typeof(bool)
&& property.GetIndexParameters().Length == 0)
return true;
var field = type.GetField(member, flags);
return field != null && field.FieldType == typeof(bool);
}
/// <summary>
/// Write a boolean property or field (matching <see cref="TryApplyMember"/>'s property-then-field
/// resolution so a bool implemented as a property is also honored). Returns false — without
/// mutating anything — when no settable bool member with that name exists.
/// </summary>
private static bool SetBool(Type type, BindingFlags flags, object boxed, string member, bool value)
{
var property = type.GetProperty(member, flags);
if (property != null && property.CanWrite && property.PropertyType == typeof(bool)
&& property.GetIndexParameters().Length == 0)
{
property.SetValue(boxed, value);
return true;
}
var field = type.GetField(member, flags);
if (field != null && field.FieldType == typeof(bool))
{
field.SetValue(boxed, value);
return true;
}
return false;
}
private static Dictionary<string, object> ReadTextureDefaults(TextureImporter texture)
{
return new Dictionary<string, object>
{
["textureType"] = texture.textureType.ToString(),
["textureShape"] = texture.textureShape.ToString(),
["sRGBTexture"] = texture.sRGBTexture,
["alphaSource"] = texture.alphaSource.ToString(),
["alphaIsTransparency"] = texture.alphaIsTransparency,
["isReadable"] = texture.isReadable,
["streamingMipmaps"] = texture.streamingMipmaps,
["mipmapEnabled"] = texture.mipmapEnabled,
["wrapMode"] = texture.wrapMode.ToString(),
["filterMode"] = texture.filterMode.ToString(),
["anisoLevel"] = texture.anisoLevel,
["spriteImportMode"] = texture.spriteImportMode.ToString(),
["spritePixelsPerUnit"] = texture.spritePixelsPerUnit,
["npotScale"] = texture.npotScale.ToString(),
["maxTextureSize"] = texture.maxTextureSize
};
}
private static Dictionary<string, object> ReadTexturePlatformOverride(TextureImporter texture, string canonicalPlatform, bool isDefault)
{
var settings = isDefault
? texture.GetDefaultPlatformTextureSettings()
: texture.GetPlatformTextureSettings(UnityPlatformName(canonicalPlatform));
return new Dictionary<string, object>
{
["overridden"] = !isDefault && settings.overridden,
["maxTextureSize"] = settings.maxTextureSize,
["resizeAlgorithm"] = settings.resizeAlgorithm.ToString(),
["format"] = settings.format.ToString(),
["textureCompression"] = settings.textureCompression.ToString(),
["compressionQuality"] = settings.compressionQuality,
["crunchedCompression"] = settings.crunchedCompression,
["androidETC2FallbackOverride"] = settings.androidETC2FallbackOverride.ToString()
};
}
// ------------------------------------------------------------------ audio platform overrides
private static void ApplyAudioPlatform(AudioImporter audio, JObject settings, List<string> keys, string canonicalPlatform, SetImportSettingsResult result, bool write)
{
var unityPlatform = UnityPlatformName(canonicalPlatform);
// overridden:false clears the override entirely (and ignores any other keys).
if (settings.TryGetValue("overridden", StringComparison.OrdinalIgnoreCase, out var overriddenToken)
&& overriddenToken.Type == JTokenType.Boolean
&& !overriddenToken.Value<bool>())
{
result.Applied.Add("overridden");
if (write)
audio.ClearSampleSettingOverride(unityPlatform);
return;
}
object boxed = audio.GetOverrideSampleSettings(unityPlatform);
var type = typeof(AudioImporterSampleSettings);
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
foreach (var key in keys)
{
if (string.Equals(key, "overridden", StringComparison.OrdinalIgnoreCase))
{
// Only accept a boolean; anything else is a misuse and goes to unknown[].
if (settings[key].Type == JTokenType.Boolean)
result.Applied.Add(key);
else
result.Unknown.Add(key);
continue;
}
if (TryApplyMember(boxed, type, flags, key, settings[key], write))
result.Applied.Add(key);
else
result.Unknown.Add(key);
}
if (write && result.Applied.Count > 0)
{
var settingsStruct = (AudioImporterSampleSettings)boxed;
audio.SetOverrideSampleSettings(unityPlatform, settingsStruct);
}
}
private static Dictionary<string, object> ReadAudioDefaults(AudioImporter audio)
{
var sample = audio.defaultSampleSettings;
return new Dictionary<string, object>
{
["forceToMono"] = audio.forceToMono,
["loadInBackground"] = audio.loadInBackground,
["ambisonic"] = audio.ambisonic,
["loadType"] = sample.loadType.ToString(),
["compressionFormat"] = sample.compressionFormat.ToString(),
["quality"] = sample.quality,
["sampleRateSetting"] = sample.sampleRateSetting.ToString(),
["sampleRateOverride"] = sample.sampleRateOverride
};
}
private static Dictionary<string, object> ReadAudioPlatformOverride(AudioImporter audio, string canonicalPlatform, bool isDefault)
{
if (isDefault)
{
var sample = audio.defaultSampleSettings;
return new Dictionary<string, object>
{
["overridden"] = false,
["loadType"] = sample.loadType.ToString(),
["compressionFormat"] = sample.compressionFormat.ToString(),
["quality"] = sample.quality,
["sampleRateSetting"] = sample.sampleRateSetting.ToString(),
["sampleRateOverride"] = sample.sampleRateOverride
};
}
var unityPlatform = UnityPlatformName(canonicalPlatform);
var overridden = audio.ContainsSampleSettingsOverride(unityPlatform);
var settings = audio.GetOverrideSampleSettings(unityPlatform);
return new Dictionary<string, object>
{
["overridden"] = overridden,
["loadType"] = settings.loadType.ToString(),
["compressionFormat"] = settings.compressionFormat.ToString(),
["quality"] = settings.quality,
["sampleRateSetting"] = settings.sampleRateSetting.ToString(),
["sampleRateOverride"] = settings.sampleRateOverride
};
}
// ------------------------------------------------------------------ model defaults (no overrides)
private static Dictionary<string, object> ReadModelDefaults(ModelImporter model)
{
return new Dictionary<string, object>
{
// Meshes
["globalScale"] = model.globalScale,
["useFileScale"] = model.useFileScale,
["meshCompression"] = model.meshCompression.ToString(),
["isReadable"] = model.isReadable,
["meshOptimizationFlags"] = model.meshOptimizationFlags.ToString(),
["weldVertices"] = model.weldVertices,
["indexFormat"] = model.indexFormat.ToString(),
["keepQuads"] = model.keepQuads,
// Normals / Tangents
["importNormals"] = model.importNormals.ToString(),
["normalCalculationMode"] = model.normalCalculationMode.ToString(),
["normalSmoothingAngle"] = model.normalSmoothingAngle,
["importTangents"] = model.importTangents.ToString(),
["importBlendShapes"] = model.importBlendShapes,
// Geometry
["importCameras"] = model.importCameras,
["importLights"] = model.importLights,
["swapUVChannels"] = model.swapUVChannels,
["generateSecondaryUV"] = model.generateSecondaryUV,
// Materials
["materialImportMode"] = model.materialImportMode.ToString(),
["materialLocation"] = model.materialLocation.ToString(),
// Animation
["importAnimation"] = model.importAnimation,
["animationType"] = model.animationType.ToString(),
["importConstraints"] = model.importConstraints,
["importVisibility"] = model.importVisibility
};
}
private static string Serialize(object value)
{
return Newtonsoft.Json.JsonConvert.SerializeObject(value);
}
}
/// <summary>
/// Result of <c>set_import_settings</c>: target platform plus which keys were applied vs. unknown.
/// </summary>
[Serializable]
public class SetImportSettingsResult
{
[Newtonsoft.Json.JsonProperty("assetPath")]
public string AssetPath { get; set; }
[Newtonsoft.Json.JsonProperty("importerType")]
public string ImporterType { get; set; }
[Newtonsoft.Json.JsonProperty("platform")]
public string Platform { get; set; }
[Newtonsoft.Json.JsonProperty("applied")]
public List<string> Applied { get; set; } = new List<string>();
[Newtonsoft.Json.JsonProperty("unknown")]
public List<string> Unknown { get; set; } = new List<string>();
}
/// <summary>
/// Result of <c>get_import_settings</c>: the importer type, default-platform settings, and (for
/// textures/audio) one platform-override block. <c>platformOverride</c> is null for ModelImporter and
/// non-import assets.
/// </summary>
[Serializable]
public class GetImportSettingsResult
{
[Newtonsoft.Json.JsonProperty("assetPath")]
public string AssetPath { get; set; }
[Newtonsoft.Json.JsonProperty("importerType")]
public string ImporterType { get; set; }
[Newtonsoft.Json.JsonProperty("settings")]
public Dictionary<string, object> Settings { get; set; }
[Newtonsoft.Json.JsonProperty("platform")]
public string Platform { get; set; }
[Newtonsoft.Json.JsonProperty("platformOverride")]
public Dictionary<string, object> PlatformOverride { get; set; }
}
/// <summary>Structured error payload serialized into the thrown ArgumentException message.</summary>
[Serializable]
public class ErrorResult
{
[Newtonsoft.Json.JsonProperty("code")]
public string Code { get; set; }
[Newtonsoft.Json.JsonProperty("message")]
public string Message { get; set; }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cb5b302104dc446d8d94f33252087062
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using Newtonsoft.Json;
using Unity.Pipeline.Models;
namespace Unity.Pipeline.Editor.Commands.Assets
{
/// <summary>
/// Structured result for the <c>find_assets</c> command (CLI-191): a list of matched assets, each
/// described as the canonical <see cref="AuthoringResult"/> (path / GUID / type) so an agent can
/// feed any entry straight back into another command as an <see cref="ObjectRef"/>.
///
/// Lives in the Editor command assembly (rather than Runtime/Models) because find_assets is an
/// Editor-only command and the foundation Runtime/Models are shared/frozen for parallel work.
/// </summary>
[System.Serializable]
public class FindAssetsResult
{
/// <summary>The AssetDatabase filter string that was executed (for diagnostics).</summary>
[JsonProperty("filter")]
public string Filter { get; set; }
/// <summary>Number of assets returned (after the limit is applied).</summary>
[JsonProperty("count")]
public int Count { get; set; }
/// <summary>True when the result was capped by the <c>limit</c> argument.</summary>
[JsonProperty("truncated")]
public bool Truncated { get; set; }
/// <summary>The matched assets, each with path / GUID / type.</summary>
[JsonProperty("assets")]
public List<AuthoringResult> Assets { get; set; } = new List<AuthoringResult>();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7ff032cead554b7f97cd2467a83abd35
@@ -0,0 +1,51 @@
using System;
using System.IO;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
namespace Unity.Pipeline.Editor.Commands.Assets
{
/// <summary>
/// Asset-folder authoring commands. Reference exemplar for the authoring foundation (CLI-190):
/// it validates input through the project-path sandbox (<see cref="ProjectPaths"/>) and returns
/// the canonical <see cref="AuthoringResult"/> envelope so an agent can reference the created
/// folder in follow-up calls.
/// </summary>
public static class FolderCommands
{
[CliCommand("create_folder", "Create a folder under the authoring root (creates intermediate folders).")]
public static AuthoringResult CreateFolder(
[CliArg("path", "Folder path relative to the authoring root (default Assets/); the Assets/ prefix is optional. e.g. Gameplay/Enemies or Assets/Gameplay/Enemies", Required = true)] string path)
{
var normalized = ProjectPaths.Resolve(path, out var error);
if (normalized == null)
throw new ArgumentException(error);
CreateFolderRecursive(normalized);
AssetDatabase.Refresh();
var folder = AssetDatabase.LoadAssetAtPath<DefaultAsset>(normalized);
var result = ObjectResolver.Describe(folder) ?? new AuthoringResult { Type = "DefaultAsset" };
result.AssetPath = normalized;
return result;
}
private static void CreateFolderRecursive(string assetsPath)
{
if (AssetDatabase.IsValidFolder(assetsPath))
return;
var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/');
var name = Path.GetFileName(assetsPath);
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
throw new ArgumentException($"Invalid folder path '{assetsPath}'.");
if (!AssetDatabase.IsValidFolder(parent))
CreateFolderRecursive(parent);
AssetDatabase.CreateFolder(parent, name);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b78420627d9cd1e43835d9088975e691
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,126 @@
using System;
using System.IO;
using Newtonsoft.Json;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
namespace Unity.Pipeline.Editor.Commands.Assets
{
/// <summary>
/// Text-file authoring commands (CLI-191): read and write UTF-8 text files inside the authoring
/// sandbox. Useful for editing data assets that are plain text (JSON/CSV/.txt/.cs/.shader, etc.).
///
/// Every path is funnelled through <see cref="ProjectPaths.Resolve"/>, so reads/writes are confined
/// to the authoring root and "../"/out-of-project paths are rejected. Writing imports the file back
/// into the AssetDatabase so Unity picks up the change.
///
/// NOTE: writing is potentially destructive (it can overwrite an existing file), so overwriting an
/// existing file requires an explicit <c>confirm</c> argument; <c>dry_run</c> is supported.
/// Filesystem writes are not part of Unity's Undo system.
/// </summary>
public static class TextFileCommands
{
[CliCommand("read_text_file", "Read a UTF-8 text file under the authoring root and return its contents.", MainThreadRequired = true)]
public static ReadTextFileResult ReadTextFile(
[CliArg("path", "Text file path relative to the authoring root. The Assets/ prefix is optional.", Required = true)] string path,
[CliArg("max_bytes", "Reject files larger than this many bytes (default 1048576 = 1 MiB) to avoid huge payloads.")] int maxBytes = 1024 * 1024)
{
var normalized = ProjectPaths.Resolve(path, out var error);
if (normalized == null)
throw new ArgumentException(error);
var absolute = ToAbsolute(normalized);
if (!File.Exists(absolute))
throw new ArgumentException($"No file at '{normalized}'.");
var length = new FileInfo(absolute).Length;
if (length > maxBytes)
throw new ArgumentException($"File '{normalized}' is {length} bytes, which exceeds max_bytes={maxBytes}.");
return new ReadTextFileResult
{
AssetPath = normalized,
Bytes = (int)length,
Contents = File.ReadAllText(absolute)
};
}
[CliCommand("write_text_file", "Write UTF-8 text to a file under the authoring root, then import it. Overwriting an existing file requires confirm=true.")]
public static AuthoringResult WriteTextFile(
[CliArg("path", "Text file path relative to the authoring root, including extension. The Assets/ prefix is optional.", Required = true)] string path,
[CliArg("contents", "The full text content to write (replaces the file).", Required = true)] string contents,
[CliArg("confirm", "Required (true) only when overwriting an existing file at the path.")] bool confirm = false,
[CliArg("dry_run", "If true, validate inputs and report what would be written without writing anything.")] bool dryRun = false)
{
var normalized = ProjectPaths.Resolve(path, out var error);
if (normalized == null)
throw new ArgumentException(error);
if (contents == null)
throw new ArgumentException("contents is required (use an empty string to write an empty file).");
var absolute = ToAbsolute(normalized);
var exists = File.Exists(absolute);
if (exists && !confirm)
throw new ArgumentException($"A file already exists at '{normalized}'. Pass confirm=true to overwrite it.");
if (dryRun)
return new AuthoringResult { AssetPath = normalized, Type = "TextAsset" };
EnsureParentFolder(normalized);
File.WriteAllText(absolute, contents);
AssetDatabase.ImportAsset(normalized, ImportAssetOptions.ForceUpdate);
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = "TextAsset" };
result.AssetPath = normalized;
return result;
}
private static void EnsureParentFolder(string assetPath)
{
var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/');
if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent))
return;
CreateFolderRecursive(parent);
}
private static void CreateFolderRecursive(string assetsPath)
{
if (AssetDatabase.IsValidFolder(assetsPath))
return;
var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/');
var name = Path.GetFileName(assetsPath);
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
throw new ArgumentException($"Invalid folder path '{assetsPath}'.");
if (!AssetDatabase.IsValidFolder(parent))
CreateFolderRecursive(parent);
AssetDatabase.CreateFolder(parent, name);
}
private static string ToAbsolute(string projectRelative)
{
return $"{ProjectPaths.ProjectRoot.Replace('\\', '/')}/{projectRelative}";
}
}
/// <summary>Result of <c>read_text_file</c>: the file contents plus its identity and size.</summary>
[Serializable]
public class ReadTextFileResult
{
[JsonProperty("assetPath")]
public string AssetPath { get; set; }
[JsonProperty("bytes")]
public int Bytes { get; set; }
[JsonProperty("contents")]
public string Contents { get; set; }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f57a6d794d1e4c40976f65ec3774b8ed
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7512e3b6952430f4b8fb5c67dd22f13f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,29 @@
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
namespace Unity.Pipeline.Editor.Commands.Authoring
{
/// <summary>
/// Commands to inspect and configure the authoring root — the base folder (under Assets/) that
/// bare authoring paths resolve against and that authoring writes are confined to. Persisted
/// per project; the default is "Assets" (full Assets access).
/// </summary>
public static class AuthoringConfigCommands
{
[CliCommand("get_authoring_root", "Get the base folder (under Assets/) that bare authoring paths resolve against.")]
public static object GetAuthoringRoot()
{
return new { root = ProjectPaths.AuthoringRoot };
}
[CliCommand("set_authoring_root", "Set the base folder (under Assets/) that bare authoring paths resolve against and are confined to. Use 'Assets' for full project access.")]
public static object SetAuthoringRoot(
[CliArg("root", "Project-relative folder under Assets/, e.g. Assets/AgentWork. Use 'Assets' to allow the whole project.", Required = true)] string root)
{
// Throws ArgumentException for invalid roots (outside Assets/ or containing ".."); the
// server surfaces that message to the caller.
ProjectPaths.AuthoringRoot = root;
return new { root = ProjectPaths.AuthoringRoot };
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c4429604fb18e8347a2e2efdefc49663
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,87 @@
using System;
using System.Reflection;
using Unity.Pipeline.Commands;
using UnityEditor;
namespace Unity.Pipeline.Editor.Commands
{
/// <summary>
/// Opt-in mode that keeps the Unity Editor ticking at full rate even when its window is
/// unfocused. Unity throttles EditorApplication.update when the editor is in the background,
/// which can stall long-running work (HTTP commands, async test runs) driven from the CLI.
///
/// When enabled, this forces EditorApplication.SignalTick() on every update, which schedules
/// the next tick immediately and keeps the loop spinning. It is off by default and changes
/// nothing in normal package behavior. Cross-platform: no native/OS calls.
///
/// Note: state is static and resets on domain reload, so auto-tick turns itself off after a
/// recompile; re-enable it if needed. Forcing full-rate ticks uses CPU like a focused editor.
/// </summary>
public static class AutoTickCommand
{
private static bool s_Enabled;
private static Action s_SignalTick;
private static EditorApplication.CallbackFunction s_Pump;
private static readonly System.Diagnostics.Stopwatch s_Stopwatch = new System.Diagnostics.Stopwatch();
private static long s_IntervalMs;
[CliCommand("set_autotick", "Keep the editor ticking while unfocused by forcing EditorApplication.SignalTick at a throttled rate", MainThreadRequired = true)]
public static string SetAutoTick(
[CliArg("enable", "Enable (true) or disable (false) auto-tick mode")] bool enable = true,
[CliArg("interval_ms", "Minimum milliseconds between forced ticks. 0 = every update (max rate, pegs a CPU core). Default 16 (~60Hz).")] int intervalMs = 16)
{
if (enable && s_Enabled)
{
s_IntervalMs = Math.Max(0, intervalMs);
return $"Auto-tick already enabled (interval updated to {s_IntervalMs}ms)";
}
if (!enable && !s_Enabled)
return "Auto-tick already disabled";
if (enable)
{
if (s_SignalTick == null)
{
var method = typeof(EditorApplication).GetMethod(
"SignalTick",
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
if (method == null)
return "Error: EditorApplication.SignalTick not found (internal API may have changed)";
try
{
s_SignalTick = (Action)Delegate.CreateDelegate(typeof(Action), method);
}
catch (Exception ex)
{
return $"Error: could not bind EditorApplication.SignalTick: {ex.Message}";
}
}
s_IntervalMs = Math.Max(0, intervalMs);
s_Stopwatch.Restart();
s_Pump = () =>
{
if (s_IntervalMs <= 0 || s_Stopwatch.ElapsedMilliseconds >= s_IntervalMs)
{
s_Stopwatch.Restart();
s_SignalTick();
}
};
EditorApplication.update += s_Pump;
s_Enabled = true;
return $"Auto-tick enabled (interval {s_IntervalMs}ms)";
}
if (s_Pump != null)
{
EditorApplication.update -= s_Pump;
s_Pump = null;
}
s_Stopwatch.Stop();
s_Enabled = false;
return "Auto-tick disabled";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 76bae7b5ceb53dd498f891ed7f1e0d19
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 758dc2e598a448e5b80e66443fede0d9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,42 @@
using System.Linq;
using UnityEngine.SceneManagement;
namespace Unity.Pipeline.Editor.Commands.Baking
{
/// <summary>
/// Shared helpers for the CLI-215 bake commands (lighting / NavMesh / occlusion). All three bakes
/// act on the <b>currently open scene(s)</b> — there is no per-asset target — so before triggering
/// any bake we verify at least one open, saved scene exists. A bake against no open/saved scene is
/// rejected up front with the documented <c>{ code: "no_scene" }</c> result (returned, not thrown,
/// to match the CLI-215 spec), and nothing is started.
/// </summary>
internal static class BakeSceneGuard
{
/// <summary>The structured "no_scene" error payload returned by a trigger with no bakeable scene.</summary>
internal static object NoSceneResult()
{
return new
{
code = "no_scene",
message = "No open, saved scene to bake. Open a scene first (open_scene) and ensure it is saved to disk."
};
}
/// <summary>
/// True when there is at least one open, loaded scene that has been saved to disk (a non-empty
/// path). An untitled/never-saved scene has an empty <see cref="Scene.path"/> and cannot host a
/// bake (its data has nowhere to live), so it does not count.
/// </summary>
internal static bool HasBakeableScene()
{
return OpenScenes().Any(s => s.isLoaded && !string.IsNullOrEmpty(s.path));
}
/// <summary>Enumerate every open scene by index.</summary>
internal static System.Collections.Generic.IEnumerable<Scene> OpenScenes()
{
for (int i = 0; i < SceneManager.sceneCount; i++)
yield return SceneManager.GetSceneAt(i);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cf05be2261cb475c9cf3d7fccbf65ff5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,640 @@
using System;
using System.IO;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Commands;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Editor.Commands.Baking
{
/// <summary>
/// CLI-215 Group A — Lightmapping bake commands. Trigger an async lightmap bake of the open
/// scene(s), poll its status, cancel it, clear baked data, and read/configure the active
/// <see cref="LightingSettings"/>.
///
/// Why async (trigger-then-poll, mirroring <see cref="BuildCommand"/>): a full lightmap bake runs
/// for seconds-to-minutes. <see cref="Lightmapping.BakeAsync"/> kicks the bake off and returns
/// immediately; <c>bake_lighting</c> therefore returns <c>{ status: "baking", bakeId }</c> right
/// away and the caller polls <c>lighting_bake_status</c> until it reports <c>completed</c>.
///
/// In-flight state is held in static fields and is domain-reload aware: a bake can survive (or
/// resume across) a domain reload, so on reload <see cref="OnReload"/> reconciles our recorded
/// state against the live <see cref="Lightmapping.isRunning"/> flag and a small status file under
/// Temp/ (which persists across the reload, mirroring RecompileCommand). Completion is detected via
/// the <see cref="Lightmapping.bakeCompleted"/> callback and, defensively, by polling
/// <see cref="Lightmapping.isRunning"/> flipping false in <see cref="EditorApplication.update"/>.
/// </summary>
[InitializeOnLoad]
public static class LightingBakeCommands
{
const string StatusFile = "Temp/pipeline_lighting_bake_status.json";
static readonly object s_Lock = new object();
// True between trigger and the completion callback / isRunning flipping false.
static bool s_Tracking;
static string s_BakeId;
static DateTime s_StartedUtc;
static LightingBakeCommands()
{
// Reconcile any in-flight bake recorded before a domain reload, then keep watching.
OnReload();
Lightmapping.bakeCompleted += OnBakeCompleted;
EditorApplication.update += OnUpdate;
}
#region bake_lighting
[CliCommand("bake_lighting", "Trigger an async lightmap bake of the open scene(s) via Lightmapping.BakeAsync(). Returns immediately; poll lighting_bake_status until completed.")]
public static object BakeLighting(
[CliArg("confirm", "Recommended (true): a bake overwrites existing lightmap data. Accepted for parity; not required.")] bool confirm = false,
[CliArg("dry_run", "If true, validate there is an open bakeable scene and return the current lighting settings without baking.")] bool dryRun = false)
{
if (!BakeSceneGuard.HasBakeableScene())
return BakeSceneGuard.NoSceneResult();
// Reconcile before the in-progress check so a stale flag from a finished/cancelled bake
// doesn't wrongly report bake_in_progress.
OnUpdate();
if (Lightmapping.isRunning)
return new { code = "bake_in_progress", message = "A lighting bake is already running. Poll lighting_bake_status." };
if (dryRun)
return new { status = "dry_run", wouldBake = true, settings = ReadLightingSettings() };
var bakeId = Guid.NewGuid().ToString("N");
lock (s_Lock)
{
s_Tracking = true;
s_BakeId = bakeId;
s_StartedUtc = DateTime.UtcNow;
}
WriteStatus(new LightingBakeStatus
{
Status = "baking",
BakeId = bakeId,
IsBaking = true,
StartedUtcTicks = s_StartedUtc.Ticks
});
// BakeAsync returns false if the bake could not be started (e.g. another job in flight).
if (!Lightmapping.BakeAsync())
{
lock (s_Lock) { s_Tracking = false; }
WriteStatus(new LightingBakeStatus { Status = "idle" });
return new { code = "bake_in_progress", message = "Lightmapping.BakeAsync() refused to start (a bake may already be running)." };
}
return new { status = "baking", bakeId };
}
[CliCommand("lighting_bake_status", "Get the status of the last lighting bake: idle | baking | completed.", MainThreadRequired = false)]
public static string LightingBakeStatus()
{
if (File.Exists(StatusFile))
return File.ReadAllText(StatusFile);
return "{\"status\":\"idle\"}";
}
[CliCommand("cancel_lighting_bake", "Cancel an in-progress lighting bake (Lightmapping.Cancel()).")]
public static object CancelLightingBake()
{
var wasRunning = Lightmapping.isRunning;
if (wasRunning)
Lightmapping.Cancel();
lock (s_Lock)
{
if (s_Tracking)
{
var ms = (int)Math.Max(0, (DateTime.UtcNow - s_StartedUtc).TotalMilliseconds);
WriteStatus(new LightingBakeStatus
{
Status = "completed",
Result = "cancelled",
BakeId = s_BakeId,
BakeTimeMs = ms
});
s_Tracking = false;
}
}
return new { cancelled = wasRunning };
}
[CliCommand("clear_baked_lighting", "Clear baked lightmap data for the open scene(s). Destructive: requires confirm=true.")]
public static object ClearBakedLighting(
[CliArg("confirm", "Must be true to actually clear (destructive, not undoable via Unity's Undo).")] bool confirm = false,
[CliArg("include_disk_cache", "If true, also clear the GI disk cache (Lightmapping.ClearDiskCache()).")] bool includeDiskCache = false,
[CliArg("dry_run", "If true, report what would be cleared without clearing.")] bool dryRun = false)
{
if (!confirm)
throw new ArgumentException("Refusing to clear baked lighting. Pass confirm=true (destructive, not undoable via Unity's Undo).");
if (dryRun)
return new { status = "dry_run", wouldClear = true, includeDiskCache };
Lightmapping.Clear();
if (includeDiskCache)
Lightmapping.ClearDiskCache();
return new { cleared = true, includeDiskCache };
}
#endregion
#region settings
[CliCommand("get_lighting_settings", "Read the active LightingSettings (lightmapper, bounces, resolution, directional mode, AO, etc.).")]
public static LightingSettingsResult GetLightingSettings()
{
return ReadLightingSettings();
}
[CliCommand("set_lighting_settings", "Apply a subset of lighting settings to the active LightingSettings. Returns { applied[], unknown[] }.")]
public static object SetLightingSettings(
[CliArg("settings", "JSON object with a subset of lighting fields to set (same names/enums as get_lighting_settings).", Required = true)] JObject settings,
[CliArg("dry_run", "If true, validate the keys and report applied/unknown without changing anything.")] bool dryRun = false)
{
if (settings == null)
throw new ArgumentException("settings is required (a JSON object of lighting fields to set).");
// In dry_run we must not create or assign a LightingSettings asset (no writes). When no
// settings exist yet, validate the requested keys against a transient throwaway instance
// that is never assigned, so applied/unknown is still reported without persisting anything.
var ls = ActiveLightingSettings(allowCreate: !dryRun, out var source);
LightingSettings transient = null;
if (ls == null)
{
transient = new LightingSettings { name = "PipelineLightingSettings (dry-run)" };
ls = transient;
source = "none";
}
var applied = new System.Collections.Generic.List<string>();
var unknown = new System.Collections.Generic.List<string>();
foreach (var prop in settings.Properties())
{
if (TryApply(ls, prop.Name, prop.Value, dryRun))
applied.Add(prop.Name);
else
unknown.Add(prop.Name);
}
if (!dryRun && applied.Count > 0)
EditorUtility.SetDirty(ls);
// The transient dry-run instance is never assigned anywhere; destroy it so it doesn't leak.
if (transient != null)
UnityEngine.Object.DestroyImmediate(transient);
return new
{
applied = applied.ToArray(),
unknown = unknown.ToArray(),
settingsSource = source,
dryRun
};
}
#endregion
#region completion / reload bookkeeping
static void OnBakeCompleted()
{
lock (s_Lock)
{
if (!s_Tracking)
return;
Finish("succeeded");
}
}
/// <summary>
/// Defensive completion detector: if we are tracking a bake but Lightmapping reports it is no
/// longer running and the completion callback didn't fire (e.g. a cancel, or a bake that ended
/// during a frame we didn't get the callback), finalize the status here.
/// </summary>
static void OnUpdate()
{
lock (s_Lock)
{
if (s_Tracking && !Lightmapping.isRunning)
Finish("succeeded");
}
}
// Must be called under s_Lock.
static void Finish(string result)
{
var ms = (int)Math.Max(0, (DateTime.UtcNow - s_StartedUtc).TotalMilliseconds);
var status = new LightingBakeStatus
{
Status = "completed",
Result = result,
BakeId = s_BakeId,
BakeTimeMs = ms,
Stats = result == "succeeded" ? CollectStats() : null
};
WriteStatus(status);
s_Tracking = false;
}
/// <summary>
/// Reconcile recorded in-flight state after a domain reload. The status file under Temp/ survives
/// the reload; if it said "baking" but Lightmapping is no longer running, the bake finished while
/// the domain was being reloaded, so mark it completed. If it is still running, resume tracking.
/// </summary>
static void OnReload()
{
if (!File.Exists(StatusFile))
return;
LightingBakeStatus prior;
try
{
prior = JsonConvert.DeserializeObject<LightingBakeStatus>(File.ReadAllText(StatusFile));
}
catch
{
return;
}
if (prior == null || prior.Status != "baking")
return;
lock (s_Lock)
{
s_BakeId = prior.BakeId;
s_StartedUtc = prior.StartedUtcTicks > 0 ? new DateTime(prior.StartedUtcTicks, DateTimeKind.Utc) : DateTime.UtcNow;
if (Lightmapping.isRunning)
{
s_Tracking = true; // resume; OnUpdate/OnBakeCompleted will finalize.
}
else
{
s_Tracking = true;
Finish("succeeded"); // ended during the reload window.
}
}
}
#endregion
#region settings read/write helpers
/// <summary>
/// The active <see cref="LightingSettings"/> for the open scene. When the scene has no explicit
/// LightingSettings asset assigned, Unity falls back to a hidden per-scene default that
/// <see cref="Lightmapping.GetLightingSettingsForScene"/> / <see cref="Lightmapping.lightingSettings"/>
/// still exposes; we read/write that. <paramref name="source"/> records which we used.
///
/// When none is available and <paramref name="allowCreate"/> is true, a new settings asset is
/// created and assigned so a real (non-dry_run) set has a target. When <paramref name="allowCreate"/>
/// is false (dry_run), this returns <c>null</c> rather than creating/assigning anything, so the
/// dry_run "no writes" contract holds.
/// </summary>
static LightingSettings ActiveLightingSettings(bool allowCreate, out string source)
{
// Lightmapping.lightingSettings returns the active scene's settings (assigned asset or the
// scene's embedded/default settings). It throws if there is genuinely none; guard for that.
try
{
var ls = Lightmapping.lightingSettings;
if (ls != null)
{
source = AssetDatabase.Contains(ls) ? "asset" : "scene-default";
return ls;
}
}
catch
{
// fall through to (optionally) creating one
}
if (!allowCreate)
{
source = "none";
return null;
}
// No settings available: create and assign one so set_lighting_settings has a target.
var created = new LightingSettings { name = "PipelineLightingSettings" };
Lightmapping.lightingSettings = created;
source = "created";
return created;
}
static LightingSettingsResult ReadLightingSettings()
{
LightingSettings ls;
try
{
ls = Lightmapping.lightingSettings;
}
catch
{
ls = null;
}
if (ls == null)
return new LightingSettingsResult { Available = false };
return new LightingSettingsResult
{
Available = true,
Lightmapper = ls.lightmapper.ToString(),
BakedGI = ls.bakedGI,
RealtimeGI = ls.realtimeGI,
DirectSampleCount = ls.directSampleCount,
IndirectSampleCount = ls.indirectSampleCount,
EnvironmentSampleCount = ls.environmentSampleCount,
Bounces = ls.maxBounces,
LightmapResolution = ls.lightmapResolution,
LightmapPadding = ls.lightmapPadding,
MaxLightmapSize = ls.lightmapMaxSize,
LightmapCompression = ls.lightmapCompression.ToString(),
// Unity 6000 serialises directionalityMode as a flags enum whose .ToString() can
// produce compound strings (e.g. "Single, Dual") for values that span multiple bits.
// Map to the two-value contract the spec guarantees: 0 → NonDirectional, else → Directional.
DirectionalMode = (int)ls.directionalityMode == 0 ? "NonDirectional" : "Directional",
Ao = ls.ao,
AoMaxDistance = ls.aoMaxDistance,
FilteringMode = ls.filteringMode.ToString()
};
}
/// <summary>
/// Apply one settings key/value to the LightingSettings. Returns false for unrecognized keys so
/// the caller can report them as <c>unknown[]</c>. Enum-valued fields accept their string names
/// (case-insensitive). In dry-run mode we still validate (parse) the value but do not assign.
/// </summary>
static bool TryApply(LightingSettings ls, string key, JToken value, bool dryRun)
{
switch (key)
{
case "lightmapper":
return SetEnum<LightingSettings.Lightmapper>(value, dryRun, v => ls.lightmapper = v);
case "bakedGI":
if (!dryRun) ls.bakedGI = value.Value<bool>();
return true;
case "realtimeGI":
if (!dryRun) ls.realtimeGI = value.Value<bool>();
return true;
case "directSampleCount":
if (!dryRun) ls.directSampleCount = value.Value<int>();
return true;
case "indirectSampleCount":
if (!dryRun) ls.indirectSampleCount = value.Value<int>();
return true;
case "environmentSampleCount":
if (!dryRun) ls.environmentSampleCount = value.Value<int>();
return true;
case "bounces":
if (!dryRun) ls.maxBounces = value.Value<int>();
return true;
case "lightmapResolution":
if (!dryRun) ls.lightmapResolution = value.Value<float>();
return true;
case "lightmapPadding":
if (!dryRun) ls.lightmapPadding = value.Value<int>();
return true;
case "maxLightmapSize":
if (!dryRun) ls.lightmapMaxSize = value.Value<int>();
return true;
case "lightmapCompression":
return SetEnum<LightmapCompression>(value, dryRun, v => ls.lightmapCompression = v);
case "directionalMode":
// Accept the two spec names ("NonDirectional", "Directional") and also Unity's
// internal name ("CombinedDirectional") for back-compat. Integer values pass through
// as-is so callers can target any raw enum slot Unity exposes.
if (value.Type == JTokenType.Integer)
{
if (!dryRun) ls.directionalityMode = (LightmapsMode)value.Value<int>();
return true;
}
var modeStr = value.Value<string>() ?? string.Empty;
if (modeStr.Equals("NonDirectional", StringComparison.OrdinalIgnoreCase))
{
if (!dryRun) ls.directionalityMode = (LightmapsMode)0;
return true;
}
if (modeStr.Equals("Directional", StringComparison.OrdinalIgnoreCase) ||
modeStr.Equals("CombinedDirectional", StringComparison.OrdinalIgnoreCase))
{
if (!dryRun) ls.directionalityMode = (LightmapsMode)1;
return true;
}
throw new ArgumentException(
$"Value '{modeStr}' is not valid for directionalMode. Valid: NonDirectional, Directional.");
case "ao":
if (!dryRun) ls.ao = value.Value<bool>();
return true;
case "aoMaxDistance":
if (!dryRun) ls.aoMaxDistance = value.Value<float>();
return true;
case "filteringMode":
return SetEnum<LightingSettings.FilterMode>(value, dryRun, v => ls.filteringMode = v);
default:
return false;
}
}
static bool SetEnum<TEnum>(JToken value, bool dryRun, Action<TEnum> assign) where TEnum : struct
{
// Accept either the enum's string name (case-insensitive) or its integer value.
if (value.Type == JTokenType.Integer)
{
var iv = value.Value<int>();
if (!Enum.IsDefined(typeof(TEnum), iv))
throw new ArgumentException($"Value '{iv}' is not valid for {typeof(TEnum).Name}.");
if (!dryRun) assign((TEnum)Enum.ToObject(typeof(TEnum), iv));
return true;
}
var name = value.Value<string>();
if (!Enum.TryParse<TEnum>(name, ignoreCase: true, out var parsed))
throw new ArgumentException(
$"Value '{name}' is not valid for {typeof(TEnum).Name}. Valid: {string.Join(", ", Enum.GetNames(typeof(TEnum)))}.");
if (!dryRun) assign(parsed);
return true;
}
#endregion
#region stats
/// <summary>
/// Collect post-bake lightmap statistics from <see cref="LightmapSettings.lightmaps"/>: the
/// number of baked lightmaps, their total texture size in bytes (color maps), the atlas size,
/// the active lightmap resolution, and the baked light-probe count.
/// </summary>
static LightingBakeStats CollectStats()
{
var stats = new LightingBakeStats();
var maps = LightmapSettings.lightmaps;
if (maps != null)
{
stats.LightmapCount = maps.Length;
long total = 0;
int atlas = 0;
foreach (var data in maps)
{
var tex = data?.lightmapColor;
if (tex == null)
continue;
atlas = Mathf.Max(atlas, Mathf.Max(tex.width, tex.height));
total += EstimateTextureBytes(tex);
}
stats.TotalLightmapSizeBytes = total;
stats.AtlasSize = atlas;
}
try
{
stats.LightmapResolution = Lightmapping.lightingSettings != null
? Lightmapping.lightingSettings.lightmapResolution
: 0f;
}
catch
{
stats.LightmapResolution = 0f;
}
var probes = LightmapSettings.lightProbes;
stats.BakedProbeCount = probes != null ? probes.count : 0;
return stats;
}
/// <summary>Rough byte estimate for a baked lightmap texture (4 bytes/pixel; good enough for a size signal).</summary>
static long EstimateTextureBytes(Texture tex)
{
return (long)tex.width * tex.height * 4;
}
#endregion
static void WriteStatus(LightingBakeStatus status)
{
try
{
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
}
catch (Exception ex)
{
Debug.LogError($"[LightingBake] Failed to write status file: {ex.Message}");
}
}
}
/// <summary>Status/result payload for <c>bake_lighting</c> / <c>lighting_bake_status</c>.</summary>
[Serializable]
public class LightingBakeStatus
{
[JsonProperty("status")]
public string Status { get; set; }
[JsonProperty("bakeId")]
public string BakeId { get; set; }
[JsonProperty("result", NullValueHandling = NullValueHandling.Ignore)]
public string Result { get; set; }
[JsonProperty("isBaking")]
public bool IsBaking { get; set; }
[JsonProperty("bakeTimeMs")]
public int BakeTimeMs { get; set; }
[JsonProperty("stats", NullValueHandling = NullValueHandling.Ignore)]
public LightingBakeStats Stats { get; set; }
// Internal bookkeeping so a bake can be reconciled across a domain reload. Not part of the
// documented schema, but harmless to expose.
[JsonProperty("startedUtcTicks")]
public long StartedUtcTicks { get; set; }
}
/// <summary>Post-bake lightmap statistics (CLI-215 acceptance: lightmapCount &gt;= 1, bakeTimeMs &gt; 0).</summary>
[Serializable]
public class LightingBakeStats
{
[JsonProperty("lightmapCount")]
public int LightmapCount { get; set; }
[JsonProperty("totalLightmapSizeBytes")]
public long TotalLightmapSizeBytes { get; set; }
[JsonProperty("atlasSize")]
public int AtlasSize { get; set; }
[JsonProperty("lightmapResolution")]
public float LightmapResolution { get; set; }
[JsonProperty("bakedProbeCount")]
public int BakedProbeCount { get; set; }
}
/// <summary>Result of <c>get_lighting_settings</c> (and the dry-run payload of bake_lighting).</summary>
[Serializable]
public class LightingSettingsResult
{
/// <summary>False when no LightingSettings could be read for the active scene.</summary>
[JsonProperty("available")]
public bool Available { get; set; }
[JsonProperty("lightmapper", NullValueHandling = NullValueHandling.Ignore)]
public string Lightmapper { get; set; }
[JsonProperty("bakedGI")]
public bool BakedGI { get; set; }
[JsonProperty("realtimeGI")]
public bool RealtimeGI { get; set; }
[JsonProperty("directSampleCount")]
public int DirectSampleCount { get; set; }
[JsonProperty("indirectSampleCount")]
public int IndirectSampleCount { get; set; }
[JsonProperty("environmentSampleCount")]
public int EnvironmentSampleCount { get; set; }
[JsonProperty("bounces")]
public int Bounces { get; set; }
[JsonProperty("lightmapResolution")]
public float LightmapResolution { get; set; }
[JsonProperty("lightmapPadding")]
public int LightmapPadding { get; set; }
[JsonProperty("maxLightmapSize")]
public int MaxLightmapSize { get; set; }
[JsonProperty("lightmapCompression", NullValueHandling = NullValueHandling.Ignore)]
public string LightmapCompression { get; set; }
[JsonProperty("directionalMode", NullValueHandling = NullValueHandling.Ignore)]
public string DirectionalMode { get; set; }
[JsonProperty("ao")]
public bool Ao { get; set; }
[JsonProperty("aoMaxDistance")]
public float AoMaxDistance { get; set; }
[JsonProperty("filteringMode", NullValueHandling = NullValueHandling.Ignore)]
public string FilteringMode { get; set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dd7529c9d1f14ff98b9eb3e9a1e8ba5b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,424 @@
using System;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Commands;
using UnityEditor;
using UnityEditor.AI;
using UnityEngine;
// CLI-215 deliberately targets the built-in legacy UnityEditor.AI.NavMeshBuilder (see class doc). It is
// [Obsolete] in Unity 6000.x (the modern path is the com.unity.ai.navigation package), but is the
// intentional v1 baker here, so suppress CS0618 file-wide rather than migrate.
#pragma warning disable CS0618
namespace Unity.Pipeline.Editor.Commands.Baking
{
/// <summary>
/// CLI-215 Group B — NavMesh bake commands, targeting the <b>built-in legacy</b> baker
/// <see cref="UnityEditor.AI.NavMeshBuilder"/> (always available; bakes a single scene NavMesh from
/// the Navigation-static geometry of the open scene). Trigger an async build, poll its status,
/// cancel it, clear the baked NavMesh, and read/configure the default agent's bake settings.
///
/// The newer AI Navigation package (<c>com.unity.ai.navigation</c>, <c>NavMeshSurface</c>
/// components) is OUT for v1 — we do NOT add an asmdef reference to it. <c>bake_navmesh_surfaces</c>
/// is a guarded stub that probes for the package by reflection and returns
/// <c>{ code: "package_not_found" }</c> when it is absent (full support is a follow-up).
///
/// Async pattern mirrors <see cref="BuildCommand"/> / <see cref="LightingBakeCommands"/>: trigger
/// returns immediately, completion is detected by <see cref="NavMeshBuilder.isRunning"/> flipping
/// false (polled in <see cref="EditorApplication.update"/>), and in-flight state is held in static
/// fields reconciled against a Temp/ status file across domain reloads.
/// </summary>
[InitializeOnLoad]
public static class NavMeshBakeCommands
{
const string StatusFile = "Temp/pipeline_navmesh_bake_status.json";
// The serialized NavMesh bake-settings live on the Navigation window's settings object, keyed by
// these property names (stable across 2019+ / Unity 6).
const string PropAgentRadius = "m_BuildSettings.agentRadius";
const string PropAgentHeight = "m_BuildSettings.agentHeight";
const string PropAgentSlope = "m_BuildSettings.agentSlope";
const string PropAgentClimb = "m_BuildSettings.agentClimb";
const string PropMinRegionArea = "m_BuildSettings.minRegionArea";
const string PropManualCellSize = "m_BuildSettings.manualCellSize";
const string PropCellSize = "m_BuildSettings.cellSize";
static readonly object s_Lock = new object();
static bool s_Tracking;
static string s_BakeId;
static DateTime s_StartedUtc;
static NavMeshBakeCommands()
{
OnReload();
EditorApplication.update += OnUpdate;
}
#region bake_navmesh
[CliCommand("bake_navmesh", "Trigger an async legacy NavMesh bake of the open scene(s) via UnityEditor.AI.NavMeshBuilder. Returns immediately; poll navmesh_bake_status until completed.")]
public static object BakeNavMesh(
[CliArg("confirm", "Accepted for parity (a bake overwrites the existing NavMesh); not required.")] bool confirm = false,
[CliArg("dry_run", "If true, validate there is an open scene and return current NavMesh settings without baking.")] bool dryRun = false)
{
if (!BakeSceneGuard.HasBakeableScene())
return BakeSceneGuard.NoSceneResult();
OnUpdate(); // reconcile a possibly-stale tracking flag first.
if (NavMeshBuilder.isRunning)
return new { code = "bake_in_progress", message = "A NavMesh bake is already running. Poll navmesh_bake_status." };
if (dryRun)
return new { status = "dry_run", wouldBake = true, settings = ReadNavMeshSettings() };
var bakeId = Guid.NewGuid().ToString("N");
lock (s_Lock)
{
s_Tracking = true;
s_BakeId = bakeId;
s_StartedUtc = DateTime.UtcNow;
}
WriteStatus(new NavMeshBakeStatus
{
Status = "baking",
BakeId = bakeId,
StartedUtcTicks = s_StartedUtc.Ticks
});
NavMeshBuilder.BuildNavMeshAsync();
return new { status = "baking", bakeId };
}
[CliCommand("navmesh_bake_status", "Get the status of the last NavMesh bake: idle | baking | completed.", MainThreadRequired = false)]
public static string NavMeshBakeStatus()
{
if (File.Exists(StatusFile))
return File.ReadAllText(StatusFile);
return "{\"status\":\"idle\"}";
}
[CliCommand("cancel_navmesh_bake", "Cancel an in-progress NavMesh bake (NavMeshBuilder.Cancel()).")]
public static object CancelNavMeshBake()
{
var wasRunning = NavMeshBuilder.isRunning;
if (wasRunning)
NavMeshBuilder.Cancel();
lock (s_Lock)
{
if (s_Tracking)
{
var ms = (int)Math.Max(0, (DateTime.UtcNow - s_StartedUtc).TotalMilliseconds);
WriteStatus(new NavMeshBakeStatus
{
Status = "completed",
Result = "cancelled",
BakeId = s_BakeId,
BakeTimeMs = ms
});
s_Tracking = false;
}
}
return new { cancelled = wasRunning };
}
[CliCommand("clear_navmesh", "Clear the baked NavMesh for the open scene(s). Destructive: requires confirm=true.")]
public static object ClearNavMesh(
[CliArg("confirm", "Must be true to actually clear (destructive, not undoable via Unity's Undo).")] bool confirm = false,
[CliArg("dry_run", "If true, report what would be cleared without clearing.")] bool dryRun = false)
{
if (!confirm)
throw new ArgumentException("Refusing to clear the NavMesh. Pass confirm=true (destructive, not undoable via Unity's Undo).");
if (dryRun)
return new { status = "dry_run", wouldClear = true };
NavMeshBuilder.ClearAllNavMeshes();
return new { cleared = true };
}
#endregion
#region settings
[CliCommand("get_navmesh_settings", "Read the default agent's legacy NavMesh bake settings (agentRadius/Height/Slope/Climb, minRegionArea, voxelSize).")]
public static NavMeshSettingsResult GetNavMeshSettings()
{
return ReadNavMeshSettings();
}
[CliCommand("set_navmesh_settings", "Apply a subset of legacy NavMesh bake settings to the default agent. Returns { applied[], unknown[] }.")]
public static object SetNavMeshSettings(
[CliArg("settings", "JSON object with a subset of NavMesh fields to set (same names as get_navmesh_settings).", Required = true)] JObject settings,
[CliArg("dry_run", "If true, validate the keys and report applied/unknown without changing anything.")] bool dryRun = false)
{
if (settings == null)
throw new ArgumentException("settings is required (a JSON object of NavMesh fields to set).");
// NavMeshBuilder.navMeshSettingsObject is a UnityEngine.Object (the Navigation window's
// settings holder); wrap it in a SerializedObject to read/write the m_BuildSettings fields.
var settingsObject = NavMeshBuilder.navMeshSettingsObject;
if (settingsObject == null)
return new { code = "settings_unavailable", message = "Could not access the legacy NavMesh settings object." };
var so = new SerializedObject(settingsObject);
so.Update();
var applied = new List<string>();
var unknown = new List<string>();
foreach (var prop in settings.Properties())
{
if (TryApply(so, prop.Name, prop.Value, dryRun))
applied.Add(prop.Name);
else
unknown.Add(prop.Name);
}
if (!dryRun && applied.Count > 0)
so.ApplyModifiedPropertiesWithoutUndo();
return new { applied = applied.ToArray(), unknown = unknown.ToArray(), dryRun };
}
/// <summary>
/// Guarded stub for the AI Navigation package (<c>com.unity.ai.navigation</c>) NavMeshSurface
/// bake path. We never reference that assembly, so probe for the type by reflection; absent → the
/// documented <c>package_not_found</c> result. Full multi-surface baking is a follow-up.
/// </summary>
[CliCommand("bake_navmesh_surfaces", "Bake NavMeshSurface components (AI Navigation package). v1 stub: returns package_not_found when the package is absent.")]
public static object BakeNavMeshSurfaces()
{
var surfaceType = Type.GetType("Unity.AI.Navigation.NavMeshSurface, Unity.AI.Navigation")
?? Type.GetType("UnityEngine.AI.NavMeshSurface, Unity.AI.Navigation");
if (surfaceType == null)
return new
{
code = "package_not_found",
message = "The AI Navigation package (com.unity.ai.navigation) is not installed. " +
"NavMeshSurface-based baking is out of scope for v1; use bake_navmesh for the built-in baker."
};
return new
{
code = "not_implemented",
message = "AI Navigation package detected, but NavMeshSurface baking is not implemented in v1 (follow-up). Use bake_navmesh."
};
}
#endregion
#region completion / reload bookkeeping
static void OnUpdate()
{
lock (s_Lock)
{
if (s_Tracking && !NavMeshBuilder.isRunning)
Finish("succeeded");
}
}
// Must be called under s_Lock.
static void Finish(string result)
{
var ms = (int)Math.Max(0, (DateTime.UtcNow - s_StartedUtc).TotalMilliseconds);
WriteStatus(new NavMeshBakeStatus
{
Status = "completed",
Result = result,
BakeId = s_BakeId,
BakeTimeMs = ms
});
s_Tracking = false;
}
static void OnReload()
{
if (!File.Exists(StatusFile))
return;
NavMeshBakeStatus prior;
try
{
prior = JsonConvert.DeserializeObject<NavMeshBakeStatus>(File.ReadAllText(StatusFile));
}
catch
{
return;
}
if (prior == null || prior.Status != "baking")
return;
lock (s_Lock)
{
s_BakeId = prior.BakeId;
s_StartedUtc = prior.StartedUtcTicks > 0 ? new DateTime(prior.StartedUtcTicks, DateTimeKind.Utc) : DateTime.UtcNow;
s_Tracking = true;
if (!NavMeshBuilder.isRunning)
Finish("succeeded");
}
}
#endregion
#region settings read/write helpers
static NavMeshSettingsResult ReadNavMeshSettings()
{
var settingsObject = NavMeshBuilder.navMeshSettingsObject;
if (settingsObject == null)
return new NavMeshSettingsResult { Available = false };
var so = new SerializedObject(settingsObject);
so.Update();
var manual = FindBool(so, PropManualCellSize, false);
return new NavMeshSettingsResult
{
Available = true,
AgentRadius = FindFloat(so, PropAgentRadius, 0.5f),
AgentHeight = FindFloat(so, PropAgentHeight, 2f),
AgentSlope = FindFloat(so, PropAgentSlope, 45f),
AgentClimb = FindFloat(so, PropAgentClimb, 0.4f),
MinRegionArea = FindFloat(so, PropMinRegionArea, 2f),
VoxelSize = FindFloat(so, PropCellSize, 0f),
ManualVoxelSize = manual
};
}
static bool TryApply(SerializedObject so, string key, JToken value, bool dryRun)
{
switch (key)
{
case "agentRadius":
return SetFloat(so, PropAgentRadius, value, dryRun);
case "agentHeight":
return SetFloat(so, PropAgentHeight, value, dryRun);
case "agentSlope":
return SetFloat(so, PropAgentSlope, value, dryRun);
case "agentClimb":
return SetFloat(so, PropAgentClimb, value, dryRun);
case "minRegionArea":
return SetFloat(so, PropMinRegionArea, value, dryRun);
case "voxelSize":
// Setting an explicit voxel size implies manual override on.
{
var ok = SetFloat(so, PropCellSize, value, dryRun);
if (ok && !dryRun)
{
var manual = so.FindProperty(PropManualCellSize);
if (manual != null) manual.boolValue = true;
}
return ok;
}
case "manualVoxelSize":
return SetBool(so, PropManualCellSize, value, dryRun);
default:
return false;
}
}
static bool SetFloat(SerializedObject so, string propPath, JToken value, bool dryRun)
{
var prop = so.FindProperty(propPath);
if (prop == null)
return false;
var f = value.Value<float>();
if (!dryRun) prop.floatValue = f;
return true;
}
static bool SetBool(SerializedObject so, string propPath, JToken value, bool dryRun)
{
var prop = so.FindProperty(propPath);
if (prop == null)
return false;
var b = value.Value<bool>();
if (!dryRun) prop.boolValue = b;
return true;
}
static float FindFloat(SerializedObject so, string propPath, float fallback)
{
var prop = so.FindProperty(propPath);
return prop != null ? prop.floatValue : fallback;
}
static bool FindBool(SerializedObject so, string propPath, bool fallback)
{
var prop = so.FindProperty(propPath);
return prop != null ? prop.boolValue : fallback;
}
#endregion
static void WriteStatus(NavMeshBakeStatus status)
{
try
{
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
}
catch (Exception ex)
{
Debug.LogError($"[NavMeshBake] Failed to write status file: {ex.Message}");
}
}
}
/// <summary>Status/result payload for <c>bake_navmesh</c> / <c>navmesh_bake_status</c>.</summary>
[Serializable]
public class NavMeshBakeStatus
{
[JsonProperty("status")]
public string Status { get; set; }
[JsonProperty("bakeId")]
public string BakeId { get; set; }
[JsonProperty("result", NullValueHandling = NullValueHandling.Ignore)]
public string Result { get; set; }
[JsonProperty("bakeTimeMs")]
public int BakeTimeMs { get; set; }
[JsonProperty("startedUtcTicks")]
public long StartedUtcTicks { get; set; }
}
/// <summary>Result of <c>get_navmesh_settings</c> (default-agent legacy bake settings).</summary>
[Serializable]
public class NavMeshSettingsResult
{
[JsonProperty("available")]
public bool Available { get; set; }
[JsonProperty("agentRadius")]
public float AgentRadius { get; set; }
[JsonProperty("agentHeight")]
public float AgentHeight { get; set; }
[JsonProperty("agentSlope")]
public float AgentSlope { get; set; }
[JsonProperty("agentClimb")]
public float AgentClimb { get; set; }
[JsonProperty("minRegionArea")]
public float MinRegionArea { get; set; }
[JsonProperty("voxelSize")]
public float VoxelSize { get; set; }
[JsonProperty("manualVoxelSize")]
public bool ManualVoxelSize { get; set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5173e39c97c44308ad4b80462eaf7982
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,265 @@
using System;
using System.IO;
using Newtonsoft.Json;
using Unity.Pipeline.Commands;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Editor.Commands.Baking
{
/// <summary>
/// CLI-215 Group C — Occlusion Culling bake commands. Trigger an async occlusion bake of the open
/// scene(s) via <see cref="StaticOcclusionCulling.GenerateInBackground"/>, poll its status, cancel
/// it, and clear the baked occlusion data.
///
/// Async pattern mirrors <see cref="BuildCommand"/> / <see cref="LightingBakeCommands"/>: trigger
/// returns immediately; completion is detected by <see cref="StaticOcclusionCulling.isRunning"/>
/// flipping false (polled in <see cref="EditorApplication.update"/>); in-flight state is held in
/// static fields and reconciled against a Temp/ status file across domain reloads.
/// </summary>
[InitializeOnLoad]
public static class OcclusionBakeCommands
{
const string StatusFile = "Temp/pipeline_occlusion_bake_status.json";
// Sentinel for "argument omitted" on the float bake parameters. float.NaN is not a
// compile-time constant (so it can't be a default parameter value), but float.MinValue is, and
// it is not a plausible real value for any of these positive bake parameters.
const float Unset = float.MinValue;
static readonly object s_Lock = new object();
static bool s_Tracking;
static string s_BakeId;
static DateTime s_StartedUtc;
static OcclusionBakeCommands()
{
OnReload();
EditorApplication.update += OnUpdate;
}
#region bake_occlusion_culling
[CliCommand("bake_occlusion_culling", "Trigger an async occlusion-culling bake of the open scene(s) via StaticOcclusionCulling.GenerateInBackground(). Returns immediately; poll occlusion_bake_status until completed.")]
public static object BakeOcclusionCulling(
[CliArg("smallest_occluder", "Smallest object that will occlude others (meters). Defaults to Unity's current value.")] float smallestOccluder = Unset,
[CliArg("smallest_hole", "Smallest gap geometry can have that the view can see through (meters). Defaults to Unity's current value.")] float smallestHole = Unset,
[CliArg("backface_threshold", "Backface threshold (1-100); lower trims more backfaces. Defaults to Unity's current value.")] float backfaceThreshold = Unset,
[CliArg("confirm", "Accepted for parity (a bake overwrites existing occlusion data); not required.")] bool confirm = false,
[CliArg("dry_run", "If true, validate there is an open scene and report the parameters that would be used without baking.")] bool dryRun = false)
{
if (!BakeSceneGuard.HasBakeableScene())
return BakeSceneGuard.NoSceneResult();
OnUpdate(); // reconcile any stale tracking flag first.
if (StaticOcclusionCulling.isRunning)
return new { code = "bake_in_progress", message = "An occlusion bake is already running. Poll occlusion_bake_status." };
// Validate explicitly provided values before resolving. An omitted arg is the Unset
// sentinel (float.MinValue); NaN is never <= Unset, so a NaN arg is treated as provided
// and rejected here. smallest_occluder / smallest_hole must be positive; backface_threshold
// is documented as 1-100.
if (!IsOmitted(smallestOccluder) && !(smallestOccluder > 0f))
throw new ArgumentException($"smallest_occluder must be greater than 0 (got {smallestOccluder}).");
if (!IsOmitted(smallestHole) && !(smallestHole > 0f))
throw new ArgumentException($"smallest_hole must be greater than 0 (got {smallestHole}).");
if (!IsOmitted(backfaceThreshold) && !(backfaceThreshold >= 1f && backfaceThreshold <= 100f))
throw new ArgumentException($"backface_threshold must be between 1 and 100 (got {backfaceThreshold}).");
// Resolve effective parameters: an omitted arg (the Unset sentinel) keeps Unity's current value.
var occluder = IsOmitted(smallestOccluder) ? StaticOcclusionCulling.smallestOccluder : smallestOccluder;
var hole = IsOmitted(smallestHole) ? StaticOcclusionCulling.smallestHole : smallestHole;
var backface = IsOmitted(backfaceThreshold) ? StaticOcclusionCulling.backfaceThreshold : backfaceThreshold;
if (dryRun)
{
return new
{
status = "dry_run",
wouldBake = true,
parameters = new { smallestOccluder = occluder, smallestHole = hole, backfaceThreshold = backface }
};
}
// Apply the (possibly overridden) bake parameters before generating.
StaticOcclusionCulling.smallestOccluder = occluder;
StaticOcclusionCulling.smallestHole = hole;
StaticOcclusionCulling.backfaceThreshold = backface;
var bakeId = Guid.NewGuid().ToString("N");
lock (s_Lock)
{
s_Tracking = true;
s_BakeId = bakeId;
s_StartedUtc = DateTime.UtcNow;
}
WriteStatus(new OcclusionBakeStatus
{
Status = "baking",
BakeId = bakeId,
StartedUtcTicks = s_StartedUtc.Ticks
});
StaticOcclusionCulling.GenerateInBackground();
return new { status = "baking", bakeId };
}
[CliCommand("occlusion_bake_status", "Get the status of the last occlusion bake: idle | baking | completed.", MainThreadRequired = false)]
public static string OcclusionBakeStatus()
{
if (File.Exists(StatusFile))
return File.ReadAllText(StatusFile);
return "{\"status\":\"idle\"}";
}
[CliCommand("cancel_occlusion_bake", "Cancel an in-progress occlusion bake (StaticOcclusionCulling.Cancel()).")]
public static object CancelOcclusionBake()
{
var wasRunning = StaticOcclusionCulling.isRunning;
if (wasRunning)
StaticOcclusionCulling.Cancel();
lock (s_Lock)
{
if (s_Tracking)
{
var ms = (int)Math.Max(0, (DateTime.UtcNow - s_StartedUtc).TotalMilliseconds);
WriteStatus(new OcclusionBakeStatus
{
Status = "completed",
Result = "cancelled",
BakeId = s_BakeId,
BakeTimeMs = ms
});
s_Tracking = false;
}
}
return new { cancelled = wasRunning };
}
[CliCommand("clear_occlusion_culling", "Clear baked occlusion-culling data for the open scene(s). Destructive: requires confirm=true.")]
public static object ClearOcclusionCulling(
[CliArg("confirm", "Must be true to actually clear (destructive, not undoable via Unity's Undo).")] bool confirm = false,
[CliArg("dry_run", "If true, report what would be cleared without clearing.")] bool dryRun = false)
{
if (!confirm)
throw new ArgumentException("Refusing to clear occlusion culling data. Pass confirm=true (destructive, not undoable via Unity's Undo).");
if (dryRun)
return new { status = "dry_run", wouldClear = true };
StaticOcclusionCulling.Clear();
return new { cleared = true };
}
// True when the caller omitted a float bake arg (left it at the Unset sentinel). NaN is never
// <= Unset, so NaN counts as provided and is caught by the range validation above.
static bool IsOmitted(float value) => value <= Unset;
#endregion
#region completion / reload bookkeeping
static void OnUpdate()
{
lock (s_Lock)
{
if (s_Tracking && !StaticOcclusionCulling.isRunning)
Finish("succeeded");
}
}
// Must be called under s_Lock.
static void Finish(string result)
{
var ms = (int)Math.Max(0, (DateTime.UtcNow - s_StartedUtc).TotalMilliseconds);
WriteStatus(new OcclusionBakeStatus
{
Status = "completed",
Result = result,
BakeId = s_BakeId,
BakeTimeMs = ms,
Stats = result == "succeeded"
? new OcclusionBakeStats { UmbraDataSizeBytes = StaticOcclusionCulling.umbraDataSize }
: null
});
s_Tracking = false;
}
static void OnReload()
{
if (!File.Exists(StatusFile))
return;
OcclusionBakeStatus prior;
try
{
prior = JsonConvert.DeserializeObject<OcclusionBakeStatus>(File.ReadAllText(StatusFile));
}
catch
{
return;
}
if (prior == null || prior.Status != "baking")
return;
lock (s_Lock)
{
s_BakeId = prior.BakeId;
s_StartedUtc = prior.StartedUtcTicks > 0 ? new DateTime(prior.StartedUtcTicks, DateTimeKind.Utc) : DateTime.UtcNow;
s_Tracking = true;
if (!StaticOcclusionCulling.isRunning)
Finish("succeeded");
}
}
#endregion
static void WriteStatus(OcclusionBakeStatus status)
{
try
{
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
}
catch (Exception ex)
{
Debug.LogError($"[OcclusionBake] Failed to write status file: {ex.Message}");
}
}
}
/// <summary>Status/result payload for <c>bake_occlusion_culling</c> / <c>occlusion_bake_status</c>.</summary>
[Serializable]
public class OcclusionBakeStatus
{
[JsonProperty("status")]
public string Status { get; set; }
[JsonProperty("bakeId")]
public string BakeId { get; set; }
[JsonProperty("result", NullValueHandling = NullValueHandling.Ignore)]
public string Result { get; set; }
[JsonProperty("bakeTimeMs")]
public int BakeTimeMs { get; set; }
[JsonProperty("stats", NullValueHandling = NullValueHandling.Ignore)]
public OcclusionBakeStats Stats { get; set; }
[JsonProperty("startedUtcTicks")]
public long StartedUtcTicks { get; set; }
}
/// <summary>Post-bake occlusion statistics (umbra data size in bytes).</summary>
[Serializable]
public class OcclusionBakeStats
{
[JsonProperty("umbraDataSizeBytes")]
public long UmbraDataSizeBytes { get; set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 00b06ed57bce4c0bb281527e016889dd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bdb8ef5b0513b0d488ac3be1a9a77185
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,577 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEngine;
namespace Unity.Pipeline.Editor.Commands.Build
{
/// <summary>
/// Triggers a Player build and reports the full <see cref="BuildReport"/> (CLI-204). Fills out the
/// original <c>build</c>/<c>build_status</c> stub (CLI-127) with the complete param contract and the
/// structured report shape so build failures can be diagnosed without the Editor UI.
///
/// Why this is async (trigger-then-poll, mirroring <see cref="RecompileCommand"/> /
/// <see cref="PackageManager.PackageManagerCommand"/>): <see cref="BuildPipeline.BuildPlayer(BuildPlayerOptions)"/>
/// runs synchronously and freezes the main thread for the whole build (seconds to minutes). If
/// <c>build</c> ran it inline it would block the server's (serial) request loop and the call could
/// never return. Instead <c>build</c> validates + queues and returns <c>queued</c> immediately; an
/// <see cref="EditorApplication.update"/> hook runs the build on the next tick. Poll <c>build_status</c>
/// until <c>status == "completed"</c>.
///
/// <c>build_status</c> is deliberately <c>MainThreadRequired = false</c>: while the build holds the
/// main thread, any main-thread command (e.g. editor_status) would hang, but build_status only reads
/// a Temp status file off-thread, so polling keeps working throughout the build. The status file also
/// survives the domain reload a build can incur, so the last report is retained until the next build.
/// </summary>
[InitializeOnLoad]
public static class BuildCommand
{
const string StatusFile = "Temp/pipeline_build_status.json";
// BuildOptions that callers may pass by name (the supported set from the ticket). Parsed
// case-insensitively; anything outside this set is a validation error rather than silently
// tolerated, so a typo surfaces instead of changing the build shape.
static readonly Dictionary<string, BuildOptions> s_SupportedOptions =
new Dictionary<string, BuildOptions>(StringComparer.OrdinalIgnoreCase)
{
["Development"] = BuildOptions.Development,
["AllowDebugging"] = BuildOptions.AllowDebugging,
["ConnectWithProfiler"] = BuildOptions.ConnectWithProfiler,
// EnableHeadlessMode is obsolete in Unity 6 (superseded by StandaloneBuildSubtarget.Server)
// but still functions and is part of this command's documented option set (CLI-204), so we
// keep accepting it and suppress only the deprecation warning here.
#pragma warning disable 618
["EnableHeadlessMode"] = BuildOptions.EnableHeadlessMode,
#pragma warning restore 618
["SymlinkSources"] = BuildOptions.SymlinkSources,
["BuildAdditionalStreamedScenes"] = BuildOptions.BuildAdditionalStreamedScenes,
["CleanBuildCache"] = BuildOptions.CleanBuildCache,
["DetailedBuildReport"] = BuildOptions.DetailedBuildReport,
};
static readonly object s_Lock = new object();
static bool s_Pending;
static bool s_Building;
// Queued request, captured under s_Lock by build() and consumed by the update pump.
static BuildTarget s_PendingTarget;
static string s_PendingOutput;
static BuildOptions s_PendingOptions;
static string[] s_PendingScenes;
static string s_PendingProfile;
static string s_PendingBuildId;
static BuildCommand()
{
EditorApplication.update += OnUpdate;
}
[CliCommand("build",
"Trigger an async Player build and report the full BuildReport. Returns immediately (queued); " +
"poll build_status until status is 'completed'. DetailedBuildReport is included by default unless " +
"'options' is supplied. Use dry_run to validate without building.",
MainThreadRequired = false)]
public static object Build(
[CliArg("target", "BuildTarget name (e.g. StandaloneWindows64). Defaults to the active target. Must be installed.")] string target = "",
[CliArg("outputPath", "Output path (absolute, or relative to the project root). Defaults to the last/auto path.")] string outputPath = "",
[CliArg("profileName", "Build Profile name to activate before building (Unity 6 only; ignored otherwise).")] string profileName = "",
[CliArg("options", "BuildOptions names. Omit to get just DetailedBuildReport; supplying any disables that default.")] string[] options = null,
[CliArg("scenes", "Scene asset paths to build (e.g. Assets/Scenes/Main.unity). Defaults to EditorBuildSettings.")] string[] scenes = null,
[CliArg("confirm", "Acknowledge and run the build; without it the call is refused. Use dry_run to validate only.")] bool confirm = false,
[CliArg("dry_run", "Validate target/outputPath/scenes without building.")] bool dryRun = false)
{
// Validation + queueing both touch Unity APIs, so run them on the main thread. When invoked
// off-thread (the normal HTTP path) this marshals through the server dispatcher; in-process
// (tests, no server) it runs inline.
return RunOnMain<object>(() => ValidateAndQueue(target, outputPath, profileName, options, scenes, confirm, dryRun));
}
static object ValidateAndQueue(string targetArg, string outputArg, string profileName,
string[] optionArgs, string[] sceneArgs, bool confirm, bool dryRun)
{
lock (s_Lock)
{
if (!dryRun && (s_Building || s_Pending))
return new { status = "busy", success = false, message = "A build is already queued or in progress. Poll build_status." };
}
var errors = new List<object>();
// ---- target ----
var target = EditorUserBuildSettings.activeBuildTarget;
if (!string.IsNullOrWhiteSpace(targetArg))
{
if (!TryParseTarget(targetArg, out target))
errors.Add(Invalid("target", $"Unknown BuildTarget '{targetArg}'. Use a name from list_build_targets."));
else if (!BuildPipeline.IsBuildTargetSupported(BuildPipeline.GetBuildTargetGroup(target), target))
errors.Add(Invalid("target", $"Build target '{target}' is not installed (isInstalled=false in list_build_targets)."));
}
// ---- options ----
var options = BuildOptions.None;
if (optionArgs != null && optionArgs.Length > 0)
{
foreach (var name in optionArgs)
{
if (string.IsNullOrWhiteSpace(name))
continue;
if (s_SupportedOptions.TryGetValue(name.Trim(), out var opt))
options |= opt;
else
errors.Add(Invalid("options", $"Unsupported BuildOptions value '{name}'."));
}
}
else
{
// Strongly recommended: without it BuildReport.packedAssets is empty.
options = BuildOptions.DetailedBuildReport;
}
// ---- scenes ----
var scenes = (sceneArgs != null && sceneArgs.Length > 0)
? sceneArgs
: EditorBuildSettings.scenes.Where(s => s.enabled).Select(s => s.path).ToArray();
if (sceneArgs != null)
{
foreach (var scene in sceneArgs)
{
if (string.IsNullOrWhiteSpace(scene) || string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(scene)))
errors.Add(Invalid("scenes", $"No scene asset at '{scene}'."));
}
}
// ---- output path ----
// Non-mutating writability check: resolve the path and confirm an existing ancestor
// directory exists (so the build's output folder can be created). BuildPipeline.BuildPlayer
// creates the leaf directory itself, so we never create anything here — a dry run stays pure.
string outputPath = null;
try
{
outputPath = ResolveOutput(outputArg, target);
if (!HasExistingAncestor(outputPath))
errors.Add(Invalid("outputPath", $"No existing parent directory for '{outputPath}'."));
}
catch (Exception ex)
{
errors.Add(Invalid("outputPath", $"Output path is invalid: {ex.Message}"));
}
if (dryRun)
{
return new
{
status = "dry_run",
valid = errors.Count == 0,
validationErrors = errors
};
}
if (errors.Count > 0)
{
// Fail immediately — do not queue (acceptance criteria 5).
return new { status = "error", success = false, validationErrors = errors, message = "Build configuration is invalid; nothing was queued." };
}
if (!confirm)
return new
{
status = "error",
success = false,
message = "Refused: this triggers a player build. Pass confirm=true to build, or dry_run=true to validate without building."
};
var buildId = NewBuildId();
lock (s_Lock)
{
s_PendingTarget = target;
s_PendingOutput = outputPath;
s_PendingOptions = options;
s_PendingScenes = scenes;
s_PendingProfile = profileName;
s_PendingBuildId = buildId;
s_Pending = true;
}
WriteStatusJson(new { status = "queued", buildId, message = "Build queued. Poll build_status until status is 'completed'." });
return new { status = "queued", buildId };
}
[CliCommand("build_status",
"Status of the current/most recent build: idle | queued | building | completed, with the full " +
"BuildReport (files, packedAssets, buildSteps, errors, warnings) once completed. Retained until the next build.",
MainThreadRequired = false)]
public static string BuildStatus()
{
if (!File.Exists(StatusFile))
return "{\"status\":\"idle\"}";
var text = File.ReadAllText(StatusFile);
// While building, recompute elapsedMs at read time so polling shows live progress.
try
{
var doc = JObject.Parse(text);
if ((string)doc["status"] == "building")
{
long elapsed = 0;
var startedStr = (string)doc["buildStartedAt"];
if (!string.IsNullOrEmpty(startedStr) &&
DateTime.TryParse(startedStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var started))
{
elapsed = (long)Math.Max(0, (DateTime.UtcNow - started.ToUniversalTime()).TotalMilliseconds);
}
return JsonConvert.SerializeObject(new
{
status = "building",
buildId = (string)doc["buildId"],
elapsedMs = elapsed
});
}
}
catch
{
// Malformed file — fall through and return it verbatim.
}
return text;
}
/// <summary>
/// Main-thread pump: picks up a queued request and runs the (blocking) build. Guarded so a build
/// runs at most once at a time.
/// </summary>
static void OnUpdate()
{
BuildTarget target;
string output;
BuildOptions options;
string[] scenes;
string profile;
string buildId;
lock (s_Lock)
{
if (!s_Pending || s_Building)
return;
s_Pending = false;
s_Building = true;
target = s_PendingTarget;
output = s_PendingOutput;
options = s_PendingOptions;
scenes = s_PendingScenes;
profile = s_PendingProfile;
buildId = s_PendingBuildId;
}
try
{
RunBuild(buildId, target, output, options, scenes, profile);
}
catch (Exception ex)
{
WriteStatusJson(new
{
status = "completed",
buildId,
result = "Failed",
totalSizeBytes = 0,
errors = new[] { new { message = $"Build failed to start: {ex.Message}" } },
warnings = Array.Empty<object>()
});
}
finally
{
lock (s_Lock) { s_Building = false; }
}
}
static void RunBuild(string buildId, BuildTarget target, string outputPath, BuildOptions options,
string[] scenes, string profileName)
{
var startedAt = DateTime.UtcNow;
WriteStatusJson(new
{
status = "building",
buildId,
buildStartedAt = startedAt.ToString("o"),
platform = target.ToString(),
outputPath
});
// Best-effort Build Profile activation (Unity 6 only). Never fails the build.
if (!string.IsNullOrWhiteSpace(profileName))
TryActivateBuildProfile(profileName);
var buildOptions = new BuildPlayerOptions
{
scenes = scenes,
locationPathName = outputPath,
target = target,
targetGroup = BuildPipeline.GetBuildTargetGroup(target),
options = options
};
var report = BuildPipeline.BuildPlayer(buildOptions);
WriteStatusJson(Project(buildId, report));
}
// ---- BuildReport projection ------------------------------------------------------------
static BuildReportResult Project(string buildId, BuildReport report)
{
var summary = report.summary;
var success = summary.result == BuildResult.Succeeded;
var started = summary.buildStartedAt;
var ended = started + summary.totalTime;
var result = new BuildReportResult
{
Status = "completed",
BuildId = buildId,
Result = summary.result.ToString(),
Platform = summary.platform.ToString(),
OutputPath = summary.outputPath,
TotalSizeBytes = (long)summary.totalSize,
BuildTimeMs = (long)summary.totalTime.TotalMilliseconds,
BuildStartedAt = started.ToUniversalTime().ToString("o"),
BuildEndedAt = ended.ToUniversalTime().ToString("o"),
TotalWarnings = summary.totalWarnings,
TotalErrors = summary.totalErrors,
BuildSteps = ProjectSteps(report, out var errors, out var warnings),
Errors = errors,
Warnings = warnings
};
// Files / packed assets are only meaningful on success (and packedAssets needs DetailedBuildReport).
if (success)
{
result.Files = ProjectFiles(report);
result.PackedAssets = ProjectPackedAssets(report);
}
return result;
}
static List<BuildFileEntry> ProjectFiles(BuildReport report)
{
var files = new List<BuildFileEntry>();
try
{
foreach (var f in report.GetFiles())
files.Add(new BuildFileEntry { Path = f.path, Role = f.role, SizeBytes = (long)f.size });
}
catch (Exception ex)
{
Debug.LogWarning($"[pipeline] build file projection failed: {ex.Message}");
}
return files;
}
static List<PackedAssetEntry> ProjectPackedAssets(BuildReport report)
{
var packed = new List<PackedAssetEntry>();
if (report.packedAssets == null)
return packed;
foreach (var bundle in report.packedAssets)
{
var entry = new PackedAssetEntry
{
BundlePath = bundle.shortPath,
OverheadBytes = (long)bundle.overhead,
Contents = new List<PackedAssetContent>()
};
if (bundle.contents != null)
{
foreach (var c in bundle.contents)
{
entry.Contents.Add(new PackedAssetContent
{
AssetPath = c.sourceAssetPath,
Type = c.type != null ? c.type.Name : null,
Guid = c.sourceAssetGUID.ToString(),
SizeBytes = (long)c.packedSize
});
}
}
packed.Add(entry);
}
return packed;
}
static List<BuildStepEntry> ProjectSteps(BuildReport report,
out List<BuildIssue> errors, out List<BuildIssue> warnings)
{
errors = new List<BuildIssue>();
warnings = new List<BuildIssue>();
var steps = new List<BuildStepEntry>();
if (report.steps == null)
return steps;
foreach (var step in report.steps)
{
var entry = new BuildStepEntry
{
Name = step.name,
DurationMs = (long)step.duration.TotalMilliseconds,
Depth = (int)step.depth,
Messages = new List<BuildStepMessageEntry>()
};
if (step.messages != null)
{
foreach (var m in step.messages)
{
entry.Messages.Add(new BuildStepMessageEntry { Type = MapLogType(m.type), Content = m.content });
if (m.type == LogType.Error || m.type == LogType.Assert || m.type == LogType.Exception)
errors.Add(BuildIssue.From(m.content));
else if (m.type == LogType.Warning)
warnings.Add(BuildIssue.From(m.content));
}
}
steps.Add(entry);
}
return steps;
}
static string MapLogType(LogType type)
{
switch (type)
{
case LogType.Warning: return "Warning";
case LogType.Error:
case LogType.Assert:
case LogType.Exception: return "Error";
default: return "Log";
}
}
// ---- Helpers ---------------------------------------------------------------------------
/// <summary>Case-insensitive parse of a <see cref="BuildTarget"/> enum name.</summary>
static bool TryParseTarget(string name, out BuildTarget target)
{
try
{
target = (BuildTarget)Enum.Parse(typeof(BuildTarget), name.Trim(), ignoreCase: true);
return Enum.IsDefined(typeof(BuildTarget), target);
}
catch
{
target = EditorUserBuildSettings.activeBuildTarget;
return false;
}
}
static object Invalid(string field, string message) => new { field, message };
static string NewBuildId() => "build_" + Guid.NewGuid().ToString("N").Substring(0, 12);
static string ResolveOutput(string output, BuildTarget target)
{
var projectRoot = ProjectPaths.ProjectRoot;
if (!string.IsNullOrWhiteSpace(output))
return Path.IsPathRooted(output) ? output : Path.GetFullPath(Path.Combine(projectRoot, output));
var product = string.IsNullOrEmpty(PlayerSettings.productName) ? "Player" : PlayerSettings.productName;
return Path.Combine(projectRoot, "Builds", target.ToString(), product + ExtensionFor(target));
}
/// <summary>True if some ancestor directory of <paramref name="path"/> already exists — a cheap,
/// non-mutating check that the build output folder is creatable.</summary>
static bool HasExistingAncestor(string path)
{
var dir = Path.GetDirectoryName(path);
while (!string.IsNullOrEmpty(dir))
{
if (Directory.Exists(dir))
return true;
dir = Path.GetDirectoryName(dir);
}
return false;
}
static string ExtensionFor(BuildTarget target)
{
switch (target)
{
case BuildTarget.StandaloneWindows:
case BuildTarget.StandaloneWindows64:
return ".exe";
case BuildTarget.StandaloneOSX:
return ".app";
case BuildTarget.Android:
return ".apk";
default:
return string.Empty; // Linux standalone, dedicated server, etc.
}
}
/// <summary>
/// Best-effort: activate a Build Profile by name via reflection so the assembly still compiles on
/// Unity versions without the Build Profile API. Any failure is logged and ignored — the build
/// proceeds with the current configuration.
/// </summary>
static void TryActivateBuildProfile(string profileName)
{
try
{
var info = BuildProfiles.FindByName(profileName);
if (info == null)
{
Debug.LogWarning($"[pipeline] build: profile '{profileName}' not found; building with current settings.");
return;
}
if (!BuildProfiles.TrySetActive(info))
Debug.LogWarning($"[pipeline] build: could not activate profile '{profileName}'; building with current settings.");
}
catch (Exception ex)
{
Debug.LogWarning($"[pipeline] build: profile activation failed ({ex.Message}); building with current settings.");
}
}
/// <summary>
/// Run <paramref name="fn"/> on the Unity main thread. Off-thread (the HTTP path) it marshals
/// through the live server's dispatcher; on the main thread (a direct in-process/test call, or no
/// server running) it runs inline. Mirrors <see cref="PackageManager.PackageManagerCommand"/>.
/// </summary>
static T RunOnMain<T>(Func<T> fn)
{
var dispatcher = PipelineServerStartup.Server?.Dispatcher;
if (dispatcher != null && dispatcher.IsInitialized && !dispatcher.IsMainThread())
return dispatcher.Invoke(fn);
return fn();
}
static void WriteStatusJson(object status)
{
try
{
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
}
catch (Exception ex)
{
Debug.LogError($"[Build] Failed to write status file: {ex.Message}");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6e718f193c77f724ea4de47fe8a2a3cb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,337 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Unity.Pipeline.Commands;
using UnityEditor;
using UnityEditor.Build;
using UnityEngine;
namespace Unity.Pipeline.Editor.Commands.Build
{
/// <summary>
/// Read/inspect build configuration for agents (CLI-204): enumerate targets, read/write the mutable
/// <c>EditorUserBuildSettings</c> fields, and list Build Profile assets. Scene-list management and
/// target switching are intentionally elsewhere (<c>add_scene_to_build</c>/<c>remove_scene_from_build</c>
/// from CLI-189, and <c>switch_build_target</c>).
/// </summary>
public static class BuildConfigCommands
{
[CliCommand("list_build_targets",
"List the known BuildTarget values with their group and whether build support is installed.",
MainThreadRequired = true)]
public static object ListBuildTargets()
{
var targets = new List<BuildTargetInfo>();
var seen = new HashSet<string>();
foreach (BuildTarget value in Enum.GetValues(typeof(BuildTarget)))
{
// Exclude NoTarget / internal sentinels (negative) and deprecated/obsolete values.
if ((int)value <= 0)
continue;
var name = value.ToString();
if (!seen.Add(name))
continue;
var field = typeof(BuildTarget).GetField(name);
if (field != null && field.GetCustomAttribute<ObsoleteAttribute>() != null)
continue;
BuildTargetGroup group;
try { group = BuildPipeline.GetBuildTargetGroup(value); }
catch { continue; }
bool installed;
try { installed = BuildPipeline.IsBuildTargetSupported(group, value); }
catch { installed = false; }
targets.Add(new BuildTargetInfo
{
Name = name,
DisplayName = DisplayNameFor(value),
TargetGroup = group.ToString(),
IsInstalled = installed
});
}
return targets.OrderBy(t => t.TargetGroup).ThenBy(t => t.Name).ToList();
}
[CliCommand("get_build_settings",
"Read the current build configuration from EditorUserBuildSettings / EditorBuildSettings.",
MainThreadRequired = true)]
public static object GetBuildSettings()
{
var target = EditorUserBuildSettings.activeBuildTarget;
var group = BuildPipeline.GetBuildTargetGroup(target);
return new BuildSettingsResult
{
ActiveBuildTarget = target.ToString(),
ActiveBuildTargetGroup = group.ToString(),
DevelopmentBuild = EditorUserBuildSettings.development,
AllowDebugging = EditorUserBuildSettings.allowDebugging,
ConnectWithProfiler = EditorUserBuildSettings.connectProfiler,
BuildScriptsOnly = EditorUserBuildSettings.buildScriptsOnly,
SymlinkSources = EditorUserBuildSettings.symlinkSources,
Il2CppCodeGeneration = ReadIl2CppCodeGeneration(group),
Scenes = EditorBuildSettings.scenes.Select(s => new BuildSceneEntry
{
Path = s.path,
Guid = s.guid.ToString(),
Enabled = s.enabled
}).ToList()
};
}
[CliCommand("set_build_settings",
"Set mutable EditorUserBuildSettings fields. Does NOT manage scenes (use add_scene_to_build / " +
"remove_scene_from_build) or switch target (use switch_build_target). Use dry_run to preview.",
MainThreadRequired = true)]
public static object SetBuildSettings(
[CliArg("settings", "Fields to change; omitted fields are left unchanged.")] SetBuildSettingsInput settings = null,
[CliArg("confirm", "Apply the changes. Without it the call is refused.")] bool confirm = false,
[CliArg("dry_run", "Preview the change without applying it.")] bool dryRun = false)
{
if (settings == null)
return SetBuildSettingsResult.Fail("No 'settings' object provided.");
if (!dryRun && !confirm)
return SetBuildSettingsResult.Fail(
"Refused: this operation mutates the project. Re-run with confirm=true to apply, or dry_run=true to preview the change.");
var result = new SetBuildSettingsResult { DryRun = dryRun, Success = true };
var group = BuildPipeline.GetBuildTargetGroup(EditorUserBuildSettings.activeBuildTarget);
// For each supplied field: change it (recording in Applied) only if it differs from the
// current value; otherwise record it in Skipped as a no-op.
Plan(result, "developmentBuild", settings.DevelopmentBuild, EditorUserBuildSettings.development,
v => EditorUserBuildSettings.development = v, dryRun);
Plan(result, "allowDebugging", settings.AllowDebugging, EditorUserBuildSettings.allowDebugging,
v => EditorUserBuildSettings.allowDebugging = v, dryRun);
Plan(result, "connectWithProfiler", settings.ConnectWithProfiler, EditorUserBuildSettings.connectProfiler,
v => EditorUserBuildSettings.connectProfiler = v, dryRun);
Plan(result, "buildScriptsOnly", settings.BuildScriptsOnly, EditorUserBuildSettings.buildScriptsOnly,
v => EditorUserBuildSettings.buildScriptsOnly = v, dryRun);
Plan(result, "symlinkSources", settings.SymlinkSources, EditorUserBuildSettings.symlinkSources,
v => EditorUserBuildSettings.symlinkSources = v, dryRun);
if (!string.IsNullOrWhiteSpace(settings.Il2CppCodeGeneration))
{
if (!TryParseIl2Cpp(settings.Il2CppCodeGeneration, out var requested))
return SetBuildSettingsResult.Fail($"Invalid il2CppCodeGeneration '{settings.Il2CppCodeGeneration}'. Use OptimizeSpeed | OptimizeSize.");
var current = ReadIl2CppCodeGeneration(group);
if (!string.Equals(current, requested.ToString(), StringComparison.Ordinal))
{
if (!dryRun)
PlayerSettings.SetIl2CppCodeGeneration(NamedBuildTarget.FromBuildTargetGroup(group), requested);
result.Applied["il2CppCodeGeneration"] = requested.ToString();
}
else
{
result.Skipped["il2CppCodeGeneration"] = current;
}
}
result.Message = dryRun
? $"Dry run — {result.Applied.Count} field(s) would change, {result.Skipped.Count} unchanged."
: $"Applied {result.Applied.Count} field(s); {result.Skipped.Count} unchanged.";
return result;
}
[CliCommand("list_build_profiles",
"List Build Profile assets in the project (Unity 6 only). Returns feature_unavailable on earlier versions.",
MainThreadRequired = true)]
public static object ListBuildProfiles()
{
if (!BuildProfiles.IsSupported)
return new { error = "Build Profiles require Unity 6 (6000.0) or later", code = "feature_unavailable" };
return BuildProfiles.List();
}
// ---- Helpers ---------------------------------------------------------------------------
static void Plan(SetBuildSettingsResult result, string name, bool? requested, bool current,
Action<bool> set, bool dryRun)
{
if (!requested.HasValue)
return;
if (requested.Value != current)
{
if (!dryRun)
set(requested.Value);
result.Applied[name] = requested.Value;
}
else
{
result.Skipped[name] = current;
}
}
static string ReadIl2CppCodeGeneration(BuildTargetGroup group)
{
try
{
return PlayerSettings.GetIl2CppCodeGeneration(NamedBuildTarget.FromBuildTargetGroup(group)).ToString();
}
catch
{
return Il2CppCodeGeneration.OptimizeSpeed.ToString();
}
}
static bool TryParseIl2Cpp(string value, out Il2CppCodeGeneration parsed)
{
return Enum.TryParse(value.Trim(), ignoreCase: true, out parsed)
&& Enum.IsDefined(typeof(Il2CppCodeGeneration), parsed);
}
/// <summary>Friendly label for common targets; falls back to the enum name.</summary>
static string DisplayNameFor(BuildTarget target)
{
switch (target)
{
case BuildTarget.StandaloneWindows: return "Windows (32-bit)";
case BuildTarget.StandaloneWindows64: return "Windows 64-bit";
case BuildTarget.StandaloneOSX: return "macOS";
case BuildTarget.StandaloneLinux64: return "Linux 64-bit";
case BuildTarget.Android: return "Android";
case BuildTarget.iOS: return "iOS";
case BuildTarget.tvOS: return "tvOS";
case BuildTarget.WebGL: return "WebGL";
case BuildTarget.WSAPlayer: return "Universal Windows Platform";
case BuildTarget.PS4: return "PlayStation 4";
case BuildTarget.PS5: return "PlayStation 5";
case BuildTarget.XboxOne: return "Xbox One";
case BuildTarget.Switch: return "Nintendo Switch";
default: return target.ToString();
}
}
}
/// <summary>
/// Reflection shim over the Unity 6 Build Profile API (<c>UnityEditor.Build.Profile.BuildProfile</c>),
/// so this assembly compiles and runs on older editors without the type. Used by
/// <c>list_build_profiles</c> and by <c>build</c>'s optional <c>profileName</c> activation.
/// </summary>
internal static class BuildProfiles
{
const string TypeName = "UnityEditor.Build.Profile.BuildProfile, UnityEditor.CoreModule";
static readonly Type s_Type = ResolveType();
public static bool IsSupported => s_Type != null;
static Type ResolveType()
{
var t = Type.GetType(TypeName);
if (t != null)
return t;
// Fall back to a full-assembly scan — the assembly-qualified name can vary across versions.
foreach (var asm in PipelineUtils.GetLoadedAssemblies())
{
t = asm.GetType("UnityEditor.Build.Profile.BuildProfile");
if (t != null)
return t;
}
return null;
}
public static List<BuildProfileInfo> List()
{
var list = new List<BuildProfileInfo>();
if (s_Type == null)
return list;
var active = GetActive();
foreach (var guid in AssetDatabase.FindAssets("t:BuildProfile"))
{
var path = AssetDatabase.GUIDToAssetPath(guid);
var asset = AssetDatabase.LoadAssetAtPath(path, s_Type);
if (asset == null)
continue;
list.Add(new BuildProfileInfo
{
Name = System.IO.Path.GetFileNameWithoutExtension(path),
Guid = guid,
Platform = ReadPlatform(asset),
IsActive = active != null && ReferenceEquals(active, asset)
});
}
return list;
}
public static BuildProfileInfo FindByName(string name)
{
return List().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
}
/// <summary>Activate the named profile via the API's static setter; returns false if unavailable.</summary>
public static bool TrySetActive(BuildProfileInfo info)
{
if (s_Type == null || info == null)
return false;
var path = AssetDatabase.GUIDToAssetPath(info.Guid);
var asset = AssetDatabase.LoadAssetAtPath(path, s_Type);
if (asset == null)
return false;
// Unity 6 exposes a static settable 'activeProfile' (and/or a SetActiveBuildProfile method).
var prop = s_Type.GetProperty("activeProfile", BindingFlags.Public | BindingFlags.Static);
if (prop != null && prop.CanWrite)
{
prop.SetValue(null, asset);
return true;
}
var method = s_Type.GetMethod("SetActiveBuildProfile", BindingFlags.Public | BindingFlags.Static);
if (method != null)
{
method.Invoke(null, new[] { asset });
return true;
}
return false;
}
static UnityEngine.Object GetActive()
{
try
{
var prop = s_Type.GetProperty("activeProfile", BindingFlags.Public | BindingFlags.Static);
if (prop != null)
return prop.GetValue(null) as UnityEngine.Object;
var method = s_Type.GetMethod("GetActiveBuildProfile", BindingFlags.Public | BindingFlags.Static);
if (method != null)
return method.Invoke(null, null) as UnityEngine.Object;
}
catch { /* best effort */ }
return null;
}
static string ReadPlatform(UnityEngine.Object asset)
{
try
{
// Public property first, then the serialized backing field.
var prop = s_Type.GetProperty("buildTarget", BindingFlags.Public | BindingFlags.Instance);
if (prop != null)
return prop.GetValue(asset)?.ToString() ?? string.Empty;
var field = s_Type.GetField("m_BuildTarget", BindingFlags.NonPublic | BindingFlags.Instance);
if (field != null)
return field.GetValue(asset)?.ToString() ?? string.Empty;
}
catch { /* best effort */ }
return string.Empty;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e65c0b78842b492d8f9363ec749bc370
@@ -0,0 +1,271 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Unity.Pipeline.Commands;
namespace Unity.Pipeline.Editor.Commands.Build
{
/// <summary>
/// One entry of <c>list_build_targets</c> (CLI-204): a <c>BuildTarget</c> enum value with the
/// information an agent needs to choose a valid target without guessing — its group and whether the
/// platform's build support is actually installed in this editor.
/// </summary>
[Serializable]
public class BuildTargetInfo
{
/// <summary><c>BuildTarget</c> enum name, e.g. "StandaloneWindows64".</summary>
[JsonProperty("name")] public string Name { get; set; }
/// <summary>Friendly label, e.g. "Windows 64-bit".</summary>
[JsonProperty("displayName")] public string DisplayName { get; set; }
/// <summary><c>BuildTargetGroup</c> enum name, e.g. "Standalone".</summary>
[JsonProperty("targetGroup")] public string TargetGroup { get; set; }
/// <summary><c>BuildPipeline.IsBuildTargetSupported(group, target)</c> — module installed and usable.</summary>
[JsonProperty("isInstalled")] public bool IsInstalled { get; set; }
}
/// <summary>One scene in the Build Settings scene list, projected for <c>get_build_settings</c>.</summary>
[Serializable]
public class BuildSceneEntry
{
[JsonProperty("path")] public string Path { get; set; }
[JsonProperty("guid")] public string Guid { get; set; }
[JsonProperty("enabled")] public bool Enabled { get; set; }
}
/// <summary>
/// Result of <c>get_build_settings</c> (CLI-204): the current build configuration read from
/// <c>EditorUserBuildSettings</c> / <c>EditorBuildSettings</c> plus the active target's IL2CPP code
/// generation mode. Scene-list management lives in <c>add_scene_to_build</c> /
/// <c>remove_scene_from_build</c> (CLI-189); this only reports the list.
/// </summary>
[Serializable]
public class BuildSettingsResult
{
[JsonProperty("activeBuildTarget")] public string ActiveBuildTarget { get; set; }
[JsonProperty("activeBuildTargetGroup")] public string ActiveBuildTargetGroup { get; set; }
[JsonProperty("developmentBuild")] public bool DevelopmentBuild { get; set; }
[JsonProperty("allowDebugging")] public bool AllowDebugging { get; set; }
[JsonProperty("connectWithProfiler")] public bool ConnectWithProfiler { get; set; }
[JsonProperty("buildScriptsOnly")] public bool BuildScriptsOnly { get; set; }
[JsonProperty("symlinkSources")] public bool SymlinkSources { get; set; }
/// <summary>"OptimizeSpeed" | "OptimizeSize" for the active build target.</summary>
[JsonProperty("il2CppCodeGeneration")] public string Il2CppCodeGeneration { get; set; }
[JsonProperty("scenes")] public List<BuildSceneEntry> Scenes { get; set; }
}
/// <summary>
/// Mutable build-settings fields for <c>set_build_settings</c> (CLI-204). Every field is nullable;
/// only the ones the caller supplies are changed. Note that <see cref="AllowDebugging"/> and
/// <see cref="ConnectWithProfiler"/> only take effect when <see cref="DevelopmentBuild"/> is also on.
/// </summary>
public class SetBuildSettingsInput : IStructuredCommandInput
{
[CliArg("developmentBuild", "Build a Development Player (enables the debugger/profiler).")]
public bool? DevelopmentBuild { get; set; }
[CliArg("allowDebugging", "Allow script debugging (only effective with developmentBuild=true).")]
public bool? AllowDebugging { get; set; }
[CliArg("connectWithProfiler", "Auto-connect the Profiler (only effective with developmentBuild=true).")]
public bool? ConnectWithProfiler { get; set; }
[CliArg("buildScriptsOnly", "Build only the scripts (skip data) for faster iteration.")]
public bool? BuildScriptsOnly { get; set; }
[CliArg("symlinkSources", "Symlink runtime/plugin sources instead of copying (where supported).")]
public bool? SymlinkSources { get; set; }
[CliArg("il2CppCodeGeneration", "IL2CPP code generation for the active target: OptimizeSpeed | OptimizeSize.")]
public string Il2CppCodeGeneration { get; set; }
}
/// <summary>
/// Result of <c>set_build_settings</c> (CLI-204): the fields that were actually changed
/// (<see cref="Applied"/>) versus the supplied fields that already had the requested value
/// (<see cref="Skipped"/>). A dry run reports the same <see cref="Applied"/> set it <em>would</em>
/// write, without changing anything.
/// </summary>
[Serializable]
public class SetBuildSettingsResult
{
[JsonProperty("success")] public bool Success { get; set; }
[JsonProperty("dryRun")] public bool DryRun { get; set; }
/// <summary>Field name → new value, for each field that changed (or would change on a dry run).</summary>
[JsonProperty("applied")] public Dictionary<string, object> Applied { get; set; } = new Dictionary<string, object>();
/// <summary>Field name → current value, for supplied fields that already matched (no-ops).</summary>
[JsonProperty("skipped")] public Dictionary<string, object> Skipped { get; set; } = new Dictionary<string, object>();
[JsonProperty("message")] public string Message { get; set; }
public static SetBuildSettingsResult Fail(string message) =>
new SetBuildSettingsResult { Success = false, Message = message };
}
/// <summary>One Build Profile asset, projected for <c>list_build_profiles</c> (Unity 6 only).</summary>
[Serializable]
public class BuildProfileInfo
{
[JsonProperty("name")] public string Name { get; set; }
[JsonProperty("guid")] public string Guid { get; set; }
/// <summary>The profile's build target name (best-effort; may be empty if unreadable).</summary>
[JsonProperty("platform")] public string Platform { get; set; }
[JsonProperty("isActive")] public bool IsActive { get; set; }
}
/// <summary>One output file produced by the build (a row of <c>BuildReport.GetFiles()</c>).</summary>
[Serializable]
public class BuildFileEntry
{
[JsonProperty("path")] public string Path { get; set; }
/// <summary><c>BuildFile.role</c> verbatim, e.g. "MainData", "AssetBundle", "BootConfig".</summary>
[JsonProperty("role")] public string Role { get; set; }
[JsonProperty("sizeBytes")] public long SizeBytes { get; set; }
}
/// <summary>One asset packed into a build bundle/file (a row of a <c>PackedAssets</c> entry).</summary>
[Serializable]
public class PackedAssetContent
{
[JsonProperty("assetPath")] public string AssetPath { get; set; }
[JsonProperty("type")] public string Type { get; set; }
[JsonProperty("guid")] public string Guid { get; set; }
[JsonProperty("sizeBytes")] public long SizeBytes { get; set; }
}
/// <summary>A packed bundle/file with its per-asset size breakdown (requires DetailedBuildReport).</summary>
[Serializable]
public class PackedAssetEntry
{
[JsonProperty("bundlePath")] public string BundlePath { get; set; }
[JsonProperty("overheadBytes")] public long OverheadBytes { get; set; }
[JsonProperty("contents")] public List<PackedAssetContent> Contents { get; set; }
}
/// <summary>One message emitted during a build step.</summary>
[Serializable]
public class BuildStepMessageEntry
{
/// <summary>"Log" | "Warning" | "Error" (mapped from <c>LogType</c>).</summary>
[JsonProperty("type")] public string Type { get; set; }
[JsonProperty("content")] public string Content { get; set; }
}
/// <summary>One step of the build (a row of <c>BuildReport.steps</c>).</summary>
[Serializable]
public class BuildStepEntry
{
[JsonProperty("name")] public string Name { get; set; }
[JsonProperty("durationMs")] public long DurationMs { get; set; }
[JsonProperty("depth")] public int Depth { get; set; }
[JsonProperty("messages")] public List<BuildStepMessageEntry> Messages { get; set; }
}
/// <summary>A build error/warning surfaced in <c>build_status</c>, with best-effort source file.</summary>
[Serializable]
public class BuildIssue
{
[JsonProperty("message")] public string Message { get; set; }
/// <summary>Source file when the message carries a "File.cs(line,col): ..." prefix; omitted otherwise.</summary>
[JsonProperty("file", NullValueHandling = NullValueHandling.Ignore)] public string File { get; set; }
/// <summary>Build a <see cref="BuildIssue"/>, reusing <see cref="BuildMessage.Parse"/> for file extraction.</summary>
public static BuildIssue From(string content)
{
var parsed = BuildMessage.Parse("info", content);
return new BuildIssue
{
Message = parsed.Message,
File = string.IsNullOrEmpty(parsed.File) ? null : parsed.File
};
}
}
/// <summary>
/// Full <c>build_status</c> result for a finished build (CLI-204) — a JSON-friendly projection of
/// <c>UnityEditor.Build.Reporting.BuildReport</c>. Optional collections are omitted when null
/// (<see cref="NullValueHandling"/>), so a failed build can carry errors/steps without empty
/// file/asset arrays. Transient states (queued/building/idle/dry_run) are reported as small,
/// purpose-built payloads, not this type.
/// </summary>
[Serializable]
public class BuildReportResult
{
[JsonProperty("status")] public string Status { get; set; }
[JsonProperty("buildId")] public string BuildId { get; set; }
/// <summary>"Succeeded" | "Failed" | "Cancelled" | "Unknown".</summary>
[JsonProperty("result")] public string Result { get; set; }
[JsonProperty("platform")] public string Platform { get; set; }
[JsonProperty("outputPath")] public string OutputPath { get; set; }
[JsonProperty("totalSizeBytes")] public long TotalSizeBytes { get; set; }
[JsonProperty("buildTimeMs")] public long BuildTimeMs { get; set; }
[JsonProperty("buildStartedAt", NullValueHandling = NullValueHandling.Ignore)] public string BuildStartedAt { get; set; }
[JsonProperty("buildEndedAt", NullValueHandling = NullValueHandling.Ignore)] public string BuildEndedAt { get; set; }
[JsonProperty("totalWarnings")] public int TotalWarnings { get; set; }
[JsonProperty("totalErrors")] public int TotalErrors { get; set; }
[JsonProperty("files", NullValueHandling = NullValueHandling.Ignore)] public List<BuildFileEntry> Files { get; set; }
[JsonProperty("packedAssets", NullValueHandling = NullValueHandling.Ignore)] public List<PackedAssetEntry> PackedAssets { get; set; }
[JsonProperty("buildSteps", NullValueHandling = NullValueHandling.Ignore)] public List<BuildStepEntry> BuildSteps { get; set; }
[JsonProperty("errors")] public List<BuildIssue> Errors { get; set; } = new List<BuildIssue>();
[JsonProperty("warnings")] public List<BuildIssue> Warnings { get; set; } = new List<BuildIssue>();
}
/// <summary>
/// A single build error/warning with best-effort file/line parsed from the message content.
/// Retained from the original <c>build</c> stub (CLI-127): it is the shared parser used to extract a
/// source location from a raw build-step message and is exercised directly by the test suite.
/// </summary>
[Serializable]
public class BuildMessage
{
[JsonProperty("severity")] public string Severity { get; set; }
[JsonProperty("file")] public string File { get; set; }
[JsonProperty("line")] public int Line { get; set; }
[JsonProperty("message")] public string Message { get; set; }
// Matches the standard compiler/build message prefix "Path/To/File.cs(line,col): rest".
static readonly Regex s_LocationPattern = new Regex(@"^(?<file>.*?)\((?<line>\d+),\d+\):\s*(?<rest>.*)$", RegexOptions.Singleline);
/// <summary>
/// Build a <see cref="BuildMessage"/> from a raw build-step message, extracting file/line when
/// the content carries the standard "File.cs(line,col): ..." prefix; otherwise the whole content
/// is kept as the message with no file/line.
/// </summary>
public static BuildMessage Parse(string severity, string content)
{
var msg = new BuildMessage { Severity = severity, Message = content ?? string.Empty };
if (!string.IsNullOrEmpty(content))
{
var match = s_LocationPattern.Match(content);
if (match.Success)
{
msg.File = match.Groups["file"].Value.Trim();
if (int.TryParse(match.Groups["line"].Value, out var line))
msg.Line = line;
msg.Message = match.Groups["rest"].Value.Trim();
}
}
return msg;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 37afada24be667e489d7e90a62f2ca1d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,237 @@
using System;
using System.IO;
using Newtonsoft.Json;
using Unity.Pipeline.Commands;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Editor.Commands.Build
{
/// <summary>
/// Switches the active build target (CLI-204). <see cref="BuildPipeline.SwitchActiveBuildTarget"/> is
/// synchronous, blocks the main thread, and triggers a full asset reimport + domain reload for the new
/// platform — minutes on a large project. So this follows the trigger-then-poll pattern: the command
/// validates and queues the switch, returns <c>switching</c> immediately, and an
/// <see cref="EditorApplication.update"/> tick performs the switch. The switch is confirm-gated
/// (destructive and long-running) and is not part of Unity's Undo.
///
/// The status is persisted to a Temp file that survives the domain reload the switch causes; on the
/// next load <see cref="RecoverInterruptedOperation"/> reconciles a left-over <c>switching</c> record
/// against the now-active target. <c>switch_build_target_status</c> reads the file off the main thread,
/// so it keeps answering while the switch holds the main thread.
/// </summary>
[InitializeOnLoad]
public static class SwitchBuildTargetCommand
{
const string StatusFile = "Temp/pipeline_switch_target_status.json";
static readonly object s_Lock = new object();
static bool s_Pending;
static bool s_Switching;
static BuildTarget s_PendingTarget;
static BuildTarget s_FromTarget;
static SwitchBuildTargetCommand()
{
EditorApplication.update += OnUpdate;
RecoverInterruptedOperation();
}
[CliCommand("switch_build_target",
"Switch the active build target (destructive, long-running: triggers a full reimport + domain " +
"reload). Requires confirm=true. Returns immediately; poll switch_build_target_status.",
MainThreadRequired = false)]
public static object SwitchBuildTarget(
[CliArg("target", "BuildTarget name to switch to (must be installed; see list_build_targets).", Required = true)] string target = "",
[CliArg("confirm", "Apply the switch. Without it the call is refused.")] bool confirm = false)
{
return RunOnMain<object>(() => ValidateAndQueue(target, confirm));
}
static object ValidateAndQueue(string targetArg, bool confirm)
{
if (string.IsNullOrWhiteSpace(targetArg))
return new { status = "error", success = false, message = "A 'target' is required." };
if (!TryParseTarget(targetArg, out var target))
return new { status = "error", success = false, message = $"Unknown BuildTarget '{targetArg}'. Use a name from list_build_targets." };
var group = BuildPipeline.GetBuildTargetGroup(target);
if (!BuildPipeline.IsBuildTargetSupported(group, target))
return new { status = "error", success = false, message = $"Build target '{target}' is not installed (isInstalled=false in list_build_targets)." };
var from = EditorUserBuildSettings.activeBuildTarget;
if (target == from)
return new { status = "completed", success = true, activeBuildTarget = from.ToString(), message = $"Already on build target '{from}'." };
lock (s_Lock)
{
if (s_Pending || s_Switching)
return new { status = "busy", success = false, message = "A target switch is already queued or in progress. Poll switch_build_target_status." };
}
// Confirm gate. The queue below only sets pending state; the switch runs on the next tick.
if (!confirm)
return new
{
status = "error",
success = false,
message = "Refused: switching the build target triggers a full reimport + domain reload. Pass confirm=true to apply."
};
lock (s_Lock)
{
s_FromTarget = from;
s_PendingTarget = target;
s_Pending = true;
}
WriteStatusJson(new
{
status = "switching",
fromTarget = from.ToString(),
toTarget = target.ToString(),
startedAt = DateTime.UtcNow.ToString("o")
});
return new { status = "switching", fromTarget = from.ToString(), toTarget = target.ToString() };
}
[CliCommand("switch_build_target_status",
"Status of the last target switch: idle | switching | completed (with success + activeBuildTarget).",
MainThreadRequired = false)]
public static string SwitchBuildTargetStatus()
{
if (File.Exists(StatusFile))
return File.ReadAllText(StatusFile);
return "{\"status\":\"idle\"}";
}
/// <summary>Main-thread pump: performs the queued switch. Blocks the main thread until it returns.</summary>
static void OnUpdate()
{
BuildTarget target;
BuildTarget from;
lock (s_Lock)
{
if (!s_Pending || s_Switching)
return;
s_Pending = false;
s_Switching = true;
target = s_PendingTarget;
from = s_FromTarget;
}
try
{
var group = BuildPipeline.GetBuildTargetGroup(target);
// Synchronous and may trigger a domain reload on success — in which case the code below
// never runs and RecoverInterruptedOperation finalizes the status after the reload.
// (SwitchActiveBuildTarget moved from BuildPipeline to EditorUserBuildSettings in Unity 6.)
var ok = EditorUserBuildSettings.SwitchActiveBuildTarget(group, target);
if (ok)
WriteStatusJson(new
{
status = "completed",
success = true,
activeBuildTarget = EditorUserBuildSettings.activeBuildTarget.ToString()
});
else
WriteStatusJson(new
{
status = "completed",
success = false,
target = target.ToString(),
errors = new[] { new { message = $"Failed to switch build target to '{target}'." } }
});
}
catch (Exception ex)
{
WriteStatusJson(new
{
status = "completed",
success = false,
target = target.ToString(),
errors = new[] { new { message = $"Switch to '{target}' threw: {ex.Message}" } }
});
}
finally
{
lock (s_Lock) { s_Switching = false; }
}
}
/// <summary>
/// On load, settle a status file left at "switching" by the domain reload a successful switch
/// causes: if the active target now matches the requested one, the switch completed; otherwise it
/// did not take effect.
/// </summary>
static void RecoverInterruptedOperation()
{
try
{
if (!File.Exists(StatusFile))
return;
var doc = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(StatusFile));
if ((string)doc["status"] != "switching")
return;
var toTarget = (string)doc["toTarget"];
var active = EditorUserBuildSettings.activeBuildTarget.ToString();
if (string.Equals(active, toTarget, StringComparison.Ordinal))
WriteStatusJson(new { status = "completed", success = true, activeBuildTarget = active });
else
WriteStatusJson(new
{
status = "completed",
success = false,
target = toTarget,
errors = new[] { new { message = $"Switch did not take effect; active build target is '{active}'." } }
});
}
catch (Exception ex)
{
Debug.LogWarning($"[pipeline] switch_build_target status recovery failed: {ex.Message}");
}
}
// ---- Helpers ---------------------------------------------------------------------------
static bool TryParseTarget(string name, out BuildTarget target)
{
try
{
target = (BuildTarget)Enum.Parse(typeof(BuildTarget), name.Trim(), ignoreCase: true);
return Enum.IsDefined(typeof(BuildTarget), target);
}
catch
{
target = EditorUserBuildSettings.activeBuildTarget;
return false;
}
}
static T RunOnMain<T>(Func<T> fn)
{
var dispatcher = PipelineServerStartup.Server?.Dispatcher;
if (dispatcher != null && dispatcher.IsInitialized && !dispatcher.IsMainThread())
return dispatcher.Invoke(fn);
return fn();
}
static void WriteStatusJson(object status)
{
try
{
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
}
catch (Exception ex)
{
Debug.LogError($"[SwitchBuildTarget] Failed to write status file: {ex.Message}");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5b3d3b7a26bf4a5c8f5522cbfdd0574b
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 87f4434ff2c6ed6419e6982345ffe0ef
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,262 @@
using System;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Editor.Commands.Capture
{
/// <summary>
/// Visual-feedback commands (CLI-199): render a camera or the Scene View to a PNG and return it
/// base64-encoded, so an agent can "see" the editor without a display. Optionally writes the PNG
/// under the project (sandboxed via <see cref="ProjectPaths"/>) and refreshes the AssetDatabase.
///
/// Captures require a GPU; in batchmode/headless the device type is
/// <see cref="GraphicsDeviceType.Null"/> and the commands throw so callers get a clear message
/// instead of an empty image.
/// </summary>
public static class CaptureCommands
{
private const int MaxDimension = 4096;
[CliCommand("capture_game_view", "Render a camera to a PNG. Returns it inline as base64, unless save_path is set (path-only result; pass include_inline_image=true to get both).")]
public static CaptureResult CaptureGameView(
[CliArg("width", "Output width in px (default 1280; capped 4096).")] int width = 1280,
[CliArg("height", "Output height in px (default 720; capped 4096).")] int height = 720,
[CliArg("camera", "Optional camera name; defaults to Camera.main, else the first enabled camera.")] string camera = null,
[CliArg("save_path", "Optional project-relative path to write the PNG (e.g. Screenshots/foo.png). When set, the result omits the inline base64 image unless include_inline_image=true.")] string savePath = null,
[CliArg("include_inline_image", "Also return the image inline as base64 when save_path is set (default false: path-only result). Only meaningful together with save_path.")] bool includeInlineImage = false,
[CliArg("max_resolution", "Cap on the inline image's longest edge (e.g. 512). Only applies when an inline image is returned (no save_path, or save_path + include_inline_image=true); the save_path file keeps the requested resolution.")] int maxResolution = 0)
{
GuardHasGpu();
var cam = ResolveCamera(camera);
if (cam == null)
throw new ArgumentException("No camera found to capture.");
return Capture(cam, width, height, $"camera:{cam.name}", savePath, includeInlineImage, maxResolution);
}
[CliCommand("capture_scene_view", "Render the active Scene View to a PNG. Returns it inline as base64, unless save_path is set (path-only result; pass include_inline_image=true to get both).")]
public static CaptureResult CaptureSceneView(
[CliArg("width", "Output width in px (default 1280; capped 4096).")] int width = 1280,
[CliArg("height", "Output height in px (default 720; capped 4096).")] int height = 720,
[CliArg("save_path", "Optional project-relative path to write the PNG (e.g. Screenshots/foo.png). When set, the result omits the inline base64 image unless include_inline_image=true.")] string savePath = null,
[CliArg("include_inline_image", "Also return the image inline as base64 when save_path is set (default false: path-only result). Only meaningful together with save_path.")] bool includeInlineImage = false,
[CliArg("max_resolution", "Cap on the inline image's longest edge (e.g. 512). Only applies when an inline image is returned (no save_path, or save_path + include_inline_image=true); the save_path file keeps the requested resolution.")] int maxResolution = 0)
{
GuardHasGpu();
var sv = SceneView.lastActiveSceneView;
if (sv == null || sv.camera == null)
throw new ArgumentException("No active Scene View to capture.");
return Capture(sv.camera, width, height, "sceneView", savePath, includeInlineImage, maxResolution);
}
/// <summary>
/// Shared capture core. Renders at the requested (clamped) size and writes the file when
/// <paramref name="savePath"/> is set. The image is inlined as base64 only when there is no
/// file, or on <paramref name="includeInlineImage"/> — a save_path result is otherwise
/// path-only, so agent tool results stay small (AUTHAPI-8). <paramref name="maxResolution"/>
/// caps the inline image's longest edge (no-op when no inline image is returned); the saved
/// file keeps the requested resolution.
/// </summary>
private static CaptureResult Capture(Camera cam, int width, int height, string source,
string savePath, bool includeInlineImage, int maxResolution)
{
var w = Mathf.Clamp(width, 1, MaxDimension);
var h = Mathf.Clamp(height, 1, MaxDimension);
var wantsFile = !string.IsNullOrEmpty(savePath);
var wantsInline = !wantsFile || includeInlineImage;
// Without a file the inline image is the only artifact: the cap applies to the render itself.
if (!wantsFile && maxResolution > 0)
(w, h) = ClampToLongEdge(w, h, maxResolution);
var png = EncodeCameraToPng(cam, w, h);
var savedPath = WriteIfRequested(png, savePath);
string base64 = null;
int? inlineW = null, inlineH = null;
if (wantsInline)
{
var inlinePng = png;
if (wantsFile && maxResolution > 0 && maxResolution < Mathf.Max(w, h))
{
// Re-render small for the inline copy; the file already has the full resolution.
var (iw, ih) = ClampToLongEdge(w, h, maxResolution);
inlinePng = EncodeCameraToPng(cam, iw, ih);
inlineW = iw;
inlineH = ih;
}
base64 = Convert.ToBase64String(inlinePng);
}
return new CaptureResult
{
Width = w,
Height = h,
Encoding = "png",
Base64 = base64,
Bytes = png.Length,
Source = source,
SavedPath = savedPath,
InlineWidth = inlineW,
InlineHeight = inlineH
};
}
/// <summary>Scale (w, h) down so the longest edge is at most <paramref name="maxEdge"/>, preserving aspect.</summary>
private static (int w, int h) ClampToLongEdge(int w, int h, int maxEdge)
{
var longest = Mathf.Max(w, h);
if (maxEdge <= 0 || longest <= maxEdge)
return (w, h);
var scale = (float)maxEdge / longest;
return (Mathf.Max(1, Mathf.RoundToInt(w * scale)), Mathf.Max(1, Mathf.RoundToInt(h * scale)));
}
/// <summary>Throw when no GPU is available (batchmode/headless), where a render would be blank.</summary>
private static void GuardHasGpu()
{
if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null)
throw new InvalidOperationException("No GPU available (batchmode/headless); cannot capture.");
}
/// <summary>
/// Resolve the camera to capture: by name (if provided), else <see cref="Camera.main"/>,
/// else the first enabled camera. Returns null when nothing matches.
/// </summary>
private static Camera ResolveCamera(string name)
{
if (!string.IsNullOrEmpty(name))
{
// Camera.allCameras only includes enabled cameras; also scan all loaded cameras so a
// disabled-but-named camera can still be targeted explicitly.
var byName = Camera.allCameras.FirstOrDefault(c => c.name == name)
?? PipelineUtils.FindObjectsByType<Camera>().FirstOrDefault(c => c.name == name);
return byName;
}
if (Camera.main != null)
return Camera.main;
return Camera.allCameras.FirstOrDefault();
}
/// <summary>
/// Render <paramref name="cam"/> off-screen into a temporary RenderTexture and encode the
/// result to PNG bytes. The camera's target texture and the active RenderTexture are restored
/// in a finally block so capturing never leaves global render state dirty.
/// </summary>
private static byte[] EncodeCameraToPng(Camera cam, int w, int h)
{
var rt = RenderTexture.GetTemporary(w, h, 24);
var prevTarget = cam.targetTexture;
var prevActive = RenderTexture.active;
try
{
cam.targetTexture = rt;
cam.Render();
RenderTexture.active = rt;
var tex = new Texture2D(w, h, TextureFormat.RGB24, false);
try
{
tex.ReadPixels(new Rect(0, 0, w, h), 0, 0);
tex.Apply();
return ImageConversion.EncodeToPNG(tex);
}
finally
{
Object.DestroyImmediate(tex);
}
}
finally
{
cam.targetTexture = prevTarget;
RenderTexture.active = prevActive;
RenderTexture.ReleaseTemporary(rt);
}
}
/// <summary>
/// When <paramref name="savePath"/> is set, validate it through the project-path sandbox,
/// write the PNG to its absolute filesystem location, refresh the AssetDatabase, and return
/// the resolved project-relative asset path. Returns null when no path was requested.
/// </summary>
private static string WriteIfRequested(byte[] png, string savePath)
{
if (string.IsNullOrEmpty(savePath))
return null;
var resolved = ProjectPaths.Resolve(savePath, out var err);
if (resolved == null)
throw new ArgumentException(err);
// ProjectPaths.Resolve returns a project-relative path; combine it with the project root
// (the folder that contains Assets/) to get the absolute file to write.
var absolute = Path.Combine(ProjectPaths.ProjectRoot, resolved);
var directory = Path.GetDirectoryName(absolute);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllBytes(absolute, png);
AssetDatabase.Refresh();
return resolved;
}
}
/// <summary>
/// Result of a capture command: the rendered dimensions, the source, the project-relative path
/// the PNG was written to (null when not saved), and the base64 payload — omitted from the JSON
/// when a save_path result is path-only (AUTHAPI-8).
/// </summary>
[Serializable]
public class CaptureResult
{
/// <summary>Rendered width in pixels (after clamping).</summary>
[JsonProperty("width")]
public int Width { get; set; }
/// <summary>Rendered height in pixels (after clamping).</summary>
[JsonProperty("height")]
public int Height { get; set; }
/// <summary>Image encoding; always "png".</summary>
[JsonProperty("encoding")]
public string Encoding { get; set; }
/// <summary>Base64-encoded PNG bytes; null (omitted) when save_path is set without include_inline_image.</summary>
[JsonProperty("base64", NullValueHandling = NullValueHandling.Ignore)]
public string Base64 { get; set; }
/// <summary>Length of the raw PNG byte array.</summary>
[JsonProperty("bytes")]
public int Bytes { get; set; }
/// <summary>What was captured, e.g. "camera:Main Camera" or "sceneView".</summary>
[JsonProperty("source")]
public string Source { get; set; }
/// <summary>Project-relative path the PNG was also written to, or null.</summary>
[JsonProperty("savedPath")]
public string SavedPath { get; set; }
/// <summary>Inline image width when max_resolution downscaled it below the saved file's; omitted otherwise.</summary>
[JsonProperty("inlineWidth", NullValueHandling = NullValueHandling.Ignore)]
public int? InlineWidth { get; set; }
/// <summary>Inline image height when max_resolution downscaled it below the saved file's; omitted otherwise.</summary>
[JsonProperty("inlineHeight", NullValueHandling = NullValueHandling.Ignore)]
public int? InlineHeight { get; set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3247a78c799021c4094dd3f7e3717aeb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,77 @@
#if UNITY_6000_7_OR_NEWER
using System.IO;
using System.Linq;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Runtime.Commands;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
namespace Unity.Pipeline.Editor.Commands
{
/// <summary>
/// Captures a UI Toolkit <see cref="VisualElement"/> from an <see cref="EditorWindow"/> to a PNG
/// and returns it base64-encoded (and writes it to disk). The element is located by a USS-like
/// selector within the window's <see cref="EditorWindow.rootVisualElement"/>.
///
/// Selection, capture, and encoding are shared with the runtime command via
/// <see cref="VisualElementCaptureSupport"/>; this command only resolves the target window.
/// </summary>
public static class CaptureEditorElementCommand
{
[CliCommand("capture_editor_element",
"Capture a UI Toolkit VisualElement (by selector) from an EditorWindow to a PNG; returns path + base64.",
MainThreadRequired = true)]
public static CaptureElementResponse CaptureEditorElement(
[CliArg("window", "EditorWindow type name (e.g. InspectorWindow) or window title to capture from.", Required = true)] string window = "",
[CliArg("selector", "Element selector: '#name', '.class', a type name (e.g. Button), descendant (space) / child ('>') chains, optional pseudo-states (:checked,:hover,:focus,:active,:enabled,:disabled,:not(...)).", Required = true)] string selector = "",
[CliArg("output", "Output PNG path (absolute, or relative to the project root). Defaults to a timestamped file under <project>/Temp/pipeline-screenshots/.")] string output = "")
{
if (string.IsNullOrWhiteSpace(window))
return CaptureElementResponse.Fail("A 'window' (EditorWindow type name or title) is required.");
if (string.IsNullOrWhiteSpace(selector))
return CaptureElementResponse.Fail("A 'selector' is required.");
var target = ResolveWindow(window);
if (target == null)
return CaptureElementResponse.Fail(
$"No open EditorWindow matched '{window}' (by type name or title). Open the window first.");
var root = target.rootVisualElement;
if (root == null)
return CaptureElementResponse.Fail($"EditorWindow '{window}' has no rootVisualElement.");
var element = VisualElementCaptureSupport.ResolveElement(root, selector);
if (element == null)
return CaptureElementResponse.Fail(
$"No element matched selector '{selector}' in window '{window}'.");
var projectRoot = Path.GetDirectoryName(Application.dataPath);
var defaultDir = Path.Combine(projectRoot, "Temp", "pipeline-screenshots");
return VisualElementCaptureSupport.CaptureAndRespond(
element, selector, $"window:{window}", output,
relativeBaseDir: projectRoot, defaultDir: defaultDir, prefix: "element");
}
/// <summary>
/// Find an open EditorWindow whose runtime type name or title matches <paramref name="window"/>.
/// Uses <c>Resources.FindObjectsOfTypeAll</c> so hidden/utility windows are included too, but
/// prefers a match whose root is actually attached to a panel — <c>FindObjectsOfTypeAll</c> can
/// also return stale/backup window instances whose <c>rootVisualElement</c> has no panel (and
/// therefore cannot be captured).
/// </summary>
static EditorWindow ResolveWindow(string window)
{
var windows = Resources.FindObjectsOfTypeAll<EditorWindow>();
bool Matches(EditorWindow w) =>
w.GetType().Name == window ||
(w.titleContent != null && w.titleContent.text == window);
return windows.FirstOrDefault(w => Matches(w)
&& w.rootVisualElement != null && w.rootVisualElement.panel != null)
?? windows.FirstOrDefault(Matches);
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d09a03ff1d920b24080d933c3191e192
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,63 @@
using System;
using System.IO;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Editor.Commands
{
/// <summary>
/// Command to get detailed Editor status information.
/// Returns rich Editor state including compilation, play mode, and project details.
/// Accessible via "unity request editor_status" and "/api/editor_status" endpoint.
/// </summary>
public static class EditorStatusCommand
{
[CliCommand("editor_status", "Get detailed Unity Editor status and state information")]
public static StatusResponse GetEditorStatus()
{
return new StatusResponse
{
Status = GetOverallStatus(),
Compiling = EditorApplication.isCompiling,
DomainReloadInProgress = EditorApplication.isUpdating,
PlayMode = GetPlayModeStatus(),
LastHeartbeat = DateTime.UtcNow,
ProjectPath = Path.GetDirectoryName(Application.dataPath),
UnityVersion = Application.unityVersion
};
}
/// <summary>
/// Get overall Editor status based on current state.
/// </summary>
private static string GetOverallStatus()
{
if (EditorApplication.isCompiling)
return "compiling";
if (EditorApplication.isUpdating)
return "reloading";
if (EditorApplication.isPlaying || EditorApplication.isPaused)
return "playing";
return "ready";
}
/// <summary>
/// Get current play mode status.
/// </summary>
private static string GetPlayModeStatus()
{
if (EditorApplication.isPaused)
return "paused";
if (EditorApplication.isPlaying)
return "playing";
return "stopped";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 953fb97ec3dd48e4aa2ed9607c412f44
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,118 @@
using System;
using System.Reflection;
using Unity.Pipeline.Commands;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Editor.Commands
{
/// <summary>
/// Brings the Unity Editor application to the foreground (and restores it if minimized) by
/// focusing the main window's HostView.
///
/// ContainerWindow / HostView / GUIView are internal, so this uses reflection. GUIView.Focus()
/// maps to the native MonoGUIView::Focus, which activates the OS window. Useful for headless
/// automation: the editor throttles/defers some work (notably script compilation) while
/// unfocused, so a client can call this to bring it forward first.
/// </summary>
public static class FocusEditorCommand
{
const BindingFlags k_All = BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
static Type s_ContainerWindowType;
static Type s_HostViewType;
static MethodInfo s_IsMainWindow;
static MethodInfo s_GuiViewFocus;
static bool s_Resolved;
[CliCommand("editor_focus", "Bring the Unity Editor window to the foreground", MainThreadRequired = true)]
public static string FocusEditor()
{
if (!ResolveReflection(out var error))
return error;
try
{
object mainWindow = null;
foreach (var w in Resources.FindObjectsOfTypeAll(s_ContainerWindowType))
{
if ((bool)s_IsMainWindow.Invoke(w, null)) { mainWindow = w; break; }
}
if (mainWindow == null)
return "Error: main editor window not found";
var rootView = GetRootView(mainWindow);
if (rootView == null)
return "Error: main window has no root view";
var host = FindHostView(rootView);
if (host == null)
return "Error: no HostView found in main window";
s_GuiViewFocus.Invoke(host, null);
return $"Editor focused via {host.GetType().Name}";
}
catch (Exception ex)
{
return $"Error: failed to focus editor: {ex.Message}";
}
}
static bool ResolveReflection(out string error)
{
error = null;
if (s_Resolved) return true;
var asm = typeof(EditorWindow).Assembly;
s_ContainerWindowType = asm.GetType("UnityEditor.ContainerWindow");
s_HostViewType = asm.GetType("UnityEditor.HostView");
var guiViewType = asm.GetType("UnityEditor.GUIView");
if (s_ContainerWindowType == null || s_HostViewType == null || guiViewType == null)
{
error = "Error: internal editor window types not found (Unity version mismatch?)";
return false;
}
s_IsMainWindow = s_ContainerWindowType.GetMethod("IsMainWindow", k_All);
s_GuiViewFocus = guiViewType.GetMethod("Focus", k_All, null, Type.EmptyTypes, null);
if (s_IsMainWindow == null || s_GuiViewFocus == null)
{
error = "Error: ContainerWindow.IsMainWindow or GUIView.Focus not found (internal API changed?)";
return false;
}
s_Resolved = true;
return true;
}
static object GetRootView(object mainWindow)
{
var prop = s_ContainerWindowType.GetProperty("rootView", k_All);
if (prop != null)
return prop.GetValue(mainWindow);
return s_ContainerWindowType.GetField("m_RootView", k_All)?.GetValue(mainWindow);
}
static object FindHostView(object rootView)
{
if (s_HostViewType.IsInstanceOfType(rootView))
return rootView;
if (!(rootView.GetType().GetProperty("allChildren", k_All)?.GetValue(rootView) is Array allChildren))
return null;
// Prefer a DockArea (an actual docked panel) over the Toolbar, but any HostView works
// to bring the main window — and thus the application — to the foreground.
object firstHost = null;
foreach (var c in allChildren)
{
if (c == null || !s_HostViewType.IsInstanceOfType(c)) continue;
if (firstHost == null) firstHost = c;
if (c.GetType().Name == "DockArea") return c;
}
return firstHost;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f146c518ed01a1b4a9be8a546cdfbde5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c2281ed89e8a4db9a14ec523905221f9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,211 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Editor.Commands.GameObjects
{
/// <summary>
/// Component authoring commands (CLI-192): add/remove a component on a GameObject and get/set its
/// serialized properties.
///
/// WHY property get/set goes exclusively through <see cref="SerializedObject"/> /
/// <see cref="SerializedProperty"/>: it is the only mutation path that dirties the object, records
/// prefab overrides, and registers a single Undo step (via <see cref="Undo.RecordObject"/> +
/// <see cref="SerializedObject.ApplyModifiedProperties"/>) the way the Inspector does. Direct
/// reflection on backing fields would silently desync the Editor. Component types are resolved by
/// name across all loaded assemblies (<see cref="TypeResolver"/>) so agents can use short or
/// fully-qualified names.
/// </summary>
public static class ComponentCommands
{
/// <summary>
/// Add a component (resolved by type name) to a GameObject. Registered with
/// <see cref="Undo.AddComponent{T}(GameObject)"/> via the non-generic overload so the addition
/// is reversible. Returns the new component's identity so its properties can be set next.
/// </summary>
[CliCommand("add_component", "Add a component (by type name) to a GameObject.")]
public static AuthoringResult AddComponent(
[CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target,
[CliArg("type", "Component type name (e.g. 'Rigidbody' or 'UnityEngine.Camera').", Required = true)] string type)
{
var go = GameObjectCommands.ResolveGameObject(target, "target");
var componentType = TypeResolver.ResolveComponentType(type);
if (componentType == null)
throw new ArgumentException($"Could not resolve component type '{type}'.");
using (new AuthoringUndoScope("Add Component"))
{
var component = Undo.AddComponent(go, componentType);
if (component == null)
throw new InvalidOperationException(
$"Failed to add component '{componentType.Name}' to '{go.name}' (it may be disallowed on this GameObject).");
GameObjectCommands.MarkDirty(go);
return ObjectResolver.Describe(component);
}
}
/// <summary>
/// Remove a component from a GameObject. The component is addressed directly by handle (an
/// <see cref="ObjectRef"/> resolving to a Component), or by GameObject handle plus a type name.
/// Uses <see cref="Undo.DestroyObjectImmediate"/> so the removal is reversible.
/// </summary>
[CliCommand("remove_component",
"Remove a component from a GameObject. Provide either a component handle (target) or a GameObject handle (target) plus a type name.")]
public static AuthoringResult RemoveComponent(
[CliArg("target", "Handle of the component to remove, OR of the GameObject when 'type' is given.", Required = true)] ObjectRef target,
[CliArg("type", "Component type name to remove from the target GameObject (omit when 'target' already points at a component).")] string type = null)
{
var component = ResolveComponent(target, type);
var go = component.gameObject;
var described = ObjectResolver.Describe(component);
using (new AuthoringUndoScope("Remove Component"))
{
Undo.DestroyObjectImmediate(component);
GameObjectCommands.MarkDirty(go);
}
return described;
}
/// <summary>
/// Read the serialized properties of a component as a JSON map. Iterates the visible serialized
/// surface (skipping the script reference) and converts each property with
/// <see cref="SerializedPropertyConverter.Read"/>.
/// </summary>
[CliCommand("get_component_properties",
"Get a component's serialized properties as a JSON map. Address the component by handle, or by GameObject handle + type.")]
public static ComponentPropertiesResult GetComponentProperties(
[CliArg("target", "Handle of the component, OR of the GameObject when 'type' is given.", Required = true)] ObjectRef target,
[CliArg("type", "Component type name on the target GameObject (omit when 'target' is a component handle).")] string type = null)
{
var component = ResolveComponent(target, type);
var so = new SerializedObject(component);
var properties = new Dictionary<string, JToken>();
var iterator = so.GetIterator();
var enterChildren = true;
while (iterator.NextVisible(enterChildren))
{
enterChildren = false; // only iterate top-level visible properties
if (iterator.name == "m_Script")
continue;
try
{
properties[iterator.name] = SerializedPropertyConverter.Read(iterator.Copy());
}
catch (Exception ex)
{
properties[iterator.name] = new JValue($"<error:{ex.Message}>");
}
}
return new ComponentPropertiesResult
{
Component = ObjectResolver.Describe(component),
Properties = properties
};
}
/// <summary>
/// Set one or more serialized properties on a component. Each entry in <paramref name="properties"/>
/// maps a property path (e.g. "m_Mass" or "myField") to a JSON value. The whole batch is one
/// Undo step: <see cref="Undo.RecordObject"/> snapshots the component, every property is written
/// through <see cref="SerializedPropertyConverter.Write"/>, and
/// <see cref="SerializedObject.ApplyModifiedProperties"/> commits + dirties as one operation.
/// An unknown property name fails the whole batch with a clear error (no partial apply).
/// </summary>
[CliCommand("set_component_properties",
"Set serialized properties on a component (one Undo step). 'properties' maps property name -> value; object references accept an ObjectRef handle.")]
public static ComponentPropertiesResult SetComponentProperties(
[CliArg("target", "Handle of the component, OR of the GameObject when 'type' is given.", Required = true)] ObjectRef target,
[CliArg("properties", "Map of serialized property name to value. Vectors/colors are arrays; object refs are handle objects.", Required = true)] JObject properties,
[CliArg("type", "Component type name on the target GameObject (omit when 'target' is a component handle).")] string type = null)
{
if (properties == null || properties.Count == 0)
throw new ArgumentException("'properties' must contain at least one property to set.");
var component = ResolveComponent(target, type);
using (new AuthoringUndoScope("Set Component Properties"))
{
Undo.RecordObject(component, "Set Component Properties");
var so = new SerializedObject(component);
foreach (var pair in properties)
{
var property = so.FindProperty(pair.Key);
if (property == null)
throw new ArgumentException(
$"Component '{component.GetType().Name}' has no serialized property '{pair.Key}'.");
SerializedPropertyConverter.Write(property, pair.Value);
}
// Applies the changes, registers Undo, and dirties the object/scene.
so.ApplyModifiedProperties();
GameObjectCommands.MarkDirty(component.gameObject);
}
// Re-read so the caller sees the committed values.
return GetComponentProperties(target, type);
}
#region Helpers
/// <summary>
/// Resolve a component either directly (the handle points at a Component) or by GameObject
/// handle + type name. Throws a descriptive error when neither resolves.
/// </summary>
private static Component ResolveComponent(ObjectRef handle, string type)
{
if (!ObjectResolver.TryResolve(handle, 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 so we never
// mutate/remove the wrong instance when the GameObject has several components of the same
// type. A 'type' here is only a constraint to validate against, NOT a reason to re-fetch
// GetComponent(type) (which returns the first match and would ignore the handle).
if (obj is Component directComponent)
{
if (!string.IsNullOrEmpty(type))
{
var requestedType = TypeResolver.ResolveComponentType(type);
if (requestedType == null)
throw new ArgumentException($"Could not resolve component type '{type}'.");
if (!requestedType.IsInstanceOfType(directComponent))
throw new ArgumentException(
$"'target' resolves to a {directComponent.GetType().Name}, which is not a '{requestedType.Name}'.");
}
return directComponent;
}
var go = obj as GameObject;
if (go == null)
throw new ArgumentException($"'target' did not resolve to a GameObject or Component (got {obj.GetType().Name}).");
if (string.IsNullOrEmpty(type))
throw new ArgumentException("'type' is required when 'target' is a GameObject.");
var componentType = TypeResolver.ResolveComponentType(type);
if (componentType == null)
throw new ArgumentException($"Could not resolve component type '{type}'.");
var component = go.GetComponent(componentType);
if (component == null)
throw new ArgumentException($"GameObject '{go.name}' has no component of type '{componentType.Name}'.");
return component;
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1a7470ef26f8497595bcbdb84ceb96dc
@@ -0,0 +1,25 @@
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Models;
namespace Unity.Pipeline.Editor.Commands.GameObjects
{
/// <summary>
/// Structured result for <c>get_component_properties</c> / <c>set_component_properties</c>.
/// WHY it pairs the component identity with the property map: the caller gets back both the
/// canonical <see cref="AuthoringResult"/> handle (to reference the component again) and the
/// current serialized values in one round trip, which also makes set return the committed state
/// for a read-after-write check.
/// </summary>
public sealed class ComponentPropertiesResult
{
/// <summary>Canonical identity of the component the properties belong to.</summary>
[JsonProperty("component")]
public AuthoringResult Component { get; set; }
/// <summary>Map of serialized property name to its JSON value.</summary>
[JsonProperty("properties")]
public Dictionary<string, JToken> Properties { get; set; } = new Dictionary<string, JToken>();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e87b3f2873ff4bac9229a28242917ac0
@@ -0,0 +1,26 @@
using System.Collections.Generic;
using Newtonsoft.Json;
using Unity.Pipeline.Models;
namespace Unity.Pipeline.Editor.Commands.GameObjects
{
/// <summary>
/// Structured result for the batch <c>create_gameobjects</c> command (CLI-223). WHY a dedicated
/// envelope rather than a bare array: it carries the created <see cref="Count"/> alongside the
/// list so an agent can confirm the whole batch landed in one round-trip without inspecting array
/// length, and mirrors <see cref="FindGameObjectsResult"/> so the GameObject command surface stays
/// consistent. Each entry is the same canonical <see cref="AuthoringResult"/> identity returned by
/// the single-object <c>create_gameobject</c>, so any created object can be fed straight back as an
/// <see cref="ObjectRef"/> in a follow-up call.
/// </summary>
public sealed class CreateGameObjectsResult
{
/// <summary>Number of GameObjects created in the batch.</summary>
[JsonProperty("count")]
public int Count { get; set; }
/// <summary>Canonical identities of the created GameObjects, in creation order.</summary>
[JsonProperty("gameObjects")]
public List<AuthoringResult> GameObjects { get; set; } = new List<AuthoringResult>();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 671f5065c22d6474b80053b6da5a84ef
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,25 @@
using System.Collections.Generic;
using Newtonsoft.Json;
using Unity.Pipeline.Models;
namespace Unity.Pipeline.Editor.Commands.GameObjects
{
/// <summary>
/// Structured result for <c>find_gameobjects</c>. WHY a dedicated envelope rather than a bare
/// array: it carries the match <see cref="Count"/> alongside the list so an agent can branch on
/// "found / not found / ambiguous" without inspecting array length, and leaves room to add query
/// metadata later without breaking the shape. Each entry is the same canonical
/// <see cref="AuthoringResult"/> identity returned by the single-object commands, so a result can
/// be fed straight back as an <see cref="ObjectRef"/> in a follow-up call.
/// </summary>
public sealed class FindGameObjectsResult
{
/// <summary>Number of GameObjects that matched the query.</summary>
[JsonProperty("count")]
public int Count { get; set; }
/// <summary>Canonical identities of the matching GameObjects.</summary>
[JsonProperty("gameObjects")]
public List<AuthoringResult> GameObjects { get; set; } = new List<AuthoringResult>();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1d12bf4a53c14a839c26a91e8ec68292
@@ -0,0 +1,533 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Editor.Commands.GameObjects
{
/// <summary>
/// GameObject authoring commands (CLI-192): creating GameObjects, querying the scene hierarchy,
/// and mutating the core GameObject/Transform surface (parenting, transform, active state, tag,
/// layer, name, deletion).
///
/// WHY these go through the same conventions as the rest of the authoring foundation:
/// every mutation is wrapped in an <see cref="AuthoringUndoScope"/> and registered with Unity's
/// Undo system so an agent's multi-step action reverts as a single collapsible step. Each
/// mutated object is marked dirty (and its scene flagged dirty) so the change survives a Save and
/// is visible to the rest of the Editor. Results come back as the canonical
/// <see cref="AuthoringResult"/> envelope (via <see cref="ObjectResolver.Describe"/>) so the
/// agent can address the same object in a follow-up call.
/// </summary>
public static class GameObjectCommands
{
/// <summary>
/// Create an empty GameObject or a built-in primitive in the active scene.
///
/// Creation goes through <see cref="GameObject.CreatePrimitive"/> for primitives (so the
/// matching mesh + collider are attached exactly as the Editor would) and a plain
/// <c>new GameObject</c> for the empty case. The object is registered with
/// <see cref="Undo.RegisterCreatedObjectUndo"/> so it can be reverted, and an optional parent
/// is resolved by handle so a whole hierarchy can be built in one sequence of calls.
/// </summary>
[CliCommand("create_gameobject",
"Create an empty GameObject or a built-in primitive (cube/sphere/capsule/cylinder/plane/quad) in the active scene.")]
public static AuthoringResult CreateGameObject(
[CliArg("name", "Name for the new GameObject. Defaults to 'GameObject' (or the primitive name).")] string name = null,
[CliArg("primitive", "Optional primitive type: cube, sphere, capsule, cylinder, plane, quad. Omit for an empty GameObject.")] string primitive = null,
[CliArg("parent", "Optional parent handle (globalId/path/guid/instanceId/hierarchyPath). The new object becomes a child of it.")] ObjectRef parent = null)
{
// Resolve the parent once, outside the scope, so a bad handle fails before any creation.
var parentTransform = ResolveParentTransform(parent);
using (new AuthoringUndoScope("Create GameObject"))
{
var go = CreateOne(primitive, name, parentTransform);
MarkDirty(go);
return ObjectResolver.Describe(go);
}
}
/// <summary>
/// Create N GameObjects (empty or a primitive) in one server round-trip (CLI-223).
///
/// WHY a separate plural command rather than overloading <see cref="CreateGameObject"/>: the
/// single command's contract — one call returns one <see cref="AuthoringResult"/> — is relied
/// on by existing callers and tests, so it is left untouched. This plural command is the batch
/// surface and returns a <see cref="CreateGameObjectsResult"/> envelope (count + per-item
/// identities). When <paramref name="count"/> &gt; 1 and no explicit name array is supplied,
/// each object is suffixed Name1..NameN so they remain individually addressable.
///
/// Per-index <paramref name="positions"/>/<paramref name="rotations"/>/<paramref name="scales"/>
/// are arrays of [x,y,z] vectors. When a vectors array is supplied its length MUST equal
/// <paramref name="count"/> (a mismatch is a validation error). Omitted channels leave the
/// object at its creation default (origin / identity / unit scale). The whole batch is wrapped
/// in ONE <see cref="AuthoringUndoScope"/> so it reverts as a single Undo step.
/// </summary>
[CliCommand("create_gameobjects",
"Batch-create N empty GameObjects or primitives in one call. Optional positions/rotations/scales are arrays of [x,y,z] (length must equal count). Returns the created identities.")]
public static CreateGameObjectsResult CreateGameObjects(
[CliArg("name", "Base name. With count>1 and no explicit names, objects are suffixed Name1..NameN.")] string name = null,
[CliArg("primitive", "Optional primitive type: cube, sphere, capsule, cylinder, plane, quad. Omit for empty GameObjects.")] string primitive = null,
[CliArg("parent", "Optional parent handle. Every created object becomes a child of it.")] ObjectRef parent = null,
[CliArg("count", "How many GameObjects to create. Default 1.")] int count = 1,
[CliArg("positions", "Local positions, one [x,y,z] per object. Length must equal count when supplied.")] float[][] positions = null,
[CliArg("rotations", "Local Euler rotations (degrees), one [x,y,z] per object. Length must equal count when supplied.")] float[][] rotations = null,
[CliArg("scales", "Local scales, one [x,y,z] per object. Length must equal count when supplied.")] float[][] scales = null)
{
if (count < 1)
throw new ArgumentException($"'count' must be at least 1, got {count}.");
ValidateVectorArray(positions, count, "positions");
ValidateVectorArray(rotations, count, "rotations");
ValidateVectorArray(scales, count, "scales");
// Resolve the parent once, outside the scope, so a bad handle fails before any creation.
var parentTransform = ResolveParentTransform(parent);
var result = new CreateGameObjectsResult();
using (new AuthoringUndoScope("Create GameObjects"))
{
for (int i = 0; i < count; i++)
{
// count==1 keeps the bare name (no "1" suffix) so a single-item batch reads
// naturally; count>1 suffixes 1..N for individual addressability.
var itemName = count > 1 && !string.IsNullOrEmpty(name) ? name + (i + 1) : name;
var go = CreateOne(primitive, itemName, parentTransform);
// Index the arg name so a null/mis-shaped entry names the offending element
// (a JSON array may contain a null entry even when its length matches count).
if (positions != null)
go.transform.localPosition = ToVector3(positions[i], $"positions[{i}]");
if (rotations != null)
go.transform.localEulerAngles = ToVector3(rotations[i], $"rotations[{i}]");
if (scales != null)
go.transform.localScale = ToVector3(scales[i], $"scales[{i}]");
MarkDirty(go);
result.GameObjects.Add(ObjectResolver.Describe(go));
}
}
result.Count = result.GameObjects.Count;
return result;
}
/// <summary>
/// Find GameObjects in the loaded scenes by name, tag, component type, and/or hierarchy path.
/// All supplied filters are combined (AND). Returns the canonical identity of each match so an
/// agent can act on the results without a second lookup.
///
/// This is intentionally a structured query rather than a single-object resolve: agents need
/// to discover what exists before they can reference it, and the result set carries the same
/// <see cref="AuthoringResult"/> identities used everywhere else.
/// </summary>
[CliCommand("find_gameobjects",
"Find GameObjects in loaded scenes by name, tag, component type, and/or hierarchy path (filters are combined). Returns structured identities.")]
public static FindGameObjectsResult FindGameObjects(
[CliArg("name", "Exact name to match.")] string name = null,
[CliArg("tag", "Tag to match (e.g. 'Player').")] string tag = null,
[CliArg("type", "Component type name to match (e.g. 'Rigidbody', 'UnityEngine.Camera').")] string type = null,
[CliArg("hierarchy_path", "Exact hierarchy path to match (e.g. '/Root/Child').")] string hierarchyPath = null,
[CliArg("include_inactive", "Include inactive GameObjects. Default true.")] bool includeInactive = true)
{
Type componentType = null;
if (!string.IsNullOrEmpty(type))
{
componentType = TypeResolver.ResolveComponentType(type);
if (componentType == null)
throw new ArgumentException($"Could not resolve component type '{type}'.");
}
// Resolve the hierarchy filter once so it is comparable to each object's computed path.
var hierarchyFilter = string.IsNullOrEmpty(hierarchyPath) ? null : NormalizeHierarchyPath(hierarchyPath);
// Validate the tag up front: comparing against an undefined tag (CompareTag / .tag) would
// log a Unity error per object. An unknown tag simply yields no matches.
if (!string.IsNullOrEmpty(tag) && !InternalEditorTagExists(tag))
return new FindGameObjectsResult { Count = 0, GameObjects = new List<AuthoringResult>() };
var matches = new List<AuthoringResult>();
foreach (var go in EnumerateSceneGameObjects(includeInactive))
{
if (!string.IsNullOrEmpty(name) && go.name != name)
continue;
if (!string.IsNullOrEmpty(tag) && go.tag != tag)
continue;
if (componentType != null && go.GetComponent(componentType) == null)
continue;
if (hierarchyFilter != null && GetHierarchyPath(go) != hierarchyFilter)
continue;
var described = ObjectResolver.Describe(go);
if (described != null)
matches.Add(described);
}
return new FindGameObjectsResult { Count = matches.Count, GameObjects = matches };
}
/// <summary>
/// Set the local transform (position, rotation in Euler degrees, scale) of a GameObject.
/// Any omitted channel is left unchanged. Goes through <see cref="Undo.RegisterCompleteObjectUndo"/>
/// on the Transform so the change is reversible and recorded as a prefab override where relevant.
/// </summary>
[CliCommand("set_transform",
"Set a GameObject's local position/rotation(euler)/scale. Omitted channels are left unchanged.")]
public static AuthoringResult SetTransform(
[CliArg("target", "Handle of the GameObject to modify.", Required = true)] ObjectRef target,
[CliArg("position", "Local position as [x,y,z].")] float[] position = null,
[CliArg("rotation", "Local rotation as Euler angles [x,y,z] in degrees.")] float[] rotation = null,
[CliArg("scale", "Local scale as [x,y,z].")] float[] scale = null)
{
var go = ResolveGameObject(target, "target");
using (new AuthoringUndoScope("Set Transform"))
{
var transform = go.transform;
Undo.RegisterCompleteObjectUndo(transform, "Set Transform");
if (position != null)
transform.localPosition = ToVector3(position, "position");
if (rotation != null)
transform.localEulerAngles = ToVector3(rotation, "rotation");
if (scale != null)
transform.localScale = ToVector3(scale, "scale");
MarkDirty(go);
return ObjectResolver.Describe(go);
}
}
/// <summary>
/// Reparent a GameObject (or clear its parent so it becomes a scene root). Uses
/// <see cref="Undo.SetTransformParent"/> so the reparent — including the sibling-index change —
/// is a single undoable operation. World position is preserved by default to match the
/// Editor's drag-to-reparent behavior.
/// </summary>
[CliCommand("set_parent",
"Reparent a GameObject under a new parent, or detach it to scene root when no parent is given.")]
public static AuthoringResult SetParent(
[CliArg("target", "Handle of the GameObject to reparent.", Required = true)] ObjectRef target,
[CliArg("parent", "Handle of the new parent. Omit (or empty) to move the object to the scene root.")] ObjectRef parent = null,
[CliArg("world_position_stays", "Keep the object's world position when reparenting. Default true.")] bool worldPositionStays = true)
{
var go = ResolveGameObject(target, "target");
Transform parentTransform = null;
if (parent != null && !parent.IsEmpty)
parentTransform = ResolveGameObject(parent, "parent").transform;
using (new AuthoringUndoScope("Set Parent"))
{
Undo.SetTransformParent(go.transform, parentTransform, worldPositionStays, "Set Parent");
MarkDirty(go);
return ObjectResolver.Describe(go);
}
}
/// <summary>Set a GameObject's active self-state.</summary>
[CliCommand("set_active", "Set a GameObject's active self-state (activeSelf).")]
public static AuthoringResult SetActive(
[CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target,
[CliArg("active", "Desired active state.", Required = true)] bool active)
{
var go = ResolveGameObject(target, "target");
using (new AuthoringUndoScope("Set Active"))
{
Undo.RegisterCompleteObjectUndo(go, "Set Active");
go.SetActive(active);
MarkDirty(go);
return ObjectResolver.Describe(go);
}
}
/// <summary>
/// Set a GameObject's tag. The tag must already exist in the project's Tag Manager; an unknown
/// tag surfaces a clear error rather than silently creating one (tag creation is a project
/// settings mutation outside this command's scope).
/// </summary>
[CliCommand("set_tag", "Set a GameObject's tag (the tag must already exist in the project).")]
public static AuthoringResult SetTag(
[CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target,
[CliArg("tag", "Tag to assign (must exist in the Tag Manager).", Required = true)] string tag)
{
var go = ResolveGameObject(target, "target");
if (!InternalEditorTagExists(tag))
throw new ArgumentException($"Tag '{tag}' does not exist in the project. Add it via the Tag Manager first.");
using (new AuthoringUndoScope("Set Tag"))
{
Undo.RegisterCompleteObjectUndo(go, "Set Tag");
go.tag = tag;
MarkDirty(go);
return ObjectResolver.Describe(go);
}
}
/// <summary>
/// Set a GameObject's layer by name or numeric index. A name is resolved via
/// <see cref="LayerMask.NameToLayer"/>; an out-of-range index or unknown name is rejected.
/// </summary>
[CliCommand("set_layer", "Set a GameObject's layer by name or numeric index (0-31).")]
public static AuthoringResult SetLayer(
[CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target,
[CliArg("layer", "Layer name (e.g. 'UI') or numeric index 0-31.", Required = true)] string layer)
{
var go = ResolveGameObject(target, "target");
var layerIndex = ResolveLayer(layer);
using (new AuthoringUndoScope("Set Layer"))
{
Undo.RegisterCompleteObjectUndo(go, "Set Layer");
go.layer = layerIndex;
MarkDirty(go);
return ObjectResolver.Describe(go);
}
}
/// <summary>Rename a GameObject.</summary>
[CliCommand("rename_gameobject", "Rename a GameObject.")]
public static AuthoringResult RenameGameObject(
[CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target,
[CliArg("name", "New name.", Required = true)] string name)
{
if (string.IsNullOrEmpty(name))
throw new ArgumentException("New name must not be empty.");
var go = ResolveGameObject(target, "target");
using (new AuthoringUndoScope("Rename GameObject"))
{
Undo.RegisterCompleteObjectUndo(go, "Rename GameObject");
go.name = name;
MarkDirty(go);
return ObjectResolver.Describe(go);
}
}
/// <summary>
/// Delete a GameObject from the scene. Uses <see cref="Undo.DestroyObjectImmediate"/> so the
/// deletion is reversible. The result describes the object's identity captured *before*
/// destruction (the live object is gone afterwards).
/// </summary>
[CliCommand("delete_gameobject", "Delete a GameObject from the scene (reversible via Undo).")]
public static AuthoringResult DeleteGameObject(
[CliArg("target", "Handle of the GameObject to delete.", Required = true)] ObjectRef target)
{
var go = ResolveGameObject(target, "target");
// Capture identity before destruction; the live object is invalid afterwards.
var described = ObjectResolver.Describe(go);
var scene = go.scene;
using (new AuthoringUndoScope("Delete GameObject"))
{
Undo.DestroyObjectImmediate(go);
if (scene.IsValid())
EditorSceneManager.MarkSceneDirty(scene);
}
return described;
}
#region Helpers
/// <summary>
/// Resolve an <see cref="ObjectRef"/> to a GameObject. Accepts either a GameObject handle or a
/// Component handle (in which case its owning GameObject is used), matching how agents tend to
/// hold references. Throws a descriptive error rather than returning null so the command
/// surfaces a clear message to the caller.
/// </summary>
internal static GameObject ResolveGameObject(ObjectRef handle, string argName)
{
if (!ObjectResolver.TryResolve(handle, out var obj, out var error))
throw new ArgumentException($"Could not resolve '{argName}': {error}");
var go = obj as GameObject ?? (obj as Component)?.gameObject;
if (go == null)
throw new ArgumentException($"'{argName}' did not resolve to a GameObject (got {obj.GetType().Name}).");
return go;
}
/// <summary>Mark a GameObject and its scene dirty so changes persist and the Editor refreshes.</summary>
internal static void MarkDirty(GameObject go)
{
EditorUtility.SetDirty(go);
if (go.scene.IsValid())
EditorSceneManager.MarkSceneDirty(go.scene);
}
/// <summary>
/// Create a single GameObject (empty or a primitive), apply an optional name, register it for
/// Undo, and parent it under <paramref name="parentTransform"/> when given. Shared by the
/// single <see cref="CreateGameObject"/> and the batch <see cref="CreateGameObjects"/> so both
/// build objects identically. MUST be called inside an <see cref="AuthoringUndoScope"/>.
/// </summary>
private static GameObject CreateOne(string primitive, string name, Transform parentTransform)
{
GameObject go;
if (!string.IsNullOrEmpty(primitive))
{
if (!TryParsePrimitive(primitive, out var primitiveType))
throw new ArgumentException(
$"Unknown primitive '{primitive}'. Expected one of: cube, sphere, capsule, cylinder, plane, quad.");
go = GameObject.CreatePrimitive(primitiveType);
}
else
{
go = new GameObject();
}
if (!string.IsNullOrEmpty(name))
go.name = name;
Undo.RegisterCreatedObjectUndo(go, "Create GameObject");
if (parentTransform != null)
Undo.SetTransformParent(go.transform, parentTransform, "Create GameObject");
return go;
}
/// <summary>
/// Resolve an optional parent handle to its Transform, or null when no parent is supplied.
/// Resolved before any creation so an invalid handle fails fast (and a batch creates nothing).
/// </summary>
private static Transform ResolveParentTransform(ObjectRef parent)
{
if (parent == null || parent.IsEmpty)
return null;
return ResolveGameObject(parent, "parent").transform;
}
/// <summary>
/// Validate a per-item vectors array (positions/rotations/scales) up front, before any object is
/// created. When supplied it must have exactly one entry per object, and every entry must be a
/// non-null [x,y,z] triple (a JSON array can carry a null element even when its length matches
/// count). A bad array is an agent error surfaced as an <see cref="ArgumentException"/> naming
/// the offending index, so a batch applies fully or creates nothing rather than throwing an
/// NRE part-way through.
/// </summary>
private static void ValidateVectorArray(float[][] vectors, int count, string argName)
{
if (vectors == null)
return;
if (vectors.Length != count)
throw new ArgumentException(
$"'{argName}' has {vectors.Length} entries but count is {count}; they must match.");
for (int i = 0; i < vectors.Length; i++)
{
if (vectors[i] == null)
throw new ArgumentException($"'{argName}[{i}]' must be an [x,y,z] array but was null.");
if (vectors[i].Length != 3)
throw new ArgumentException(
$"'{argName}[{i}]' must have exactly 3 components [x,y,z], got {vectors[i].Length}.");
}
}
private static bool TryParsePrimitive(string value, out PrimitiveType primitiveType)
{
switch (value.Trim().ToLowerInvariant())
{
case "cube": primitiveType = PrimitiveType.Cube; return true;
case "sphere": primitiveType = PrimitiveType.Sphere; return true;
case "capsule": primitiveType = PrimitiveType.Capsule; return true;
case "cylinder": primitiveType = PrimitiveType.Cylinder; return true;
case "plane": primitiveType = PrimitiveType.Plane; return true;
case "quad": primitiveType = PrimitiveType.Quad; return true;
default: primitiveType = default; return false;
}
}
private static Vector3 ToVector3(float[] values, string argName)
{
if (values == null)
throw new ArgumentException($"'{argName}' must be an [x,y,z] array but was null.");
if (values.Length != 3)
throw new ArgumentException($"'{argName}' must have exactly 3 components [x,y,z], got {values.Length}.");
return new Vector3(values[0], values[1], values[2]);
}
private static int ResolveLayer(string layer)
{
if (int.TryParse(layer, out var index))
{
if (index < 0 || index > 31)
throw new ArgumentException($"Layer index {index} is out of range (0-31).");
return index;
}
var named = LayerMask.NameToLayer(layer);
if (named < 0)
throw new ArgumentException($"Layer '{layer}' does not exist in the project.");
return named;
}
private static bool InternalEditorTagExists(string tag)
{
return UnityEditorInternal.InternalEditorUtility.tags.Contains(tag);
}
private static IEnumerable<GameObject> EnumerateSceneGameObjects(bool includeInactive)
{
for (int s = 0; s < SceneManager.sceneCount; s++)
{
var scene = SceneManager.GetSceneAt(s);
if (!scene.isLoaded)
continue;
foreach (var root in scene.GetRootGameObjects())
{
foreach (var go in EnumerateRecursive(root, includeInactive))
yield return go;
}
}
}
private static IEnumerable<GameObject> EnumerateRecursive(GameObject go, bool includeInactive)
{
if (!includeInactive && !go.activeInHierarchy)
yield break;
yield return go;
var transform = go.transform;
for (int i = 0; i < transform.childCount; i++)
{
foreach (var child in EnumerateRecursive(transform.GetChild(i).gameObject, includeInactive))
yield return child;
}
}
private static string GetHierarchyPath(GameObject go)
{
var sb = new System.Text.StringBuilder(go.name);
var parent = go.transform.parent;
while (parent != null)
{
sb.Insert(0, parent.name + "/");
parent = parent.parent;
}
return "/" + sb;
}
private static string NormalizeHierarchyPath(string path)
{
var trimmed = "/" + path.Trim().Trim('/');
return trimmed;
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a1aac051d9234c78b4918a8dd366514a
@@ -0,0 +1,224 @@
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.GameObjects
{
/// <summary>
/// Reads and writes a single <see cref="SerializedProperty"/> from/to a JSON value.
///
/// WHY everything goes through SerializedProperty rather than reflection on the C# field: it is
/// the only path that correctly participates in dirtying, prefab override tracking, and Undo
/// (when the change is applied via <see cref="SerializedObject.ApplyModifiedProperties"/>).
/// Reflection would bypass all three and leave the Editor's view stale.
///
/// Supported surface: primitives (bool/int/long/float/double/string), enums (by name or index),
/// <see cref="Vector2"/>/<see cref="Vector3"/>/<see cref="Vector4"/>, <see cref="Color"/>, and
/// object references (assigned from an <see cref="ObjectRef"/> handle resolved by
/// <see cref="ObjectResolver"/>).
/// </summary>
internal static class SerializedPropertyConverter
{
/// <summary>
/// Read a property into a JSON-friendly value (primitive, string, or small array/object for
/// vector/color types). Unsupported property kinds return a short type descriptor string so a
/// get_component_properties dump never throws on an exotic field.
/// </summary>
public static JToken Read(SerializedProperty property)
{
// Array/list fields surface as Generic with isArray=true; return their elements as a JSON
// array so a get/set round-trips. (String also reports isArray, hence the exclusion.)
if (property.isArray && property.propertyType != SerializedPropertyType.String)
{
var elements = new JArray();
for (int i = 0; i < property.arraySize; i++)
elements.Add(Read(property.GetArrayElementAtIndex(i)));
return elements;
}
switch (property.propertyType)
{
case SerializedPropertyType.Boolean:
return new JValue(property.boolValue);
case SerializedPropertyType.Integer:
return new JValue(property.longValue);
case SerializedPropertyType.Float:
return new JValue(property.doubleValue);
case SerializedPropertyType.String:
return new JValue(property.stringValue);
case SerializedPropertyType.Enum:
// enumValueIndex indexes enumNames; guard against -1 (mixed/unknown).
return property.enumValueIndex >= 0 && property.enumValueIndex < property.enumNames.Length
? new JValue(property.enumNames[property.enumValueIndex])
: new JValue(property.intValue);
case SerializedPropertyType.Vector2:
return ToArray(property.vector2Value.x, property.vector2Value.y);
case SerializedPropertyType.Vector3:
return ToArray(property.vector3Value.x, property.vector3Value.y, property.vector3Value.z);
case SerializedPropertyType.Vector4:
return ToArray(property.vector4Value.x, property.vector4Value.y, property.vector4Value.z, property.vector4Value.w);
case SerializedPropertyType.Color:
var c = property.colorValue;
return ToArray(c.r, c.g, c.b, c.a);
case SerializedPropertyType.ObjectReference:
return property.objectReferenceValue != null
? JToken.FromObject(ObjectResolver.Describe(property.objectReferenceValue))
: JValue.CreateNull();
default:
// Bounds, Quaternion, AnimationCurve, etc. are out of MVP scope: surface a hint.
return new JValue($"<unsupported:{property.propertyType}>");
}
}
/// <summary>
/// Write a JSON value into a property. Throws <see cref="ArgumentException"/> with a clear
/// message on a type mismatch so set_component_properties reports exactly which field/value
/// failed. Object references are resolved from an <see cref="ObjectRef"/>-shaped JSON object
/// (or a bare string treated as an asset path / globalId).
/// </summary>
public static void Write(SerializedProperty property, JToken value)
{
// Array/list fields (Generic + isArray) are written from a JSON array: resize, then recurse
// per element so arrays of primitives AND object references (e.g. MeshRenderer.m_Materials)
// both work. (String also reports isArray, hence the exclusion.)
if (property.isArray && property.propertyType != SerializedPropertyType.String)
{
if (!(value is JArray array))
throw new ArgumentException(
$"Property '{property.name}' is an array; provide a JSON array of elements.");
property.arraySize = array.Count;
for (int i = 0; i < array.Count; i++)
Write(property.GetArrayElementAtIndex(i), array[i]);
return;
}
switch (property.propertyType)
{
case SerializedPropertyType.Boolean:
property.boolValue = value.ToObject<bool>();
break;
case SerializedPropertyType.Integer:
property.longValue = value.ToObject<long>();
break;
case SerializedPropertyType.Float:
property.doubleValue = value.ToObject<double>();
break;
case SerializedPropertyType.String:
property.stringValue = value.Type == JTokenType.Null ? string.Empty : value.ToObject<string>();
break;
case SerializedPropertyType.Enum:
WriteEnum(property, value);
break;
case SerializedPropertyType.Vector2:
{
var a = ToFloats(value, 2, property.name);
property.vector2Value = new Vector2(a[0], a[1]);
break;
}
case SerializedPropertyType.Vector3:
{
var a = ToFloats(value, 3, property.name);
property.vector3Value = new Vector3(a[0], a[1], a[2]);
break;
}
case SerializedPropertyType.Vector4:
{
var a = ToFloats(value, 4, property.name);
property.vector4Value = new Vector4(a[0], a[1], a[2], a[3]);
break;
}
case SerializedPropertyType.Color:
{
var a = ToFloats(value, 4, property.name);
property.colorValue = new Color(a[0], a[1], a[2], a[3]);
break;
}
case SerializedPropertyType.ObjectReference:
property.objectReferenceValue = ResolveObjectReference(value, property.name);
break;
default:
throw new ArgumentException(
$"Property '{property.name}' has unsupported type '{property.propertyType}' for set.");
}
}
private static void WriteEnum(SerializedProperty property, JToken value)
{
// Accept either the enum constant name or its index/value.
if (value.Type == JTokenType.String)
{
var name = value.ToObject<string>();
var index = Array.IndexOf(property.enumNames, name);
if (index < 0)
{
// Fall back to display names (Editor humanized form), then error.
index = Array.IndexOf(property.enumDisplayNames, name);
if (index < 0)
throw new ArgumentException(
$"Enum value '{name}' is not valid for property '{property.name}'. Valid: [{string.Join(", ", property.enumNames)}].");
}
property.enumValueIndex = index;
}
else
{
// A numeric value is the enum's underlying integer, NOT an index into enumNames:
// those only coincide when declaration order matches the constant values. Mirror Read,
// which falls back to property.intValue, so round-tripping a numeric enum is stable.
property.intValue = value.ToObject<int>();
}
}
private static Object ResolveObjectReference(JToken value, string propertyName)
{
// An explicit null clears the reference; anything else MUST resolve — never silently no-op
// (a handle that doesn't resolve used to be dropped, so the command reported success while
// assigning nothing).
if (value == null || value.Type == JTokenType.Null)
return null;
// Route every shape (string handle, handle object, or bare-integer instanceId) through
// ObjectRefConverter so path / guid / globalId / instanceId forms all parse identically.
var handle = value.ToObject<ObjectRef>();
if (handle == null || handle.IsEmpty)
throw new ArgumentException(
$"Property '{propertyName}': '{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 for '{propertyName}': {error}");
return obj;
}
private static float[] ToFloats(JToken value, int expected, string propertyName)
{
if (value is JArray array)
{
if (array.Count != expected)
throw new ArgumentException(
$"Property '{propertyName}' expects {expected} components, got {array.Count}.");
var result = new float[expected];
for (int i = 0; i < expected; i++)
result[i] = array[i].ToObject<float>();
return result;
}
throw new ArgumentException(
$"Property '{propertyName}' expects an array of {expected} numbers.");
}
private static JArray ToArray(params float[] values)
{
var array = new JArray();
foreach (var v in values)
array.Add(v);
return array;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 85f605c1109e40898fe47ff1e2a8a4d0
@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace Unity.Pipeline.Editor.Commands.GameObjects
{
/// <summary>
/// Resolves a <see cref="UnityEngine.Component"/>-derived <see cref="Type"/> from an
/// agent-supplied name. WHY this is non-trivial: agents pass either a short name ("Rigidbody")
/// or a fully-qualified one ("UnityEngine.Camera"), and the type may live in any loaded assembly
/// (UnityEngine modules, user assemblies, packages). We therefore try, in order: an exact
/// fully-qualified <see cref="Type.GetType(string)"/>, then a scan of all loaded assemblies for a
/// full-name match, then a short-name match. Only <see cref="Component"/> subclasses are accepted
/// so the result is always valid for AddComponent/GetComponent.
/// </summary>
public static class TypeResolver
{
/// <summary>
/// Resolve a component type by name, or return null if no unambiguous <see cref="Component"/>
/// subclass matches. A short name that matches multiple types in different namespaces is
/// treated as ambiguous and returns null (the caller should ask for a fully-qualified name).
/// </summary>
public static Type ResolveComponentType(string typeName)
{
if (string.IsNullOrWhiteSpace(typeName))
return null;
typeName = typeName.Trim();
// 1. Exact, assembly-qualified or fully-qualified lookup.
var direct = Type.GetType(typeName, throwOnError: false, ignoreCase: false);
if (IsComponent(direct))
return direct;
var assemblies = PipelineUtils.GetLoadedAssemblies();
// 2. Full-name match across loaded assemblies.
var fullNameMatches = new List<Type>();
// 3. Short-name match (collected in the same pass) as a fallback.
var shortNameMatches = new List<Type>();
foreach (var assembly in assemblies)
{
Type[] types;
try
{
types = assembly.GetTypes();
}
catch (System.Reflection.ReflectionTypeLoadException ex)
{
types = ex.Types.Where(t => t != null).ToArray();
}
foreach (var type in types)
{
if (!IsComponent(type))
continue;
if (string.Equals(type.FullName, typeName, StringComparison.Ordinal))
fullNameMatches.Add(type);
else if (string.Equals(type.Name, typeName, StringComparison.Ordinal))
shortNameMatches.Add(type);
}
}
if (fullNameMatches.Count == 1)
return fullNameMatches[0];
if (fullNameMatches.Count > 1)
return null; // genuinely ambiguous full names (rare) — caller must disambiguate.
// Distinct in case the same type is reachable through multiple assembly references.
var distinctShort = shortNameMatches.Distinct().ToList();
return distinctShort.Count == 1 ? distinctShort[0] : null;
}
private static bool IsComponent(Type type)
{
return type != null && typeof(Component).IsAssignableFrom(type) && !type.IsAbstract;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f033287d429f4957bfcfd6795f731b48
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4841c84bba2a471eb3fb429f79e3b118
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,405 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Editor.Commands.Materials
{
/// <summary>
/// Materials authoring commands (CLI-213): read and write a material's shader, shader properties
/// (floats, colors, vectors, textures, ints), keywords, and render queue.
///
/// These sit on the CLI-190 authoring foundation:
/// - the material is addressed with an <see cref="ObjectRef"/> resolved by <see cref="ObjectResolver"/>
/// and confined to the authoring root (a GUID/globalId handle can't edit a material outside the sandbox),
/// - texture property values are <see cref="ObjectRef"/>s likewise confined to the root,
/// - writes are wrapped in an <see cref="AuthoringUndoScope"/> with <see cref="Undo.RecordObject"/>
/// (material property changes ARE covered by Undo.RecordObject, unlike AssetDatabase ops), then
/// <see cref="EditorUtility.SetDirty"/> + <see cref="AssetDatabase.SaveAssetIfDirty"/> persist them,
/// - unknown property names and type mismatches are reported in <c>unknown[]</c> rather than failing
/// the whole call (as long as at least one input applied).
///
/// Material CREATION already exists in <c>create_asset</c> (CLI-221); this command set is the
/// read/write surface on top of an existing material.
/// </summary>
public static class MaterialCommands
{
[CliCommand("get_material_properties",
"Read a material's shader, render queue, enabled keywords, and all shader properties with their current values (Color as [r,g,b,a], Vector as [x,y,z,w], Texture as an object reference).")]
public static MaterialPropertiesResult GetMaterialProperties(
[CliArg("material", "Reference to the .mat asset (or a loaded material) to read (path / guid / globalId / instanceId).", Required = true)] ObjectRef material)
{
var mat = ResolveMaterial(material, out var assetPath);
var result = new MaterialPropertiesResult
{
AssetPath = assetPath,
Shader = mat.shader != null ? mat.shader.name : null,
// rawRenderQueue is -1 when the material inherits from the shader and a positive
// integer when explicitly overridden — returning the raw value preserves the
// round-trip contract with set_material_properties renderQueue:-1 (inherit).
RenderQueue = GetRawRenderQueue(mat),
EnabledKeywords = GetEnabledKeywords(mat),
};
var shader = mat.shader;
if (shader != null)
{
// Enumerate the shader's declared properties via the Shader instance APIs (single index
// space, matching get_shader_properties), so name/type/range-limits always line up.
int count = shader.GetPropertyCount();
for (int i = 0; i < count; i++)
{
var name = shader.GetPropertyName(i);
var propType = shader.GetPropertyType(i);
var entry = new MaterialPropertyValue
{
Name = name,
Type = PublicValueType(propType),
Value = MaterialValueConverter.ReadValue(mat, name, propType),
};
if (propType == ShaderPropertyType.Range)
{
var limits = shader.GetPropertyRangeLimits(i);
entry.Range = new MaterialRange
{
Min = limits.x,
Max = limits.y,
};
}
result.Properties.Add(entry);
}
}
return result;
}
[CliCommand("set_material_properties",
"Set shader properties on a material (Float/Range/Int=number; Color=[r,g,b,a] or \"#RRGGBBAA\" hex; Vector=[x,y,z,w]; Texture=an object reference or null to clear), optionally reassign the shader, set the render queue, and toggle keywords. Unknown names / type mismatches are reported in unknown[].")]
public static SetMaterialPropertiesResult SetMaterialProperties(
[CliArg("material", "Reference to the .mat asset (or a loaded material) to edit (path / guid / globalId / instanceId).", Required = true)] ObjectRef material,
[CliArg("shader", "Reassign the material's shader by name (e.g. \"Standard\", \"Universal Render Pipeline/Lit\", or a Shader Graph shader name). Applied before properties so new property names resolve against the new shader.")] string shader = null,
[CliArg("properties", "JSON object of shader property name -> value. Names must include the leading underscore (e.g. _BaseColor). Float/Range/Int=number; Color=[r,g,b,a] or hex string; Vector=[x,y,z,w]; Texture=an object reference {guid/path} or null.")] JObject properties = null,
[CliArg("renderQueue", "Explicit render queue, or -1 to inherit from the shader. Omit to leave unchanged.")] int? renderQueue = null,
[CliArg("enableKeywords", "Shader keywords to enable (e.g. _NORMALMAP, _EMISSION).")] string[] enableKeywords = null,
[CliArg("disableKeywords", "Shader keywords to disable.")] string[] disableKeywords = null,
[CliArg("confirm", "Reserved for parity; editing an existing material is non-destructive and undoable, so it is not required.")] bool confirm = false,
[CliArg("dry_run", "If true, validate the shader, resolve property names and texture refs, and report applied[]/unknown[] without writing anything.")] bool dryRun = false)
{
var mat = ResolveMaterial(material, out var assetPath);
// Resolve the (optional) replacement shader UP FRONT so dry_run validates it too, and a
// shader_not_found writes nothing (acceptance #9). Shader.Find only finds compiled/included
// shaders — surface that in the hint.
Shader newShader = null;
if (shader != null)
{
newShader = Shader.Find(shader);
if (newShader == null)
throw new ShaderNotFoundException(
$"Shader '{shader}' not found. Shader.Find only resolves shaders that are compiled/included in the build (or referenced by a material/scene). Use list_shaders to discover valid names.");
}
var result = new SetMaterialPropertiesResult();
// ---- dry_run: validate against the would-be shader, never mutate ----------------------
if (dryRun)
{
var effectiveShader = newShader ?? mat.shader;
ValidateProperties(effectiveShader, properties, result);
if (renderQueue.HasValue)
result.Applied.Add("renderQueue");
AddKeywordTokens(result.Applied, enableKeywords, disableKeywords);
GuardAtLeastOneApplied(result, shaderReassigned: newShader != null);
CopyIdentity(mat, assetPath, result);
result.Shader = effectiveShader != null ? effectiveShader.name : null;
return result;
}
// ---- apply --------------------------------------------------------------------------
using (new AuthoringUndoScope("Set Material Properties"))
{
// Material property/shader/keyword/renderQueue changes are all covered by Undo.RecordObject.
Undo.RecordObject(mat, "Set Material Properties");
if (newShader != null)
mat.shader = newShader;
if (properties != null)
{
foreach (var pair in properties)
{
if (pair.Key == "token") // injected by the server; never a material property.
continue;
var name = pair.Key;
if (!mat.HasProperty(name))
{
result.Unknown.Add($"{name}: unknown property (not declared by shader '{(mat.shader != null ? mat.shader.name : "<none>")}')");
continue;
}
var propType = GetPropertyType(mat.shader, name);
if (propType == null)
{
// HasProperty was true but the type couldn't be read off the shader (e.g. a
// built-in property like unity_Lightmaps not declared in the shader's list).
result.Unknown.Add($"{name}: property is not a settable shader property on '{(mat.shader != null ? mat.shader.name : "<none>")}'");
continue;
}
try
{
MaterialValueConverter.ApplyValue(mat, name, propType.Value, pair.Value);
result.Applied.Add(name);
}
catch (MaterialPropertyTypeMismatch mismatch)
{
result.Unknown.Add(mismatch.Message);
}
}
}
if (renderQueue.HasValue)
{
mat.renderQueue = renderQueue.Value;
result.Applied.Add("renderQueue");
}
ApplyKeywords(mat, enableKeywords, disableKeywords, result.Applied);
GuardAtLeastOneApplied(result, shaderReassigned: newShader != null);
EditorUtility.SetDirty(mat);
if (!string.IsNullOrEmpty(assetPath))
AssetDatabase.SaveAssetIfDirty(mat);
}
CopyIdentity(mat, assetPath, result);
result.Shader = mat.shader != null ? mat.shader.name : null;
return result;
}
private static int GetRawRenderQueue(Material mat)
{
var serialized = new SerializedObject(mat);
var customRenderQueue = serialized.FindProperty("m_CustomRenderQueue");
return customRenderQueue != null ? customRenderQueue.intValue : mat.renderQueue;
}
/// <summary>
/// Resolve an <see cref="ObjectRef"/> to a <see cref="Material"/>, confining any on-disk asset to
/// the authoring root. Returns the project-relative asset path (null for a non-asset/in-memory
/// material). Throws a clear <see cref="ArgumentException"/> on miss / wrong type / out-of-root.
/// </summary>
internal static Material ResolveMaterial(ObjectRef material, out string assetPath)
{
if (material == null || material.IsEmpty)
throw new ArgumentException("'material' is required.");
if (!ObjectResolver.TryResolve(material, out var obj, out var error))
throw new ArgumentException($"Could not resolve material: {error}");
if (!(obj is Material mat))
throw new ArgumentException($"Reference '{material}' resolved to a {obj.GetType().Name}, not a Material.");
assetPath = AssetDatabase.GetAssetPath(mat);
if (!string.IsNullOrEmpty(assetPath))
{
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
if (confined == null)
throw new ArgumentException(
$"Material '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
assetPath = confined;
}
return mat;
}
/// <summary>
/// Read the material's enabled keyword set. Uses <see cref="Material.enabledKeywords"/>
/// (LocalKeyword[]) which is the supported API on the package's min Unity version (Unity 6 /
/// 6000.0); the legacy <c>shaderKeywords</c> string[] is deprecated.
/// </summary>
private static List<string> GetEnabledKeywords(Material mat)
{
var keywords = new List<string>();
foreach (var keyword in mat.enabledKeywords)
keywords.Add(keyword.name);
keywords.Sort(StringComparer.Ordinal);
return keywords;
}
private static void ApplyKeywords(Material mat, string[] enable, string[] disable, List<string> applied)
{
// Use the LocalKeyword API (Unity 2022.1+) so that Enable/Disable and the
// enabledKeywords reader are talking to the same keyword table. The legacy
// string-based EnableKeyword(string) affects global keywords, whereas
// mat.enabledKeywords returns LocalKeyword[], so they would disagree on round-trip.
if (enable != null)
{
foreach (var keyword in enable)
{
if (string.IsNullOrWhiteSpace(keyword) || mat.shader == null)
continue;
mat.EnableKeyword(new LocalKeyword(mat.shader, keyword));
applied.Add($"+keyword:{keyword}");
}
}
if (disable != null)
{
foreach (var keyword in disable)
{
if (string.IsNullOrWhiteSpace(keyword) || mat.shader == null)
continue;
mat.DisableKeyword(new LocalKeyword(mat.shader, keyword));
applied.Add($"-keyword:{keyword}");
}
}
}
private static void AddKeywordTokens(List<string> applied, string[] enable, string[] disable)
{
if (enable != null)
foreach (var keyword in enable.Where(k => !string.IsNullOrWhiteSpace(k)))
applied.Add($"+keyword:{keyword}");
if (disable != null)
foreach (var keyword in disable.Where(k => !string.IsNullOrWhiteSpace(k)))
applied.Add($"-keyword:{keyword}");
}
/// <summary>
/// dry_run validation: classify each supplied property as applicable (name resolves AND value
/// shape matches the declared type) or unknown, WITHOUT mutating the material.
/// </summary>
private static void ValidateProperties(Shader shader, JObject properties, SetMaterialPropertiesResult result)
{
if (properties == null)
return;
foreach (var pair in properties)
{
if (pair.Key == "token")
continue;
var name = pair.Key;
var propType = shader != null ? GetPropertyType(shader, name) : null;
if (propType == null)
{
result.Unknown.Add($"{name}: unknown property (not declared by shader '{(shader != null ? shader.name : "<none>")}')");
continue;
}
// Validate the value shape against the type by attempting a conversion on a throwaway
// material instance — never the caller's material (dry_run must leave no trace).
Material probe = null;
try
{
probe = new Material(shader);
MaterialValueConverter.ApplyValue(probe, name, propType.Value, pair.Value);
result.Applied.Add(name);
}
catch (MaterialPropertyTypeMismatch mismatch)
{
result.Unknown.Add(mismatch.Message);
}
finally
{
if (probe != null)
Object.DestroyImmediate(probe);
}
}
}
/// <summary>
/// Look up a property's <see cref="UnityEngine.Rendering.ShaderPropertyType"/> by name on a shader.
/// Returns null when the shader has no property with that exact name (case-sensitive; names include
/// the leading underscore).
/// </summary>
internal static ShaderPropertyType? GetPropertyType(Shader shader, string name)
{
if (shader == null)
return null;
int count = shader.GetPropertyCount();
for (int i = 0; i < count; i++)
{
if (shader.GetPropertyName(i) == name)
return shader.GetPropertyType(i);
}
return null;
}
private static void GuardAtLeastOneApplied(SetMaterialPropertiesResult result, bool shaderReassigned)
{
// A shader reassignment is itself a successful change, so it satisfies the "at least one
// thing applied" guard even when every supplied property is unknown for the new shader.
if (!shaderReassigned && result.Applied.Count == 0 && result.Unknown.Count > 0)
throw new ArgumentException(
$"None of the supplied inputs could be applied (unknown property name or type mismatch): {string.Join("; ", result.Unknown)}.");
}
private static void CopyIdentity(Material mat, string assetPath, SetMaterialPropertiesResult result)
{
var identity = ObjectResolver.Describe(mat);
if (identity != null)
{
result.GlobalId = identity.GlobalId;
result.Guid = identity.Guid;
result.FileId = identity.FileId;
result.InstanceId = identity.InstanceId;
result.HierarchyPath = identity.HierarchyPath;
result.Type = identity.Type;
}
else
{
result.Type = nameof(Material);
}
// Prefer the confined asset path computed by ResolveMaterial.
if (!string.IsNullOrEmpty(assetPath))
result.AssetPath = assetPath;
}
/// <summary>Map a <see cref="UnityEngine.Rendering.ShaderPropertyType"/> to the value-shape label used by get_material_properties.</summary>
private static string PublicValueType(ShaderPropertyType type)
{
switch (type)
{
case ShaderPropertyType.Color: return "Color";
case ShaderPropertyType.Vector: return "Vector";
case ShaderPropertyType.Float: return "Float";
case ShaderPropertyType.Range: return "Range";
case ShaderPropertyType.Texture: return "Texture";
case ShaderPropertyType.Int: return "Int";
default: return type.ToString();
}
}
}
/// <summary>
/// Thrown when a requested shader name does not resolve via <see cref="Shader.Find"/>. Carries the
/// stable <c>shader_not_found</c> code (also prefixed onto the message, since the server propagates
/// only the exception message over the wire) so a client can distinguish it from other failures.
/// </summary>
public sealed class ShaderNotFoundException : Exception
{
public const string CodeValue = "shader_not_found";
public string Code => CodeValue;
public ShaderNotFoundException(string message) : base($"[{CodeValue}] {message}") { }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a14c4a97ac9e4d61b904de30d1016d92
@@ -0,0 +1,147 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Unity.Pipeline.Models;
namespace Unity.Pipeline.Editor.Commands.Materials
{
/// <summary>
/// Result of <c>get_material_properties</c> (CLI-213): the material's shader, render queue, enabled
/// keywords, and the full list of shader properties with their current values.
/// </summary>
[Serializable]
public class MaterialPropertiesResult
{
[JsonProperty("assetPath")]
public string AssetPath { get; set; }
/// <summary>Shader name, e.g. "Universal Render Pipeline/Lit".</summary>
[JsonProperty("shader")]
public string Shader { get; set; }
/// <summary>Render queue; -1 means "inherit from shader".</summary>
[JsonProperty("renderQueue")]
public int RenderQueue { get; set; }
[JsonProperty("enabledKeywords")]
public List<string> EnabledKeywords { get; set; } = new List<string>();
[JsonProperty("properties")]
public List<MaterialPropertyValue> Properties { get; set; } = new List<MaterialPropertyValue>();
}
/// <summary>A single shader property of a material with its current value.</summary>
[Serializable]
public class MaterialPropertyValue
{
[JsonProperty("name")]
public string Name { get; set; }
/// <summary>"Float" | "Range" | "Color" | "Vector" | "Texture" | "Int".</summary>
[JsonProperty("type")]
public string Type { get; set; }
/// <summary>
/// number | [r,g,b,a] | [x,y,z,w] | { texture: ObjectRef } | null, per the property type.
/// </summary>
[JsonProperty("value")]
public object Value { get; set; }
/// <summary>Min/max for a Range property; null otherwise.</summary>
[JsonProperty("range", NullValueHandling = NullValueHandling.Ignore)]
public MaterialRange Range { get; set; }
}
/// <summary>Inclusive min/max bounds of a Range shader property.</summary>
[Serializable]
public class MaterialRange
{
[JsonProperty("min")]
public float Min { get; set; }
[JsonProperty("max")]
public float Max { get; set; }
}
/// <summary>
/// Result of <c>set_material_properties</c> (CLI-213): the canonical <see cref="AuthoringResult"/>
/// identity of the edited material, extended with which properties applied vs. were unknown (with a
/// reason), and the material's (possibly reassigned) shader name.
/// </summary>
[Serializable]
public class SetMaterialPropertiesResult : AuthoringResult
{
/// <summary>Shader name after any reassignment.</summary>
[JsonProperty("shader")]
public string Shader { get; set; }
/// <summary>Property names (and keyword/renderQueue tokens) that were applied.</summary>
[JsonProperty("applied")]
public List<string> Applied { get; set; } = new List<string>();
/// <summary>
/// Inputs that could not be applied, each as "name: reason" (unknown property name, or a type
/// mismatch like "_Metallic: expected Float, got array").
/// </summary>
[JsonProperty("unknown")]
public List<string> Unknown { get; set; } = new List<string>();
}
/// <summary>A single discovered shader entry from <c>list_shaders</c>.</summary>
[Serializable]
public class ShaderInfo
{
[JsonProperty("name")]
public string Name { get; set; }
/// <summary>Project asset path for a project shader; null for a built-in/engine shader.</summary>
[JsonProperty("assetPath")]
public string AssetPath { get; set; }
[JsonProperty("isBuiltin")]
public bool IsBuiltin { get; set; }
/// <summary>Reflects <c>Shader.isSupported</c> for the active render pipeline.</summary>
[JsonProperty("isSupported")]
public bool IsSupported { get; set; }
}
/// <summary>Result of <c>get_shader_properties</c> (CLI-213): a shader's declared property list.</summary>
[Serializable]
public class ShaderPropertiesResult
{
[JsonProperty("shader")]
public string Shader { get; set; }
[JsonProperty("properties")]
public List<ShaderPropertyInfo> Properties { get; set; } = new List<ShaderPropertyInfo>();
}
/// <summary>A single declared shader property, introspected via <c>ShaderUtil</c>.</summary>
[Serializable]
public class ShaderPropertyInfo
{
[JsonProperty("name")]
public string Name { get; set; }
/// <summary>ShaderUtil property description (the UI label).</summary>
[JsonProperty("description")]
public string Description { get; set; }
/// <summary>"Color" | "Vector" | "Float" | "Range" | "TexEnv" | "Int".</summary>
[JsonProperty("type")]
public string Type { get; set; }
/// <summary>Min/max for a Range property; null otherwise.</summary>
[JsonProperty("range", NullValueHandling = NullValueHandling.Ignore)]
public MaterialRange Range { get; set; }
/// <summary>"Tex2D" | "Cube" | "Tex3D" | "Tex2DArray" for a TexEnv property; null otherwise.</summary>
[JsonProperty("textureDimension", NullValueHandling = NullValueHandling.Ignore)]
public string TextureDimension { get; set; }
/// <summary>Property flags, e.g. "HideInInspector", "Normal", "HDR", "NoScaleOffset".</summary>
[JsonProperty("flags")]
public List<string> Flags { get; set; } = new List<string>();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 52b9d1506e9b4a95982ff554d70179b3
@@ -0,0 +1,310 @@
using System;
using System.Globalization;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Editor.Commands.Materials
{
/// <summary>
/// Bridges agent-supplied JSON values and a <see cref="Material"/>'s shader properties for the
/// get_material_properties / set_material_properties commands (CLI-213).
///
/// Value encoding by shader property type:
/// - Float / Range / Int : a JSON number
/// - Color : <c>[r, g, b, a]</c> (floats 01) or a <c>"#RRGGBB"</c>/<c>"#RRGGBBAA"</c> hex string
/// - Vector : <c>[x, y, z, w]</c>
/// - Texture (TexEnv) : an <see cref="ObjectRef"/> object (resolved + confined to the authoring
/// root), or <c>null</c> to clear
///
/// Reads return the same shapes (Color as <c>[r,g,b,a]</c>, Vector as <c>[x,y,z,w]</c>, Texture as
/// <c>{ texture: ObjectRef }</c> or null) so a read round-trips back through a write.
/// </summary>
internal static class MaterialValueConverter
{
/// <summary>
/// Read a single shader property's current value off the material into a plain object suitable
/// for JSON serialization. The shape matches the <see cref="ShaderPropertyType"/>.
/// </summary>
public static object ReadValue(Material material, string propertyName, ShaderPropertyType type)
{
switch (type)
{
case ShaderPropertyType.Color:
{
var c = material.GetColor(propertyName);
return new[] { c.r, c.g, c.b, c.a };
}
case ShaderPropertyType.Vector:
{
var v = material.GetVector(propertyName);
return new[] { v.x, v.y, v.z, v.w };
}
case ShaderPropertyType.Float:
case ShaderPropertyType.Range:
return material.GetFloat(propertyName);
case ShaderPropertyType.Int:
return material.GetInteger(propertyName);
case ShaderPropertyType.Texture:
{
var tex = material.GetTexture(propertyName);
// A re-usable handle for the assigned texture, or null when no texture is bound.
var handle = ObjectResolver.Describe(tex);
return handle == null ? null : new { texture = handle };
}
default:
return null;
}
}
/// <summary>
/// Apply a JSON value to a single shader property on the material, converting by the shader's
/// declared property type. Throws <see cref="MaterialPropertyTypeMismatch"/> when the supplied
/// JSON shape does not match the property's type (so the caller can record it in
/// <c>unknown[]</c> with a reason). Texture refs are resolved via <see cref="ObjectResolver"/>
/// and confined to the authoring root; an out-of-root or unresolved ref throws
/// <see cref="ArgumentException"/> (a hard failure — never silently dropped).
/// </summary>
public static void ApplyValue(Material material, string propertyName, ShaderPropertyType type, JToken value)
{
switch (type)
{
case ShaderPropertyType.Float:
material.SetFloat(propertyName, ToFloat(propertyName, "Float", value));
break;
case ShaderPropertyType.Range:
material.SetFloat(propertyName, ToFloat(propertyName, "Range", value));
break;
case ShaderPropertyType.Int:
// Unity 6: Material.SetInteger is the typed setter for an Int shader property.
material.SetInteger(propertyName, ToInt(propertyName, value));
break;
case ShaderPropertyType.Color:
material.SetColor(propertyName, ToColor(propertyName, value));
break;
case ShaderPropertyType.Vector:
material.SetVector(propertyName, ToVector4(propertyName, value));
break;
case ShaderPropertyType.Texture:
material.SetTexture(propertyName, ToTexture(propertyName, value));
break;
default:
throw new MaterialPropertyTypeMismatch(
$"{propertyName}: unsupported shader property type '{type}'.");
}
}
private static float ToFloat(string property, string typeLabel, JToken value)
{
if (value == null || value.Type == JTokenType.Null)
throw new MaterialPropertyTypeMismatch($"{property}: expected {typeLabel}, got null.");
if (value.Type != JTokenType.Integer && value.Type != JTokenType.Float)
throw new MaterialPropertyTypeMismatch(
$"{property}: expected {typeLabel} (a number), got {Describe(value)}.");
return value.ToObject<float>();
}
private static int ToInt(string property, JToken value)
{
if (value == null || value.Type == JTokenType.Null)
throw new MaterialPropertyTypeMismatch($"{property}: expected Int, got null.");
if (value.Type != JTokenType.Integer && value.Type != JTokenType.Float)
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Int (a number), got {Describe(value)}.");
// Reject fractional JSON numbers (e.g. 1.5) rather than silently rounding/truncating.
// An Int shader property must receive a whole number.
var asDouble = value.ToObject<double>();
if (asDouble != Math.Truncate(asDouble))
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Int (a whole number), got the non-integer value {value}.");
return value.ToObject<int>();
}
/// <summary>
/// Parse a Color from either <c>[r,g,b,a]</c> (3 or 4 floats) or a hex string
/// (<c>"#RRGGBB"</c> / <c>"#RRGGBBAA"</c>, leading '#' optional). Default alpha is 1.0.
/// </summary>
private static Color ToColor(string property, JToken value)
{
if (value is JArray arr)
{
if (arr.Count != 3 && arr.Count != 4)
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Color as [r,g,b,a] (or [r,g,b]), got an array of length {arr.Count}.");
// Route each component through ToFloat so non-numeric/null elements surface as a clean
// MaterialPropertyTypeMismatch rather than throwing and failing the whole command.
float r = ToFloat(property, "Color", arr[0]);
float g = ToFloat(property, "Color", arr[1]);
float b = ToFloat(property, "Color", arr[2]);
float a = arr.Count == 4 ? ToFloat(property, "Color", arr[3]) : 1f;
return new Color(r, g, b, a);
}
if (value != null && value.Type == JTokenType.String)
{
if (TryParseHexColor(value.ToObject<string>(), out var color))
return color;
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Color as [r,g,b,a] or a '#RRGGBB'/'#RRGGBBAA' hex string, got '{value}'.");
}
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Color as [r,g,b,a] or a hex string, got {Describe(value)}.");
}
private static Vector4 ToVector4(string property, JToken value)
{
if (!(value is JArray arr))
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Vector as [x,y,z,w], got {Describe(value)}.");
if (arr.Count == 0 || arr.Count > 4)
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Vector as [x,y,z,w] (14 numbers), got an array of length {arr.Count}.");
// Missing trailing components default to 0 (matches Unity's Vector4 component default),
// so [x,y,z] and [x,y] are accepted as shorthand for a 4-component vector.
// Route each present component through ToFloat so non-numeric/null elements surface as a
// clean MaterialPropertyTypeMismatch rather than throwing and failing the whole command.
float x = arr.Count > 0 ? ToFloat(property, "Vector", arr[0]) : 0f;
float y = arr.Count > 1 ? ToFloat(property, "Vector", arr[1]) : 0f;
float z = arr.Count > 2 ? ToFloat(property, "Vector", arr[2]) : 0f;
float w = arr.Count > 3 ? ToFloat(property, "Vector", arr[3]) : 0f;
return new Vector4(x, y, z, w);
}
/// <summary>
/// Resolve a texture property value: an explicit <c>null</c> clears it, otherwise the value must
/// be an <see cref="ObjectRef"/> that resolves to a <see cref="Texture"/> confined to the
/// authoring root. A non-texture object, an out-of-root asset, or an unresolved handle is a hard
/// failure (never silently dropped).
/// </summary>
private static Texture ToTexture(string property, JToken value)
{
if (value == null || value.Type == JTokenType.Null)
return null;
// ReadValue returns textures as { "texture": <ObjectRef> } so a get->set round-trip works.
// Accept both that wrapper form and a bare ObjectRef supplied directly by the caller.
JToken refToken = value;
if (value is JObject wrapper && wrapper.ContainsKey("texture"))
refToken = wrapper["texture"];
ObjectRef handle;
try
{
handle = refToken.ToObject<ObjectRef>();
}
catch (Exception)
{
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Texture as an object reference {{guid/path/...}} or null, got {Describe(value)}.");
}
if (handle == null || handle.IsEmpty)
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Texture as an object reference {{guid/path/...}} or null, got {Describe(value)}.");
if (!ObjectResolver.TryResolve(handle, out var obj, out var error))
throw new ArgumentException($"{property}: could not resolve texture reference: {error}");
if (!(obj is Texture texture))
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Texture, got a {obj.GetType().Name}.");
// Confine to the authoring root: a GUID/globalId/path handle must bind a texture that lives
// on disk under the sandbox. An in-memory/scene Texture has an empty asset path; allowing it
// would bypass the root confinement, so reject any ref that doesn't resolve to a persisted
// asset. Mirrors AssetCommands.ResolveAssetPath's confinement.
var assetPath = AssetDatabase.GetAssetPath(texture);
if (string.IsNullOrEmpty(assetPath))
throw new ArgumentException(
$"{property}: texture reference does not resolve to an on-disk asset under the authoring root '{ProjectPaths.AuthoringRoot}'; in-memory or scene textures are not allowed.");
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
if (confined == null)
throw new ArgumentException(
$"{property}: texture '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
return texture;
}
/// <summary>
/// Parse a hex color string ("#RRGGBB", "#RRGGBBAA", or without the leading '#'). Returns false
/// for any malformed input. Channels are 0255 mapped to 01; default alpha is 1.0.
/// </summary>
public static bool TryParseHexColor(string hex, out Color color)
{
color = Color.white;
if (string.IsNullOrWhiteSpace(hex))
return false;
var s = hex.Trim();
if (s.StartsWith("#", StringComparison.Ordinal))
s = s.Substring(1);
if (s.Length != 6 && s.Length != 8)
return false;
if (!byte.TryParse(s.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) ||
!byte.TryParse(s.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) ||
!byte.TryParse(s.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b))
return false;
byte a = 255;
if (s.Length == 8 &&
!byte.TryParse(s.Substring(6, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out a))
return false;
color = new Color(r / 255f, g / 255f, b / 255f, a / 255f);
return true;
}
/// <summary>Map a <see cref="UnityEngine.Rendering.ShaderPropertyType"/> to the public type label used in results.</summary>
public static string TypeLabel(ShaderPropertyType type)
{
switch (type)
{
case ShaderPropertyType.Color: return "Color";
case ShaderPropertyType.Vector: return "Vector";
case ShaderPropertyType.Float: return "Float";
case ShaderPropertyType.Range: return "Range";
case ShaderPropertyType.Texture: return "TexEnv";
case ShaderPropertyType.Int: return "Int";
default: return type.ToString();
}
}
private static string Describe(JToken value)
{
if (value == null)
return "null";
switch (value.Type)
{
case JTokenType.Array: return "array";
case JTokenType.Object: return "object";
case JTokenType.Null: return "null";
default: return value.Type.ToString().ToLowerInvariant();
}
}
}
/// <summary>
/// Thrown when a supplied value's JSON shape does not match the shader property's declared type.
/// The command catches this and records the property in <c>unknown[]</c> with the message as the
/// reason, rather than failing the whole call.
/// </summary>
internal sealed class MaterialPropertyTypeMismatch : Exception
{
public MaterialPropertyTypeMismatch(string message) : base(message) { }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fc8956be3c264ccb8096787d7146f330
@@ -0,0 +1,186 @@
using System;
using System.Collections.Generic;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
namespace Unity.Pipeline.Editor.Commands.Materials
{
/// <summary>
/// Shader discovery / introspection commands (CLI-213): <c>list_shaders</c> so an agent can pick a
/// valid shader name, and <c>get_shader_properties</c> so it knows a shader's settable property
/// names / types / ranges / texture dimensions / flags before authoring a material.
///
/// Property introspection uses <see cref="Shader"/> instance APIs exclusively:
/// <c>GetPropertyCount</c>, <c>GetPropertyName</c>, <c>GetPropertyType</c>,
/// <c>GetPropertyDescription</c>, <c>GetPropertyRangeLimits</c>,
/// <c>GetPropertyTextureDimension</c>, and <c>GetPropertyFlags</c>. The reported type set is
/// <see cref="UnityEngine.Rendering.ShaderPropertyType"/>: Color, Vector, Float, Range, Texture, Int.
/// </summary>
public static class ShaderCommands
{
[CliCommand("list_shaders",
"Discover available shaders so an agent can pick a valid name for set_material_properties / create_asset. Returns [{ name, assetPath|null, isBuiltin, isSupported }].")]
public static List<ShaderInfo> ListShaders(
[CliArg("filter", "Case-insensitive substring matched against the shader name (e.g. \"URP\", \"Lit\").")] string filter = null,
[CliArg("includeBuiltin", "Include built-in/engine shaders (those with no project asset path). Default true.")] bool includeBuiltin = true,
[CliArg("limit", "Maximum number of shaders to return (default 200).")] int limit = 200)
{
if (limit <= 0)
limit = 200;
var results = new List<ShaderInfo>();
var seen = new HashSet<string>(StringComparer.Ordinal);
// AssetDatabase.FindAssets("t:Shader") enumerates project + package shaders (each with an
// asset path); ShaderUtil.GetAllShaderInfo() additionally surfaces built-in engine shaders
// that have no asset path (e.g. "Standard", "Sprites/Default"). Merge both, dedup by name.
foreach (var guid in AssetDatabase.FindAssets("t:Shader"))
{
var path = AssetDatabase.GUIDToAssetPath(guid);
if (string.IsNullOrEmpty(path))
continue;
var shader = AssetDatabase.LoadAssetAtPath<Shader>(path);
if (shader == null)
continue;
if (!seen.Add(shader.name))
continue;
if (!PassesFilter(shader.name, filter))
continue;
results.Add(new ShaderInfo
{
Name = shader.name,
AssetPath = path,
IsBuiltin = false,
IsSupported = shader.isSupported,
});
if (results.Count >= limit)
return results;
}
if (includeBuiltin)
{
// GetAllShaderInfo() enumerates every shader the editor knows about, including built-in
// engine shaders that have no project/package asset path. Any name NOT already added by
// the FindAssets("t:Shader") pass above is a built-in (assetPath null). `info.supported`
// already reflects Shader::IsSupported(), so we don't re-run Shader.Find per entry.
foreach (var info in ShaderUtil.GetAllShaderInfo())
{
if (!seen.Add(info.name))
continue;
if (!PassesFilter(info.name, filter))
continue;
results.Add(new ShaderInfo
{
Name = info.name,
AssetPath = null,
IsBuiltin = true,
IsSupported = info.supported,
});
if (results.Count >= limit)
break;
}
}
return results;
}
[CliCommand("get_shader_properties",
"Introspect a shader's declared property list (name, description, type Color|Vector|Float|Range|TexEnv|Int, range, textureDimension, flags). Provide 'shader' (by name) OR 'material' (read the shader off that material).")]
public static ShaderPropertiesResult GetShaderProperties(
[CliArg("shader", "Shader name (e.g. \"Universal Render Pipeline/Lit\"). Provide this OR 'material'.")] string shader = null,
[CliArg("material", "Reference to a material to read the shader from instead of naming it. Provide this OR 'shader'.")] ObjectRef material = null)
{
Shader resolved;
if (!string.IsNullOrWhiteSpace(shader))
{
resolved = Shader.Find(shader);
if (resolved == null)
throw new ShaderNotFoundException(
$"Shader '{shader}' not found. Shader.Find only resolves shaders that are compiled/included in the build (or referenced by a material/scene). Use list_shaders to discover valid names.");
}
else if (material != null && !material.IsEmpty)
{
var mat = MaterialCommands.ResolveMaterial(material, out _);
resolved = mat.shader;
if (resolved == null)
throw new ArgumentException($"Material '{material}' has no shader assigned.");
}
else
{
throw new ArgumentException("Provide either 'shader' (a shader name) or 'material' (a material reference).");
}
var result = new ShaderPropertiesResult { Shader = resolved.name };
int count = resolved.GetPropertyCount();
for (int i = 0; i < count; i++)
{
var propType = resolved.GetPropertyType(i);
var entry = new ShaderPropertyInfo
{
Name = resolved.GetPropertyName(i),
Description = resolved.GetPropertyDescription(i),
Type = MaterialValueConverter.TypeLabel(propType),
Flags = DescribeFlags(resolved.GetPropertyFlags(i)),
};
if (propType == ShaderPropertyType.Range)
{
var limits = resolved.GetPropertyRangeLimits(i);
entry.Range = new MaterialRange
{
Min = limits.x,
Max = limits.y,
};
}
if (propType == ShaderPropertyType.Texture)
entry.TextureDimension = resolved.GetPropertyTextureDimension(i).ToString();
result.Properties.Add(entry);
}
return result;
}
private static bool PassesFilter(string name, string filter)
{
if (string.IsNullOrWhiteSpace(filter))
return true;
return name.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0;
}
/// <summary>
/// Decompose a <see cref="ShaderPropertyFlags"/> bitmask into its individual flag names (the
/// enum is a [Flags] type). <c>None</c> is reported as an empty list rather than ["None"].
/// </summary>
private static List<string> DescribeFlags(ShaderPropertyFlags flags)
{
var list = new List<string>();
if (flags == ShaderPropertyFlags.None)
return list;
foreach (ShaderPropertyFlags flag in Enum.GetValues(typeof(ShaderPropertyFlags)))
{
if (flag == ShaderPropertyFlags.None)
continue;
if ((flags & flag) == flag)
list.Add(flag.ToString());
}
return list;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1007e4472d7b4216a6a866f8aaba76c7
@@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
using Unity.Pipeline.Commands;
using UnityEditor;
namespace Unity.Pipeline.Editor.Commands
{
/// <summary>
/// Executes an Editor menu item by its path (e.g. "Assets/Reimport All"), or — when called with
/// no path — lists the available menu items. Backs the <c>unity menu</c> CLI command (CLI-110):
/// <c>unity menu</c> lists options, <c>unity menu "&lt;path&gt;"</c> executes one. The CLI only
/// forwards the request; all logic lives here, per the two-commands design.
///
/// Named <c>MenuItemCommand</c> (not <c>MenuCommand</c>) to avoid colliding with the built-in
/// <see cref="UnityEditor.MenuCommand"/> type, which would make unqualified references ambiguous
/// in any file that also imports <c>UnityEditor</c>.
///
/// Execution wraps <see cref="EditorApplication.ExecuteMenuItem"/>, which returns <c>false</c>
/// when the item could not be invoked — either because the path does not exist or because the
/// item is currently disabled (its validation function returned false). The API does not
/// distinguish those cases, so a failure is reported as "not found or disabled"; the CLI maps a
/// failed result to its "menu item does not exist" exit code.
///
/// Listing reflects the <em>actual</em> Editor menu via the internal
/// <c>UnityEditor.Menu.GetMenuItems</c> API (accessed by reflection, mirroring
/// <see cref="FocusEditorCommand"/>). Unlike scanning <c>[MenuItem]</c> attributes, this also
/// covers menus Unity defines natively (much of <c>GameObject/…</c>, <c>Assets/Create/…</c>,
/// <c>File/…</c>, etc.), which carry no attribute and would otherwise be missed.
///
/// Note: some menu items are heavy, show a modal confirmation, or trigger a domain reload/quit
/// (which tears the server down before it can reply). That is the nature of the invoked item;
/// such cases are a known limitation of driving arbitrary menus over a request/response call.
/// </summary>
public static class MenuItemCommand
{
[CliCommand("menu", "Execute an Editor menu item by path, or list available items when no path is given", MainThreadRequired = true)]
public static MenuResponse ExecuteMenu(
[CliArg("path", "Menu item path to execute, e.g. \"Assets/Reimport All\". Omit to list available menu items.")] string path = "")
{
// No path → discovery mode: return the available menu items instead of executing.
if (string.IsNullOrWhiteSpace(path))
{
List<string> items;
try
{
items = DiscoverMenuItems();
}
catch (Exception ex)
{
return MenuResponse.Fail(null, $"Failed to list menu items: {ex.Message}");
}
return new MenuResponse
{
Success = true,
Items = items,
Message = $"{items.Count} menu item(s) available."
};
}
bool executed;
try
{
executed = EditorApplication.ExecuteMenuItem(path);
}
catch (Exception ex)
{
return MenuResponse.Fail(path, $"Failed to execute menu item '{path}': {ex.Message}");
}
if (!executed)
return MenuResponse.Fail(path, $"Menu item '{path}' was not found or is currently disabled.");
return new MenuResponse
{
Success = true,
Path = path,
Message = $"Executed menu item '{path}'"
};
}
const BindingFlags k_All = BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
// Reflection handles for the internal UnityEditor.Menu.GetMenuItems(string, bool, bool) API,
// which returns an array of internal UnityEditor.ScriptingMenuItem. Resolved once and cached;
// we read each item's `path` and `isSeparator` member (property or field, depending on the
// Unity version). See FocusEditorCommand for the same reflect-into-internal-API approach.
static MethodInfo s_GetMenuItems;
static Func<object, string> s_GetPath;
static Func<object, bool> s_GetIsSeparator;
static bool s_Resolved;
static void EnsureReflection()
{
if (s_Resolved)
return;
s_Resolved = true;
var editorAsm = typeof(EditorApplication).Assembly;
var menuType = editorAsm.GetType("UnityEditor.Menu");
s_GetMenuItems = menuType?.GetMethod(
"GetMenuItems", k_All, null, new[] { typeof(string), typeof(bool), typeof(bool) }, null);
var itemType = editorAsm.GetType("UnityEditor.ScriptingMenuItem");
s_GetPath = BuildAccessor<string>(itemType, "path", "m_Path");
s_GetIsSeparator = BuildAccessor<bool>(itemType, "isSeparator", "m_IsSeparator");
}
/// <summary>
/// Build a getter for a member that may be exposed as a property or a backing field, so the
/// reflection survives the internal type's shape changing across Unity versions.
/// </summary>
static Func<object, T> BuildAccessor<T>(Type type, string propertyName, string fieldName)
{
var prop = type?.GetProperty(propertyName, k_All);
if (prop != null)
return o => (T)prop.GetValue(o);
var field = type?.GetField(propertyName, k_All) ?? type?.GetField(fieldName, k_All);
if (field != null)
return o => (T)field.GetValue(o);
return null;
}
// Standard top-level Editor menus. Unity defines these natively (no [MenuItem]), so they are
// not discoverable from attributes; we seed them so their native children are enumerated.
static readonly string[] k_NativeRoots =
{
"File", "Edit", "Assets", "GameObject", "Component", "Window", "Help"
};
/// <summary>
/// Collect the distinct menu paths from the live Editor menu. <c>Menu.GetMenuItems</c> returns
/// the full (recursive) set of items under a given root — including ones Unity defines
/// natively — but it does not enumerate the roots themselves, and no internal API does. So we
/// union the native roots above with the first path segment of every <c>[MenuItem]</c> (which
/// surfaces custom/package roots), then expand each root through the internal API. The items
/// always come from the live menu, so native entries are included.
/// </summary>
static List<string> DiscoverMenuItems()
{
EnsureReflection();
if (s_GetMenuItems == null || s_GetPath == null)
throw new InvalidOperationException(
"UnityEditor.Menu.GetMenuItems is unavailable in this Unity version.");
var paths = new SortedSet<string>(StringComparer.Ordinal);
foreach (var root in CollectRoots())
CollectItemsUnder(root, paths);
return paths.ToList();
}
/// <summary>
/// The top-level menu names to expand: the native roots, plus the first segment of every
/// attributed menu item (covering custom/package menus). Component context menus
/// (<c>CONTEXT/…</c>) are excluded, as they are not part of the main menu bar.
/// </summary>
static IEnumerable<string> CollectRoots()
{
var roots = new SortedSet<string>(StringComparer.Ordinal);
foreach (var r in k_NativeRoots)
roots.Add(r);
foreach (var method in TypeCache.GetMethodsWithAttribute<MenuItem>())
{
foreach (var attr in method.GetCustomAttributes(typeof(MenuItem), false).Cast<MenuItem>())
{
if (attr.validate || string.IsNullOrEmpty(attr.menuItem))
continue;
var slash = attr.menuItem.IndexOf('/');
var root = slash > 0 ? attr.menuItem.Substring(0, slash) : attr.menuItem;
if (root.StartsWith("CONTEXT", StringComparison.Ordinal))
continue;
roots.Add(root);
}
}
return roots;
}
/// <summary>
/// Append every non-separator menu path under <paramref name="root"/> to
/// <paramref name="paths"/>. A root that isn't present in this Editor simply contributes
/// nothing (the API returns an empty array).
/// </summary>
static void CollectItemsUnder(string root, SortedSet<string> paths)
{
if (!(s_GetMenuItems.Invoke(null, new object[] { root, false, false }) is Array items))
return;
foreach (var item in items)
{
if (item == null)
continue;
if (s_GetIsSeparator != null && s_GetIsSeparator(item))
continue;
var path = s_GetPath(item);
if (string.IsNullOrEmpty(path))
continue;
paths.Add(path);
}
}
}
/// <summary>
/// Result of the <c>menu</c> command. On execution, <see cref="Success"/> false means the item
/// could not be invoked (not found or disabled). In list mode, <see cref="Items"/> holds the
/// available menu paths.
/// </summary>
[Serializable]
public class MenuResponse
{
[JsonProperty("success")]
public bool Success { get; set; }
[JsonProperty("path")]
public string Path { get; set; }
[JsonProperty("items")]
public List<string> Items { get; set; }
[JsonProperty("message")]
public string Message { get; set; }
public static MenuResponse Fail(string path, string message) => new MenuResponse
{
Success = false,
Path = path,
Message = message
};
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f48719fc11d44809a97c890b8317c0d7
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 31cd60e0286e77f4cb5baaaa7a3d6377
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,303 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEditor.Search;
using UnityEngine;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Editor.Commands.Navigation
{
/// <summary>
/// Navigation and targeting commands (CLI-200): read and drive the Editor selection, and run
/// Unity Search queries — both returning the canonical <see cref="AuthoringResult"/> object
/// identity (via <see cref="ObjectResolver.Describe"/>) so an agent can reference results in a
/// follow-up call. All commands are read-only or non-destructive (set_selection only changes the
/// Editor selection, which carries no undo/safety policy), and run on the main thread.
/// </summary>
public static class NavigationCommands
{
private const int MaxSearchResults = 200;
[CliCommand("get_selection", "Read the current Editor selection as structured object identities.")]
public static SelectionResult GetSelection()
{
return DescribeSelection(null);
}
/// <summary>
/// <para>Set the Editor selection from instance ids and/or asset paths.</para>
/// <para>
/// We accept simple <paramref name="instanceIds"/> / <paramref name="paths"/> rather than full
/// <see cref="ObjectRef"/>[] handles because nested-object parameter schemas (arrays of objects)
/// are pending CAT-2508. Once that lands this can take an <see cref="ObjectRef"/>[] and route
/// each through <see cref="ObjectResolver.TryResolve"/> for the full handle surface.
/// </para>
/// </summary>
[CliCommand("set_selection", "Set the Editor selection to the given assets/scene objects.")]
public static SelectionResult SetSelection(
[CliArg("instance_ids", "Scene/loaded object instance IDs to select.")] ObjectId[] instanceIds = null,
[CliArg("paths", "Asset paths to select (e.g. Assets/Foo.prefab).")] string[] paths = null)
{
var resolved = new List<Object>();
var unresolved = new List<string>();
if (instanceIds != null)
{
foreach (var id in instanceIds)
{
var obj = PipelineUtils.IdToObject(id);
if (obj != null)
resolved.Add(obj);
else
unresolved.Add($"instanceId:{id}");
}
}
if (paths != null)
{
foreach (var path in paths)
{
var obj = string.IsNullOrEmpty(path) ? null : AssetDatabase.LoadMainAssetAtPath(path);
if (obj != null)
resolved.Add(obj);
else if (path == null)
unresolved.Add("<null>");
else if (path.Length == 0)
unresolved.Add("<empty>");
else
unresolved.Add(path);
}
}
var hadInputs = (instanceIds != null && instanceIds.Length > 0) ||
(paths != null && paths.Length > 0);
// Inputs were given but none resolved: that's a hard error, not a silent clear.
if (resolved.Count == 0 && hadInputs)
throw new ArgumentException($"No objects resolved from the given inputs. Unresolved: {string.Join(", ", unresolved)}");
// No inputs at all is a valid request to clear the selection.
var objects = resolved.ToArray();
Selection.objects = objects;
Selection.activeObject = resolved.FirstOrDefault();
return DescribeSelection(unresolved.ToArray());
}
[CliCommand("search", "Run a Unity Search query and return structured results.")]
public static SearchResult Search(
[CliArg("query", "Unity Search query string, e.g. 't:Material', 'p: my asset', 'h: Main Camera'.", Required = true)] string query,
[CliArg("limit", "Max results to return (capped 200).")] int limit = 50)
{
if (string.IsNullOrEmpty(query))
throw new ArgumentException("query must be a non-empty Unity Search query string.");
var clamped = Mathf.Clamp(limit, 0, MaxSearchResults);
// A non-positive limit can't return any rows; skip the potentially expensive request.
if (clamped <= 0)
{
return new SearchResult
{
Query = query,
Count = 0,
Results = Array.Empty<SearchResultItem>()
};
}
var results = new List<SearchResultItem>();
ISearchList list = null;
try
{
// Synchronous request returns the items already resolved on the main thread.
list = SearchService.Request(query, SearchFlags.Synchronous);
foreach (var item in list)
{
if (results.Count >= clamped)
break;
if (item == null)
continue;
// One malformed item must not fail the whole call.
try
{
results.Add(MapItem(item));
}
catch
{
// skip the unmappable item
}
}
}
catch (Exception ex)
{
throw new InvalidOperationException($"Unity Search query '{query}' failed: {ex.Message}", ex);
}
finally
{
// The Synchronous Request overload returns an ISearchList that owns a SearchContext;
// dispose it when present so we don't leak the context.
(list as IDisposable)?.Dispose();
}
return new SearchResult
{
Query = query,
Count = results.Count,
Results = results.ToArray()
};
}
private static SearchResultItem MapItem(SearchItem item)
{
string label;
try
{
label = item.GetLabel(item.context, true);
}
catch
{
label = null;
}
if (string.IsNullOrEmpty(label))
label = item.label ?? item.id;
string description = null;
try
{
description = item.GetDescription(item.context, true);
}
catch
{
// leave description null
}
string path = null;
try
{
var obj = item.ToObject();
if (obj != null)
{
var assetPath = AssetDatabase.GetAssetPath(obj);
if (!string.IsNullOrEmpty(assetPath))
path = assetPath;
}
}
catch
{
// leave path null
}
return new SearchResultItem
{
Id = item.id,
Label = label,
Description = description,
Provider = item.provider?.id,
Path = path
};
}
/// <summary>
/// Snapshot the current <see cref="Selection"/> as canonical identities, optionally tagging on
/// the <paramref name="unresolved"/> list from a set_selection call.
/// </summary>
private static SelectionResult DescribeSelection(string[] unresolved)
{
var objects = Selection.objects ?? Array.Empty<Object>();
var described = objects
.Select(ObjectResolver.Describe)
.Where(r => r != null)
.ToArray();
return new SelectionResult
{
Count = described.Length,
Active = ObjectResolver.Describe(Selection.activeObject),
Objects = described,
Unresolved = unresolved
};
}
}
/// <summary>
/// Structured snapshot of the Editor selection: the active object plus every selected object,
/// each as a canonical <see cref="AuthoringResult"/> identity. <see cref="Unresolved"/> is only
/// populated by set_selection (inputs that did not resolve to a loaded object/asset).
/// </summary>
[Serializable]
public class SelectionResult
{
/// <summary>Number of selected objects (excludes nulls).</summary>
[JsonProperty("count")]
public int Count { get; set; }
/// <summary>The active selection object, or null when the selection is empty.</summary>
[JsonProperty("active")]
public AuthoringResult Active { get; set; }
/// <summary>Every selected object as a canonical identity (nulls skipped).</summary>
[JsonProperty("objects")]
public AuthoringResult[] Objects { get; set; }
/// <summary>Inputs that did not resolve (set_selection only); null for get_selection.</summary>
[JsonProperty("unresolved")]
public string[] Unresolved { get; set; }
}
/// <summary>
/// Structured result of a Unity Search query: the echoed query, the returned count, and the
/// mapped <see cref="SearchResultItem"/> rows (capped at the requested limit).
/// </summary>
[Serializable]
public class SearchResult
{
/// <summary>The query that was executed (echoed back).</summary>
[JsonProperty("query")]
public string Query { get; set; }
/// <summary>Number of results returned (after the limit cap).</summary>
[JsonProperty("count")]
public int Count { get; set; }
/// <summary>The mapped search result rows.</summary>
[JsonProperty("results")]
public SearchResultItem[] Results { get; set; }
}
/// <summary>
/// One Unity Search result, flattened to portable fields. <see cref="Path"/> is populated only
/// when the item resolves to an asset object.
/// </summary>
[Serializable]
public class SearchResultItem
{
/// <summary>Provider-specific item id (e.g. an asset path or scene object id).</summary>
[JsonProperty("id")]
public string Id { get; set; }
/// <summary>Display label for the item.</summary>
[JsonProperty("label")]
public string Label { get; set; }
/// <summary>Longer description, when the provider supplies one (may be null).</summary>
[JsonProperty("description")]
public string Description { get; set; }
/// <summary>Id of the search provider that produced this item (e.g. "asset", "scene").</summary>
[JsonProperty("provider")]
public string Provider { get; set; }
/// <summary>Project-relative asset path, when the item resolves to an asset (else null).</summary>
[JsonProperty("path")]
public string Path { get; set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a16797feca5bc694cac48b892634bb7a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7a7cc3922a14b4741a17d985bb6be540
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using Unity.Pipeline.Commands;
namespace Unity.Pipeline.Editor.Commands.Observability
{
/// <summary>
/// Read-only observability commands over the captured Editor console (CLI-198). Logs are captured
/// continuously by <see cref="ConsoleLogBuffer"/>; these commands let an agent read them back as
/// structured data and clear the buffer (and the Unity console) between runs.
/// </summary>
public static class ConsoleCommands
{
[CliCommand("get_console_logs", "Read recently captured Editor console logs (structured).")]
public static object GetConsoleLogs(
[CliArg("severity", "Filter: all | log | warning | error. 'all' = every entry; 'log' = Log only; 'warning' = Warning only; 'error' = Error/Exception/Assert only.")] string severity = "all",
[CliArg("limit", "Max entries to return (most-recent first), capped at 1000.")] int limit = 100)
{
// Snapshot is oldest-first; filter by tier, then take the most-recent `limit` entries
// and present them newest-first.
var snapshot = ConsoleLogBuffer.Snapshot();
var filtered = new List<ConsoleLogEntryDto>(snapshot.Count);
foreach (var entry in snapshot)
{
if (MatchesSeverity(entry.Type, severity))
filtered.Add(entry);
}
var clampedLimit = Math.Min(Math.Max(limit, 1), ConsoleLogBuffer.MaxEntries);
var logs = new List<ConsoleLogEntryDto>(Math.Min(clampedLimit, filtered.Count));
for (var i = filtered.Count - 1; i >= 0 && logs.Count < clampedLimit; i--)
logs.Add(filtered[i]);
return new
{
total = filtered.Count,
returned = logs.Count,
logs
};
}
[CliCommand("clear_console", "Clear the captured log buffer and the Unity Editor console.")]
public static object ClearConsole()
{
ConsoleLogBuffer.Clear();
// Best-effort: also clear Unity's own console window. UnityEditor.LogEntries is internal,
// so reach it via reflection and swallow any failure (API is not part of the public contract).
// LogEntries.Clear is a static, non-public method, so include NonPublic in the binding flags —
// GetMethod("Clear") with no flags only finds public methods and would silently return null.
try
{
Type.GetType("UnityEditor.LogEntries,UnityEditor")
?.GetMethod("Clear", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
?.Invoke(null, null);
}
catch
{
// Ignore — clearing the Editor console is auxiliary; the buffer is already cleared.
}
return new { cleared = true };
}
/// <summary>
/// Map a Unity <see cref="UnityEngine.LogType"/> name onto the requested severity tier.
/// The tiers are distinct: "all" matches every entry; "log" matches Log only;
/// "warning" matches Warning only; "error" matches Error/Exception/Assert only.
/// Any unknown value falls back to "all" (matches everything).
/// </summary>
private static bool MatchesSeverity(string type, string severity)
{
switch (severity?.ToLowerInvariant())
{
case "log":
return type == "Log";
case "warning":
return type == "Warning";
case "error":
return type == "Error" || type == "Exception" || type == "Assert";
case "all":
default:
return true;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b7dd929510db8d248b64b05361bfabb6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,113 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Editor.Commands.Observability
{
/// <summary>
/// A single captured Editor console entry. Returned (most-recent-first) by the
/// <c>get_console_logs</c> command so an agent can read structured log output without scraping
/// the Editor UI. Mirrors the fields Unity surfaces on <see cref="Application.logMessageReceivedThreaded"/>.
/// </summary>
[Serializable]
public class ConsoleLogEntryDto
{
/// <summary>Unity <see cref="LogType"/> name, e.g. "Log", "Warning", "Error", "Exception", "Assert".</summary>
[JsonProperty("type")]
public string Type { get; set; }
/// <summary>The logged message text.</summary>
[JsonProperty("message")]
public string Message { get; set; }
/// <summary>Captured stack trace, if Unity provided one for the entry.</summary>
[JsonProperty("stackTrace")]
public string StackTrace { get; set; }
/// <summary>Capture time in UTC, ISO-8601 round-trip ("o") format.</summary>
[JsonProperty("timestampUtc")]
public string TimestampUtc { get; set; }
}
/// <summary>
/// Captures Editor console output into a bounded, thread-safe ring buffer so it can be read back
/// structurally over the pipeline (CLI-198). Subscribes to
/// <see cref="Application.logMessageReceivedThreaded"/> on load — the threaded variant is used
/// because Unity may raise log callbacks off the main thread, and the handler is fully lock-guarded.
///
/// The buffer holds at most <see cref="MaxEntries"/> entries; the oldest is dropped when full.
/// </summary>
public static class ConsoleLogBuffer
{
/// <summary>Maximum number of entries retained; oldest entries are dropped past this.</summary>
public const int MaxEntries = 1000;
private static readonly object m_Lock = new object();
private static readonly Queue<ConsoleLogEntryDto> m_Entries = new Queue<ConsoleLogEntryDto>(MaxEntries);
/// <summary>
/// Subscribe to Unity's threaded log callback as soon as the Editor domain loads, so capture
/// is live before any command runs. Idempotent across domain reloads (handler is removed
/// first to avoid double-subscription if Init somehow runs twice).
/// </summary>
[InitializeOnLoadMethod]
private static void Init()
{
Application.logMessageReceivedThreaded -= OnLogMessageThreaded;
Application.logMessageReceivedThreaded += OnLogMessageThreaded;
}
private static void OnLogMessageThreaded(string condition, string stackTrace, LogType type)
{
var entry = new ConsoleLogEntryDto
{
Type = type.ToString(),
Message = condition,
StackTrace = stackTrace,
TimestampUtc = DateTime.UtcNow.ToString("o")
};
lock (m_Lock)
{
if (m_Entries.Count >= MaxEntries)
m_Entries.Dequeue();
m_Entries.Enqueue(entry);
}
}
/// <summary>
/// Take a point-in-time copy of the buffer in chronological (oldest-first) order. The copy is
/// detached from the live buffer, so callers can filter/reverse it without holding the lock.
/// </summary>
public static IReadOnlyList<ConsoleLogEntryDto> Snapshot()
{
lock (m_Lock)
{
return new List<ConsoleLogEntryDto>(m_Entries);
}
}
/// <summary>Number of entries currently retained in the buffer.</summary>
public static int Count
{
get
{
lock (m_Lock)
{
return m_Entries.Count;
}
}
}
/// <summary>Drop all captured entries.</summary>
public static void Clear()
{
lock (m_Lock)
{
m_Entries.Clear();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d212c06f2c819e04c892f7e41520f975
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,171 @@
using System;
using Newtonsoft.Json;
using Unity.Pipeline.Commands;
using UnityEditor;
using UnityEngine;
using UnityEngine.Profiling;
namespace Unity.Pipeline.Editor.Commands.Observability
{
/// <summary>
/// Render statistics for the most recently rendered Editor frame, read from
/// <see cref="UnityStats"/>. These reflect the last frame the Editor drew (Game/Scene view), not
/// an averaged measurement — treat them as a snapshot.
/// </summary>
[Serializable]
public class RenderStats
{
/// <summary>Draw calls issued for the last rendered frame.</summary>
[JsonProperty("drawCalls")]
public int DrawCalls { get; set; }
/// <summary>Total batches (dynamic + static + instanced) for the last rendered frame.</summary>
[JsonProperty("batches")]
public int Batches { get; set; }
/// <summary>SetPass calls (shader pass switches) for the last rendered frame.</summary>
[JsonProperty("setPassCalls")]
public int SetPassCalls { get; set; }
/// <summary>Triangles submitted for the last rendered frame.</summary>
[JsonProperty("triangles")]
public int Triangles { get; set; }
/// <summary>Vertices submitted for the last rendered frame.</summary>
[JsonProperty("vertices")]
public int Vertices { get; set; }
}
/// <summary>
/// Process memory usage read from <see cref="Profiler"/>. Values are in bytes and are always
/// available at runtime regardless of whether the Profiler window is open.
/// </summary>
[Serializable]
public class MemoryStats
{
/// <summary>Total memory currently allocated by Unity (bytes).</summary>
[JsonProperty("totalAllocatedBytes")]
public long TotalAllocatedBytes { get; set; }
/// <summary>Total memory reserved by Unity (bytes).</summary>
[JsonProperty("totalReservedBytes")]
public long TotalReservedBytes { get; set; }
/// <summary>Mono managed heap memory in use (bytes).</summary>
[JsonProperty("monoUsedBytes")]
public long MonoUsedBytes { get; set; }
/// <summary>Mono managed heap size reserved (bytes).</summary>
[JsonProperty("monoHeapBytes")]
public long MonoHeapBytes { get; set; }
}
/// <summary>
/// Latest CPU/GPU frame timing from <see cref="FrameTimingManager"/>. The Frame Timing Manager
/// must have a timing captured for <see cref="Available"/> to be true; when unavailable the
/// timing fields are left at zero.
/// </summary>
[Serializable]
public class FrameTimingStats
{
/// <summary>True when a frame timing was captured and the milliseconds fields are meaningful.</summary>
[JsonProperty("available")]
public bool Available { get; set; }
/// <summary>Total CPU frame time in milliseconds.</summary>
[JsonProperty("cpuFrameTimeMs")]
public double CpuFrameTimeMs { get; set; }
/// <summary>Total GPU frame time in milliseconds.</summary>
[JsonProperty("gpuFrameTimeMs")]
public double GpuFrameTimeMs { get; set; }
/// <summary>CPU main-thread frame time in milliseconds.</summary>
[JsonProperty("cpuMainThreadFrameTimeMs")]
public double CpuMainThreadFrameTimeMs { get; set; }
}
/// <summary>
/// Composite performance snapshot returned by <c>get_performance_stats</c>: render counters,
/// process memory, and the latest frame timing.
/// </summary>
[Serializable]
public class PerformanceStats
{
/// <summary>Render counters for the last rendered Editor frame.</summary>
[JsonProperty("render")]
public RenderStats Render { get; set; }
/// <summary>Process memory usage at capture time.</summary>
[JsonProperty("memory")]
public MemoryStats Memory { get; set; }
/// <summary>Latest CPU/GPU frame timing, if one was captured.</summary>
[JsonProperty("frameTiming")]
public FrameTimingStats FrameTiming { get; set; }
}
/// <summary>
/// Read-only telemetry command (CLI-198) that snapshots render, memory, and frame-timing stats so
/// an agent can reason about Editor performance without opening the Profiler or Stats overlay.
/// </summary>
public static class PerformanceCommands
{
[CliCommand("get_performance_stats", "Read render, memory, and frame-timing stats (structured, read-only).")]
public static PerformanceStats GetPerformanceStats()
{
return new PerformanceStats
{
Render = new RenderStats
{
// UnityStats reflects the last frame rendered by the Editor (Game/Scene view).
DrawCalls = UnityStats.drawCalls,
Batches = UnityStats.dynamicBatches + UnityStats.staticBatches + UnityStats.instancedBatches,
SetPassCalls = UnityStats.setPassCalls,
Triangles = UnityStats.triangles,
Vertices = UnityStats.vertices
},
Memory = new MemoryStats
{
TotalAllocatedBytes = Profiler.GetTotalAllocatedMemoryLong(),
TotalReservedBytes = Profiler.GetTotalReservedMemoryLong(),
MonoUsedBytes = Profiler.GetMonoUsedSizeLong(),
MonoHeapBytes = Profiler.GetMonoHeapSizeLong()
},
FrameTiming = CaptureFrameTiming()
};
}
/// <summary>
/// Capture the latest CPU/GPU frame timing. The Frame Timing Manager only yields data once a
/// timing has been captured (and platform support exists), so any failure or empty result is
/// reported as <see cref="FrameTimingStats.Available"/> = false rather than throwing.
/// </summary>
private static FrameTimingStats CaptureFrameTiming()
{
try
{
FrameTimingManager.CaptureFrameTimings();
var timings = new FrameTiming[1];
uint received = FrameTimingManager.GetLatestTimings(1, timings);
if (received > 0)
{
return new FrameTimingStats
{
Available = true,
CpuFrameTimeMs = timings[0].cpuFrameTime,
GpuFrameTimeMs = timings[0].gpuFrameTime,
CpuMainThreadFrameTimeMs = timings[0].cpuMainThreadFrameTime
};
}
}
catch
{
// Frame timing is platform-dependent and may be unsupported; report as unavailable.
}
return new FrameTimingStats { Available = false };
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ae9262a38e20733409c3a084cb810bdc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fdbdf731f3ad41fca20634ebcf5f4d7e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,161 @@
using System;
namespace Unity.Pipeline.Editor.Commands.PackageManager
{
/// <summary>Where an added package comes from, derived from its identifier string (CLI-203).</summary>
public enum PackageSourceKind
{
/// <summary>Could not be classified (e.g. empty input).</summary>
Unknown,
/// <summary>A registry package name, optionally pinned: <c>com.unity.foo</c> or <c>com.unity.foo@1.2.3</c>.</summary>
Registry,
/// <summary>A git URL: <c>https://…​.git</c>, <c>git+…</c>, <c>ssh://…</c>, <c>git@host:…</c>, optionally <c>#revision</c>.</summary>
Git,
/// <summary>A local package reference: <c>file:../RelativePath</c> or <c>file:/abs/path</c>.</summary>
Local
}
/// <summary>A parsed UPM <c>package_add</c> identifier — the normalized string handed to
/// <c>Client.Add</c> plus the pieces extracted for plan/audit text.</summary>
public sealed class ParsedPackageId
{
/// <summary>How the package will be resolved by UPM.</summary>
public PackageSourceKind Kind { get; set; }
/// <summary>The identifier passed verbatim to <c>UnityEditor.PackageManager.Client.Add</c>.</summary>
public string Identifier { get; set; }
/// <summary>Registry package name (Registry only); null otherwise.</summary>
public string Name { get; set; }
/// <summary>Registry version or git revision when given; null otherwise.</summary>
public string Version { get; set; }
/// <summary>Human-readable summary for plan/audit text.</summary>
public string Description { get; set; }
}
/// <summary>
/// Pure, editor-independent classification of a <c>package_add</c> identifier into a registry name,
/// git URL, or local path (CLI-203). Kept free of any Unity API so the add-path's input handling is
/// unit-testable without a live editor; the command layer feeds the result to
/// <c>UnityEditor.PackageManager.Client.Add</c>.
/// </summary>
public static class PackageIdentifier
{
/// <summary>Classify an identifier without parsing out its parts.</summary>
public static PackageSourceKind Classify(string identifier)
{
if (string.IsNullOrWhiteSpace(identifier))
return PackageSourceKind.Unknown;
var id = identifier.Trim();
if (StartsWith(id, "file:"))
return PackageSourceKind.Local;
// Explicit git forms and any URL scheme (UPM treats http(s) identifiers as git URLs).
if (StartsWith(id, "git+") || StartsWith(id, "ssh://") || id.StartsWith("git@", StringComparison.Ordinal))
return PackageSourceKind.Git;
if (StartsWith(id, "http://") || StartsWith(id, "https://") || id.Contains("://"))
return PackageSourceKind.Git;
// A bare "owner/repo.git" (no scheme) is still a git reference. Strip an optional
// "#revision" before testing the suffix.
var core = id;
var hash = core.IndexOf('#');
if (hash >= 0)
core = core.Substring(0, hash);
if (core.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
return PackageSourceKind.Git;
return PackageSourceKind.Registry;
}
/// <summary>
/// Classify and split <paramref name="identifier"/>. Returns false with <paramref name="error"/>
/// set for empty/unclassifiable input.
/// </summary>
public static bool TryParse(string identifier, out ParsedPackageId parsed, out string error)
{
parsed = null;
error = null;
if (string.IsNullOrWhiteSpace(identifier))
{
error = "Package identifier is empty. Provide a name (com.unity.foo[@version]), a git URL, or a file: path.";
return false;
}
var id = identifier.Trim();
switch (Classify(id))
{
case PackageSourceKind.Local:
parsed = new ParsedPackageId
{
Kind = PackageSourceKind.Local,
Identifier = id,
Description = $"local package '{id}'"
};
return true;
case PackageSourceKind.Git:
{
string revision = null;
var hash = id.IndexOf('#');
if (hash >= 0 && hash < id.Length - 1)
revision = id.Substring(hash + 1);
parsed = new ParsedPackageId
{
Kind = PackageSourceKind.Git,
Identifier = id,
Version = revision,
Description = revision != null ? $"git package '{id}' @ {revision}" : $"git package '{id}'"
};
return true;
}
case PackageSourceKind.Registry:
{
var name = id;
string version = null;
// Split on the version separator '@'. Use the last '@' so npm-scoped names
// ("@scope/pkg@1.0.0") still split on the version, not the leading scope marker.
var at = id.LastIndexOf('@');
if (at > 0)
{
name = id.Substring(0, at);
version = id.Substring(at + 1);
}
if (string.IsNullOrWhiteSpace(name))
{
error = $"Package name is empty in '{identifier}'.";
return false;
}
parsed = new ParsedPackageId
{
Kind = PackageSourceKind.Registry,
Identifier = id,
Name = name,
Version = version,
Description = version != null ? $"'{name}' @ {version}" : $"'{name}' (latest compatible)"
};
return true;
}
default:
error = $"Could not classify package identifier '{identifier}'.";
return false;
}
}
private static bool StartsWith(string value, string prefix) =>
value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b95405e8a4f04c93acbdd564a74f8cc2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,644 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using Newtonsoft.Json;
using Unity.Pipeline.Commands;
using UnityEditor;
using UnityEditor.PackageManager;
using UnityEditor.PackageManager.Requests;
using UnityEngine;
using PackageInfo = UnityEditor.PackageManager.PackageInfo;
namespace Unity.Pipeline.Editor.Commands.PackageManager
{
/// <summary>
/// UPM package management over the Client API (CLI-203): list / search / add / remove / resolve.
///
/// Threading: UPM <see cref="Client"/> operations are asynchronous (the <see cref="Request"/> only
/// progresses while the editor ticks) and their members are main-thread only. Commands that wait on a
/// request therefore run off the main thread (<c>MainThreadRequired = false</c>) and marshal every
/// UPM touch back onto the main thread via the server dispatcher (<see cref="RunOnMain"/>), so they
/// can block without freezing the editor.
///
/// Command surface:
/// - <c>package_list</c> (installed scope) reads the resolved set inline; <c>available</c>/<c>all</c>
/// and <c>package_search</c> query the registry and wait for it — all return <b>synchronously</b>.
/// - <c>package_add</c>/<c>package_remove</c> mutate the manifest and trigger a <b>domain reload</b>
/// that tears down the in-flight request and the HTTP connection. They are <b>dual-mode</b>:
/// <list type="bullet">
/// <item><description><b>async (default)</b> — kick the op off, return <c>in_progress</c> immediately, and let
/// an <see cref="EditorApplication.update"/> poller finalize a Temp status file. Poll
/// <c>package_status</c> until <c>completed</c>/<c>failed</c>, then <c>recompile_status</c>. This
/// keeps the (single-request) server responsive and survives the reload.</description></item>
/// <item><description><b>synchronous</b> (<c>wait=true</c>) — block until the request completes and return the
/// full result. The result is captured in one main-thread hop the moment the request completes
/// (before the compile-triggered reload). Reliable for add; for remove the reload can fire fast
/// enough to drop the reply — the status file still settles, so <c>package_status</c> confirms it.</description></item>
/// </list>
/// Both write the status file, so a lost reply (or a reload mid-wait) is recoverable via
/// <c>package_status</c>; <see cref="RecoverInterruptedOperation"/> settles an <c>in_progress</c>
/// file on the next load.
/// - <c>package_resolve</c> records its status too, so <c>package_status</c> validates it.
///
/// Mutating commands (add / remove) follow the shared <c>confirm</c>/<c>dry_run</c> convention.
/// UPM operations are not part of Unity's Undo.
/// </summary>
[InitializeOnLoad]
public static class PackageManagerCommand
{
const string StatusFile = "Temp/pipeline_package_status.json";
// Bound on a synchronous wait, kept under the CLI's default request timeout so a stuck operation
// surfaces as a clean error rather than a dropped connection.
const int WaitTimeoutSeconds = 25;
const int PollIntervalMs = 50;
static readonly object s_Lock = new object();
// The in-flight mutating request (add/remove) and how to project its result. s_InProgress is a
// plain flag (read off-thread by IsBusy) so the busy check never touches main-thread-only Request
// members. Both are reset by a domain reload; RecoverInterruptedOperation settles the status file.
static volatile bool s_InProgress;
static Request s_Request;
static string s_Operation;
static string s_Argument;
static Func<Request, PackageSummary> s_ReadPackage;
static PackageManagerCommand()
{
RecoverInterruptedOperation();
}
// ---- List / search (synchronous) -------------------------------------------------------
[CliCommand("package_list",
"List packages by scope: installed (default) | available (registry) | all (both). Returns the full " +
"result synchronously — available/all block until the registry query completes.",
MainThreadRequired = false)]
public static object PackageList(
[CliArg("scope", "Which packages to list: installed (default) | available | all.")] string scope = "installed",
[CliArg("include_indirect", "Include indirect (transitive) installed dependencies (applies to scope=installed/all).")]
bool includeIndirect = true,
[CliArg("offline", "For available/all: query the local cache instead of the registry.")] bool offline = false)
{
switch ((scope ?? "installed").Trim().ToLowerInvariant())
{
case "installed":
return RunOnMain(() => ListInstalled(includeIndirect));
case "available":
return ListFromRegistry("available", includeIndirect, offline);
case "all":
return ListFromRegistry("all", includeIndirect, offline);
default:
return new PackageListResponse
{
Success = false,
Scope = scope,
Message = $"Unknown scope '{scope}'. Use installed | available | all."
};
}
}
/// <summary>
/// Installed scope: <see cref="PackageInfo.GetAllRegisteredPackages"/> reflects the resolved
/// set without a registry round-trip. Runs on the main thread (via <see cref="RunOnMain"/>).
/// </summary>
static PackageListResponse ListInstalled(bool includeIndirect)
{
var summaries = new List<PackageSummary>();
foreach (var p in PackageInfo.GetAllRegisteredPackages())
{
if (includeIndirect || p.isDirectDependency)
summaries.Add(Map(p, isInstalled: true));
}
return new PackageListResponse
{
Success = true,
Scope = "installed",
Count = summaries.Count,
Packages = summaries,
Manifest = SafeReadManifest(),
Message = $"{summaries.Count} installed package(s)."
};
}
/// <summary>
/// available / all scope: these need the registry, so a <c>Client.SearchAll</c> request is
/// started on the main thread and awaited here (on the command's background thread). For
/// <c>all</c>, the installed set is merged in (and marked) so callers see one unified list.
/// </summary>
static object ListFromRegistry(string scope, bool includeIndirect, bool offline)
{
var request = RunOnMain(() => Client.SearchAll(offline));
if (!WaitForCompletion(request, out var waitError))
return new PackageListResponse { Success = false, Scope = scope, Message = waitError };
return RunOnMain<object>(() =>
{
if (request.Status != StatusCode.Success)
return new PackageListResponse
{
Success = false,
Scope = scope,
Message = $"Registry query failed: {request.Error?.message ?? "unknown error"}"
};
var packages = BuildScopedList(scope, request.Result, includeIndirect);
return new PackageListResponse
{
Success = true,
Scope = scope,
Count = packages.Count,
Packages = packages,
Manifest = SafeReadManifest(),
Message = $"{packages.Count} package(s)."
};
});
}
/// <summary>
/// Combine the registry search result with the installed set per <paramref name="scope"/>:
/// <c>available</c> returns every registry package (flagged whether it is installed);
/// <c>all</c> returns installed packages (resolved info) plus the registry packages that are
/// not installed.
/// </summary>
static List<PackageSummary> BuildScopedList(string scope, PackageInfo[] available, bool includeIndirect)
{
var installed = new Dictionary<string, PackageInfo>();
foreach (var p in PackageInfo.GetAllRegisteredPackages())
installed[p.name] = p;
var result = new List<PackageSummary>();
if (scope == "available")
{
foreach (var p in available ?? Array.Empty<PackageInfo>())
result.Add(Map(p, isInstalled: installed.ContainsKey(p.name)));
return result;
}
// scope == "all": installed entries first (with their resolved info), then registry-only ones.
var seen = new HashSet<string>();
foreach (var entry in installed.Values)
{
if (!includeIndirect && !entry.isDirectDependency)
continue;
result.Add(Map(entry, isInstalled: true));
seen.Add(entry.name);
}
foreach (var p in available ?? Array.Empty<PackageInfo>())
{
if (!seen.Contains(p.name))
result.Add(Map(p, isInstalled: false));
}
return result;
}
[CliCommand("package_search",
"Search packages available in the registry. Provide a name (e.g. com.unity.foo) or omit to list all. " +
"Returns the full result synchronously (blocks until the registry query completes).",
MainThreadRequired = false)]
public static object PackageSearch(
[CliArg("query", "Package name to search for. Omit/empty to list all available packages.")] string query = "",
[CliArg("offline", "Search the local cache only.")] bool offline = false)
{
var trimmed = (query ?? string.Empty).Trim();
var request = RunOnMain(() => string.IsNullOrEmpty(trimmed)
? Client.SearchAll(offline)
: Client.Search(trimmed, offline));
if (!WaitForCompletion(request, out var waitError))
return new PackageSearchResponse { Success = false, Query = trimmed, Message = waitError };
return RunOnMain<object>(() =>
{
if (request.Status != StatusCode.Success)
return new PackageSearchResponse
{
Success = false,
Query = trimmed,
Message = $"Search failed: {request.Error?.message ?? "unknown error"}"
};
var packages = MapMarkingInstalled(request.Result);
return new PackageSearchResponse
{
Success = true,
Query = trimmed,
Count = packages.Count,
Packages = packages,
Message = $"{packages.Count} package(s)."
};
});
}
// ---- Mutating operations (dual-mode, CAT-2509 gated) -----------------------------------
[CliCommand("package_add",
"Add a UPM package by name@version, git URL, or 'file:' local path. Async by default (returns " +
"in_progress; poll package_status); pass wait=true to block until added. A recompile/domain reload " +
"follows — poll recompile_status. Requires confirm=true; use dry_run to preview.",
MainThreadRequired = false)]
public static object PackageAdd(
[CliArg("identifier", "Package to add: 'com.unity.foo@1.2.3', a git URL, or 'file:../Path'.", Required = true)] string identifier = "",
[CliArg("confirm", "Apply the change. Without it the call is refused.")] bool confirm = false,
[CliArg("dry_run", "Preview the change without applying it.")] bool dryRun = false,
[CliArg("wait", "Block until the operation completes and return the result (synchronous). Default: return immediately and poll package_status.")] bool wait = false)
{
if (!PackageIdentifier.TryParse(identifier, out var parsed, out var parseError))
return PackageMutationResponse.Failed("add", identifier, parseError);
if (IsBusy())
return PackageMutationResponse.Busy("add");
return ExecuteMutation(
operation: "add",
commandName: "package_add",
argument: parsed.Identifier,
planText: $"Add package {parsed.Description} [{parsed.Kind}]",
confirm: confirm,
dryRun: dryRun,
wait: wait,
start: () => Client.Add(parsed.Identifier),
readPackage: req =>
{
var added = (req as AddRequest)?.Result;
return added != null ? Map(added, isInstalled: true) : null;
});
}
[CliCommand("package_remove",
"Remove a UPM package by name. Async by default (returns in_progress; poll package_status); pass " +
"wait=true to block until removed. A recompile/domain reload follows — poll recompile_status. " +
"Requires confirm=true; use dry_run to preview.",
MainThreadRequired = false)]
public static object PackageRemove(
[CliArg("name", "Package name to remove (e.g. com.unity.foo).", Required = true)] string name = "",
[CliArg("confirm", "Apply the change. Without it the call is refused.")] bool confirm = false,
[CliArg("dry_run", "Preview the change without applying it.")] bool dryRun = false,
[CliArg("wait", "Block until the operation completes and return the result (synchronous). Default: return immediately and poll package_status.")] bool wait = false)
{
if (string.IsNullOrWhiteSpace(name))
return PackageMutationResponse.Failed("remove", name, "A package name is required.");
var packageName = name.Trim();
if (IsBusy())
return PackageMutationResponse.Busy("remove");
return ExecuteMutation(
operation: "remove",
commandName: "package_remove",
argument: packageName,
planText: $"Remove package '{packageName}'",
confirm: confirm,
dryRun: dryRun,
wait: wait,
start: () => Client.Remove(packageName),
readPackage: null);
}
[CliCommand("package_resolve",
"Resolve/refresh packages from the manifest (re-fetch and re-link). May trigger a recompile/domain " +
"reload — poll recompile_status. Its outcome is recorded for package_status.",
MainThreadRequired = true)]
public static object PackageResolve()
{
WriteStatus(new PackageStatus
{
Status = "in_progress",
Operation = "resolve",
StartedAt = NowIso(),
Message = "Resolving packages..."
});
try
{
// Client.Resolve() is fire-and-forget (returns void) and schedules a resolve on a later
// tick, so the response flushes before any reload — no request to wait on.
Client.Resolve();
}
catch (Exception ex)
{
var failed = new PackageStatus
{
Status = "failed",
Operation = "resolve",
Success = false,
Error = ex.Message,
Manifest = SafeReadManifest(),
CompletedAt = NowIso(),
Message = $"Resolve failed: {ex.Message}"
};
WriteStatus(failed);
return PackageMutationResponse.Failed("resolve", null, failed.Error);
}
var done = new PackageStatus
{
Status = "completed",
Operation = "resolve",
Success = true,
RequiresRecompile = true,
Manifest = SafeReadManifest(),
CompletedAt = NowIso(),
Message = "Resolve requested. If assemblies changed, a domain reload follows — poll recompile_status."
};
WriteStatus(done);
return ToMutationResponse(done, plan: null);
}
// ---- Status ----------------------------------------------------------------------------
[CliCommand("package_status",
"Status of the last async package operation (add/remove/resolve): idle | in_progress | completed | " +
"failed, with the added package, manifest, and any error.",
MainThreadRequired = false)]
public static string GetPackageStatus()
{
if (File.Exists(StatusFile))
return File.ReadAllText(StatusFile);
return "{\"status\":\"idle\"}";
}
// ---- Mutation execution ----------------------------------------------------------------
/// <summary>
/// Gate (confirm / dry-run) and kick off the mutating op, then
/// either return <c>in_progress</c> (async, default) or block until completion (<paramref name="wait"/>).
/// Either way the operation is tracked and a status file is written, so the result is recoverable
/// via <c>package_status</c> even if the domain reload severs a synchronous reply.
/// </summary>
static object ExecuteMutation(
string operation, string commandName, string argument, string planText,
bool confirm, bool dryRun, bool wait,
Func<Request> start, Func<Request, PackageSummary> readPackage)
{
if (dryRun)
return PackageMutationResponse.DryRunPreview(operation, argument, planText, $"Dry run — {planText}");
if (!confirm)
return PackageMutationResponse.Rejected(operation, argument,
"Refused: this changes project packages. Re-run with confirm=true to apply, or dry_run=true to preview.");
// UPM operations are not part of Unity's Undo system, so there is no undo scope here.
var request = RunOnMain(() =>
{
var r = start();
// async mode finalizes via the update-loop poller; sync mode finalizes itself.
BeginTracking(operation, argument, r, readPackage, subscribePoll: !wait);
return r;
});
if (request == null)
return PackageMutationResponse.Failed(operation, argument, "Operation was confirmed but failed to start.");
if (!wait)
return PackageMutationResponse.InProgress(operation, argument, planText);
// Synchronous: poll until complete, capturing status + result atomically on the main thread
// (a reload can only fire between ticks, so the snapshot can't be interleaved).
var deadline = DateTime.UtcNow.AddSeconds(WaitTimeoutSeconds);
while (true)
{
var status = RunOnMain(TryFinalize);
if (status != null)
return ToMutationResponse(status, planText);
if (DateTime.UtcNow > deadline)
{
// Hand off to the update-loop poller so package_status still settles after we bail.
RunOnMain<object>(() => { SubscribePoll(); return null; });
return PackageMutationResponse.Failed(operation, argument,
$"Timed out after {WaitTimeoutSeconds}s; the operation may still be in progress — poll package_status.");
}
Thread.Sleep(PollIntervalMs);
}
}
/// <summary>Record the in-flight op, write the in_progress status, and (async only) start polling.</summary>
static void BeginTracking(string operation, string argument, Request request,
Func<Request, PackageSummary> readPackage, bool subscribePoll)
{
lock (s_Lock)
{
s_Request = request;
s_Operation = operation;
s_Argument = argument;
s_ReadPackage = readPackage;
s_InProgress = true;
}
WriteStatus(new PackageStatus
{
Status = "in_progress",
Operation = operation,
Argument = argument,
StartedAt = NowIso(),
Message = $"{operation} in progress. Poll package_status."
});
if (subscribePoll)
SubscribePoll();
}
static void SubscribePoll()
{
EditorApplication.update -= Poll;
EditorApplication.update += Poll;
}
/// <summary>Update-loop poller for async mode (and a timed-out sync wait): finalize when done.</summary>
static void Poll() => TryFinalize();
/// <summary>
/// If the tracked request has completed, write its final status, clear tracking, and return the
/// status; otherwise return null. Main-thread only (reads Request members); guarded so the sync
/// waiter and the update-loop poller can't double-finalize.
/// </summary>
static PackageStatus TryFinalize()
{
lock (s_Lock)
{
var request = s_Request;
if (request == null)
{
EditorApplication.update -= Poll;
return null;
}
if (!request.IsCompleted)
return null;
var status = BuildStatus(s_Operation, s_Argument, request, s_ReadPackage);
WriteStatus(status);
s_Request = null;
s_Operation = null;
s_Argument = null;
s_ReadPackage = null;
s_InProgress = false;
EditorApplication.update -= Poll;
return status;
}
}
static PackageStatus BuildStatus(string operation, string argument, Request request,
Func<Request, PackageSummary> readPackage)
{
var status = new PackageStatus
{
Operation = operation,
Argument = argument,
CompletedAt = NowIso(),
Manifest = SafeReadManifest()
};
if (request.Status == StatusCode.Success)
{
status.Status = "completed";
status.Success = true;
status.RequiresRecompile = true;
try { status.Package = readPackage?.Invoke(request); }
catch (Exception ex) { Debug.LogWarning($"[pipeline] package result projection failed: {ex.Message}"); }
status.Message = $"{operation} completed.";
}
else
{
status.Status = "failed";
status.Success = false;
status.Error = request.Error?.message ?? "Unknown package manager error.";
status.Message = $"{operation} failed: {status.Error}";
}
return status;
}
static PackageMutationResponse ToMutationResponse(PackageStatus s, string plan) => new PackageMutationResponse
{
Success = s.Success,
Operation = s.Operation,
Argument = s.Argument,
Status = s.Status,
Applied = s.Success,
Plan = plan,
Package = s.Package,
Manifest = s.Manifest,
RequiresRecompile = s.RequiresRecompile,
Message = s.Message
};
/// <summary>
/// On load, settle a status file left at "in_progress" by a domain reload (the expected outcome of
/// a successful add/remove). The manifest change that triggered the reload has taken effect, so
/// the op is recorded completed; the follow-on recompile is observed via recompile_status.
/// </summary>
static void RecoverInterruptedOperation()
{
try
{
if (!File.Exists(StatusFile))
return;
var status = JsonConvert.DeserializeObject<PackageStatus>(File.ReadAllText(StatusFile));
if (status == null || status.Status != "in_progress")
return;
status.Status = "completed";
status.Success = true;
status.RequiresRecompile = true;
status.CompletedAt = NowIso();
status.Manifest = SafeReadManifest();
status.Message = $"{status.Operation} completed (domain reload). Manifest updated; poll recompile_status for the recompile.";
WriteStatus(status);
}
catch (Exception ex)
{
Debug.LogWarning($"[pipeline] package status recovery failed: {ex.Message}");
}
}
// ---- Helpers ---------------------------------------------------------------------------
static bool IsBusy() => s_InProgress;
/// <summary>
/// Block until <paramref name="request"/> completes, polling its (main-thread-only) state via
/// <see cref="RunOnMain"/> while this thread sleeps between checks. Returns false with
/// <paramref name="error"/> set on timeout. Used by the read-only registry queries.
/// </summary>
static bool WaitForCompletion(Request request, out string error)
{
error = null;
var deadline = DateTime.UtcNow.AddSeconds(WaitTimeoutSeconds);
while (!RunOnMain(() => request.IsCompleted))
{
if (DateTime.UtcNow > deadline)
{
error = $"Timed out after {WaitTimeoutSeconds}s waiting for the package manager.";
return false;
}
Thread.Sleep(PollIntervalMs);
}
return true;
}
/// <summary>
/// Run <paramref name="fn"/> on the Unity main thread. When invoked off-thread (the normal case
/// for the waiting commands), it marshals through the live server's dispatcher; when already on
/// the main thread (e.g. a direct in-process/test call, or no server running) it runs inline.
/// </summary>
static T RunOnMain<T>(Func<T> fn)
{
var dispatcher = PipelineServerStartup.Server?.Dispatcher;
if (dispatcher != null && dispatcher.IsInitialized && !dispatcher.IsMainThread())
return dispatcher.Invoke(fn);
return fn();
}
static Dictionary<string, string> SafeReadManifest() =>
PackageManifest.TryRead(out var deps, out _) ? deps : null;
static PackageSummary Map(PackageInfo p, bool isInstalled = false) => new PackageSummary
{
Name = p.name,
Version = p.version,
DisplayName = p.displayName,
Source = p.source.ToString(),
ResolvedPath = p.resolvedPath,
IsDirectDependency = p.isDirectDependency,
IsInstalled = isInstalled
};
/// <summary>Map registry results, flagging which are currently installed.</summary>
static List<PackageSummary> MapMarkingInstalled(IEnumerable<PackageInfo> packages)
{
var installed = new HashSet<string>();
foreach (var p in PackageInfo.GetAllRegisteredPackages())
installed.Add(p.name);
var list = new List<PackageSummary>();
if (packages != null)
foreach (var p in packages)
list.Add(Map(p, installed.Contains(p.name)));
return list;
}
static string NowIso() => DateTime.UtcNow.ToString("o");
static void WriteStatus(PackageStatus status)
{
try
{
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
}
catch (Exception ex)
{
Debug.LogError($"[Package] Failed to write status file: {ex.Message}");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 248529bee7274235a46b64bf3f03da11
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace Unity.Pipeline.Editor.Commands.PackageManager
{
/// <summary>
/// Reads the project's UPM manifest (<c>Packages/manifest.json</c>) <c>dependencies</c> as a flat
/// name → version map, satisfying CLI-203's "return manifest state". The JSON parsing is split into
/// a pure <see cref="ReadDependencies(string)"/> seam so it is unit-testable without a live project;
/// <see cref="TryRead"/> resolves the project path and is main-thread only (it reads
/// <see cref="Application.dataPath"/>).
/// </summary>
public static class PackageManifest
{
/// <summary>Project-relative path to the UPM manifest.</summary>
public const string RelativePath = "Packages/manifest.json";
/// <summary>Absolute path to the manifest for the current project. Main thread only.</summary>
public static string ManifestPath
{
get
{
var projectRoot = Path.GetDirectoryName(Application.dataPath);
return Path.Combine(projectRoot, "Packages", "manifest.json");
}
}
/// <summary>
/// Parse the <c>dependencies</c> object of a manifest.json document into a name → version map.
/// Returns an empty map for null/empty input or a manifest without dependencies.
/// </summary>
public static Dictionary<string, string> ReadDependencies(string manifestJson)
{
var result = new Dictionary<string, string>();
if (string.IsNullOrWhiteSpace(manifestJson))
return result;
var root = JObject.Parse(manifestJson);
if (root["dependencies"] is JObject deps)
{
foreach (var kv in deps)
result[kv.Key] = kv.Value?.ToString();
}
return result;
}
/// <summary>
/// Read the current project's manifest dependencies. Returns false with
/// <paramref name="error"/> set when the manifest is missing or unreadable.
/// </summary>
public static bool TryRead(out Dictionary<string, string> dependencies, out string error)
{
dependencies = null;
error = null;
try
{
var path = ManifestPath;
if (!File.Exists(path))
{
error = $"manifest not found at {path}";
return false;
}
dependencies = ReadDependencies(File.ReadAllText(path));
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0401cb232d5143bbb185ac248086a09f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace Unity.Pipeline.Editor.Commands.PackageManager
{
/// <summary>
/// One package in a list/search/add result — a JSON-friendly projection of
/// <c>UnityEditor.PackageManager.PackageInfo</c> (CLI-203).
/// </summary>
[Serializable]
public class PackageSummary
{
[JsonProperty("name")] public string Name { get; set; }
[JsonProperty("version")] public string Version { get; set; }
[JsonProperty("displayName")] public string DisplayName { get; set; }
/// <summary>Resolved source: Registry, Embedded, Local, Git, BuiltIn, LocalTarball, Unknown.</summary>
[JsonProperty("source")] public string Source { get; set; }
[JsonProperty("resolvedPath")] public string ResolvedPath { get; set; }
/// <summary>True when the package is a direct manifest dependency (vs. a transitive one).</summary>
[JsonProperty("isDirectDependency")] public bool IsDirectDependency { get; set; }
/// <summary>True when the package is currently installed/resolved in the project (vs. only
/// available in the registry). Always true for an installed-scope listing.</summary>
[JsonProperty("isInstalled")] public bool IsInstalled { get; set; }
}
/// <summary>
/// Synchronous result of <c>package_list</c> (CLI-203). The command returns the full result in one
/// call for every scope — <c>installed</c> reads the resolved set inline; <c>available</c>/<c>all</c>
/// run a registry query and block until it completes — so listing never uses the trigger-then-poll
/// path that the mutating commands do.
/// </summary>
[Serializable]
public class PackageListResponse
{
[JsonProperty("success")] public bool Success { get; set; }
/// <summary>The scope that was listed: installed | available | all.</summary>
[JsonProperty("scope")] public string Scope { get; set; }
[JsonProperty("count")] public int Count { get; set; }
/// <summary>The packages in the requested scope.</summary>
[JsonProperty("packages")] public List<PackageSummary> Packages { get; set; }
/// <summary>Manifest dependencies (name → version).</summary>
[JsonProperty("manifest")] public Dictionary<string, string> Manifest { get; set; }
[JsonProperty("message")] public string Message { get; set; }
}
/// <summary>
/// Synchronous result of <c>package_search</c> (CLI-203). Like <c>package_list</c>, the registry
/// query is awaited inside the command, so the matches are returned in one call. Each entry carries
/// <see cref="PackageSummary.IsInstalled"/> so callers can tell which results are already installed.
/// </summary>
[Serializable]
public class PackageSearchResponse
{
[JsonProperty("success")] public bool Success { get; set; }
/// <summary>The search query (empty when listing all available packages).</summary>
[JsonProperty("query")] public string Query { get; set; }
[JsonProperty("count")] public int Count { get; set; }
/// <summary>The matching packages available in the registry.</summary>
[JsonProperty("packages")] public List<PackageSummary> Packages { get; set; }
[JsonProperty("message")] public string Message { get; set; }
}
/// <summary>
/// Persisted status of the last async mutating operation (add / remove / resolve), written to a Temp
/// status file that survives the domain reload an add/remove/resolve triggers (CLI-203). Read back
/// verbatim by <c>package_status</c> so a caller can poll an async op — or validate one whose
/// synchronous reply was lost to the reload.
/// </summary>
[Serializable]
public class PackageStatus
{
/// <summary>idle | in_progress | completed | failed.</summary>
[JsonProperty("status")] public string Status { get; set; }
/// <summary>add | remove | resolve.</summary>
[JsonProperty("operation")] public string Operation { get; set; }
/// <summary>The operation's subject (identifier/name), when applicable.</summary>
[JsonProperty("argument")] public string Argument { get; set; }
[JsonProperty("success")] public bool Success { get; set; }
[JsonProperty("error")] public string Error { get; set; }
[JsonProperty("message")] public string Message { get; set; }
/// <summary>The operation triggers a recompile/domain reload; poll <c>recompile_status</c>.</summary>
[JsonProperty("requiresRecompile")] public bool RequiresRecompile { get; set; }
/// <summary>The added package (add only); null for remove/resolve.</summary>
[JsonProperty("package")] public PackageSummary Package { get; set; }
/// <summary>Manifest dependencies (name → version) after the operation.</summary>
[JsonProperty("manifest")] public Dictionary<string, string> Manifest { get; set; }
[JsonProperty("startedAt")] public string StartedAt { get; set; }
[JsonProperty("completedAt")] public string CompletedAt { get; set; }
}
/// <summary>
/// Result of a mutating package command — <c>package_add</c> / <c>package_remove</c> /
/// <c>package_resolve</c> (CLI-203). These are dual-mode: by default the call returns
/// <c>in_progress</c> immediately (poll <c>package_status</c>); with <c>wait=true</c> it blocks and
/// this carries the final <c>completed</c>/<c>failed</c> outcome. It reports the <c>confirm</c>/<c>dry_run</c>
/// gate via <see cref="DryRun"/> / <see cref="Applied"/>. A successful add/remove
/// leaves a recompile/domain reload in flight (<see cref="RequiresRecompile"/>) — poll
/// <c>recompile_status</c> to wait for the editor to be ready.
/// </summary>
[Serializable]
public class PackageMutationResponse
{
[JsonProperty("success")] public bool Success { get; set; }
/// <summary>add | remove | resolve.</summary>
[JsonProperty("operation")] public string Operation { get; set; }
/// <summary>The operation's subject (identifier/name), when applicable.</summary>
[JsonProperty("argument")] public string Argument { get; set; }
/// <summary>in_progress | completed | dry_run | rejected | failed | busy.</summary>
[JsonProperty("status")] public string Status { get; set; }
/// <summary>True only when the operation actually mutated the project.</summary>
[JsonProperty("applied")] public bool Applied { get; set; }
[JsonProperty("dryRun")] public bool DryRun { get; set; }
/// <summary>Human-readable description of the intended/performed change.</summary>
[JsonProperty("plan")] public string Plan { get; set; }
/// <summary>The operation leaves a recompile/domain reload in flight; poll <c>recompile_status</c>.</summary>
[JsonProperty("requiresRecompile")] public bool RequiresRecompile { get; set; }
/// <summary>The added package (add only); null for remove/resolve.</summary>
[JsonProperty("package")] public PackageSummary Package { get; set; }
/// <summary>Manifest dependencies (name → version) after the operation.</summary>
[JsonProperty("manifest")] public Dictionary<string, string> Manifest { get; set; }
[JsonProperty("message")] public string Message { get; set; }
public static PackageMutationResponse InProgress(string operation, string argument, string plan) =>
new PackageMutationResponse
{
Success = true,
Operation = operation,
Argument = argument,
Status = "in_progress",
Applied = true,
Plan = plan,
Message = $"{operation} started. Poll package_status until status is 'completed' or 'failed'."
};
public static PackageMutationResponse Busy(string operation) =>
new PackageMutationResponse
{
Success = false,
Operation = operation,
Status = "busy",
Message = "Another package operation is in progress. Poll package_status, then retry."
};
public static PackageMutationResponse DryRunPreview(string operation, string argument, string plan, string message) =>
new PackageMutationResponse
{
Success = true,
Operation = operation,
Argument = argument,
Status = "dry_run",
DryRun = true,
Plan = plan,
Message = message
};
public static PackageMutationResponse Rejected(string operation, string argument, string message) =>
new PackageMutationResponse
{
Success = false,
Operation = operation,
Argument = argument,
Status = "rejected",
Message = message
};
public static PackageMutationResponse Failed(string operation, string argument, string message) =>
new PackageMutationResponse
{
Success = false,
Operation = operation,
Argument = argument,
Status = "failed",
Message = message
};
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8fa665b51bbb4f2d8543fc02de12c377
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,55 @@
using Unity.Pipeline.Commands;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Editor.Commands
{
/// <summary>
/// Commands for controlling Unity Editor play mode state.
/// Enables automation workflows to control Editor play/stop/pause from CLI tools.
/// </summary>
public static class PlayModeCommands
{
// TODO Pipeline: should all commands have name with namespace: editor.play build.generate instead of using _
[CliCommand("editor_play", "Enter Unity Editor play mode")]
public static string EnterPlayMode()
{
if (EditorApplication.isPlaying)
{
Debug.LogWarning("Editor is already in play mode");
return "Already in play mode";
}
EditorApplication.isPlaying = true;
return "Entered play mode";
}
[CliCommand("editor_stop", "Exit Unity Editor play mode")]
public static string ExitPlayMode()
{
if (!EditorApplication.isPlaying)
{
Debug.LogWarning("Editor is already in edit mode");
return "Already in edit mode";
}
EditorApplication.isPlaying = false;
return "Exited play mode";
}
[CliCommand("editor_pause", "Pause Unity Editor play mode")]
public static string PausePlayMode()
{
if (!EditorApplication.isPlaying)
{
Debug.LogWarning("Cannot pause - Editor is not in play mode");
return "Cannot pause - not in play mode";
}
EditorApplication.isPaused = !EditorApplication.isPaused;
string state = EditorApplication.isPaused ? "paused" : "unpaused";
return $"Play mode {state}";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c86c7028f3fb8f24691d8a3a207f53ff
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1e6ed0649e884a59b7403a2f28a87644
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Some files were not shown because too many files have changed in this diff Show More