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,116 @@
---
name: unity-pipeline
description: Drive a running Unity Editor or development Player from the command line via the unity-pipeline package — install the package, keep the editor ticking while unfocused, run the edit→recompile→run_tests loop, evaluate C#, and hot-reload files at runtime. Use when an agent needs to control a live Unity instance, automate Unity tests, recompile scripts headlessly, or apply runtime hot reloads. Assumes the `unity` CLI is on PATH.
---
# Unity Pipeline (agent control)
Invoke commands with `unity command <name> [args]`. Run `unity command` with no name to
list what an instance exposes. Two servers exist: **Editor** (`7800-7849`, auto-starts with
the editor) and **Runtime** (`7900-7949`, only in a dev Player build). See the package
`README.md` "Commands" reference for the full list and parameters.
## 1. Install & verify
```bash
unity pipeline install # install into current project (or --project-path)
unity pipeline list # confirm the editor instance + server are reachable
unity command editor_status # confirm the editor server answers
```
## 2. Autonomous edit loop (Editor)
This is the core agent workflow: keep the editor alive, change code, recompile, test.
```bash
# 1. Keep the editor ticking even when unfocused/minimized. REQUIRED before headless work —
# Unity otherwise throttles or stalls update/compile when it isn't the active app.
unity command set_autotick --enable true
# 2. Edit C# source files on disk normally.
# 3. Recompile (async: triggers a domain reload, then poll until done).
unity command recompile
unity command recompile_status # repeat until "completed" or "up_to_date"
# Tolerate connection errors while the domain reload is in flight — that is expected.
# If recompile_status reports failed=true, read its "errors" array and fix before testing.
# 4. (Optional) List available tests without running them.
unity command list_tests --mode editor # mode: all | editor | playmode
# 5. Run tests (filter to keep it fast).
unity command run_tests --mode editor --filter MyFixture.MyTest
```
`run_tests` modes: `all` | `editor` | `playmode`. `filter_type`: `testName` | `assembly` |
`category`. For long runs use `--async_tests true` and poll `unity command test_status`
(abort with `unity command cancel_tests`).
> **Known caveat:** when any selected test *fails*, `run_tests` may surface an opaque
> result instead of the failure details. Re-run a narrower `--filter`, or inspect the
> editor's Test Runner / logs to get the real failure.
## 3. Runtime hot reload
Change gameplay code in a **running** game with no domain reload. The game must be live:
enter Editor Play Mode (`unity command editor_play`) or run a dev Player. A
`RuntimePipelineManager` in the scene auto-discovers tagged methods on `Awake` (no manual
registration). Mono only — Editor Play Mode and Mono desktop dev builds, not IL2CPP. The token
is auto-injected for local requests.
### Prefer: in-place — `reload_file`
Edit the method body directly; no separate file, no boilerplate.
1. Tag the method `[HotReload]` on the MonoBehaviour.
2. Edit its body on disk.
3. Apply (re-run to iterate):
```bash
unity command reload_file --filename Assets/Spinner.cs
```
Add `--pdb` to make it debuggable — emits a portable PDB mapped to your source so breakpoints in
the original file bind (attach the IDE + enable Editor Attaching; compiles unoptimized):
```bash
unity command reload_file --filename Assets/Spinner.cs --pdb
```
Constraints: `void` instance methods only; **public** members only; debugging requires `--pdb`
(the default emits no symbols).
### Alternative: with helper — `reload_file_override`
Keep the original method; put the tweak in a **separate** override file.
1. Tag the method `[HotReloadWithOverrides]` and route it via
`HotReloadHelper.ExecuteWithHotReload(this, "Update", OriginalUpdate)` (original body in `OriginalUpdate`).
2. In a **separate file that does not redeclare the target type**, add a `public static`
method tagged `[HotReloadOverrideMethod("BossController.Update")]` taking the instance first.
3. Apply: `unity command reload_file_override --filename Assets/HotReload/BossOverrides.cs`
Constraints: **public** members only; you cannot break into the override.
`unity command hotreload_status` shows active overrides. `reload_file_override*` options: `--timeout <ms>`
(default 30000), `--assemblyDir <dir>` (persist DLLs instead of in-memory);
`cleanup_hotreload --assemblyDir <dir>` clears old DLLs. Both commands validate the file up front
and return a clear error on misconfiguration (no override found, target type redeclared, bad signature).
## 4. Quick C# eval (Runtime)
```bash
unity command eval "return 2 + 2;"
# Or evaluate a .cs file on disk (contents run through the same path):
unity command eval_file Assets/Scratch.cs
```
## Gotchas
- **`set_autotick` first.** Without it, recompile and tests can hang while the editor is
unfocused. The package's watchdog relies on the tick loop staying alive.
- **Hot reload needs the game running.** `reload_file_override` / `reload_file` apply to a live
game — enter Editor Play Mode (`unity command editor_play`) first, or run a dev Player.
- **Player-only commands need a dev Player.** `log`, `set_timescale`, `runtime_status`, etc. hit
the Runtime server, which does not run in the Editor.
- **Async commands poll.** `recompile`→`recompile_status`, `run_tests --async_tests`→
`test_status`. Never assume completion from the trigger call's response.
- **Target a specific instance** with `--instance host:port` or `--project-path <path>`
when more than one is running.
@@ -0,0 +1 @@
{"timestamp":1784898270,"signature":"m1B6AxHNRb3eYS2lmIt0kYUTKJpC60vksUpgeHUJJw2CZquWt5NTve2Qoyqy6rj28gpbtQyivBdokixwRC8tW5lOTj2/FTYzGTL9lRRKJYEeOqH/7f7RbKaydQnwYPkO/KTwDy7ad3tv2abdbvaqtrKSpuBYZfonfz3LFUoTacjds3tU7tZrx7jEyGVEzzha4KrNbFUJtEtHqmePABe4KCGeFo06FaSMxt6PSkJEwdkVJJ0LQaeBvDMAaQ71o63Ue5nPHEbj7TKjcGrmRMDCtsSWLxE2bsVgGX79gclMH2khqJKD6Z2fvsFWd6JUahlKjnDgZ29zkb0Wkrhu3i/GT1IK4zFY0xcs8HjbliTQuqxWhfHVkiSKa3mYntEWOa/xGaoZ9wsm44jP3e2ROCJcjLzPDDa2bWleMk0nIzqwYNvPbfgPF0/3SmrvefA6L2oL8e4DyogrWBOxxUmzxdFKe54SUdu0S0Or8Zua9h68JNoPxIJC+waXkaltmitaFSsr","publicKey":"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQm9qQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FZOEFNSUlCaWdLQ0FZRUFzdUhXYUhsZ0I1cVF4ZEJjTlJKSAordHR4SmoxcVY1NTdvMlZaRE1XaXhYRVBkRTBEMVFkT1JIRXNSS1RscmplUXlERU83ZlNQS0ZwZ1A3MU5TTnJCCkFHM2NFSU45aHNQVDhOVmllZmdWem5QTkVMenFkVmdEbFhpb2VpUnV6OERKWFgvblpmU1JWKytwbk9ySTRibG4KS0twelJlNW14OTc1SjhxZ1FvRktKT0NNRlpHdkJMR2MxSzZZaEIzOHJFODZCZzgzbUovWjBEYkVmQjBxZm13cgo2ZDVFUXFsd0E5Y3JZT1YyV1VpWXprSnBLNmJZNzRZNmM1TmpBcEFKeGNiaTFOaDlRVEhUcU44N0ZtMDF0R1ZwCjVNd1pXSWZuYVRUemEvTGZLelR5U0pka0tldEZMVGdkYXpMYlpzUEE2aHBSK0FJRTJhc0tLTi84UUk1N3UzU2cKL2xyMnZKS1IvU2l5eEN1Q20vQWJkYnJMbXk0WjlSdm1jMGdpclA4T0lLQWxBRWZ2TzV5Z2hSKy8vd1RpTFlzUQp1SllDM0V2UE16ZGdKUzdGR2FscnFLZzlPTCsxVzROY05yNWdveVdSUUJ0cktKaWlTZEJVWmVxb0RvSUY5NHpCCndGbzJJT1JFdXFqcU51M3diMWZIM3p1dGdtalFra3IxVjJhd3hmcExLWlROQWdNQkFBRT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg"}
@@ -0,0 +1,53 @@
# Changelog
All notable changes to this package will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.0-exp.1] - 2026-07-23
- Persist the pipeline server auth token across editor domain reloads so long-lived clients (MCP/IDE) no longer get `401` after a recompile. (CLI-412)
- `capture_game_view`/`capture_scene_view` with `save_path` now return a path-only result (no base64) so agent tool results stay small; pass `include_inline_image=true` for the old behavior. Add `max_resolution` to cap the inline image size. (AUTHAPI-8)
- Object-reference string handles now accept authoring-root-relative asset paths (e.g. `Materials/Floor.mat`, not just `Assets/Materials/Floor.mat`): a relative string with a file extension is treated as an asset path and normalized under the authoring root, and a failed lookup now reports every strategy tried instead of a misleading hierarchy-path-only error. (AUTHAPI-9)
## [0.3.1-exp.1] - 2026-07-16
- Update docs
## [0.3.0-exp.1] - 2026-07-13
- Improve security by ensuring token usage and enforcing read control on the token.
- Fix all upm-pvp warnings.
- Fix Samples installation.
- All server works if the App is minimized (in the RunInBackground).
- Rework all docs.
- Improve connectivity regaridng IPv4 vs IPv6 support.
- Add `eval_file` command: evaluate C# code read from a `.cs` file on disk, as a file-based alternative to `eval` (which takes inline `code`). Both commands run the source through the same evaluation path.
- Add a large set of Editor automation commands for agentic content-pipeline control:
- **Assets & files:** `create_asset`, `import_asset`, `move_asset`, `copy_asset`, `rename_asset`, `delete_asset`, `find_assets`, `create_folder`, `get_import_settings`, `set_import_settings`, `read_text_file`, `write_text_file`.
- **Scenes:** `create_scene`, `open_scene`, `save_scene`, `save_all`, `list_open_scenes`, `set_active_scene`, `get_scene_hierarchy`, `add_scene_to_build`, `remove_scene_from_build`.
- **GameObjects & components:** `create_gameobject`, `create_gameobjects`, `delete_gameobject`, `find_gameobjects`, `rename_gameobject`, `set_parent`, `set_transform`, `set_active`, `set_tag`, `set_layer`, `add_component`, `remove_component`, `get_component_properties`, `set_component_properties`.
- **Prefabs:** `create_prefab`, `create_prefab_variant`, `instantiate_prefab`, `apply_prefab_overrides`, `revert_prefab_overrides`, `unpack_prefab`, `save_prefab_contents`.
- **Scripts & serialized fields:** `create_script`, `attach_script`, `get_serialized_fields`, `set_serialized_field`.
- **Selection & search:** `get_selection`, `set_selection`, `search`.
- **Capture & screenshots:** `screenshot`, `capture_game_view`, `capture_scene_view`, `capture_editor_element`, `capture_runtime_element`.
- **Build:** `build`, `build_status`.
- **Console & diagnostics:** `console`, `clear_console`, `get_console_logs`, `get_performance_stats`.
- **Editor menus & authoring root:** `menu`, `get_authoring_root`, `set_authoring_root`.
- **Materials & shaders:** `get_material_properties`, `set_material_properties`, `get_shader_properties`, `list_shaders`.
- **Animation & Animator:** `create_animation_clip`, `get_animation_clip`, `set_animation_curve`, `remove_animation_curve`, `create_animator_controller`, `get_animator_controller`, `add_animator_layer`, `add_animator_parameter`, `add_animator_state`, `add_animator_transition`.
- **Timeline:** `create_timeline`, `get_timeline`, `add_timeline_track`, `add_timeline_clip`.
- **Lighting:** `bake_lighting`, `cancel_lighting_bake`, `lighting_bake_status`, `clear_baked_lighting`, `get_lighting_settings`, `set_lighting_settings`.
- **NavMesh:** `bake_navmesh`, `bake_navmesh_surfaces`, `cancel_navmesh_bake`, `navmesh_bake_status`, `clear_navmesh`, `get_navmesh_settings`, `set_navmesh_settings`.
- **Occlusion culling:** `bake_occlusion_culling`, `cancel_occlusion_bake`, `occlusion_bake_status`, `clear_occlusion_culling`.
- Update Wrench
- Warn user if Unity Editor is started in non-automated mode.
## [0.2.0-exp.2] - 2026-06-24
- Fix security audit flaws
- First official published version
## [0.1.0-exp.1] - 2026-06-09
### This is the first release of _Unity Pipeline_.
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 75cd0dccc034c6543afb4f969beca6af
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,50 @@
# com.unity.pipeline
Unity package for **remote-controlling a running Unity Editor (or dev Player) over HTTP**.
A client — CLI, CI, or an agent — connects and executes registered commands: recompile,
run tests, eval C#, hot-reload files, play-mode control, status/heartbeat.
## Layout (standard UPM package)
| Folder | Assembly | Notes |
|--------|----------|-------|
| `Runtime/` | `Unity.Pipeline` | Ships in player builds. HTTP server base, command registry, models, runtime commands, hot reload, player server. |
| `Editor/` | `Unity.Pipeline.Editor` | Editor-only. Live server + its owner, settings asset, editor commands, test runner. |
| `CodeGen/` | `Unity.Pipeline.CodeGen` | ILPostProcessor (Mono.Cecil) for in-place hot reload. Root-level by Unity convention (cf. Burst/Entities) — leave as-is. |
| `Tests/Editor/` | `Unity.Pipeline.Tests.Editor` | **EditMode** suite (the main one). |
| `Tests/Runtime/` | `Unity.Pipeline.Tests.Runtime` | **PlayMode** suite. |
| `Samples/` | — | Hot-reload sample scenes. |
## Architecture entry points
- **`Runtime/Common/BasePipelineServer.cs`** — shared HTTP listener + routing (`/api/exec`,
`/api/status`, `/api/commands`, …). Subclassed by `Editor/EditorPipelineServer.cs` and
`Runtime/PlayerSupport/RuntimePipelineServer.cs`.
- **`Editor/EditorPipelineStartup.cs`** (`PipelineServerStartup`) — `[InitializeOnLoad]` static
owner of the live editor server; survives domain reloads; auto-enables autotick.
- **`Editor/EditorPipelineManager.cs`** — inspectable settings asset (`Pipeline/Settings...` menu).
- Commands are discovered via `[CliCommand]` attributes. Editor command groups live under
`Editor/Commands/` (Assets, Scenes, GameObjects, Prefabs, Scripts, Build, PackageManager,
ProjectSettings, …); runtime commands under `Runtime/`.
- **`Editor/Authoring/`** — helpers shared by state-changing commands: `AuthoringUndoScope`
(collapses a command's `Undo`-registered mutations into one step) and `ProjectPaths`/`ObjectResolver`
(authoring-root path resolution + object handles).
Destructive/overwriting commands gate on `confirm`/`dry_run` inline (see `delete_asset`). Structured
multi-field command args implement `IStructuredCommandInput` (`Runtime/Common/`). See
`Documentation~/safety-and-mutations.md`, `Documentation~/authoring-commands.md`, and
`Documentation~/creating-commands.md`.
## Driving & verifying (agents)
Use the **`unity-pipeline` skill**, which drives the live editor via the `unity` CLI.
Canonical verb is `command`; the auth token is the `evalToken` field inside the port file
`<liveProject>/Library/Pipeline/.unity-pipeline-port`, sent as `Authorization: Bearer <token>`.
Edit→verify loop: make a logical change (may span several files) → `command recompile`
poll `command recompile_status``command run_tests --filter <TestClass>`. The server keeps the
editor ticking while unfocused, so compiles proceed even when focus is elsewhere.
## Conventions
- Private fields (including static): `m_PascalCase`. Consts: `PascalCase`.
- Don't `git commit`/`push` without an explicit request.
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 6433e36ff8e1bc44fb3cde740ee86080
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,2 @@
# see https://github.com/Unity-Technologies/com.unity.pipeline for more information
* @Unity-Technologies/core-authoring
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: cbc57dbad3c6f114d94ced75a1f9737d
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8b1d193bfb6e52d44975ae298a3c060a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,265 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Mono.Cecil;
using Mono.Cecil.Cil;
using Mono.Cecil.Rocks;
using Unity.CompilationPipeline.Common.Diagnostics;
using Unity.CompilationPipeline.Common.ILPostProcessing;
namespace Unity.Pipeline.CodeGen
{
/// <summary>
/// Weaves a hot-reload dispatch prologue into every method tagged [HotReload] at compile
/// time, so a running method can route to a hot-reloaded override with no domain reload. The
/// injected prologue is the auto-generated equivalent of the helper workflow's
/// HotReloadHelper.ExecuteWithHotReload(...) call:
///
/// if (HotReloadRegistry.TryInvokeHotReload("Type.Method", this, args)) return;
/// // ... original body ...
///
/// MVP: instance methods returning void (parameters supported, boxed into object[]). Non-void
/// methods are skipped with a diagnostic (return-value dispatch is deferred).
/// </summary>
public class HotReloadInPlaceILPostProcessor : ILPostProcessor
{
private const string RuntimeAssemblyName = "Unity.Pipeline";
private const string AttributeName = "HotReloadAttribute";
private const string RegistryTypeFullName = "Unity.Pipeline.HotReload.HotReloadRegistry";
private const string DispatchMethodName = "TryInvokeHotReload";
public override ILPostProcessor GetInstance() => this;
public override bool WillProcess(ICompiledAssembly compiledAssembly)
{
// Only assemblies that reference the runtime assembly can use [HotReload] or call
// the registry. This naturally excludes the runtime assembly itself (it does not
// reference itself) and the CodeGen assembly (no runtime reference).
return compiledAssembly.References.Any(
r => Path.GetFileNameWithoutExtension(r) == RuntimeAssemblyName);
}
public override ILPostProcessResult Process(ICompiledAssembly compiledAssembly)
{
var diagnostics = new List<DiagnosticMessage>();
using (var resolver = new PostProcessorAssemblyResolver(compiledAssembly.References))
using (var assembly = ReadAssembly(compiledAssembly, resolver))
{
var module = assembly.MainModule;
var targets = module.GetTypes()
.SelectMany(t => t.Methods)
.Where(m => m.HasBody && m.CustomAttributes.Any(a => a.AttributeType.Name == AttributeName))
.ToList();
if (targets.Count == 0)
return new ILPostProcessResult(null, diagnostics);
var dispatch = ResolveDispatchMethod(module, resolver, diagnostics);
if (dispatch == null)
return new ILPostProcessResult(null, diagnostics);
int wovenCount = 0;
foreach (var method in targets)
{
if (method.IsStatic)
{
diagnostics.Add(Warn($"[HotReload] '{method.FullName}' is static and was skipped (instance methods only)."));
continue;
}
if (method.ReturnType.MetadataType != MetadataType.Void)
{
diagnostics.Add(Warn($"[HotReload] '{method.FullName}' returns a value and was skipped (void methods only for now)."));
continue;
}
WeaveDispatchPrologue(method, dispatch);
wovenCount++;
}
if (wovenCount == 0)
return new ILPostProcessResult(null, diagnostics);
return new ILPostProcessResult(WriteAssembly(assembly), diagnostics);
}
}
/// <summary>
/// Inject, at the very top of the method:
/// if (HotReloadRegistry.TryInvokeHotReload("Type.Method", this, args)) return;
/// </summary>
private static void WeaveDispatchPrologue(MethodDefinition method, MethodReference dispatch)
{
var body = method.Body;
body.SimplifyMacros();
var il = body.GetILProcessor();
var first = body.Instructions[0];
var methodId = $"{method.DeclaringType.Name}.{method.Name}";
var prologue = new List<Instruction>
{
Instruction.Create(OpCodes.Ldstr, methodId),
Instruction.Create(OpCodes.Ldarg_0), // this
};
// Build the object[] of parameters (null when parameterless).
var ps = method.Parameters;
if (ps.Count == 0)
{
prologue.Add(Instruction.Create(OpCodes.Ldnull));
}
else
{
prologue.Add(Instruction.Create(OpCodes.Ldc_I4, ps.Count));
prologue.Add(Instruction.Create(OpCodes.Newarr, method.Module.TypeSystem.Object));
for (int i = 0; i < ps.Count; i++)
{
prologue.Add(Instruction.Create(OpCodes.Dup));
prologue.Add(Instruction.Create(OpCodes.Ldc_I4, i));
prologue.Add(Instruction.Create(OpCodes.Ldarg, ps[i]));
if (ps[i].ParameterType.IsValueType || ps[i].ParameterType.IsGenericParameter)
prologue.Add(Instruction.Create(OpCodes.Box, ps[i].ParameterType));
prologue.Add(Instruction.Create(OpCodes.Stelem_Ref));
}
}
prologue.Add(Instruction.Create(OpCodes.Call, dispatch));
prologue.Add(Instruction.Create(OpCodes.Brfalse, first)); // no override -> run original body
prologue.Add(Instruction.Create(OpCodes.Ret)); // override ran -> skip body
foreach (var instr in prologue)
il.InsertBefore(first, instr);
body.OptimizeMacros();
}
/// <summary>
/// Resolve HotReloadRegistry.TryInvokeHotReload(string, object, object[]) from the runtime
/// assembly and import it into the target module.
/// </summary>
private static MethodReference ResolveDispatchMethod(
ModuleDefinition module, PostProcessorAssemblyResolver resolver, List<DiagnosticMessage> diagnostics)
{
var runtimeRef = module.AssemblyReferences.FirstOrDefault(a => a.Name == RuntimeAssemblyName);
if (runtimeRef == null)
{
diagnostics.Add(Warn($"Could not find a reference to {RuntimeAssemblyName} while weaving {module.Name}."));
return null;
}
var runtime = resolver.Resolve(runtimeRef);
var registry = runtime?.MainModule.GetType(RegistryTypeFullName);
if (registry == null)
{
diagnostics.Add(Warn($"Could not resolve {RegistryTypeFullName} in {RuntimeAssemblyName}."));
return null;
}
var method = registry.Methods.FirstOrDefault(m =>
m.Name == DispatchMethodName &&
m.IsStatic &&
!m.HasGenericParameters &&
m.Parameters.Count == 3 &&
m.Parameters[0].ParameterType.MetadataType == MetadataType.String &&
m.Parameters[1].ParameterType.MetadataType == MetadataType.Object);
if (method == null)
{
diagnostics.Add(Warn($"Could not find non-generic {DispatchMethodName}(string, object, object[]) on {RegistryTypeFullName}."));
return null;
}
return module.ImportReference(method);
}
private static AssemblyDefinition ReadAssembly(ICompiledAssembly compiledAssembly, IAssemblyResolver resolver)
{
var pdb = compiledAssembly.InMemoryAssembly.PdbData;
var hasSymbols = pdb != null && pdb.Length > 0;
var readerParameters = new ReaderParameters
{
AssemblyResolver = resolver,
ReadingMode = ReadingMode.Immediate,
ReadWrite = true,
ReadSymbols = hasSymbols,
SymbolReaderProvider = hasSymbols ? new PortablePdbReaderProvider() : null,
SymbolStream = hasSymbols ? new MemoryStream(pdb) : null,
};
var peStream = new MemoryStream(compiledAssembly.InMemoryAssembly.PeData);
return AssemblyDefinition.ReadAssembly(peStream, readerParameters);
}
private static InMemoryAssembly WriteAssembly(AssemblyDefinition assembly)
{
var pe = new MemoryStream();
var pdb = new MemoryStream();
var writerParameters = new WriterParameters
{
SymbolWriterProvider = new PortablePdbWriterProvider(),
WriteSymbols = true,
SymbolStream = pdb,
};
assembly.Write(pe, writerParameters);
return new InMemoryAssembly(pe.ToArray(), pdb.ToArray());
}
private static DiagnosticMessage Warn(string message) => new DiagnosticMessage
{
DiagnosticType = DiagnosticType.Warning,
MessageData = "HotReloadInPlace: " + message,
};
}
/// <summary>
/// Minimal IAssemblyResolver that resolves referenced assemblies from the compiled assembly's
/// reference paths (which are absolute file paths supplied by the compilation pipeline).
/// </summary>
internal sealed class PostProcessorAssemblyResolver : IAssemblyResolver
{
private readonly string[] _referencePaths;
private readonly Dictionary<string, AssemblyDefinition> _cache = new Dictionary<string, AssemblyDefinition>();
public PostProcessorAssemblyResolver(string[] referencePaths)
{
_referencePaths = referencePaths;
}
public AssemblyDefinition Resolve(AssemblyNameReference name)
=> Resolve(name, new ReaderParameters(ReadingMode.Deferred));
public AssemblyDefinition Resolve(AssemblyNameReference name, ReaderParameters parameters)
{
lock (_cache)
{
if (_cache.TryGetValue(name.Name, out var cached))
return cached;
var path = _referencePaths.FirstOrDefault(
r => Path.GetFileNameWithoutExtension(r) == name.Name);
if (path == null || !File.Exists(path))
return null;
parameters.AssemblyResolver = this;
var definition = AssemblyDefinition.ReadAssembly(path, parameters);
_cache[name.Name] = definition;
return definition;
}
}
public void Dispose()
{
lock (_cache)
{
foreach (var def in _cache.Values)
def.Dispose();
_cache.Clear();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3eb482c6630e46b98100dd9c13a3f7d3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,21 @@
{
"name": "Unity.Pipeline.CodeGen",
"rootNamespace": "Unity.Pipeline.CodeGen",
"references": [],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"Mono.Cecil.dll",
"Mono.Cecil.Mdb.dll",
"Mono.Cecil.Pdb.dll",
"Mono.Cecil.Rocks.dll"
],
"autoReferenced": false,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3d86918fcc1e40b8a17542a4bacf8dbc
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,25 @@
* [Home](index.md)
* Guides
* [Creating commands](creating-commands.md)
* [Creating authoring commands](authoring-commands.md)
* [Safety & mutations](safety-and-mutations.md)
* [Connectivity](connectivity.md)
* [Runtime connection & setup](runtime-setup.md)
* [Hot reload](hot-reload.md)
* [Tests architecture](testing.md)
* Command reference
* [Asset & file commands](commands/assets-and-files.md)
* [Scene commands](commands/scenes.md)
* [GameObject & component commands](commands/gameobjects-and-components.md)
* [Prefab commands](commands/prefabs.md)
* [Script commands](commands/scripts.md)
* [Animation commands](commands/animation.md)
* [Material & shader commands](commands/materials.md)
* [Baking commands](commands/baking.md)
* [Navigation & selection commands](commands/navigation.md)
* [Capture commands](commands/capture.md)
* [Build, compilation & test commands](commands/build-and-compilation.md)
* [Project settings commands](commands/project-settings.md)
* [Package Manager commands](commands/package-manager.md)
* [Editor lifecycle & observability commands](commands/editor-lifecycle-and-observability.md)
* [Runtime commands](commands/runtime.md)
@@ -0,0 +1,319 @@
# Creating authoring commands
*Authoring commands* are the subset of commands that **create or mutate project content** — assets,
scenes, GameObjects, components — on behalf of an agent. They build on the base command API in
[Creating commands](creating-commands.md); this page covers the four concerns that are specific to
content authoring:
- **The authoring root** — the sandbox that bare paths resolve against and writes are confined to.
- **`ObjectRef`** — how a command *receives* a reference to an existing object.
- **`AuthoringResult`** — how a command *returns* the identity of an object it created or touched.
- **Undo/redo** — grouping a command's scene mutations into a single, revertible step.
Together these let one command's output feed the next command's input, so an agent can chain calls
(`create_gameobject``add_component``set_component_properties``create_prefab`) without ever
handling a raw Unity object itself. If you want to support a content type the package doesn't cover
yet (materials, audio, lighting, terrain, …), this is the pattern to follow.
Read [Creating commands](creating-commands.md) first — this page assumes you know how `[CliCommand]`,
`[CliArg]`, `MainThreadRequired`, and the response envelope work.
## Authoring vs. management commands
Not every state-changing command is an *authoring* command. This page — and the [checklist](#checklist-for-a-new-authoring-command)
at the end — applies to commands that **create or mutate project content** (assets, scenes,
GameObjects, components) and hand the agent back an object identity.
**Management commands** change *configuration* or *drive tooling* rather than author content:
project settings (`Editor/Commands/ProjectSettings/`), builds (`Build/`), and packages
(`PackageManager/`). They are a **different category** and deliberately do
**not** follow the full authoring contract. They share only the cross-cutting safety conventions
(the `confirm`/`dry_run` gate and, where relevant, `ObjectResolver`/`ProjectPaths`) — see
[Safety & mutations](safety-and-mutations.md).
| Concern | Authoring command | Management command |
|---|---|---|
| Object input | `ObjectRef``ObjectResolver.TryResolve` | same, when it references an object (e.g. a scene in the build list) |
| Path input | `ProjectPaths.Resolve` (confined to the authoring root) | as needed; may be unconfined by design (e.g. a build output path) |
| Undo | scene/object mutations wrapped in `AuthoringUndoScope` + registered `Undo` APIs | **none** — settings / AssetDatabase / UPM writes don't participate in Undo, so no scope; note `Not undoable via Ctrl+Z.` in the description instead |
| Destructive gate | `confirm`/`dry_run` where destructive | `confirm`/`dry_run` where destructive (same convention) |
| Return value | `AuthoringResult` via `ObjectResolver.Describe` | a domain model (e.g. `ProjectSettingsResponse`, `PackageMutationResponse`) or a plain result object |
| Failure | **throw** (`ArgumentException` / `InvalidOperationException`) | throw, **or** return a structured `{ success = false, code, error }` object — consistent within the area |
If you're adding a config/build/package command, follow the management column and the shared safety
conventions; the checklist below is for content-authoring commands.
## The building blocks
| Type | Namespace | Role |
|------|-----------|------|
| `ProjectPaths` | `Unity.Pipeline.Editor.Authoring` | Resolve/confine agent-supplied paths to the authoring root. |
| `ObjectRef` | `Unity.Pipeline.Models` | Input handle to an existing object (asset or scene object). |
| `AuthoringResult` | `Unity.Pipeline.Models` | Output identity of an object your command created or acted on. |
| `ObjectResolver` | `Unity.Pipeline.Editor.Authoring` | `TryResolve(ObjectRef …)` (handle → object) and `Describe(object)` (object → `AuthoringResult`). |
| `AuthoringUndoScope` | `Unity.Pipeline.Editor.Authoring` | Collapses all `Undo`-registered mutations in its lifetime into one editor undo step. |
Authoring commands are Editor-only. Put them under `Editor/Commands/<Area>/` in the
`Unity.Pipeline.Editor.Commands.<Area>` namespace (see `Editor/Commands/Authoring/AuthoringConfigCommands.cs`).
## The authoring root (`set_authoring_root`)
Agents pass **bare, relative paths** (`"Materials/Stone"`), not full project paths. `ProjectPaths`
resolves those against a configurable **authoring root** and *confines* every write to it. The root
defaults to `Assets` (full project access); an agent can narrow it to a sub-folder to sandbox itself:
```
get_authoring_root → { "root": "Assets" }
set_authoring_root --root Assets/AgentWork
```
These are just thin commands over `ProjectPaths.AuthoringRoot` (`Editor/Commands/Authoring/AuthoringConfigCommands.cs`):
```csharp
[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 };
}
```
**Every path parameter your command accepts must go through `ProjectPaths.Resolve`.** This is the
sandbox boundary — it rejects `..` traversal and anything that escapes the root, and it lets callers
omit the `Assets/` prefix. Do not build asset paths by string concatenation.
```csharp
var normalized = ProjectPaths.Resolve(path, out var error);
if (normalized == null)
throw new ArgumentException(error); // e.g. "Path '../secrets' must not contain '..'."
// normalized is now a project-relative path guaranteed to live under the authoring root.
```
Resolution rules (`Editor/Authoring/ProjectPaths.cs`):
- Bare paths (`"Materials/Stone"`) are taken relative to the root → `Assets/AgentWork/Materials/Stone`.
- Explicit `Assets/…` / `Packages/…` paths are used as-is (but still confined to the root).
- Absolute paths must live under the project root and are converted to project-relative.
- `..` anywhere, or a result outside the root, returns `null` + an `error` string.
## Receiving objects: `ObjectRef`
When a command needs to act on an object that *already* exists, take an `ObjectRef` parameter. The
agent supplies **one** of several forms; `ObjectResolver.TryResolve` tries them in order — `globalId`,
`path`, `guid` (+ optional `fileId`), `instanceId`, `hierarchyPath` — and hands you the live object:
```csharp
public static Renderer ResolveRenderer(ObjectRef target)
{
if (!ObjectResolver.TryResolve(target, out var obj, out var error))
throw new ArgumentException(error);
var go = obj as GameObject ?? (obj as Component)?.gameObject;
var renderer = go != null ? go.GetComponent<Renderer>() : null;
if (renderer == null)
throw new ArgumentException($"Object '{target}' has no Renderer.");
return renderer;
}
```
Resolve **outside** any undo scope / before mutating, so a bad handle fails before your command
changes anything (see `create_gameobject`, which resolves its `parent` before entering the scope).
When the handle is a plain string (the usual agent input), a value with a file extension and no
leading `/` — e.g. `"Materials/Floor.mat"` — is taken as an asset path and normalized under the
authoring root, so the `Assets/` prefix is optional (mirroring path-taking commands). A leading `/`
or an extension-less value stays a `hierarchyPath`; a dotted scene name like `"Cube.001"` still
resolves because the `path` branch falls back to a hierarchy lookup.
## Returning objects: `AuthoringResult`
Any command that creates or modifies an object should return its identity so the agent can reference
it in a follow-up call. Don't build this by hand — call `ObjectResolver.Describe(obj)`, which fills in
the right fields for the object kind:
- **Assets** get `assetPath`, `guid`, `fileId`, `type` (+ `globalId`).
- **Scene / loaded objects** get `instanceId`, `hierarchyPath`, `type` (+ `globalId`).
```csharp
var result = ObjectResolver.Describe(asset) ?? new AuthoringResult { Type = nameof(Material) };
result.AssetPath = assetPath; // ensure the path is set even if Describe returned a fresh result
return result;
```
`AuthoringResult` is **identity only** — no success flag, no message. Success/failure and timing live
in the outer `CommandExecutionResponse` (the server adds them). To report a failure, **throw**
(`ArgumentException` for bad input, `InvalidOperationException` for an operation that failed); the
server converts the exception into a failure envelope. For a batch, return a model that holds an
`AuthoringResult[]` (see `CreateGameObjectsResult`).
## Undo/redo
Undo is Unity's native `UnityEditor.Undo`, grouped per command by `AuthoringUndoScope` so a single
call reverts as a single Ctrl+Z step. Register each mutation with the matching `Undo` API **inside**
the scope:
```csharp
using (new AuthoringUndoScope("Set Material"))
{
Undo.RecordObject(renderer, "Set Material"); // record BEFORE mutating
renderer.sharedMaterial = material;
EditorSceneManager.MarkSceneDirty(renderer.gameObject.scene);
}
```
Which `Undo` call to use:
| Mutation | Call |
|----------|------|
| New scene object | `Undo.RegisterCreatedObjectUndo(obj, name)` |
| Change fields on an existing object | `Undo.RecordObject(obj, name)` / `RegisterCompleteObjectUndo` (before the change) |
| Add a component | `Undo.AddComponent(go, type)` |
| Reparent | `Undo.SetTransformParent(child, parent, name)` |
| Serialized properties | `SerializedObject` + `so.ApplyModifiedProperties()` (registers undo itself) |
> **Important caveat.** `AssetDatabase` operations are **not** part of Unity's undo system. Creating a
> folder or asset, importing, `AssetDatabase.CreateAsset`, `SaveAsPrefabAsset`, and file writes are
> **not undone** by Ctrl+Z. `AuthoringUndoScope` only covers scene/object mutations. If your command
> writes to disk, say so in its description and don't rely on undo to clean up — validate up front and
> fail before writing.
## Worked example — a new content type
Say the package has no material (rendering) commands and you want to add them. Two commands cover the
whole pattern: one that **creates an asset** (path handling + `AuthoringResult` out, no undo because
it's an `AssetDatabase` op) and one that **mutates a scene object** (`ObjectRef` in + undo).
```csharp
using System.IO;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
namespace Unity.Pipeline.Editor.Commands.Rendering
{
/// <summary>Authoring commands for materials (rendering).</summary>
public static class MaterialCommands
{
[CliCommand("create_material",
"Create a Material asset (default shader 'Standard') under the authoring root. " +
"NOTE: asset creation is not undoable via Ctrl+Z.")]
public static AuthoringResult CreateMaterial(
[CliArg("path", "Asset path relative to the authoring root; the Assets/ prefix and the .mat extension are optional. e.g. Materials/Stone", Required = true)] string path,
[CliArg("shader", "Shader name to assign. Defaults to 'Standard'.")] string shader = "Standard")
{
// 1. Resolve + confine the path (the sandbox boundary).
var normalized = ProjectPaths.Resolve(path, out var error);
if (normalized == null)
throw new ArgumentException(error);
if (!normalized.EndsWith(".mat", System.StringComparison.OrdinalIgnoreCase))
normalized += ".mat";
var found = Shader.Find(shader);
if (found == null)
throw new ArgumentException($"Shader '{shader}' was not found.");
// 2. Do the work (AssetDatabase op — no undo scope; it wouldn't be undoable anyway).
EnsureParentFolder(normalized);
var material = new Material(found);
AssetDatabase.CreateAsset(material, normalized);
AssetDatabase.SaveAssets();
// 3. Return the created object's identity so the agent can reference it next.
var result = ObjectResolver.Describe(material) ?? new AuthoringResult { Type = nameof(Material) };
result.AssetPath = normalized;
return result;
}
[CliCommand("set_material", "Assign a material asset to a Renderer on a scene GameObject.")]
public static AuthoringResult SetMaterial(
[CliArg("target", "Handle of the GameObject (or Renderer) to modify.", Required = true)] ObjectRef target,
[CliArg("material", "Handle of the material asset to assign (path/guid/globalId).", Required = true)] ObjectRef material)
{
// Resolve both handles up front, before mutating, so a bad handle changes nothing.
var renderer = ResolveRenderer(target);
if (!ObjectResolver.TryResolve(material, out var matObj, out var matError))
throw new ArgumentException(matError);
if (matObj is not Material mat)
throw new ArgumentException($"'{material}' is not a Material.");
using (new AuthoringUndoScope("Set Material"))
{
Undo.RecordObject(renderer, "Set Material"); // record BEFORE the change
renderer.sharedMaterial = mat;
EditorSceneManager.MarkSceneDirty(renderer.gameObject.scene);
}
return ObjectResolver.Describe(renderer);
}
private static Renderer ResolveRenderer(ObjectRef target)
{
if (!ObjectResolver.TryResolve(target, out var obj, out var error))
throw new ArgumentException(error);
var go = obj as GameObject ?? (obj as Component)?.gameObject;
var renderer = go != null ? go.GetComponent<Renderer>() : null;
if (renderer == null)
throw new ArgumentException($"Object '{target}' has no Renderer.");
return renderer;
}
// Mirrors the shared helper used by the built-in asset commands.
private static void EnsureParentFolder(string assetPath)
{
var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/');
if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent))
return;
// Create intermediate folders (see create_folder / CreateFolderRecursive).
Directory.CreateDirectory(ProjectPaths.ProjectRoot + "/" + parent);
AssetDatabase.Refresh();
}
}
}
```
An agent chains them by feeding the first result into the second:
```
create_material --path Materials/Stone --shader Standard
→ { "assetPath": "Assets/AgentWork/Materials/Stone.mat", "guid": "…", "type": "Material" }
set_material --target '{"hierarchyPath":"/Ground"}' --material '{"path":"Assets/AgentWork/Materials/Stone.mat"}'
→ { "instanceId": …, "hierarchyPath": "/Ground", "type": "MeshRenderer" }
```
## Checklist for a new authoring command
For *content-authoring* commands. Config/build/package commands follow the lighter
[management-command conventions](#authoring-vs-management-commands) instead.
- [ ] Editor-only, under `Editor/Commands/<Area>/`, `static` (any accessibility — `public`/`internal`/`private`), tagged `[CliCommand]`.
- [ ] Every path parameter resolved through `ProjectPaths.Resolve` (never concatenated).
- [ ] Existing-object inputs taken as `ObjectRef`, resolved via `ObjectResolver.TryResolve`; resolve **before** mutating.
- [ ] Scene/object mutations wrapped in an `AuthoringUndoScope` and registered with the matching `Undo` API.
- [ ] `AssetDatabase` writes noted as non-undoable in the description; validate before writing.
- [ ] Returns `AuthoringResult` (via `ObjectResolver.Describe`) or a model containing `AuthoringResult[]`.
- [ ] Reports failures by throwing; never build the response envelope yourself.
## Build & verify
New commands register automatically after the next recompile (see
[Creating commands → Discovery](creating-commands.md#discovery)). Drive the live editor to verify:
```
command recompile
command recompile_status # poll until done
command create_material --path Materials/Stone
```
## See also
- [Creating commands](creating-commands.md) — the base command API this page builds on.
- [Asset & file commands](commands/assets-and-files.md) — reference for the built-in asset commands.
- [GameObject & component commands](commands/gameobjects-and-components.md) — the scene-mutation commands.
@@ -0,0 +1,191 @@
# Animation commands
Author animation assets: AnimationClips (and their float curves), AnimatorControllers (parameters, layers, states, transitions), and Timeline assets. Create commands write under the authoring root and follow the shared `confirm`/`dry_run` convention.
## AnimationClips
### `create_animation_clip`
Create an empty .anim AnimationClip asset under the authoring root, with an optional frame rate and loop flag.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `path` | yes | `` | Asset path ending in .anim, relative to the authoring root. The Assets/ prefix is optional. |
| `frameRate` | no | `60` | Sampling frame rate of the clip. |
| `loop` | no | `false` | If true, set the clip's loop-time flag in its AnimationClipSettings. |
| `confirm` | no | `false` | Required (true) only when overwriting an existing asset at the path. |
| `dry_run` | no | `false` | If true, validate inputs and report what would be created without writing anything. |
**Returns:** `AuthoringResult`
### `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.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `clip` | yes | `` | Reference to the AnimationClip to edit (path / guid / globalId). |
| `path` | no | `""` | GameObject path relative to the animated root the property lives on. Empty string targets the root. |
| `type` | yes | `` | Component type the property lives on, e.g. "Transform", "UnityEngine.Light". Resolved via the component TypeResolver. |
| `property` | yes | `` | Curve property name, e.g. "m_LocalPosition.x", "m_LocalScale.y", "localEulerAnglesRaw.z". |
| `keys` | yes | `` | Keyframes: `[{ time, value, inTangent?, outTangent?, weightedMode?: "None"\|"In"\|"Out"\|"Both" }]`. Omitted tangents default to 0 (flat); this is NOT Unity's Auto tangent mode. |
| `dry_run` | no | `false` | If true, validate type/property/keys without writing the curve. |
**Returns:** `SetAnimationCurveResult`
### `get_animation_clip`
Read an AnimationClip's metadata and all float curve bindings (optionally with keyframes).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `clip` | yes | `` | Reference to the AnimationClip to read (path / guid / globalId). |
| `includeKeys` | no | `false` | If true, include each binding's keyframes. |
**Returns:** `AnimationClipInfo`
### `remove_animation_curve`
Remove a float curve binding from an AnimationClip (SetEditorCurve(clip, binding, null)). Destructive: requires confirm=true.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `clip` | yes | `` | Reference to the AnimationClip to edit (path / guid / globalId). |
| `path` | no | `""` | GameObject path relative to the animated root the binding lives on. Empty string targets the root. |
| `type` | yes | `` | Component type of the binding to remove, e.g. "Transform". Resolved via the component TypeResolver. |
| `property` | yes | `` | Curve property name to remove, e.g. "m_LocalPosition.x". |
| `confirm` | no | `false` | Must be true to actually remove the binding (destructive guard). |
| `dry_run` | no | `false` | If true, report the binding that would be removed without removing it. |
**Returns:** `SetAnimationCurveResult`
## AnimatorControllers
### `create_animator_controller`
Create an .controller AnimatorController asset (with a default Base Layer) under the authoring root.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `path` | yes | `` | Asset path ending in .controller, relative to the authoring root. The Assets/ prefix is optional. |
| `confirm` | no | `false` | Required (true) only when overwriting an existing asset at the path. |
| `dry_run` | no | `false` | If true, validate inputs and report what would be created without writing anything. |
**Returns:** `AuthoringResult`
### `add_animator_parameter`
Add a parameter (Float | Int | Bool | Trigger) to an AnimatorController. A duplicate name returns code 'duplicate_parameter'.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `controller` | yes | `` | Reference to the AnimatorController to edit (path / guid / globalId). |
| `name` | yes | `` | Parameter name. |
| `type` | yes | `` | Parameter type: Float \| Int \| Bool \| Trigger. |
| `defaultValue` | no | `` | Default value for Float/Int/Bool (ignored for Trigger). |
| `dry_run` | no | `false` | If true, validate inputs without writing the parameter. |
**Returns:** `object`
### `add_animator_layer`
Add a layer to an AnimatorController.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `controller` | yes | `` | Reference to the AnimatorController to edit (path / guid / globalId). |
| `name` | yes | `` | Layer name. |
| `weight` | no | `1` | Layer weight. |
| `blendingMode` | no | `Override` | Blending mode: Override \| Additive. |
| `dry_run` | no | `false` | If true, validate inputs without writing the layer. |
**Returns:** `object`
### `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'.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `controller` | yes | `` | Reference to the AnimatorController to edit (path / guid / globalId). |
| `layer` | no | `0` | Layer index (int) or name (string). Default 0 (Base Layer). |
| `name` | yes | `` | State name. |
| `motion` | no | `` | Optional AnimationClip or BlendTree asset to assign as the state's motion. |
| `isDefault` | no | `false` | If true, set this state as the layer's default state. |
| `position` | no | `` | Optional [x, y] node position in the graph (cosmetic). |
| `dry_run` | no | `false` | If true, validate inputs without writing the state. |
**Returns:** `object`
### `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.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `controller` | yes | `` | Reference to the AnimatorController to edit (path / guid / globalId). |
| `layer` | no | `0` | Layer index (int) or name (string). Default 0 (Base Layer). |
| `fromState` | yes | `` | Source state name, or the special "AnyState" / "Entry". |
| `toState` | yes | `` | Destination state name, or the special "Exit". |
| `conditions` | no | `` | Optional conditions: `[{ parameter, mode: "If"\|"IfNot"\|"Greater"\|"Less"\|"Equals"\|"NotEqual", threshold? }]`. |
| `hasExitTime` | no | `false` | If true, the transition uses exit time. |
| `exitTime` | no | `0` | Normalized exit time (0..1) when hasExitTime is set. |
| `duration` | no | `0.25` | Transition duration in seconds. |
| `hasFixedDuration` | no | `true` | If true, duration is in seconds; otherwise normalized. |
| `dry_run` | no | `false` | If true, validate everything (states, parameters, mode/type) without writing the transition. |
**Returns:** `object`
### `get_animator_controller`
Read an AnimatorController's full structure: parameters, layers, states (with motion / default), and transitions (with conditions).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `controller` | yes | `` | Reference to the AnimatorController to read (path / guid / globalId). |
**Returns:** `AnimatorControllerInfo`
## Timeline
The Timeline commands require the `com.unity.timeline` package.
### `create_timeline`
Create a .playable TimelineAsset under the authoring root (optional frame rate).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `path` | yes | `` | Asset path ending in .playable, relative to the authoring root. The Assets/ prefix is optional. |
| `frameRate` | no | `60` | Timeline frame rate. |
| `confirm` | no | `false` | Required (true) only when overwriting an existing asset at the path. |
| `dry_run` | no | `false` | If true, validate inputs and report what would be created without writing anything. |
**Returns:** `object`
### `add_timeline_track`
Add a track (Animation | Audio | Activation | Control | Playable | Signal | Marker) to a TimelineAsset, optionally nested under a parent group/track.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `timeline` | yes | `` | Reference to the TimelineAsset to edit (path / guid / globalId). |
| `trackType` | yes | `` | Track type: Animation \| Audio \| Activation \| Control \| Playable \| Signal \| Marker. |
| `name` | no | `` | Optional track display name. |
| `parentTrack` | no | `` | Optional name of an existing group/track to nest the new track under. |
| `dry_run` | no | `false` | If true, validate inputs without writing the track. |
**Returns:** `object`
### `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.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `timeline` | yes | `` | Reference to the TimelineAsset to edit (path / guid / globalId). |
| `track` | yes | `` | Target track name. |
| `start` | yes | `` | Clip start time in seconds. |
| `duration` | yes | `` | Clip duration in seconds. |
| `asset` | no | `` | Source asset: for Animation tracks an AnimationClip; for Audio tracks an AudioClip. Required for those track types. |
| `dry_run` | no | `false` | If true, validate inputs without writing the clip. |
**Returns:** `object`
### `get_timeline`
Read a TimelineAsset's structure: frame rate, duration, and its tracks with their clips.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `timeline` | yes | `` | Reference to the TimelineAsset to read (path / guid / globalId). |
**Returns:** `object`
See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md).
@@ -0,0 +1,150 @@
# Asset & file commands
Commands for creating, importing, moving, copying, renaming, deleting and finding project assets, plus folder and text-file operations. Every agent-supplied path is funnelled through the authoring-root sandbox (it rejects `../` and out-of-project writes); destructive or overwriting operations require `confirm=true` and support `dry_run`. AssetDatabase create/move/copy/rename/delete are not part of Unity's Undo system.
### `create_asset`
Create a new ScriptableObject (or other UnityEngine.Object) asset of the given type at a path under the authoring root.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `path` | yes | | Asset path relative to the authoring root, including extension (e.g. Data/Config.asset or Materials/Wall.mat). The Assets/ prefix is optional. |
| `type` | yes | | Fully-qualified or short type name to instantiate (e.g. UnityEngine.Material, MyGame.GameConfig). Must derive from UnityEngine.Object and be creatable. |
| `shader` | no | `` | 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). |
| `confirm` | no | `false` | Required (true) only when overwriting an existing asset at the path. Ignored when the path is empty. |
| `dry_run` | no | `false` | If true, validate inputs and report what would be created without writing anything. |
**Returns:** `AuthoringResult`
**Notes:** `confirm=true` required to overwrite an existing asset; supports `dry_run`.
### `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.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `source` | yes | | Absolute filesystem path to the external file to import. |
| `path` | yes | | Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional. |
| `confirm` | no | `false` | Required (true) only when overwriting an existing asset at the destination path. |
| `dry_run` | no | `false` | If true, validate inputs and report what would be imported without writing anything. |
**Returns:** `AuthoringResult`
**Notes:** `confirm=true` required to overwrite an existing asset; supports `dry_run`.
### `move_asset`
Move (or rename via a new path) an asset to a new location under the authoring root. Preserves the asset's GUID.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `asset` | yes | | Reference to the asset to move (path / guid / globalId). |
| `destination` | yes | | Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional. |
| `dry_run` | no | `false` | If true, validate the move (via AssetDatabase.ValidateMoveAsset) without performing it. |
**Returns:** `AuthoringResult`
**Notes:** Supports `dry_run`.
### `copy_asset`
Copy an asset to a new path under the authoring root. The copy gets a fresh GUID.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `asset` | yes | | Reference to the asset to copy (path / guid / globalId). |
| `destination` | yes | | Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional. |
| `confirm` | no | `false` | Required (true) only when overwriting an existing asset at the destination path. |
| `dry_run` | no | `false` | If true, validate inputs and report what would be copied without writing anything. |
**Returns:** `AuthoringResult`
**Notes:** `confirm=true` required to overwrite an existing asset; supports `dry_run`.
### `rename_asset`
Rename an asset in place (keeps it in the same folder, keeps its GUID).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `asset` | yes | | Reference to the asset to rename (path / guid / globalId). |
| `new_name` | yes | | New file name WITHOUT a folder path. The extension is preserved if omitted. |
| `dry_run` | no | `false` | If true, validate the rename without performing it. |
**Returns:** `AuthoringResult`
**Notes:** Supports `dry_run`.
### `delete_asset`
Delete an asset from the project. Destructive: requires confirm=true.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `asset` | yes | | Reference to the asset to delete (path / guid / globalId). |
| `confirm` | no | `false` | Must be true to actually delete. Without it the command refuses (destructive guard). |
| `dry_run` | no | `false` | If true, report the asset that would be deleted without deleting it. |
**Returns:** `AuthoringResult`
**Notes:** Destructive — `confirm=true` required; supports `dry_run`. Not undoable via Unity's Undo.
### `find_assets`
Find assets by type and/or name and/or label, returning their path, GUID and type. At least one filter is required.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `type` | no | `` | 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. |
| `name` | no | `` | Name substring to filter by (AssetDatabase name filter). |
| `label` | no | `` | Asset label to filter by (AssetDatabase 'l:' filter). |
| `search_in` | no | `` | Folder to scope the search to, relative to the authoring root (default: the authoring root). |
| `limit` | no | `200` | Maximum number of results to return (default 200). |
**Returns:** `FindAssetsResult`
**Notes:** At least one of `type`, `name`, or `label` is required.
### `set_import_settings`
Set import settings on an asset's AssetImporter (e.g. a texture's isReadable) and re-import it.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `asset` | yes | | Reference to the asset whose importer to edit (path / guid / globalId). |
| `settings` | yes | | JSON object of importer property/field names to values, e.g. {"isReadable": true, "textureType": "NormalMap"}. |
| `dry_run` | no | `false` | If true, validate which settings would apply (and which are unknown) without writing or re-importing. |
**Returns:** `SetImportSettingsResult`
**Notes:** Supports `dry_run`. Import settings live in the asset's .meta file and are not part of Unity's Undo system.
### `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.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `asset` | yes | | Reference to the asset whose importer to read (path / guid / globalId). |
| `platform` | no | `Default` | Platform whose override to read: Default \| Standalone \| iOS \| Android \| WebGL \| tvOS. |
**Returns:** `GetImportSettingsResult`
### `create_folder`
Create a folder under the authoring root (creates intermediate folders).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `path` | yes | | Folder path relative to the authoring root (default Assets/); the Assets/ prefix is optional. e.g. Gameplay/Enemies or Assets/Gameplay/Enemies |
**Returns:** `AuthoringResult`
### `read_text_file`
Read a UTF-8 text file under the authoring root and return its contents.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `path` | yes | | Text file path relative to the authoring root. The Assets/ prefix is optional. |
| `max_bytes` | no | `1048576` | Reject files larger than this many bytes (default 1048576 = 1 MiB) to avoid huge payloads. |
**Returns:** `ReadTextFileResult`
**Notes:** `MainThreadRequired = true`.
### `write_text_file`
Write UTF-8 text to a file under the authoring root, then import it. Overwriting an existing file requires confirm=true.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `path` | yes | | Text file path relative to the authoring root, including extension. The Assets/ prefix is optional. |
| `contents` | yes | | The full text content to write (replaces the file). |
| `confirm` | no | `false` | Required (true) only when overwriting an existing file at the path. |
| `dry_run` | no | `false` | If true, validate inputs and report what would be written without writing anything. |
**Returns:** `AuthoringResult`
**Notes:** `confirm=true` required to overwrite an existing file; supports `dry_run`. Filesystem writes are not part of Unity's Undo system.
See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md).
@@ -0,0 +1,140 @@
# Baking commands
Bake and clear precomputed scene data: lightmaps, NavMesh, and occlusion culling. Each `bake_*` runs asynchronously (returns immediately) — poll the matching `*_bake_status` until `completed`. The `clear_*` commands are destructive and require `confirm=true`. The `get_*_settings` / `set_*_settings` pairs read and tune the bake parameters.
## Lighting
### `bake_lighting`
Trigger an async lightmap bake of the open scene(s) via Lightmapping.BakeAsync(). Returns immediately; poll `lighting_bake_status` until completed.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `confirm` | no | `false` | Recommended (true): a bake overwrites existing lightmap data. Accepted for parity; not required. |
| `dry_run` | no | `false` | If true, validate there is an open bakeable scene and return the current lighting settings without baking. |
**Returns:** `object`
### `lighting_bake_status`
Get the status of the last lighting bake: idle | baking | completed.
**Returns:** `string`
### `cancel_lighting_bake`
Cancel an in-progress lighting bake (Lightmapping.Cancel()).
**Returns:** `object`
### `clear_baked_lighting`
Clear baked lightmap data for the open scene(s). Destructive: requires confirm=true.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `confirm` | no | `false` | Must be true to actually clear (destructive, not undoable via Unity's Undo). |
| `include_disk_cache` | no | `false` | If true, also clear the GI disk cache (Lightmapping.ClearDiskCache()). |
| `dry_run` | no | `false` | If true, report what would be cleared without clearing. |
**Returns:** `object`
### `get_lighting_settings`
Read the active LightingSettings (lightmapper, bounces, resolution, directional mode, AO, etc.).
**Returns:** `LightingSettingsResult`
### `set_lighting_settings`
Apply a subset of lighting settings to the active LightingSettings. Returns `{ applied[], unknown[] }`.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `settings` | yes | `` | JSON object with a subset of lighting fields to set (same names/enums as get_lighting_settings). |
| `dry_run` | no | `false` | If true, validate the keys and report applied/unknown without changing anything. |
**Returns:** `object`
## NavMesh
### `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.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `confirm` | no | `false` | Accepted for parity (a bake overwrites the existing NavMesh); not required. |
| `dry_run` | no | `false` | If true, validate there is an open scene and return current NavMesh settings without baking. |
**Returns:** `object`
### `navmesh_bake_status`
Get the status of the last NavMesh bake: idle | baking | completed.
**Returns:** `string`
### `cancel_navmesh_bake`
Cancel an in-progress NavMesh bake (NavMeshBuilder.Cancel()).
**Returns:** `object`
### `clear_navmesh`
Clear the baked NavMesh for the open scene(s). Destructive: requires confirm=true.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `confirm` | no | `false` | Must be true to actually clear (destructive, not undoable via Unity's Undo). |
| `dry_run` | no | `false` | If true, report what would be cleared without clearing. |
**Returns:** `object`
### `get_navmesh_settings`
Read the default agent's legacy NavMesh bake settings (agentRadius/Height/Slope/Climb, minRegionArea, voxelSize).
**Returns:** `NavMeshSettingsResult`
### `set_navmesh_settings`
Apply a subset of legacy NavMesh bake settings to the default agent. Returns `{ applied[], unknown[] }`.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `settings` | yes | `` | JSON object with a subset of NavMesh fields to set (same names as get_navmesh_settings). |
| `dry_run` | no | `false` | If true, validate the keys and report applied/unknown without changing anything. |
**Returns:** `object`
### `bake_navmesh_surfaces`
Bake NavMeshSurface components (AI Navigation package). v1 stub: returns package_not_found when the package is absent.
**Returns:** `object`
## Occlusion culling
### `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.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `smallest_occluder` | no | `` | Smallest object that will occlude others (meters). Defaults to Unity's current value. |
| `smallest_hole` | no | `` | Smallest gap geometry can have that the view can see through (meters). Defaults to Unity's current value. |
| `backface_threshold` | no | `` | Backface threshold (1-100); lower trims more backfaces. Defaults to Unity's current value. |
| `confirm` | no | `false` | Accepted for parity (a bake overwrites existing occlusion data); not required. |
| `dry_run` | no | `false` | If true, validate there is an open scene and report the parameters that would be used without baking. |
**Returns:** `object`
### `occlusion_bake_status`
Get the status of the last occlusion bake: idle | baking | completed.
**Returns:** `string`
### `cancel_occlusion_bake`
Cancel an in-progress occlusion bake (StaticOcclusionCulling.Cancel()).
**Returns:** `object`
### `clear_occlusion_culling`
Clear baked occlusion-culling data for the open scene(s). Destructive: requires confirm=true.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `confirm` | no | `false` | Must be true to actually clear (destructive, not undoable via Unity's Undo). |
| `dry_run` | no | `false` | If true, report what would be cleared without clearing. |
**Returns:** `object`
See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md).
@@ -0,0 +1,188 @@
# Build, compilation & test commands
These commands drive the build/compile/test dev loop, plus build configuration: triggering Player builds and reading the resulting BuildReport, switching the active build target, enumerating targets, reading/writing EditorUserBuildSettings, and listing Build Profiles. Several commands are asynchronous: trigger the operation, then poll a matching `*_status` command until it reports completion. The client must tolerate connection errors during a domain reload (recompile, target switch) or a long blocking build.
### `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.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `target` | no | `` | BuildTarget name (e.g. StandaloneWindows64). Defaults to the active target. Must be installed. |
| `outputPath` | no | `` | Output path (absolute, or relative to the project root). Defaults to the last/auto path. |
| `profileName` | no | `` | Build Profile name to activate before building (Unity 6 only; ignored otherwise). |
| `options` | no | `` | BuildOptions names. Omit to get just DetailedBuildReport; supplying any disables that default. |
| `scenes` | no | `` | Scene asset paths to build (e.g. Assets/Scenes/Main.unity). Defaults to EditorBuildSettings. |
| `confirm` | no | `false` | Acknowledge and run the build; without it the call is refused. Use `dry_run` to validate only. |
| `dry_run` | no | `false` | Validate target/outputPath/scenes without building. |
Supported `options` values (case-insensitive): `Development`, `AllowDebugging`, `ConnectWithProfiler`, `EnableHeadlessMode`, `SymlinkSources`, `BuildAdditionalStreamedScenes`, `CleanBuildCache`, `DetailedBuildReport`. Any unrecognized value is a validation error.
**Returns:** `object` (transient payloads: `queued` / `busy` / `dry_run` / `error`; the finished report is read via `build_status`).
**Notes:** `MainThreadRequired = false`. Async — validates + queues, returns `queued` immediately, then an editor tick runs the (blocking) build; poll `build_status` until status is `completed`. Only one build at a time (returns `busy` otherwise). Mutating: gated by the `confirm`/`dry_run` convention — pass `confirm=true` for a real build, or `dry_run=true` to validate target/outputPath/scenes without building (nothing is queued on a dry run or on validation failure).
### `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.
No parameters.
**Returns:** `string` (JSON). `idle` when no build has run; `queued`/`building` (the latter with live `elapsedMs`) while in progress; a full `BuildReportResult` once `completed`.
`BuildReportResult` fields (returned when `status` is `completed`):
| Field | Description |
|-------|-------------|
| `status` | Always `completed` here. |
| `buildId` | Id returned by the triggering `build` call. |
| `result` | `Succeeded` \| `Failed` \| `Cancelled` \| `Unknown`. |
| `platform` | Build target the report is for. |
| `outputPath` | Final output location. |
| `totalSizeBytes` | Total build size in bytes. |
| `buildTimeMs` | Total build duration in milliseconds. |
| `buildStartedAt` / `buildEndedAt` | ISO-8601 timestamps (omitted when unset). |
| `totalWarnings` / `totalErrors` | Aggregate counts from the report summary. |
| `files` | Output files (path, role, sizeBytes). Present only on success. |
| `packedAssets` | Packed bundles with per-asset breakdown. Present only on success and requires DetailedBuildReport. |
| `buildSteps` | Per-step name, durationMs, depth, and messages. |
| `errors` / `warnings` | Build issues, each with `message` and best-effort `file`. |
**Notes:** `MainThreadRequired = false`. Reads a Temp status file off-thread, so polling keeps working while a build holds the main thread. The file also survives the domain reload a build can incur, so the last report is retained until the next build.
### `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.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `target` | yes | `` | BuildTarget name to switch to (must be installed; see list_build_targets). |
| `confirm` | no | `false` | Apply the switch. Without it the call is refused. |
**Returns:** `object` (`switching` / `busy` / `completed` / `error`). Returns `completed` immediately if already on the requested target.
**Notes:** `MainThreadRequired = false`. Async — validates + queues, returns `switching` immediately, then an editor tick performs the (blocking) switch; poll `switch_build_target_status` until `completed`. Only one switch at a time (returns `busy` otherwise). Mutating and confirm-gated: without `confirm=true` the call is refused. The status file survives the domain reload the switch causes, and is reconciled against the active target on the next load.
### `switch_build_target_status`
Status of the last target switch: idle | switching | completed (with success + activeBuildTarget).
No parameters.
**Returns:** `string` (JSON). `idle`, `switching`, or `completed` (with `success` and `activeBuildTarget`, or `errors` on failure).
**Notes:** `MainThreadRequired = false`. Reads a Temp status file off-thread, so it keeps answering while the switch holds the main thread.
### `list_build_targets`
List the known BuildTarget values with their group and whether build support is installed.
No parameters.
**Returns:** `object` (list of `BuildTargetInfo`: `name`, `displayName`, `targetGroup`, `isInstalled`), sorted by group then name. Obsolete and sentinel targets are excluded.
**Notes:** `MainThreadRequired = true`.
### `get_build_settings`
Read the current build configuration from EditorUserBuildSettings / EditorBuildSettings.
No parameters.
**Returns:** `object` (`BuildSettingsResult`).
`BuildSettingsResult` fields:
| Field | Description |
|-------|-------------|
| `activeBuildTarget` | Active BuildTarget name. |
| `activeBuildTargetGroup` | Active BuildTargetGroup name. |
| `developmentBuild` | Whether a Development Player is configured. |
| `allowDebugging` | Whether script debugging is allowed. |
| `connectWithProfiler` | Whether the Profiler auto-connects. |
| `buildScriptsOnly` | Whether only scripts are built (skip data). |
| `symlinkSources` | Whether runtime/plugin sources are symlinked. |
| `il2CppCodeGeneration` | `OptimizeSpeed` \| `OptimizeSize` for the active target. |
| `scenes` | Build Settings scene list (each: `path`, `guid`, `enabled`). |
**Notes:** `MainThreadRequired = true`.
### `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.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `settings` | no | `` | Fields to change; omitted fields are left unchanged. |
| `confirm` | no | `false` | Apply the changes. Without it the call is refused. |
| `dry_run` | no | `false` | Preview the change without applying it. |
`settings` fields (a structured input DTO; every field is optional — only supplied fields change):
| Field | Description |
|-------|-------------|
| `developmentBuild` | Build a Development Player (enables the debugger/profiler). |
| `allowDebugging` | Allow script debugging (only effective with developmentBuild=true). |
| `connectWithProfiler` | Auto-connect the Profiler (only effective with developmentBuild=true). |
| `buildScriptsOnly` | Build only the scripts (skip data) for faster iteration. |
| `symlinkSources` | Symlink runtime/plugin sources instead of copying (where supported). |
| `il2CppCodeGeneration` | IL2CPP code generation for the active target: OptimizeSpeed \| OptimizeSize. |
**Returns:** `object` (`SetBuildSettingsResult`: `success`, `dryRun`, `applied` map of fields that changed, `skipped` map of supplied fields that already matched, `message`).
**Notes:** `MainThreadRequired = true`. Mutating: refused unless `confirm=true` (or `dry_run=true` to preview). Fields already at the requested value are reported under `skipped` rather than re-applied. Fails if no `settings` object is provided.
### `list_build_profiles`
List Build Profile assets in the project (Unity 6 only). Returns feature_unavailable on earlier versions.
No parameters.
**Returns:** `object` (list of `BuildProfileInfo`: `name`, `guid`, `platform`, `isActive`), or `{ error, code = "feature_unavailable" }` on editors older than Unity 6.
**Notes:** `MainThreadRequired = true`.
### `recompile`
Force a script recompile (works while unfocused/minimized). Poll recompile_status for completion.
No parameters.
**Returns:** `object`
**Notes:** `MainThreadRequired = true`. Async — poll `recompile_status` until status is `completed` or `up_to_date`. A successful compile triggers a domain reload, so the triggering request cannot stay open.
### `recompile_status`
Get the status of the last recompile: idle | triggered | compiling | completed | up_to_date.
No parameters.
**Returns:** `string`
**Notes:** `MainThreadRequired = false`.
### `list_tests`
List all available tests (EditMode and/or PlayMode) without running them.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `mode` | no | `all` | Test mode: all, editor, playmode (default: all) |
**Returns:** `TestListResponse`
**Notes:** `MainThreadRequired = true`.
### `run_tests`
Execute Unity tests with filtering options.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `mode` | no | `all` | Test mode: all, editor, playmode (default: all) |
| `filter` | no | `` | Test name filter pattern (case-insensitive partial match) |
| `filter_type` | no | `testName` | Filter type: testName, assembly, category (default: testName) |
| `include_explicit` | no | `false` | Include tests marked with [Explicit] attribute |
| `async_tests` | no | `false` | Run asynchronously - return immediately, poll /test-status for results |
| `timeout` | no | `300` | Test execution timeout in seconds (default: 300) |
**Returns:** `TestExecutionResponse`
**Notes:** `MainThreadRequired = true`. Synchronous by default; with `async_tests=true` it returns immediately — poll `test_status` for results.
### `test_status`
Get status of running async test execution.
No parameters.
**Returns:** `string`
**Notes:** `MainThreadRequired = false`.
### `cancel_tests`
Cancel running test execution.
No parameters.
**Returns:** `object`
**Notes:** `MainThreadRequired = true`.
See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md).
@@ -0,0 +1,58 @@
# Capture commands
Commands that render a camera, the Scene View, or a UI Toolkit element to a PNG and return it base64-encoded so an agent can "see" the editor (or a running player) without a display.
### `capture_game_view`
Render a camera to a PNG. Returns it inline as base64, unless `save_path` is set — then the result is **path-only** (no base64) so agent tool results stay small; pass `include_inline_image=true` to get both.
Parameter interactions: `include_inline_image` is only meaningful together with `save_path` (without one, the inline image is always returned). `max_resolution` only applies when an inline image is actually returned — i.e. no `save_path`, or `save_path` + `include_inline_image=true`; it has **no effect** when `save_path` is set without `include_inline_image=true`.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `width` | no | `1280` | Output width in px (default 1280; capped 4096). |
| `height` | no | `720` | Output height in px (default 720; capped 4096). |
| `camera` | no | `` | Optional camera name; defaults to Camera.main, else the first enabled camera. |
| `save_path` | no | `` | 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`. |
| `include_inline_image` | no | `false` | Also return the image inline as base64 when `save_path` is set. |
| `max_resolution` | no | `0` | Cap on the inline image's longest edge (e.g. 512), preserving aspect ratio. No effect when `save_path` is set without `include_inline_image=true`. The `save_path` file keeps the requested resolution; a downscaled inline copy is reported via `inlineWidth`/`inlineHeight`. |
**Returns:** `CaptureResult`
### `capture_scene_view`
Render the active Scene View to a PNG. Same result contract and parameter interactions as `capture_game_view`: inline base64, or path-only when `save_path` is set (opt back in with `include_inline_image=true`; `max_resolution` has no effect when `save_path` is set without it).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `width` | no | `1280` | Output width in px (default 1280; capped 4096). |
| `height` | no | `720` | Output height in px (default 720; capped 4096). |
| `save_path` | no | `` | 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`. |
| `include_inline_image` | no | `false` | Also return the image inline as base64 when `save_path` is set. |
| `max_resolution` | no | `0` | Cap on the inline image's longest edge (e.g. 512), preserving aspect ratio. No effect when `save_path` is set without `include_inline_image=true`. The `save_path` file keeps the requested resolution; a downscaled inline copy is reported via `inlineWidth`/`inlineHeight`. |
**Returns:** `CaptureResult`
### `capture_editor_element`
Capture a UI Toolkit VisualElement (by selector) from an EditorWindow to a PNG; returns path + base64.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `window` | yes | `` | EditorWindow type name (e.g. InspectorWindow) or window title to capture from. |
| `selector` | yes | `` | Element selector: '#name', '.class', a type name (e.g. Button), descendant (space) / child ('>') chains, optional pseudo-states (:checked,:hover,:focus,:active,:enabled,:disabled,:not(...)). |
| `output` | no | `` | Output PNG path (absolute, or relative to the project root). Defaults to a timestamped file under <project>/Temp/pipeline-screenshots/. |
**Returns:** `CaptureElementResponse`
**Notes:** `MainThreadRequired = true`. Unity 6000.7+ only.
### `capture_runtime_element`
Capture a UI Toolkit VisualElement (by selector) from a live runtime panel (UIDocument or PanelRenderer) to a PNG; returns path + base64.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `panel` | no | `` | Name of the target panel: matches the PanelSettings asset name or the host GameObject name (UIDocument or PanelRenderer). Optional when exactly one panel exists. |
| `selector` | yes | `` | Element selector: '#name', '.class', a type name (e.g. Button), descendant (space) / child ('>') chains, optional pseudo-states (:checked,:hover,:focus,:active,:enabled,:disabled,:not(...)). |
| `output` | no | `` | Output PNG path (absolute, or relative to Application.persistentDataPath). Defaults to a timestamped file under Application.persistentDataPath. |
**Returns:** `CaptureElementResponse`
**Notes:** `MainThreadRequired = true`, `RuntimeOnly`. Unity 6000.7+ only.
See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md).
@@ -0,0 +1,115 @@
# Editor lifecycle & observability commands
Commands to control Editor play mode, focus, and menus, and to observe the Editor's state, console, and performance.
### `editor_play`
Enter Unity Editor play mode.
No parameters.
**Returns:** `string`
### `editor_stop`
Exit Unity Editor play mode.
No parameters.
**Returns:** `string`
### `editor_pause`
Pause Unity Editor play mode.
No parameters.
**Returns:** `string`
### `editor_status`
Get detailed Unity Editor status and state information.
No parameters.
**Returns:** `StatusResponse`
### `editor_focus`
Bring the Unity Editor window to the foreground.
No parameters.
**Returns:** `string`
**Notes:** `MainThreadRequired = true`.
### `menu`
Execute an Editor menu item by path, or list available items when no path is given.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `path` | no | `` | Menu item path to execute, e.g. "Assets/Reimport All". Omit to list available menu items. |
**Returns:** `MenuResponse`
**Notes:** `MainThreadRequired = true`.
### `screenshot`
Capture the Scene or Game view as a PNG and return its file path.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `view` | no | `game` | Which view to capture: 'game' (default) or 'scene' |
| `output` | no | `` | Output PNG path (absolute, or relative to the project root). Defaults to a timestamped file under <project>/Temp/pipeline-screenshots/. |
| `width` | no | `0` | Output width in pixels. 0 (default) uses the view camera's current width. |
| `height` | no | `0` | Output height in pixels. 0 (default) uses the view camera's current height. |
**Returns:** `ScreenshotResponse`
**Notes:** `MainThreadRequired = true`.
### `set_autotick`
Keep the editor ticking while unfocused by forcing EditorApplication.SignalTick at a throttled rate.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `enable` | no | `true` | Enable (true) or disable (false) auto-tick mode |
| `interval_ms` | no | `16` | Minimum milliseconds between forced ticks. 0 = every update (max rate, pegs a CPU core). Default 16 (~60Hz). |
**Returns:** `string`
**Notes:** `MainThreadRequired = true`. State is static and resets on domain reload (turns itself off after a recompile).
### `get_console_logs`
Read recently captured Editor console logs (structured).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `severity` | no | `all` | Filter: all | log | warning | error. 'all' = every entry; 'log' = Log only; 'warning' = Warning only; 'error' = Error/Exception/Assert only. |
| `limit` | no | `100` | Max entries to return (most-recent first), capped at 1000. |
**Returns:** `object`
### `clear_console`
Clear the captured log buffer and the Unity Editor console.
No parameters.
**Returns:** `object`
### `get_performance_stats`
Read render, memory, and frame-timing stats (structured, read-only).
No parameters.
**Returns:** `PerformanceStats`
### `get_authoring_root`
Get the base folder (under Assets/) that bare authoring paths resolve against.
No parameters.
**Returns:** `object`
### `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.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `root` | yes | `` | Project-relative folder under Assets/, e.g. Assets/AgentWork. Use 'Assets' to allow the whole project. |
**Returns:** `object`
See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md).
@@ -0,0 +1,169 @@
# GameObject & component commands
Commands for creating GameObjects, querying the scene hierarchy, mutating the core GameObject/Transform surface, and adding/removing components and editing their serialized properties. Scene mutations are wrapped in Unity's Undo system (so a multi-step action reverts as one collapsible step) and are blocked during Play mode. Objects are referenced by an `ObjectRef` handle (globalId/path/guid/instanceId/hierarchyPath), and results come back as `AuthoringResult` identities you can chain into a follow-up call.
### `create_gameobject`
Create an empty GameObject or a built-in primitive (cube/sphere/capsule/cylinder/plane/quad) in the active scene.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `name` | no | `` | Name for the new GameObject. Defaults to 'GameObject' (or the primitive name). |
| `primitive` | no | `` | Optional primitive type: cube, sphere, capsule, cylinder, plane, quad. Omit for an empty GameObject. |
| `parent` | no | `` | Optional parent handle (globalId/path/guid/instanceId/hierarchyPath). The new object becomes a child of it. |
**Returns:** `AuthoringResult`
**Notes:** Undo-able; blocked during Play mode.
### `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.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `name` | no | `` | Base name. With count>1 and no explicit names, objects are suffixed Name1..NameN. |
| `primitive` | no | `` | Optional primitive type: cube, sphere, capsule, cylinder, plane, quad. Omit for empty GameObjects. |
| `parent` | no | `` | Optional parent handle. Every created object becomes a child of it. |
| `count` | no | `1` | How many GameObjects to create. Default 1. |
| `positions` | no | `` | Local positions, one [x,y,z] per object. Length must equal count when supplied. |
| `rotations` | no | `` | Local Euler rotations (degrees), one [x,y,z] per object. Length must equal count when supplied. |
| `scales` | no | `` | Local scales, one [x,y,z] per object. Length must equal count when supplied. |
**Returns:** `CreateGameObjectsResult`
**Notes:** Undo-able (the whole batch reverts as one step); blocked during Play mode.
### `find_gameobjects`
Find GameObjects in loaded scenes by name, tag, component type, and/or hierarchy path (filters are combined). Returns structured identities.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `name` | no | `` | Exact name to match. |
| `tag` | no | `` | Tag to match (e.g. 'Player'). |
| `type` | no | `` | Component type name to match (e.g. 'Rigidbody', 'UnityEngine.Camera'). |
| `hierarchy_path` | no | `` | Exact hierarchy path to match (e.g. '/Root/Child'). |
| `include_inactive` | no | `true` | Include inactive GameObjects. Default true. |
**Returns:** `FindGameObjectsResult`
### `set_transform`
Set a GameObject's local position/rotation(euler)/scale. Omitted channels are left unchanged.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `target` | yes | | Handle of the GameObject to modify. |
| `position` | no | `` | Local position as [x,y,z]. |
| `rotation` | no | `` | Local rotation as Euler angles [x,y,z] in degrees. |
| `scale` | no | `` | Local scale as [x,y,z]. |
**Returns:** `AuthoringResult`
**Notes:** Undo-able; blocked during Play mode.
### `set_parent`
Reparent a GameObject under a new parent, or detach it to scene root when no parent is given.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `target` | yes | | Handle of the GameObject to reparent. |
| `parent` | no | `` | Handle of the new parent. Omit (or empty) to move the object to the scene root. |
| `world_position_stays` | no | `true` | Keep the object's world position when reparenting. Default true. |
**Returns:** `AuthoringResult`
**Notes:** Undo-able; blocked during Play mode.
### `set_active`
Set a GameObject's active self-state (activeSelf).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `target` | yes | | Handle of the GameObject. |
| `active` | yes | | Desired active state. |
**Returns:** `AuthoringResult`
**Notes:** Undo-able; blocked during Play mode.
### `set_tag`
Set a GameObject's tag (the tag must already exist in the project).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `target` | yes | | Handle of the GameObject. |
| `tag` | yes | | Tag to assign (must exist in the Tag Manager). |
**Returns:** `AuthoringResult`
**Notes:** Undo-able; blocked during Play mode.
### `set_layer`
Set a GameObject's layer by name or numeric index (0-31).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `target` | yes | | Handle of the GameObject. |
| `layer` | yes | | Layer name (e.g. 'UI') or numeric index 0-31. |
**Returns:** `AuthoringResult`
**Notes:** Undo-able; blocked during Play mode.
### `rename_gameobject`
Rename a GameObject.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `target` | yes | | Handle of the GameObject. |
| `name` | yes | | New name. |
**Returns:** `AuthoringResult`
**Notes:** Undo-able; blocked during Play mode.
### `delete_gameobject`
Delete a GameObject from the scene (reversible via Undo).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `target` | yes | | Handle of the GameObject to delete. |
**Returns:** `AuthoringResult`
**Notes:** Undo-able; blocked during Play mode. The result describes the object's identity captured before destruction.
### `add_component`
Add a component (by type name) to a GameObject.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `target` | yes | | Handle of the GameObject. |
| `type` | yes | | Component type name (e.g. 'Rigidbody' or 'UnityEngine.Camera'). |
**Returns:** `AuthoringResult`
**Notes:** Undo-able; blocked during Play mode.
### `remove_component`
Remove a component from a GameObject. Provide either a component handle (target) or a GameObject handle (target) plus a type name.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `target` | yes | | Handle of the component to remove, OR of the GameObject when 'type' is given. |
| `type` | no | `` | Component type name to remove from the target GameObject (omit when 'target' already points at a component). |
**Returns:** `AuthoringResult`
**Notes:** Undo-able; blocked during Play mode.
### `get_component_properties`
Get a component's serialized properties as a JSON map. Address the component by handle, or by GameObject handle + type.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `target` | yes | | Handle of the component, OR of the GameObject when 'type' is given. |
| `type` | no | `` | Component type name on the target GameObject (omit when 'target' is a component handle). |
**Returns:** `ComponentPropertiesResult`
### `set_component_properties`
Set serialized properties on a component (one Undo step). 'properties' maps property name -> value; object references accept an ObjectRef handle.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `target` | yes | | Handle of the component, OR of the GameObject when 'type' is given. |
| `properties` | yes | | Map of serialized property name to value. Vectors/colors are arrays; object refs are handle objects. |
| `type` | no | `` | Component type name on the target GameObject (omit when 'target' is a component handle). |
**Returns:** `ComponentPropertiesResult`
**Notes:** Undo-able (one Undo step); blocked during Play mode. An unknown property name fails the whole batch (no partial apply).
See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md).
@@ -0,0 +1,55 @@
# Material & shader commands
Read and edit material shader properties, and discover/introspect shaders so an agent can pick a valid shader name and property set.
## Materials
### `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).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `material` | yes | `` | Reference to the .mat asset (or a loaded material) to read (path / guid / globalId / instanceId). |
**Returns:** `MaterialPropertiesResult`
### `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[]`.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `material` | yes | `` | Reference to the .mat asset (or a loaded material) to edit (path / guid / globalId / instanceId). |
| `shader` | no | `` | 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. |
| `properties` | no | `` | 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. |
| `renderQueue` | no | `` | Explicit render queue, or -1 to inherit from the shader. Omit to leave unchanged. |
| `enableKeywords` | no | `` | Shader keywords to enable (e.g. _NORMALMAP, _EMISSION). |
| `disableKeywords` | no | `` | Shader keywords to disable. |
| `confirm` | no | `false` | Reserved for parity; editing an existing material is non-destructive and undoable, so it is not required. |
| `dry_run` | no | `false` | If true, validate the shader, resolve property names and texture refs, and report applied[]/unknown[] without writing anything. |
**Returns:** `SetMaterialPropertiesResult`
## Shaders
### `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 }]`.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `filter` | no | `` | Case-insensitive substring matched against the shader name (e.g. "URP", "Lit"). |
| `includeBuiltin` | no | `true` | Include built-in/engine shaders (those with no project asset path). |
| `limit` | no | `200` | Maximum number of shaders to return. |
**Returns:** `object` (list of `ShaderInfo`)
### `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).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `shader` | no | `` | Shader name (e.g. "Universal Render Pipeline/Lit"). Provide this OR 'material'. |
| `material` | no | `` | Reference to a material to read the shader from instead of naming it. Provide this OR 'shader'. |
**Returns:** `ShaderPropertiesResult`
See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md).
@@ -0,0 +1,32 @@
# Navigation & selection commands
Commands to read and drive the Editor selection, and to run Unity Search queries. All commands are read-only or non-destructive and run on the main thread.
### `get_selection`
Read the current Editor selection as structured object identities.
No parameters.
**Returns:** `SelectionResult`
### `set_selection`
Set the Editor selection to the given assets/scene objects.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `instance_ids` | no | `` | Scene/loaded object instance IDs to select. |
| `paths` | no | `` | Asset paths to select (e.g. Assets/Foo.prefab). |
**Returns:** `SelectionResult`
### `search`
Run a Unity Search query and return structured results.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `query` | yes | `` | Unity Search query string, e.g. 't:Material', 'p: my asset', 'h: Main Camera'. |
| `limit` | no | `50` | Max results to return (capped 200). |
**Returns:** `SearchResult`
See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md).
@@ -0,0 +1,70 @@
# Package Manager commands
These commands query and modify the project's UPM package set (list, search, add, remove, resolve, status) over `UnityEditor.PackageManager`. Read-only queries block until the registry responds and return synchronously; mutating commands (`package_add`/`package_remove`) require `confirm=true`, support `dry_run` previews, and are async by default (poll `package_status`) with an optional `wait=true` to block until done.
### `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.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `scope` | no | `installed` | Which packages to list: installed (default) | available | all. |
| `include_indirect` | no | `true` | Include indirect (transitive) installed dependencies (applies to scope=installed/all). |
| `offline` | no | `false` | For available/all: query the local cache instead of the registry. |
**Returns:** `object`
**Notes:** `MainThreadRequired = false`. Synchronous — `available`/`all` start a registry query and block until it completes (up to a 25s timeout); `installed` reads the resolved set inline on the main thread.
### `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).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `query` | no | `` | Package name to search for. Omit/empty to list all available packages. |
| `offline` | no | `false` | Search the local cache only. |
**Returns:** `object`
**Notes:** `MainThreadRequired = false`. Synchronous — blocks until the registry query completes (up to a 25s timeout). Each result is flagged with whether it is currently installed.
### `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.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `identifier` | yes | `` | Package to add: 'com.unity.foo@1.2.3', a git URL, or 'file:../Path'. |
| `confirm` | no | `false` | Apply the change. Without it the call is refused. |
| `dry_run` | no | `false` | Preview the change without applying it. |
| `wait` | no | `false` | Block until the operation completes and return the result (synchronous). Default: return immediately and poll package_status. |
**Returns:** `object`
**Notes:** `MainThreadRequired = false`. Mutating — requires `confirm=true`; `dry_run=true` returns a preview without applying. Async by default (returns `in_progress`; poll `package_status`); with `wait=true` it blocks until completion (up to a 25s timeout). A successful add triggers a recompile/domain reload — poll `recompile_status`. Returns `busy` if another package operation is already in progress.
### `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.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `name` | yes | `` | Package name to remove (e.g. com.unity.foo). |
| `confirm` | no | `false` | Apply the change. Without it the call is refused. |
| `dry_run` | no | `false` | Preview the change without applying it. |
| `wait` | no | `false` | Block until the operation completes and return the result (synchronous). Default: return immediately and poll package_status. |
**Returns:** `object`
**Notes:** `MainThreadRequired = false`. Mutating — requires `confirm=true`; `dry_run=true` returns a preview without applying. Async by default (returns `in_progress`; poll `package_status`); with `wait=true` it blocks until completion (up to a 25s timeout). A successful remove triggers a recompile/domain reload — poll `recompile_status`; the reload can drop a synchronous reply, so `package_status` confirms the outcome. Returns `busy` if another package operation is already in progress.
### `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.
No parameters.
**Returns:** `object`
**Notes:** `MainThreadRequired = true`. Fire-and-forget — `Client.Resolve()` schedules a resolve on a later tick, so the response returns before any reload. May trigger a recompile/domain reload — poll `recompile_status`. Its outcome is recorded in the status file, so `package_status` validates it.
### `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.
No parameters.
**Returns:** `string`
**Notes:** `MainThreadRequired = false`. Reads a Temp status file that survives the domain reload an add/remove/resolve triggers, so a lost synchronous reply or an interrupted async op is recoverable by polling. Returns `{"status":"idle"}` when no operation has run.
See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md).
@@ -0,0 +1,84 @@
# Prefab commands
Commands covering the prefab lifecycle an agent needs to build content procedurally: saving a configured GameObject as a prefab asset, instantiating it into a scene, deriving a variant, applying/reverting instance overrides, unpacking, and editing prefab contents through the prefab stage. Asset paths are sandboxed to the authoring root; scene-side mutations are Undo-able, but prefab asset writes are AssetDatabase operations and are not part of Unity's Undo system.
### `create_prefab`
Save a GameObject as a prefab asset at a project path; the source becomes a connected instance.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `source` | yes | | Reference to the source GameObject to save as a prefab (globalId/path/guid/instanceId/hierarchyPath). |
| `path` | yes | | Prefab asset path relative to the authoring root (the Assets/ prefix is optional and the .prefab extension is added if missing). e.g. Prefabs/Enemy or Prefabs/Enemy.prefab |
**Returns:** `AuthoringResult`
**Notes:** Scene-side effects are Undo-able; the prefab asset write is not undoable.
### `instantiate_prefab`
Instantiate a prefab asset into a loaded scene and return the created instance.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `prefab` | yes | | Reference to the prefab asset to instantiate (path/guid/globalId). |
| `scene_path` | no | `` | Optional path of a loaded scene to instantiate into; defaults to the active scene. |
| `name` | no | `` | Optional name for the created instance; defaults to the prefab name. |
**Returns:** `AuthoringResult`
**Notes:** Undo-able.
### `create_prefab_variant`
Create a prefab variant asset that inherits from a base prefab.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `base` | yes | | Reference to the base prefab asset (path/guid/globalId). |
| `path` | yes | | Variant prefab asset path relative to the authoring root (.prefab added if missing). |
**Returns:** `AuthoringResult`
**Notes:** Prefab asset write is not undoable.
### `apply_prefab_overrides`
Apply a prefab instance's overrides back to its source prefab asset.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `instance` | yes | | Reference to a prefab instance GameObject in a scene (instanceId/hierarchyPath/globalId). |
**Returns:** `AuthoringResult`
**Notes:** Scene-side effects are Undo-able; the asset write is not. The instance's source prefab asset must live under the authoring root.
### `revert_prefab_overrides`
Revert a prefab instance's overrides so it matches its source prefab asset.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `instance` | yes | | Reference to a prefab instance GameObject in a scene (instanceId/hierarchyPath/globalId). |
**Returns:** `AuthoringResult`
**Notes:** Undo-able.
### `unpack_prefab`
Unpack a prefab instance into plain GameObjects (outermost level or completely).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `instance` | yes | | Reference to a prefab instance GameObject in a scene (instanceId/hierarchyPath/globalId). |
| `completely` | no | `false` | If true, unpack all nested prefab levels (Completely); if false, only the outermost level (OutermostRoot). |
**Returns:** `AuthoringResult`
**Notes:** Undo-able.
### `save_prefab_contents`
Open a prefab asset in an isolated prefab stage, apply a declarative edit, and save it back (nested-prefab safe).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `prefab` | yes | | Reference to the prefab asset to edit (path/guid/globalId). |
| `rename_child` | no | `` | Optional child name (relative path under the root, e.g. 'Body/Head') to rename. |
| `new_name` | no | `` | New name for the child identified by rename_child. |
| `set_active_child` | no | `` | Optional child name (relative path under the root) whose active state to set. |
| `active` | no | `true` | Active state to apply when set_active_child is provided. |
**Returns:** `AuthoringResult`
**Notes:** Prefab asset write (prefab-stage save) is not part of Unity's Undo system. The prefab asset must live under the authoring root.
See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md).
@@ -0,0 +1,239 @@
# Project settings commands
Read and change a representative slice of Unity's project-wide settings (Audio, Graphics, Input, Physics, Player, Quality, Tags & Layers, Time). `get_*` commands read a group's current values into a `ProjectSettingsResponse`; `set_*` commands mutate them through the shared `confirm`/`dry_run` convention, so `confirm=true` is required to apply and `dry_run=true` previews the change without applying it. Every command in this group is `MainThreadRequired = true`. On a `set_*`, omitted fields are left unchanged, and the group is re-read after the write so the response reflects the resulting state. Settings writes are not undoable via Ctrl+Z.
### `get_audio_settings`
Read project Audio settings (volume, rolloff scale, doppler factor).
No parameters.
**Returns:** `ProjectSettingsResponse`
**Notes:** `MainThreadRequired = true`.
### `set_audio_settings`
Change project Audio settings. Requires confirm=true; use dry_run to preview.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `settings` | no | `` | Fields to change; omitted fields are left unchanged (fields below). |
| `confirm` | no | `false` | Apply the change. Without it the call is refused. |
| `dry_run` | no | `false` | Preview the change without applying it. |
`settings` fields:
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `volume` | no | `` | Global audio volume (0..1). |
| `rolloffScale` | no | `` | Global rolloff scale. |
| `dopplerFactor` | no | `` | Global doppler factor. |
**Returns:** `ProjectSettingsResponse`
**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`.
### `get_graphics_settings`
Read GraphicsSettings (default render pipeline).
No parameters.
**Returns:** `ProjectSettingsResponse`
**Notes:** `MainThreadRequired = true`.
### `set_graphics_settings`
Set the default render pipeline asset. Requires confirm=true; use dry_run to preview.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `settings` | no | `` | Fields to change; omitted fields are left unchanged (fields below). |
| `confirm` | no | `false` | Apply the change. Without it the call is refused. |
| `dry_run` | no | `false` | Preview the change without applying it. |
`settings` fields:
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `renderPipelineAsset` | no | `` | Reference (path / guid / globalId) to a RenderPipelineAsset to set as the default. Pass an empty reference (`{}`) to select the built-in pipeline; omit to leave unchanged. |
**Returns:** `ProjectSettingsResponse`
**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`.
### `get_input_settings`
Read the legacy Input Manager axes (names and count).
No parameters.
**Returns:** `ProjectSettingsResponse`
**Notes:** `MainThreadRequired = true`.
### `set_input_settings`
Tune a legacy Input Manager axis (sensitivity/gravity/dead) by name. Requires confirm=true; use dry_run to preview.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `settings` | no | `` | Axis change. 'axis' selects the axis by name; omitted numeric fields are left unchanged (fields below). |
| `confirm` | no | `false` | Apply the change. Without it the call is refused. |
| `dry_run` | no | `false` | Preview the change without applying it. |
`settings` fields:
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `axis` | yes | | Name of the axis to modify (e.g. 'Horizontal'). |
| `sensitivity` | no | `` | New sensitivity. |
| `gravity` | no | `` | New gravity. |
| `dead` | no | `` | New dead-zone size. |
**Returns:** `ProjectSettingsResponse`
**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`.
### `get_physics_settings`
Read Physics settings (gravity, solver iterations, bounce threshold).
No parameters.
**Returns:** `ProjectSettingsResponse`
**Notes:** `MainThreadRequired = true`.
### `set_physics_settings`
Change Physics settings. Requires confirm=true; use dry_run to preview.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `settings` | no | `` | Fields to change; omitted fields are left unchanged (fields below). |
| `confirm` | no | `false` | Apply the change. Without it the call is refused. |
| `dry_run` | no | `false` | Preview the change without applying it. |
`settings` fields:
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `gravityX` | no | `` | Gravity X component. |
| `gravityY` | no | `` | Gravity Y component (e.g. -9.81). |
| `gravityZ` | no | `` | Gravity Z component. |
| `defaultSolverIterations` | no | `` | Default solver iteration count. |
| `bounceThreshold` | no | `` | Bounce threshold velocity. |
**Returns:** `ProjectSettingsResponse`
**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`.
### `get_player_settings`
Read PlayerSettings (company/product/version, scripting backend, API level).
No parameters.
**Returns:** `ProjectSettingsResponse`
**Notes:** `MainThreadRequired = true`.
### `set_player_settings`
Change PlayerSettings. Requires confirm=true; use dry_run to preview. Scripting backend / API level changes trigger a domain reload.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `settings` | no | `` | Fields to change; omitted fields are left unchanged (fields below). |
| `confirm` | no | `false` | Apply the change. Without it the call is refused. |
| `dry_run` | no | `false` | Preview the change without applying it. |
`settings` fields:
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `companyName` | no | `` | Company name. |
| `productName` | no | `` | Product name. |
| `bundleVersion` | no | `` | Bundle/application version string. |
| `scriptingBackend` | no | `` | Scripting backend (e.g. Mono2x, IL2CPP). Triggers a domain reload. |
| `apiCompatibilityLevel` | no | `` | API compatibility level (e.g. NET_Standard_2_0, NET_Unity_4_8). Triggers a domain reload. |
**Returns:** `ProjectSettingsResponse`
**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`. Changing `scriptingBackend` or `apiCompatibilityLevel` triggers a domain reload (`requiresDomainReload`); scripting-backend / API-level changes are read and written against the active build target.
### `get_quality_settings`
Read QualitySettings (current level, level names, vSync, anti-aliasing).
No parameters.
**Returns:** `ProjectSettingsResponse`
**Notes:** `MainThreadRequired = true`.
### `set_quality_settings`
Change QualitySettings. Requires confirm=true; use dry_run to preview.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `settings` | no | `` | Fields to change; omitted fields are left unchanged (fields below). |
| `confirm` | no | `false` | Apply the change. Without it the call is refused. |
| `dry_run` | no | `false` | Preview the change without applying it. |
`settings` fields:
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `level` | no | `` | Quality level index (see levelNames from get_quality_settings). |
| `vSyncCount` | no | `` | VSync count (0 = off, 1, 2). |
| `antiAliasing` | no | `` | MSAA sample count (0, 2, 4, 8). |
**Returns:** `ProjectSettingsResponse`
**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`.
### `get_tags_layers`
Read the project's tags and (named) layers.
No parameters.
**Returns:** `ProjectSettingsResponse`
**Notes:** `MainThreadRequired = true`.
### `set_tags_layers`
Add/remove tags and assign user layer names (index 8-31). Requires confirm=true; use dry_run to preview.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `settings` | no | `` | Tag/layer changes to make (fields below). |
| `confirm` | no | `false` | Apply the change. Without it the call is refused. |
| `dry_run` | no | `false` | Preview the change without applying it. |
`settings` fields:
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `addTags` | no | `` | Tag names to add. |
| `removeTags` | no | `` | Tag names to remove. |
| `setLayers` | no | `` | User layer assignments (index 8-31); array of layer-assignment objects (fields below). |
`setLayers[]` (layer-assignment) fields:
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `index` | yes | | Layer index (8-31 for user layers). |
| `name` | yes | | Layer name (empty string clears the slot). |
**Returns:** `ProjectSettingsResponse`
**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`. User layers are indices 8-31 (0-7 are reserved).
### `get_time_settings`
Read Time settings (fixedDeltaTime, maximumDeltaTime, timeScale).
No parameters.
**Returns:** `ProjectSettingsResponse`
**Notes:** `MainThreadRequired = true`.
### `set_time_settings`
Change Time settings. Requires confirm=true; use dry_run to preview.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `settings` | no | `` | Fields to change; omitted fields are left unchanged (fields below). |
| `confirm` | no | `false` | Apply the change. Without it the call is refused. |
| `dry_run` | no | `false` | Preview the change without applying it. |
`settings` fields:
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `fixedDeltaTime` | no | `` | Fixed timestep in seconds (e.g. 0.02). |
| `maximumDeltaTime` | no | `` | Maximum allowed timestep in seconds. |
| `timeScale` | no | `` | Time scale (1 = real-time). |
**Returns:** `ProjectSettingsResponse`
**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`.
See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md).
@@ -0,0 +1,162 @@
# Runtime commands
Commands served by the Player / dev-build server. Many require a Development build running `RuntimePipelineManager` — see [Runtime setup](../runtime-setup.md). Commands marked `RuntimeOnly` are only registered in a player build.
### `runtime_status`
Get comprehensive runtime application status.
No parameters.
**Returns:** `RuntimeStatusResponse`
**Notes:** `MainThreadRequired = true`, `RuntimeOnly`.
### `quit`
Gracefully quit the Unity application.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `exitCode` | no | `0` | Exit code for the application |
**Returns:** `string`
**Notes:** `MainThreadRequired = true`, `RuntimeOnly`.
### `set_target_framerate`
Set the target frame rate for the application.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `frameRate` | yes | `` | Target frame rate (-1 for platform default, 0 for unlimited) |
**Returns:** `string`
**Notes:** `MainThreadRequired = true`, `RuntimeOnly`.
### `set_timescale`
Set the time scale for the application.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `scale` | yes | `` | Time scale multiplier (0.0 to pause, 1.0 for normal speed) |
**Returns:** `string`
**Notes:** `MainThreadRequired = true`, `RuntimeOnly`.
### `simulate_key`
Simulate a keyboard key event (Input System). Drives the running app.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `key` | yes | `` | Input System Key name, e.g. Space, W, Enter, LeftArrow. |
| `action` | no | `press` | down \| up \| press (down+up). |
**Returns:** `InputSimulationResponse`
**Notes:** `MainThreadRequired = true`, `RuntimeOnly`. Requires the Input System package (`ENABLE_INPUT_SYSTEM`); reports unavailable otherwise.
### `simulate_pointer`
Simulate a mouse/pointer event at screen coordinates (Input System).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `x` | yes | `` | Screen X in pixels (origin bottom-left). |
| `y` | yes | `` | Screen Y in pixels (origin bottom-left). |
| `action` | no | `click` | move \| down \| up \| click (down+up). |
| `button` | no | `left` | left \| right \| middle. |
**Returns:** `InputSimulationResponse`
**Notes:** `MainThreadRequired = true`, `RuntimeOnly`. Requires the Input System package (`ENABLE_INPUT_SYSTEM`); reports unavailable otherwise.
### `log`
Write a message to Unity console.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `message` | yes | `` | Message to log to console |
| `level` | no | `info` | Log level: info, warning, error |
**Returns:** `string`
**Notes:** `MainThreadRequired = true`, `RuntimeOnly`.
### `console`
Get captured Unity console output (Editor or Player; supports tail, level filtering, and follow via a cursor).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `tail` | no | `100` | Maximum number of most-recent entries to return |
| `level` | no | `log` | Minimum severity to include: log | warn | error |
| `since` | no | `-1` | Cursor: only return entries newer than this seq. Use the 'cursor' from a previous response to follow. |
**Returns:** `ConsoleLogResponse`
**Notes:** `MainThreadRequired = false`. Available in both the Editor and player builds.
### `eval`
Evaluate C# code dynamically using Roslyn compiler.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `code` | yes | `` | C# code to evaluate |
| `timeout` | no | `5000` | Timeout in milliseconds |
**Returns:** `EvalResponse`
**Notes:** `MainThreadRequired = true`. Available on both editor and runtime.
### `eval_file`
Evaluate C# code read from a `.cs` file on disk. A convenience alternative to `eval` for code
too long to pass inline — edit the file, then evaluate it. The resolved source is run through the
same evaluation path as `eval`.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `file` | yes | `` | Path to a `.cs` file to evaluate |
| `timeout` | no | `5000` | Timeout in milliseconds |
The `file` is read on the Unity side (relative paths resolve against the Unity process working
directory) and must end in `.cs`. A missing file, a non-`.cs` extension, or an empty file returns
a `Bad Request`.
**Returns:** `EvalResponse`
**Notes:** `MainThreadRequired = true`. Available on both editor and runtime.
### `reload_file`
Compile and apply in-place [HotReload] edits from a source file.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `filename` | yes | `` | Source file containing [HotReload] methods (e.g. Assets/Scripts/Player.cs) |
| `timeout` | no | `30000` | Compilation timeout in milliseconds |
| `assemblyDir` | no | `` | Directory to save compiled assemblies to disk (optional, default is in-memory only) |
| `pdb` | no | `false` | Emit debug symbols (portable PDB) mapped to the original source so breakpoints bind in your editor. Compiles unoptimized. |
**Returns:** `HotReloadResponse`
**Notes:** `MainThreadRequired = true`. See [Hot reload](../hot-reload.md).
### `reload_file_override`
Compile and apply hot reload file changes immediately.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `filename` | yes | `` | Hot reload source file to compile (e.g. PlayerTweaks.cs) |
| `timeout` | no | `30000` | Compilation timeout in milliseconds |
| `assemblyDir` | no | `` | Directory to save compiled assemblies to disk (optional, default is in-memory only) |
**Returns:** `HotReloadResponse`
**Notes:** `MainThreadRequired = true`. See [Hot reload](../hot-reload.md).
### `hotreload_status`
Show current hot reload registry status and statistics.
No parameters.
**Returns:** `HotReloadResponse`
**Notes:** `MainThreadRequired = true`, `RuntimeOnly`.
### `cleanup_hotreload`
Remove old hot reload DLL versions and clear registry.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `assemblyDir` | yes | `` | Directory containing assemblies to cleanup |
| `force_domain_reload` | no | `true` | Force Unity domain reload after cleanup |
**Returns:** `HotReloadResponse`
**Notes:** `MainThreadRequired = true`, `RuntimeOnly`.
See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md).
@@ -0,0 +1,93 @@
# Scene commands
Commands for creating, opening, saving and inspecting scenes, controlling the active scene, snapshotting a scene's hierarchy, and managing the Build Settings scene list. Mutating scene operations are blocked while the editor is entering or in play mode (they fail with a recoverable error before touching any state); read-only commands are safe in play mode.
### `create_scene`
Create a new scene and save it to the given path under the authoring root.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `path` | yes | | Scene path relative to the authoring root (default Assets/); the Assets/ prefix and the .unity extension are optional. e.g. Scenes/Level1 |
| `additive` | no | `false` | Open the new scene additively alongside currently open scenes instead of replacing them. |
| `template` | no | `empty` | Initial contents: 'empty' (default) for a blank scene, or 'default' to seed a Main Camera + Directional Light matching Unity's built-in 3D template. |
**Returns:** `AuthoringResult`
**Notes:** Blocked during Play mode.
### `open_scene`
Open an existing scene from the given path.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `path` | yes | | Scene path relative to the authoring root (default Assets/); the Assets/ prefix and the .unity extension are optional. |
| `additive` | no | `false` | Open additively alongside currently open scenes instead of replacing them. |
**Returns:** `AuthoringResult`
**Notes:** Blocked during Play mode.
### `save_scene`
Save an open scene. Saves the active scene when no path is given.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `path` | no | `` | Path of the open scene to save (authoring-root relative; Assets/ prefix and .unity optional). Omit to save the active scene. |
**Returns:** `AuthoringResult`
**Notes:** Blocked during Play mode.
### `save_all`
Save all open scenes that have unsaved changes.
No parameters.
**Returns:** `object`
**Notes:** Blocked during Play mode.
### `list_open_scenes`
List all currently open scenes with their load/active/dirty state.
No parameters.
**Returns:** `object`
### `set_active_scene`
Set which open scene is the active scene (new objects are created in the active scene).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `path` | yes | | Path of an already-open scene to make active (authoring-root relative; Assets/ prefix and .unity optional). |
**Returns:** `AuthoringResult`
**Notes:** Blocked during Play mode.
### `get_scene_hierarchy`
Return the GameObject tree of an open scene (or the active scene). Each node carries instanceId + hierarchyPath usable by GameObject commands.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `path` | no | `` | Path of the open scene to snapshot (authoring-root relative; Assets/ prefix and .unity optional). Omit for the active scene. |
**Returns:** `SceneHierarchy`
### `add_scene_to_build`
Add a scene to the Build Settings scene list (idempotent). Optionally enable it.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `path` | yes | | Scene path to add (authoring-root relative; Assets/ prefix and .unity optional). |
| `enabled` | no | `true` | Whether the scene is enabled in the build list. |
**Returns:** `object`
**Notes:** Blocked during Play mode.
### `remove_scene_from_build`
Remove a scene from the Build Settings scene list (idempotent).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `path` | yes | | Scene path to remove (authoring-root relative; Assets/ prefix and .unity optional). |
**Returns:** `object`
**Notes:** Blocked during Play mode.
See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md).
@@ -0,0 +1,55 @@
# Script commands
Commands for creating C# script files, attaching MonoBehaviours to GameObjects, and reading/writing serialized fields. Writing a `.cs` file does not make its type available — Unity must import and compile it (a domain reload) first. The flow is: `create_script``recompile` → poll `recompile_status` (until completed/up_to_date) → `attach_script`.
### `create_script`
Create a new C# script (default base class MonoBehaviour) from a template under the authoring root. NOTE: the type does not exist until a recompile completes — to attach it, call recompile, poll recompile_status, then attach_script.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `name` | yes | | Class/file name without extension, e.g. PlayerController. Must be a valid C# identifier. |
| `path` | no | `` | Folder (relative to the authoring root; the Assets/ prefix is optional) to write the .cs into. Defaults to the authoring root. |
| `namespace` | no | `` | Optional namespace to wrap the class in. Omit for the global namespace. |
| `base_class` | no | `MonoBehaviour` | Base class to derive from. Defaults to MonoBehaviour. |
| `overwrite` | no | `false` | Overwrite the file if it already exists. Defaults to false (an existing file is an error). |
**Returns:** `AuthoringResult`
**Notes:** Does not trigger a recompile; the created type is not usable until a recompile completes (poll `recompile_status`).
### `attach_script`
Add a MonoBehaviour to a GameObject by its (compiled) type name OR by its script asset path. Provide exactly one of 'type' or 'script'. If the type isn't compiled yet, returns a recoverable error: recompile, poll recompile_status, then retry.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `target` | yes | | Reference to the GameObject to add the component to (globalId/path/guid/instanceId/hierarchyPath). |
| `type` | no | `` | Component type name to add, e.g. PlayerController or Game.Player.PlayerController. Must already be compiled. Mutually exclusive with 'script'. |
| `script` | no | `` | Script asset path, e.g. 'Assets/Pool/Scripts/CueShooter.cs'. The backing class is resolved via MonoScript.GetClass(), so the class name may differ from the filename. Mutually exclusive with 'type'. |
**Returns:** `AuthoringResult`
**Notes:** Undo-able. Provide exactly one of `type` or `script`. A not-yet-compiled type returns a recoverable error (recompile, poll `recompile_status`, then retry).
### `set_serialized_field`
Set a serialized field on a component/asset. Supports primitives, enums, Vector/Color/Rect/Bounds, object references (value = an ObjectRef: asset by guid/fileId/path or scene object by instanceId/hierarchyPath), and array elements via 'name.Array.data[i]' (or 'name.Array.size' to resize).
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `target` | yes | | Reference to the component or asset to modify (globalId/path/guid/instanceId/hierarchyPath). May be a GameObject when 'component' is given. |
| `field` | yes | | SerializedProperty path, e.g. 'speed', 'settings.speed', or 'waypoints.Array.data[0]'. |
| `value` | yes | | JSON value to assign. For object references pass an ObjectRef object (or null to clear). For enums pass the value name. |
| `component` | no | `` | Component type name on the target GameObject (e.g. 'Rigidbody'). Use when 'target' is a GameObject; omit when 'target' is already a component handle. |
**Returns:** `AuthoringResult`
**Notes:** Undo-able.
### `get_serialized_fields`
Read serialized fields of a component/asset. Returns each top-level field's name, type and value (object references are returned as re-usable handles). Pass 'field' to read a single SerializedProperty path.
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `target` | yes | | Reference to the component or asset to read (globalId/path/guid/instanceId/hierarchyPath). May be a GameObject when 'component' is given. |
| `field` | no | `` | Optional single SerializedProperty path to read (e.g. 'speed' or 'items.Array.data[0]'). Omit to read all top-level fields. |
| `component` | no | `` | Component type name on the target GameObject (e.g. 'Rigidbody'). Use when 'target' is a GameObject; omit when 'target' is already a component handle. |
**Returns:** `object`
See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md).
@@ -0,0 +1,178 @@
# Connectivity
How a client reaches a running Unity instance: the loopback-only HTTP servers, their port ranges, the port descriptor file used for discovery, and the bearer-token authentication every request must carry.
The rest of this page covers the implementation. If you just want to connect, start with the two sections below.
## Connecting to a running Editor
Connections go through the `unity` CLI. Run `unity command` with no command name to connect to a Unity instance and list its available commands:
```bash
# Auto-discover a Unity instance from the current directory
unity command
# Connect to a specific project
unity command --project-path /path/to/your/unity/project
```
Once connected, run any command by name, e.g. `unity command editor_status`.
## Connecting to a running Player (game)
To target a running development Player instead of the Editor, use `--runtime` (by process name) or `--runtime-path` (by the location of the runtime port file). These options go **after** `command` and **before** the command name:
```bash
# By Player process/executable name
unity command --runtime MyGame.exe runtime_status
# By the folder/bundle where the runtime port file lives
unity command --runtime-path "C:\Builds\MyGame" runtime_status # Windows: next to the .exe
unity command --runtime-path "/Users/me/Builds/MyGame.app" runtime_status # macOS: the .app bundle
```
> The Runtime server only runs in a **development Player build** with the runtime manager enabled — see [Runtime connection & setup](runtime-setup.md).
## Finding the port in the Unity logs
The [port descriptor file](#port-descriptor-file) is the primary discovery channel, but the server also reports itself to the Unity log when it starts and stops — useful when you can't read the descriptor file directly. Look for `Pipeline`-prefixed lines.
- **Runtime (Player)** — logs its port **and** the descriptor file location on start, and a line on stop:
```
Pipeline: Runtime server started successfully on port 7901
Pipeline: Runtime descriptor written to /path/to/MyGame/.unity-pipeline-runtime-port
Pipeline: Runtime server stopped
```
- **Editor** — logs its port when the server is (re)started from the **Pipeline ▸ Start Server** menu or re-opened by its watchdog (`Pipeline Server started on port 7801`). Its descriptor always lives at the fixed `Library/Pipeline/.unity-pipeline-port` path.
### Where the logs live
**Editor log** (`Editor.log`):
| OS | Path |
|----|------|
| Windows | `C:\Users\<user>\AppData\Local\Unity\Editor\Editor.log` |
| macOS | `~/Library/Logs/Unity/Editor.log` |
**Player log** (`Player.log`):
| OS | Path |
|----|------|
| Windows | `C:\Users\<user>\AppData\LocalLow\<company>\<product>\Player.log` |
| macOS | `~/Library/Logs/<company>/<product>/Player.log` |
(`<company>` and `<product>` are the project's **Company Name** and **Product Name** from Player Settings.)
## Loopback-only binding
Both the Editor and Runtime servers bind to the **IPv4 loopback** (`127.0.0.1`) — plus the `localhost` hostname for compatibility:
```csharp
if (Socket.OSSupportsIPv4)
m_HttpListener.Prefixes.Add($"http://127.0.0.1:{m_Port}/");
m_HttpListener.Prefixes.Add($"http://localhost:{m_Port}/");
```
The server is never exposed on a routable interface — it is reachable only from the same machine.
Clients should connect to **`127.0.0.1`** explicitly rather than `localhost`. Unity's Mono `HttpListener` only reliably serves the IPv4 loopback: a request arriving over the IPv6 loopback (`::1`) is answered with `400` because Mono mis-parses the bracketed `[::1]` host. Since `localhost` resolves to `::1` or `127.0.0.1` non-deterministically (notably on Windows with Node's default DNS order), dialing `localhost` caused intermittent connection failures — dialing `127.0.0.1` avoids the IPv6 path entirely.
In addition, the server refuses any request that carries an `Origin` header (legitimate CLI/CI clients never send one), which blocks a browser page from reaching the local server and short-circuits CORS preflights.
## Port ranges
Each server type picks the first free port in its range (or you can pin one explicitly):
| Server | Production range | Test range |
|--------|------------------|------------|
| **Editor** | `7800``7849` | `7850``7899` |
| **Runtime** (Player) | `7900``7949` | `7950``7999` |
If no port in the range is free, startup throws (`No available ports in range …`).
## Port Descriptor File
When a server starts, it writes a small JSON **instance descriptor** so clients can discover it. This is the only discovery channel — there is no broadcast or registry.
| Server | Descriptor path |
|--------|-----------------|
| **Editor** | `<projectPath>/Library/Pipeline/.unity-pipeline-port` |
| **Runtime** | `<workingDirectory>/.unity-pipeline-runtime-port` |
The Editor descriptor lives under the git-ignored `Library/` folder. The file is created with permissions restricted to the current user (it carries the auth token). The server rewrites it on every heartbeat to refresh `lastHeartbeat`, and deletes it on shutdown.
The Runtime descriptor is written next to the Player, at `{Application.dataPath}/..`:
- **Windows / Linux** — beside the executable, e.g. `C:\Builds\MyGame\.unity-pipeline-runtime-port`.
- **macOS**`Application.dataPath` is `<Game>.app/Contents`, so the descriptor lands at the **`.app` bundle root**: `MyGame.app/.unity-pipeline-runtime-port`. (The file is inside the `.app` bundle.) This is why `unity command --runtime-path` takes the `.app` bundle path on macOS — see [Runtime connection & setup](runtime-setup.md).
> Test servers do **not** write a descriptor (they override `WritesDescriptor => false`) — the test already knows its port, so it never clobbers the live server's file. See [Tests architecture](testing.md).
### Editor descriptor fields
```json
{
"pid": 12345,
"port": 7800,
"projectPath": "/path/to/Project",
"projectName": "Project",
"unityVersion": "6000.x.y",
"mode": "editor",
"startedAt": "2026-06-25T10:00:00Z",
"lastHeartbeat": "2026-06-25T10:05:00Z",
"evalToken": "<base64 token>"
}
```
| Field | Meaning |
|-------|---------|
| `pid` | Process id of the Unity Editor. |
| `port` | Port the server is listening on. |
| `projectPath` | Absolute path to the Unity project. |
| `projectName` | Project folder name. |
| `unityVersion` | Editor version string. |
| `mode` | `"editor"` or `"batchmode"`. |
| `startedAt` | When the instance started (UTC). |
| `lastHeartbeat` | Last heartbeat (UTC), refreshed on status calls. |
| `evalToken` | Bearer token for authenticating requests. |
### Runtime descriptor fields
The runtime descriptor shares `pid`, `port`, `unityVersion`, `startedAt`, `lastHeartbeat`, and `evalToken`, and adds:
| Field | Meaning |
|-------|---------|
| `platform` | Unity runtime platform (e.g. `WindowsPlayer`). |
| `buildGuid` | Unique build identifier (`Application.buildGUID`). |
| `workingDirectory` | Directory the Player is running from. |
(The runtime descriptor carries `platform`/`buildGuid`/`workingDirectory` in place of the editor's `projectPath`/`projectName`/`mode`.)
## Authentication
Every request must authenticate with a bearer token:
```
Authorization: Bearer <evalToken>
```
- The server **generates the token at startup** (`SecurityTokenManager.GetOrCreateToken()` — 256 bits of CSPRNG output, base64-encoded). In the Editor the token is persisted in `SessionState`, so it **survives domain reloads** within an editor session (recompiles, entering play mode) — long-lived clients (MCP sessions, IDE integrations) keep working instead of getting `401` after every reload. It is regenerated only when the Editor **restarts**, or on an explicit rotation (`SecurityTokenManager.ClearCache()` / `RotateToken()`). Player builds use a per-process token.
- The token is published in the descriptor's `evalToken` field. The descriptor is **re-advertised with the live token on every heartbeat**, so the port file never advertises a token the server would reject (e.g. after a reload or rotation).
- The server validates the bearer token on **every** request (before routing) using a constant-time comparison. A missing or wrong token returns `401 Unauthorized`.
## Discovering and calling an instance
A client connects by:
1. Reading the descriptor file (editor: `Library/Pipeline/.unity-pipeline-port`).
2. Taking the `port` and `evalToken` from it.
3. Sending requests to `http://127.0.0.1:<port>/...` with `Authorization: Bearer <evalToken>`.
Endpoints exposed by the server include `/api/status`, `/api/editor_status`, `/api/commands` (lists available commands), `/api/exec` (POST — runs a command), and `/api/test-status`.
## See also
- [Runtime connection & setup](runtime-setup.md) — enabling the server in a Player build.
- [Creating commands](creating-commands.md) — authoring the commands clients call.
@@ -0,0 +1,189 @@
# Creating commands
A *command* is a `static` method that the Pipeline server can invoke over HTTP. The method's accessibility does not matter — `public`, `internal`, and `private` static methods can all be registered. This page covers the command-authoring API: how to declare a command, describe its parameters, return a result, and have it discovered automatically.
## The handler + response pattern
Authoring a command has two halves:
1. **The handler** — your `static` method, tagged `[CliCommand]`. It does the work and returns a value.
2. **The response** — the server wraps whatever your handler returns in a [`CommandExecutionResponse`](#the-commandexecutionresponse-envelope) and serializes it to JSON for the client.
You never build the HTTP response yourself. Return a `string`, a number, an anonymous object, a typed model, or `null`; the server takes care of the envelope, timing, and error reporting.
## Declaring a command
Tag a `static` method with `[CliCommand]` (the examples below use `public`, but any accessibility works):
```csharp
[CliCommand("name", "description", MainThreadRequired = true, RuntimeOnly = false)]
public static <ReturnType> Handler(...);
```
| Argument | Meaning |
|----------|---------|
| `name` | Unique command name used by the client (`unity command <name>`). |
| `description` | Human-readable text shown in help and the `/api/commands` listing. |
| `MainThreadRequired` | Whether the handler must run on Unity's main thread. **Default `true`.** |
| `RuntimeOnly` | Whether the command is hidden from an Editor server's command listing. **Default `false`.** |
The method **must be `static`**, but its accessibility does not matter: `public`, `internal`, and `private` static methods can all be registered. `CommandRegistry` invokes handlers through reflection, so a `private` handler runs exactly like a `public` one. Only a non-static (instance) method fails to register — `CommandRegistry` skips it and logs a warning.
### Describing parameters
Tag each parameter with `[CliArg]`:
```csharp
[CliArg("name", "description", Required = false, DefaultValue = null)]
```
| Property | Meaning |
|----------|---------|
| `name` | Parameter name as it appears in client arguments (`--name value`). |
| `description` | Human-readable description for help text. |
| `Required` | Whether the parameter must be supplied. Defaults to `false` — but if the parameter has no C# default value it is treated as required. |
| `DefaultValue` | Value used when the client omits the parameter (a C# default value takes precedence). |
`[CliArg]` is optional metadata. A parameter without it still works: its name defaults to the C# parameter name and `Required` defaults to "does this parameter lack a C# default value?".
## Worked example 1 — returning a string
A command can return a plain `string`. The server places it in the response `result` field.
```csharp
using Unity.Pipeline.Commands;
using UnityEditor;
using UnityEngine;
public static class PlayModeCommands
{
[CliCommand("editor_play", "Enter Unity Editor play mode")]
public static string EnterPlayMode()
{
if (EditorApplication.isPlaying)
return "Already in play mode";
EditorApplication.isPlaying = true;
return "Entered play mode";
}
}
```
Calling `editor_play` yields a `CommandExecutionResponse` whose `result` is `"Entered play mode"`.
## Worked example 2 — returning a response model
For richer results, return a model. Returning a type that extends `CommandExecutionResponse` (like `EvalResponse`) lets you populate response fields directly; you can also return any plain serializable model (like `AuthoringResult`) and let the server wrap it.
```csharp
using Unity.Pipeline.Commands;
using Unity.Pipeline.Models;
using Unity.Pipeline.Compilation;
public static class CodeEvalCommand
{
[CliCommand("eval", "Evaluate C# code dynamically using Roslyn compiler", MainThreadRequired = true)]
public static EvalResponse EvaluateCode(
[CliArg("code", "C# code to evaluate", Required = true)] string code,
[CliArg("timeout", "Timeout in milliseconds")] int timeout = 5000)
{
if (string.IsNullOrWhiteSpace(code))
return EvalResponse.EvalFailure("Bad Request", "Code parameter is required and cannot be empty");
var result = EvalCodeCompiler.CompileAndExecuteOnMainThread(code, timeout, null);
return result ?? EvalResponse.EvalFailure("Unknown Error", "Compilation returned null result");
}
}
```
`EvalResponse` adds `output` and `diagnostics` on top of the standard envelope. Another common shape is a domain model such as `AuthoringResult` — the canonical identity (asset path, GUID, instance id, hierarchy path) of an object a command created, returned so the client can reference it in a follow-up call.
## Structured (multi-field) parameters
When a command needs a structured argument with several fields, don't spread them across many `[CliArg]` parameters — declare a small DTO that implements `IStructuredCommandInput` and take it as a single parameter. The type is advertised to clients as a nested JSON **object** schema in `GET /api/commands` (instead of collapsing to `string`), and the value is deserialized automatically via Newtonsoft — no extra wiring.
```csharp
[CliCommand("set_time_settings", "Change Time settings. Requires confirm=true; use dry_run to preview.")]
public static ProjectSettingsResponse Set(
[CliArg("settings", "Fields to change; omitted fields are left unchanged.")] TimeSettingsInput settings = null,
[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)
{ /* ... */ }
public class TimeSettingsInput : IStructuredCommandInput
{
[CliArg("fixedDeltaTime", "Fixed timestep in seconds (e.g. 0.02).")]
public float? FixedDeltaTime { get; set; }
[CliArg("timeScale", "Time scale (1 = real-time).")]
public float? TimeScale { get; set; }
}
```
`JsonSchemaGenerator` reflects over the type's public, writable fields and properties to emit `{ "type": "object", "properties": { ... } }`, recursing into nested `IStructuredCommandInput` members and arrays/lists of them. Member metadata mirrors command parameters:
- `[CliArg(name, description, Required = ...)]` controls the property name, description, and whether it appears in the schema's `required` array.
- Without a `[CliArg]`, the member (or its Newtonsoft `[JsonProperty]`) name is used and it is optional. Use nullable types (`float?`) for "omitted = leave unchanged" semantics.
- `[JsonIgnore]` members are omitted from the schema.
`[CliArg]` is valid on parameters, fields, and properties, so the same attribute annotates DTO members. Most commands that take an `IStructuredCommandInput` are mutations — pair the DTO with `confirm`/`dry_run` and follow the [safety conventions](safety-and-mutations.md) (inline gate + `AuthoringUndoScope`).
## The `CommandExecutionResponse` envelope
Whatever your handler returns, the client receives a `CommandExecutionResponse`:
| Field | Type | Meaning |
|-------|------|---------|
| `success` | `bool` | Whether the command ran without throwing. |
| `command` | `string` | The command name. |
| `result` | `object` | Your handler's return value (a string, model, anonymous object, or `null`). |
| `executionTimeMs` | `long?` | How long the command took. |
| `error` | `string` | Error summary when `success` is `false`. |
If your handler throws, the server catches it and returns a failure envelope with `success = false` and the exception message in `error` — you do not need to catch-and-wrap yourself unless you want a tailored message.
## `MainThreadRequired`
- **Default: `true`.** Most Unity APIs (scene, GameObject, asset, play-mode access) must run on the main thread. The server marshals these handlers onto the main thread via its dispatcher.
- **Set `false` only** for handlers that are thread-safe and read-only / pollable (e.g. a status or buffer-read command). These run on a background thread so they never block the main thread or deadlock against a busy editor.
When in doubt, leave it `true`.
## `RuntimeOnly`
- **Default: `false`** — the command is advertised in an Editor server's `/api/commands` listing.
- **Set `true`** to hide a command from the Editor command listing. It remains **executable**; it is simply not advertised when a client is connected to an Editor (Runtime/Player servers still list it). Use this for commands that only make sense against a running Player.
## Discovery
Commands are discovered by `CommandRegistry`, which scans for `[CliCommand]`-tagged methods through a pluggable `ICommandDiscovery`:
- In the **Editor**, `TypeCacheCommandDiscovery` provides fast `TypeCache`-based discovery.
- In a **Player**, the registry falls back to reflection over loaded assemblies.
Results are cached until the next domain reload. **A newly added command becomes available after the next recompile** — no registration call is needed; just declare it and recompile.
## Minimal custom command template
```csharp
using Unity.Pipeline.Commands;
public static class MyCommands
{
[CliCommand("my_command", "What this command does")]
public static object MyCommand(
[CliArg("text", "Some input", Required = true)] string text,
[CliArg("count", "How many times")] int count = 1)
{
// Do work on the main thread (MainThreadRequired defaults to true).
return new { echoed = text, count }; // anonymous object → response.result
}
}
```
Recompile, then invoke it from your client (`unity command my_command --text hello --count 3`).
## See also
- [Command reference](commands/runtime.md)
- [Connectivity](connectivity.md) — how clients reach the server and authenticate.
@@ -0,0 +1,145 @@
# Hot reload
Apply edited C# to running code without restarting Play Mode or rebuilding the Player. Two flavors are supported: **in-place** reload of a tagged method body, and **override** reload that routes a method through a separately-compiled replacement.
Hot reload runs in **Editor Play Mode and Mono standalone development builds only** — not IL2CPP. Like code eval, it is gated behind `#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG)`.
## Flavor 1 — in-place (`reload_file`)
Tag the method you want to be reloadable with `[HotReload]`, then edit its body and apply the file. The running instance picks up the new body.
```csharp
using Unity.Pipeline.HotReload;
using UnityEngine;
public class Spinner : MonoBehaviour
{
public float rotationSpeed = 90f;
[HotReload]
void Update()
{
transform.Rotate(Vector3.up, rotationSpeed * Time.deltaTime);
}
}
```
Edit the body of `Update`, then apply it:
```bash
unity command reload_file "<absolute path>/Spinner.cs"
# Emit debug symbols so breakpoints bind (compiles unoptimized):
unity command reload_file "<absolute path>/Spinner.cs" --pdb
```
`reload_file` parameters:
| Arg | Default | Meaning |
|-----|---------|---------|
| `filename` | *(required)* | Source file containing `[HotReload]` methods. |
| `timeout` | `30000` | Compilation timeout (ms). |
| `assemblyDir` | `null` | Optional directory to also save the compiled assembly to disk (default: in-memory only). |
| `pdb` | `false` | Emit a portable PDB mapped to the original source so debugger breakpoints bind. Compiles unoptimized. |
**Constraints (in-place):** the `[HotReload]` method must be a **`void` instance method** that is **`public`**, and reload works on **Mono only**. The CodeGen ILPostProcessor skips static methods and value-returning methods at build time (with a warning), so only matching methods are weaved for dispatch.
## Flavor 2 — override (`reload_file_override`)
When you want to swap behavior from a *separate* file (leaving the original untouched), tag the method with `[HotReloadWithOverrides]` and route it through `HotReloadHelper.ExecuteWithHotReload`:
```csharp
using Unity.Pipeline.HotReload;
using UnityEngine;
public class BossController : MonoBehaviour
{
[HotReloadWithOverrides]
void Update()
{
HotReloadHelper.ExecuteWithHotReload(this, "Update", OriginalUpdate);
}
public void OriginalUpdate()
{
transform.Rotate(45 * Time.deltaTime, 0, 0);
}
}
```
Put the tweaked behavior in a **separate file** as a **`public static` method** tagged `[HotReloadOverrideMethod("Type.Method")]`. The override **takes the target instance as its first parameter**:
```csharp
// BossOverrides.cs — a separate file
using Unity.Pipeline.HotReload;
using UnityEngine;
public static class BossOverrides
{
[HotReloadOverrideMethod("BossController.Update")]
public static void TweakedUpdate(BossController instance)
{
instance.transform.Rotate(0, 90 * Time.deltaTime, 0); // new behaviour
}
}
```
Apply the override file:
```bash
unity command reload_file_override "<absolute path>/BossOverrides.cs"
```
`reload_file_override` parameters:
| Arg | Default | Meaning |
|-----|---------|---------|
| `filename` | *(required)* | The override source file to compile. |
| `timeout` | `30000` | Compilation timeout (ms). |
| `assemblyDir` | `null` | Optional directory to also save the compiled assembly to disk (default: in-memory only). |
The override method's signature must be `public static <ReturnType> Name(<TargetType> instance, ...)` — return type and trailing parameters matching the original. Do **not** redeclare the target type in the override file (a common cause of "signature mismatch"). Until an override is applied, `ExecuteWithHotReload` calls `OriginalUpdate`; once applied, dispatch routes to the override.
`ExecuteWithHotReload` has matching overloads for both value-returning (`Func<object>`) and void (`Action`) originals:
```csharp
public static object ExecuteWithHotReload<T>(T instance, string methodName, Func<object> originalMethod, params object[] parameters);
public static void ExecuteWithHotReload<T>(T instance, string methodName, Action originalMethod, params object[] parameters);
```
## Supporting commands
- `hotreload_status` — show registry stats (which methods have overrides registered).
- `cleanup_hotreload` — remove old hot-reload assemblies and clear the registry.
## Architecture
The pipeline that turns an edited source file into running code:
1. **Compile (Roslyn, in-memory).** `RoslynCompilationService.Compile` parses and compiles the source to a `DynamicallyLinkedLibrary` entirely in memory — IL bytes in one `MemoryStream`, and (when `pdb`/`EmitDebugInformation` is requested) a **portable PDB** in another. **Nothing is written to disk** by default. When emitting debug info it compiles unoptimized (`OptimizationLevel.Debug`) so sequence points survive and breakpoints can bind.
2. **Load into the running AppDomain.** The IL (and optional PDB) bytes are loaded via `PipelineUtils.LoadFromBytes`, which selects the load mechanism by Unity version:
```csharp
public static Assembly LoadFromBytes(byte[] bytes, byte[] pdb = null)
{
#if UNITY_6000_5_OR_NEWER
return UnityEngine.Assemblies.CurrentAssemblies.LoadFromBytes(bytes, pdb);
#else
return System.Reflection.Assembly.Load(bytes, pdb);
#endif
}
```
On Unity 6000.5+ it uses Unity's `CurrentAssemblies.LoadFromBytes`; on older versions it falls back to `Assembly.Load(bytes, pdb)`. Either way the freshly compiled code becomes a **sibling assembly** loaded next to the original in the live AppDomain.
3. **Dispatch.** Calls are routed through `HotReloadRegistry`. The registry keys dispatch on `TypeName.MethodName`; when an override is registered for that id, `TryInvokeHotReload` invokes it with the instance as the first argument (marshaling to the main thread via the injected `Dispatcher` if the override requires it). If no override is registered, the original code runs.
4. **In-place weaving (CodeGen).** For the in-place flavor, the **`HotReloadInPlaceILPostProcessor`** (built on Mono.Cecil, in the `Unity.Pipeline.CodeGen` assembly) weaves a dispatch prologue into every `[HotReload]` method **at build/compile time**. That prologue checks the registry for a reloaded body and calls it instead of the original — which is how an edited `[HotReload]` body takes effect without changing the call sites. The post-processor enforces the void-instance-method constraint, skipping (with a warning) any static or value-returning `[HotReload]` method.
Because hot reload depends on Mono's ability to load a sibling assembly and dispatch into it, it is limited to **Editor Play Mode and Mono standalone dev builds — not IL2CPP**.
## See also
- [Runtime connection & setup](runtime-setup.md) — enabling hot reload in a Player build.
- [Command reference](commands/runtime.md)
@@ -0,0 +1,267 @@
# Pipeline Documentation
`com.unity.pipeline` lets a client — CLI, CI, or an agent — drive a running Unity Editor (or a
development Player) over a local HTTP API by executing registered commands. This is the documentation
index; the [README](../README.md) has the full narrative overview, install steps, and CLI walkthrough.
## Guides
| Page | What it covers |
|------|----------------|
| [Creating commands](creating-commands.md) | The command-authoring API: `[CliCommand]`/`[CliArg]`, the handler + response pattern, and the `MainThreadRequired` / `RuntimeOnly` flags. |
| [Creating authoring commands](authoring-commands.md) | Building content-authoring commands: the authoring root sandbox, `ObjectRef` in / `AuthoringResult` out, and undo grouping — with a worked example for a new content type. |
| [Safety & mutations](safety-and-mutations.md) | Conventions shared by state-changing commands: the `confirm`/`dry_run` gate, Undo grouping via `AuthoringUndoScope`, and the path sandbox. |
| [Connectivity](connectivity.md) | How servers bind localhost, the port ranges, the port/descriptor file, and the bearer-token auth workflow. |
| [Runtime connection & setup](runtime-setup.md) | Running the server in a development Player via `RuntimePipelineManager` and the dev-build gating. |
| [Hot reload](hot-reload.md) | The two hot-reload flavors (in-place and override) with examples, plus the Roslyn / in-memory-assembly architecture. |
| [Tests architecture](testing.md) | Writing command tests via `PipelineClient` (over HTTP) and via direct command calls. |
## Command reference
| Page | Commands |
|------|----------|
| [Asset & file commands](commands/assets-and-files.md) | Create / import / move / copy / rename / delete / find assets, import settings, folders, text files. |
| [Scene commands](commands/scenes.md) | Create / open / save scenes, build-settings list, hierarchy, active scene. |
| [GameObject & component commands](commands/gameobjects-and-components.md) | Create / find / transform / parent / tag / layer GameObjects; add / remove / read / set components. |
| [Prefab commands](commands/prefabs.md) | Create, instantiate, variant, apply / revert overrides, unpack, edit prefab contents. |
| [Script commands](commands/scripts.md) | Create and attach scripts; get / set serialized fields. |
| [Animation commands](commands/animation.md) | Create AnimationClips (+ curves), AnimatorControllers (parameters / layers / states / transitions), and Timeline assets. |
| [Material & shader commands](commands/materials.md) | Read / set material shader properties and keywords; list and introspect shaders. |
| [Baking commands](commands/baking.md) | Bake / clear lighting, NavMesh, and occlusion culling (async — poll the matching `*_bake_status`). |
| [Navigation & selection commands](commands/navigation.md) | Read / set the Editor selection; run Unity Search queries. |
| [Capture commands](commands/capture.md) | Screenshot the game / scene view and capture UI Toolkit visual elements. |
| [Build, compilation & test commands](commands/build-and-compilation.md) | Build the Player, switch build target, read/write build settings, list targets/profiles, recompile scripts, and run tests (async — poll the matching `*_status`). |
| [Project settings commands](commands/project-settings.md) | Read / change Audio, Graphics, Input, Physics, Player, Quality, Tags & Layers, and Time settings (`get_*`/`set_*`, gated by `confirm`/`dry_run`). |
| [Package Manager commands](commands/package-manager.md) | List / search / add / remove / resolve UPM packages and poll operation status. |
| [Editor lifecycle & observability commands](commands/editor-lifecycle-and-observability.md) | Play / stop / pause, focus, menus, screenshots, console logs, performance stats, authoring root. |
| [Runtime commands](commands/runtime.md) | Player-side commands: status, quit, frame rate, time scale, log, console, eval, hot reload. |
## Command index
Every available command, grouped by area. Each name links to its full reference (parameters + return type).
### Asset & file commands
| Command | Description |
|---------|-------------|
| [`create_asset`](commands/assets-and-files.md#create_asset) | Create a ScriptableObject / Object asset at a path. |
| [`import_asset`](commands/assets-and-files.md#import_asset) | Import an external file into the project. |
| [`move_asset`](commands/assets-and-files.md#move_asset) | Move / rename an asset (keeps its GUID). |
| [`copy_asset`](commands/assets-and-files.md#copy_asset) | Copy an asset (fresh GUID). |
| [`rename_asset`](commands/assets-and-files.md#rename_asset) | Rename an asset in place. |
| [`delete_asset`](commands/assets-and-files.md#delete_asset) | Delete an asset (needs confirm). |
| [`find_assets`](commands/assets-and-files.md#find_assets) | Find assets by type / name / label. |
| [`set_import_settings`](commands/assets-and-files.md#set_import_settings) | Set an asset's importer settings and re-import. |
| [`get_import_settings`](commands/assets-and-files.md#get_import_settings) | Read an asset's importer settings. |
| [`create_folder`](commands/assets-and-files.md#create_folder) | Create a folder under the authoring root. |
| [`read_text_file`](commands/assets-and-files.md#read_text_file) | Read a UTF-8 text file. |
| [`write_text_file`](commands/assets-and-files.md#write_text_file) | Write a UTF-8 text file, then import it. |
### Scene commands
| Command | Description |
|---------|-------------|
| [`create_scene`](commands/scenes.md#create_scene) | Create and save a new scene. |
| [`open_scene`](commands/scenes.md#open_scene) | Open an existing scene. |
| [`save_scene`](commands/scenes.md#save_scene) | Save an open scene. |
| [`save_all`](commands/scenes.md#save_all) | Save all dirty open scenes. |
| [`list_open_scenes`](commands/scenes.md#list_open_scenes) | List open scenes and their state. |
| [`set_active_scene`](commands/scenes.md#set_active_scene) | Set which open scene is active. |
| [`get_scene_hierarchy`](commands/scenes.md#get_scene_hierarchy) | Return a scene's GameObject tree. |
| [`add_scene_to_build`](commands/scenes.md#add_scene_to_build) | Add a scene to Build Settings. |
| [`remove_scene_from_build`](commands/scenes.md#remove_scene_from_build) | Remove a scene from Build Settings. |
### GameObject & component commands
| Command | Description |
|---------|-------------|
| [`create_gameobject`](commands/gameobjects-and-components.md#create_gameobject) | Create a GameObject or primitive. |
| [`create_gameobjects`](commands/gameobjects-and-components.md#create_gameobjects) | Batch-create GameObjects / primitives. |
| [`find_gameobjects`](commands/gameobjects-and-components.md#find_gameobjects) | Find GameObjects by name / tag / component / path. |
| [`set_transform`](commands/gameobjects-and-components.md#set_transform) | Set local position / rotation / scale. |
| [`set_parent`](commands/gameobjects-and-components.md#set_parent) | Reparent a GameObject (or detach to root). |
| [`set_active`](commands/gameobjects-and-components.md#set_active) | Set a GameObject's active state. |
| [`set_tag`](commands/gameobjects-and-components.md#set_tag) | Set a GameObject's tag. |
| [`set_layer`](commands/gameobjects-and-components.md#set_layer) | Set a GameObject's layer. |
| [`rename_gameobject`](commands/gameobjects-and-components.md#rename_gameobject) | Rename a GameObject. |
| [`delete_gameobject`](commands/gameobjects-and-components.md#delete_gameobject) | Delete a GameObject (undoable). |
| [`add_component`](commands/gameobjects-and-components.md#add_component) | Add a component by type name. |
| [`remove_component`](commands/gameobjects-and-components.md#remove_component) | Remove a component. |
| [`get_component_properties`](commands/gameobjects-and-components.md#get_component_properties) | Read a component's serialized properties. |
| [`set_component_properties`](commands/gameobjects-and-components.md#set_component_properties) | Set a component's serialized properties. |
### Prefab commands
| Command | Description |
|---------|-------------|
| [`create_prefab`](commands/prefabs.md#create_prefab) | Save a GameObject as a prefab. |
| [`instantiate_prefab`](commands/prefabs.md#instantiate_prefab) | Instantiate a prefab into a scene. |
| [`create_prefab_variant`](commands/prefabs.md#create_prefab_variant) | Create a prefab variant. |
| [`apply_prefab_overrides`](commands/prefabs.md#apply_prefab_overrides) | Apply instance overrides to the source. |
| [`revert_prefab_overrides`](commands/prefabs.md#revert_prefab_overrides) | Revert instance overrides. |
| [`unpack_prefab`](commands/prefabs.md#unpack_prefab) | Unpack a prefab instance. |
| [`save_prefab_contents`](commands/prefabs.md#save_prefab_contents) | Edit a prefab in an isolated stage and save. |
### Script commands
| Command | Description |
|---------|-------------|
| [`create_script`](commands/scripts.md#create_script) | Create a C# script from a template. |
| [`attach_script`](commands/scripts.md#attach_script) | Attach a MonoBehaviour by type / asset. |
| [`set_serialized_field`](commands/scripts.md#set_serialized_field) | Set a serialized field on a component / asset. |
| [`get_serialized_fields`](commands/scripts.md#get_serialized_fields) | Read serialized fields. |
### Animation commands
| Command | Description |
|---------|-------------|
| [`create_animation_clip`](commands/animation.md#create_animation_clip) | Create an empty .anim AnimationClip. |
| [`set_animation_curve`](commands/animation.md#set_animation_curve) | Add / replace a float curve on a clip. |
| [`get_animation_clip`](commands/animation.md#get_animation_clip) | Read a clip's curves and metadata. |
| [`remove_animation_curve`](commands/animation.md#remove_animation_curve) | Remove a curve binding (needs confirm). |
| [`create_animator_controller`](commands/animation.md#create_animator_controller) | Create an .controller AnimatorController. |
| [`add_animator_parameter`](commands/animation.md#add_animator_parameter) | Add a parameter to a controller. |
| [`add_animator_layer`](commands/animation.md#add_animator_layer) | Add a layer to a controller. |
| [`add_animator_state`](commands/animation.md#add_animator_state) | Add a state to a layer. |
| [`add_animator_transition`](commands/animation.md#add_animator_transition) | Add a transition between states. |
| [`get_animator_controller`](commands/animation.md#get_animator_controller) | Read a controller's full structure. |
| [`create_timeline`](commands/animation.md#create_timeline) | Create a .playable Timeline asset. |
| [`add_timeline_track`](commands/animation.md#add_timeline_track) | Add a track to a Timeline. |
| [`add_timeline_clip`](commands/animation.md#add_timeline_clip) | Add a clip to a Timeline track. |
| [`get_timeline`](commands/animation.md#get_timeline) | Read a Timeline's structure. |
### Material & shader commands
| Command | Description |
|---------|-------------|
| [`get_material_properties`](commands/materials.md#get_material_properties) | Read a material's shader and properties. |
| [`set_material_properties`](commands/materials.md#set_material_properties) | Set material shader properties / keywords. |
| [`list_shaders`](commands/materials.md#list_shaders) | Discover available shaders. |
| [`get_shader_properties`](commands/materials.md#get_shader_properties) | Introspect a shader's property list. |
### Baking commands
| Command | Description |
|---------|-------------|
| [`bake_lighting`](commands/baking.md#bake_lighting) | Async lightmap bake (poll `lighting_bake_status`). |
| [`lighting_bake_status`](commands/baking.md#lighting_bake_status) | Status of the last lighting bake. |
| [`cancel_lighting_bake`](commands/baking.md#cancel_lighting_bake) | Cancel an in-progress lighting bake. |
| [`clear_baked_lighting`](commands/baking.md#clear_baked_lighting) | Clear baked lightmaps (needs confirm). |
| [`get_lighting_settings`](commands/baking.md#get_lighting_settings) | Read the active LightingSettings. |
| [`set_lighting_settings`](commands/baking.md#set_lighting_settings) | Apply a subset of lighting settings. |
| [`bake_navmesh`](commands/baking.md#bake_navmesh) | Async legacy NavMesh bake (poll `navmesh_bake_status`). |
| [`navmesh_bake_status`](commands/baking.md#navmesh_bake_status) | Status of the last NavMesh bake. |
| [`cancel_navmesh_bake`](commands/baking.md#cancel_navmesh_bake) | Cancel an in-progress NavMesh bake. |
| [`clear_navmesh`](commands/baking.md#clear_navmesh) | Clear the baked NavMesh (needs confirm). |
| [`get_navmesh_settings`](commands/baking.md#get_navmesh_settings) | Read legacy NavMesh bake settings. |
| [`set_navmesh_settings`](commands/baking.md#set_navmesh_settings) | Apply a subset of NavMesh settings. |
| [`bake_navmesh_surfaces`](commands/baking.md#bake_navmesh_surfaces) | Bake NavMeshSurface components (AI Navigation). |
| [`bake_occlusion_culling`](commands/baking.md#bake_occlusion_culling) | Async occlusion bake (poll `occlusion_bake_status`). |
| [`occlusion_bake_status`](commands/baking.md#occlusion_bake_status) | Status of the last occlusion bake. |
| [`cancel_occlusion_bake`](commands/baking.md#cancel_occlusion_bake) | Cancel an in-progress occlusion bake. |
| [`clear_occlusion_culling`](commands/baking.md#clear_occlusion_culling) | Clear baked occlusion data (needs confirm). |
### Navigation & selection commands
| Command | Description |
|---------|-------------|
| [`get_selection`](commands/navigation.md#get_selection) | Read the current Editor selection. |
| [`set_selection`](commands/navigation.md#set_selection) | Set the Editor selection. |
| [`search`](commands/navigation.md#search) | Run a Unity Search query. |
### Capture commands
| Command | Description |
|---------|-------------|
| [`capture_game_view`](commands/capture.md#capture_game_view) | Render a camera to a PNG (base64). |
| [`capture_scene_view`](commands/capture.md#capture_scene_view) | Render the Scene View to a PNG (base64). |
| [`capture_editor_element`](commands/capture.md#capture_editor_element) | Capture a UI Toolkit element from an EditorWindow (6000.7+). |
| [`capture_runtime_element`](commands/capture.md#capture_runtime_element) | Capture a UI Toolkit element from a runtime panel (6000.7+). |
### Build, compilation & test commands
| Command | Description |
|---------|-------------|
| [`build`](commands/build-and-compilation.md#build) | Async Player build (poll `build_status`). |
| [`build_status`](commands/build-and-compilation.md#build_status) | Status / report of the current build. |
| [`switch_build_target`](commands/build-and-compilation.md#switch_build_target) | Switch the active build target (needs confirm). |
| [`switch_build_target_status`](commands/build-and-compilation.md#switch_build_target_status) | Status of the last target switch. |
| [`list_build_targets`](commands/build-and-compilation.md#list_build_targets) | List known build targets. |
| [`get_build_settings`](commands/build-and-compilation.md#get_build_settings) | Read build configuration. |
| [`set_build_settings`](commands/build-and-compilation.md#set_build_settings) | Set build configuration fields. |
| [`list_build_profiles`](commands/build-and-compilation.md#list_build_profiles) | List Build Profile assets (Unity 6). |
| [`recompile`](commands/build-and-compilation.md#recompile) | Force a script recompile (poll `recompile_status`). |
| [`recompile_status`](commands/build-and-compilation.md#recompile_status) | Status of the last recompile. |
| [`list_tests`](commands/build-and-compilation.md#list_tests) | List available tests without running. |
| [`run_tests`](commands/build-and-compilation.md#run_tests) | Run Unity tests with filters. |
| [`test_status`](commands/build-and-compilation.md#test_status) | Status of async test execution. |
| [`cancel_tests`](commands/build-and-compilation.md#cancel_tests) | Cancel running tests. |
### Project settings commands
| Command | Description |
|---------|-------------|
| [`get_audio_settings`](commands/project-settings.md#get_audio_settings) | Read Audio settings. |
| [`set_audio_settings`](commands/project-settings.md#set_audio_settings) | Change Audio settings (needs confirm). |
| [`get_graphics_settings`](commands/project-settings.md#get_graphics_settings) | Read Graphics settings. |
| [`set_graphics_settings`](commands/project-settings.md#set_graphics_settings) | Set the default render pipeline (needs confirm). |
| [`get_input_settings`](commands/project-settings.md#get_input_settings) | Read legacy Input Manager axes. |
| [`set_input_settings`](commands/project-settings.md#set_input_settings) | Tune a legacy input axis (needs confirm). |
| [`get_physics_settings`](commands/project-settings.md#get_physics_settings) | Read Physics settings. |
| [`set_physics_settings`](commands/project-settings.md#set_physics_settings) | Change Physics settings (needs confirm). |
| [`get_player_settings`](commands/project-settings.md#get_player_settings) | Read PlayerSettings. |
| [`set_player_settings`](commands/project-settings.md#set_player_settings) | Change PlayerSettings (needs confirm). |
| [`get_quality_settings`](commands/project-settings.md#get_quality_settings) | Read QualitySettings. |
| [`set_quality_settings`](commands/project-settings.md#set_quality_settings) | Change QualitySettings (needs confirm). |
| [`get_tags_layers`](commands/project-settings.md#get_tags_layers) | Read tags and layers. |
| [`set_tags_layers`](commands/project-settings.md#set_tags_layers) | Add / remove tags, name layers (needs confirm). |
| [`get_time_settings`](commands/project-settings.md#get_time_settings) | Read Time settings. |
| [`set_time_settings`](commands/project-settings.md#set_time_settings) | Change Time settings (needs confirm). |
### Package Manager commands
| Command | Description |
|---------|-------------|
| [`package_list`](commands/package-manager.md#package_list) | List packages by scope. |
| [`package_search`](commands/package-manager.md#package_search) | Search the registry. |
| [`package_add`](commands/package-manager.md#package_add) | Add a UPM package (needs confirm). |
| [`package_remove`](commands/package-manager.md#package_remove) | Remove a UPM package (needs confirm). |
| [`package_resolve`](commands/package-manager.md#package_resolve) | Resolve / refresh packages. |
| [`package_status`](commands/package-manager.md#package_status) | Status of the last package operation. |
### Editor lifecycle & observability commands
| Command | Description |
|---------|-------------|
| [`editor_play`](commands/editor-lifecycle-and-observability.md#editor_play) | Enter play mode. |
| [`editor_stop`](commands/editor-lifecycle-and-observability.md#editor_stop) | Exit play mode. |
| [`editor_pause`](commands/editor-lifecycle-and-observability.md#editor_pause) | Pause play mode. |
| [`editor_status`](commands/editor-lifecycle-and-observability.md#editor_status) | Detailed Editor status. |
| [`editor_focus`](commands/editor-lifecycle-and-observability.md#editor_focus) | Bring the Editor to the foreground. |
| [`menu`](commands/editor-lifecycle-and-observability.md#menu) | Execute (or list) Editor menu items. |
| [`screenshot`](commands/editor-lifecycle-and-observability.md#screenshot) | Capture Scene / Game view to a PNG file. |
| [`set_autotick`](commands/editor-lifecycle-and-observability.md#set_autotick) | Keep the editor ticking while unfocused. |
| [`get_console_logs`](commands/editor-lifecycle-and-observability.md#get_console_logs) | Read captured Editor console logs. |
| [`clear_console`](commands/editor-lifecycle-and-observability.md#clear_console) | Clear the console / log buffer. |
| [`get_performance_stats`](commands/editor-lifecycle-and-observability.md#get_performance_stats) | Read render / memory / frame stats. |
| [`get_authoring_root`](commands/editor-lifecycle-and-observability.md#get_authoring_root) | Get the authoring-root folder. |
| [`set_authoring_root`](commands/editor-lifecycle-and-observability.md#set_authoring_root) | Set the authoring-root folder. |
### Runtime commands
| Command | Description |
|---------|-------------|
| [`runtime_status`](commands/runtime.md#runtime_status) | Runtime application status. |
| [`quit`](commands/runtime.md#quit) | Quit the application. |
| [`set_target_framerate`](commands/runtime.md#set_target_framerate) | Set the target frame rate. |
| [`set_timescale`](commands/runtime.md#set_timescale) | Set the time scale. |
| [`simulate_key`](commands/runtime.md#simulate_key) | Simulate a keyboard event (Input System). |
| [`simulate_pointer`](commands/runtime.md#simulate_pointer) | Simulate a pointer event (Input System). |
| [`log`](commands/runtime.md#log) | Write a message to the Unity console. |
| [`console`](commands/runtime.md#console) | Read captured console output. |
| [`eval`](commands/runtime.md#eval) | Evaluate C# via Roslyn. |
| [`eval_file`](commands/runtime.md#eval_file) | Evaluate C# from a .cs file. |
| [`reload_file`](commands/runtime.md#reload_file) | Apply in-place [HotReload] edits from a file. |
| [`reload_file_override`](commands/runtime.md#reload_file_override) | Compile & apply override hot reload. |
| [`hotreload_status`](commands/runtime.md#hotreload_status) | Hot reload registry status. |
| [`cleanup_hotreload`](commands/runtime.md#cleanup_hotreload) | Clear old hot reload DLLs / registry. |
@@ -0,0 +1,60 @@
# Runtime connection & setup
How to run the Pipeline server inside a built Player so a client can drive it the same way it drives the Editor. The runtime server only exists in **development builds**, so setup is mostly about building correctly.
## The `RuntimePipelineManager` component
The runtime server is owned by a `RuntimePipelineManager` MonoBehaviour. Add it to a scene via the component menu:
> **Add Component → Pipeline → Runtime Pipeline Manager**
It is `[DisallowMultipleComponent]` and survives scene loads (`DontDestroyOnLoad`); only one instance should exist. Its `Update` pumps the server's dispatcher each frame (a Player has no `EditorApplication.update` to do this) and drives the listener watchdog.
### Inspector fields
| Field | Default | Meaning |
|-------|---------|---------|
| `enableInBuilds` | `false` | Master switch. The server only starts when this is `true`. **Off by default for safety.** |
| `autoStart` | `true` | Start the server automatically in `Start()` (requires `enableInBuilds`). |
| `port` | `0` | Listen port. `0` = auto-assign from `7900``7949`. |
| `requestTimeoutMs` | `30000` | Per-request timeout. |
| `enableAuditLogging` | `true` | Log remote requests for auditing. |
| `maxWorkItemsPerFrame` | `10` | Dispatcher work items processed per frame. |
If `autoStart` is off you can start/stop manually with `StartServer()` / `StopServer()`.
## Development-build gating
The runtime server, code evaluation, and hot reload are all gated behind a compile-time guard:
```csharp
#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG)
```
`DEBUG` is defined for **standalone Development Builds** only. In a non-development (release) build the compilation/eval/hot-reload code is compiled out entirely, so it cannot run — even if `enableInBuilds` is `true`. To use the runtime server in a Player you therefore need **both**:
1. A **Development Build** (defines `DEBUG`), built for a **standalone** target (Windows/macOS/Linux).
2. `enableInBuilds = true` on the `RuntimePipelineManager`.
## Step-by-step: enable in a dev build
1. Add a **Runtime Pipeline Manager** component to a GameObject in your startup scene (component menu *Pipeline → Runtime Pipeline Manager*).
2. Tick **`enableInBuilds`** on that component. Leave `autoStart` on and `port` at `0` (auto).
3. Open **File → Build Settings** and enable **Development Build** for a **Standalone** platform.
4. Build and run the Player.
5. On start, the manager creates a `RuntimePipelineServer`, which binds the first free port in `7900``7949` and writes the runtime descriptor `.unity-pipeline-runtime-port` (with `port` and `evalToken`).
6. Connect a client using that port and token.
## Security
The runtime server applies the same protections as the editor server (see [Connectivity](connectivity.md)):
- It binds the **IPv4 loopback** (`http://127.0.0.1:<port>/`, plus the `localhost` hostname), so it is reachable only from the same machine and never exposed on a routable interface. Clients should connect to `127.0.0.1` explicitly — see [Loopback-only binding](connectivity.md#loopback-only-binding) for why `localhost` can resolve to the IPv6 loopback (`::1`), which Unity's Mono `HttpListener` cannot serve.
- Every request must present `Authorization: Bearer <evalToken>`; the token is generated at startup and published in the runtime descriptor.
Because the server exposes code evaluation and hot reload, only enable it in development/QA builds — never in a production build without additional safeguards.
## See also
- [Connectivity](connectivity.md) — ports, descriptor file, and auth in detail.
- [Hot reload](hot-reload.md) — applying live code changes to a running Player.
@@ -0,0 +1,90 @@
# Safety & mutations
Mutating commands — asset writes, project-settings changes, package/build changes —
share two conventions so command authors and clients know what to expect: a `confirm`/`dry_run` gate
on destructive or overwriting operations, and Undo grouping via `AuthoringUndoScope`. Commands that
resolve caller-supplied asset paths confine them with `ProjectPaths` (see
[Creating authoring commands](authoring-commands.md)). The helpers live in `Editor/Authoring/`.
### The confirm / dry_run convention
Destructive or overwriting commands expose two boolean `[CliArg]`s — `confirm` and `dry_run` — and
gate their own mutation on them. There is no central dispatcher; each command applies the same simple
rules inline:
- **`dry_run == true`** → validate and describe the intended change, mutate nothing, return a preview.
This wins even if `confirm` is also true.
- **`confirm == false`** (and not a dry run) → refuse without mutating. Return the failure the way the
command reports its other errors — `throw new ArgumentException(...)` for the throwing style (e.g.
`delete_asset`, `clear_baked_lighting`), or a structured error object for commands that return
structured results (e.g. `build`, which returns `{ success = false, status = "error", message = "…" }`).
- **otherwise** (`confirm == true`, not a dry run) → perform the mutation. Group *scene/object*
mutations in an `AuthoringUndoScope` (see below). Asset/settings/package writes are **not** part of
Unity's Undo, so they run directly — say so in the command description ("Not undoable via Ctrl+Z.").
```csharp
[CliCommand("clear_baked_lighting", "Clear baked lightmap data for the open scene(s). Destructive: requires confirm=true (use dry_run to preview). Not undoable via Ctrl+Z.")]
public static object ClearBakedLighting(
[CliArg("confirm", "Apply the clear. Without it the call is refused.")] bool confirm = false,
[CliArg("dry_run", "Preview what would be cleared without clearing it.")] bool dryRun = false)
{
// …resolve the open scene(s)…
if (dryRun)
return new { status = "dry_run", wouldClear = true };
if (!confirm)
throw new ArgumentException("Refusing to clear baked lighting. Pass confirm=true to apply, or dry_run=true to preview.");
// Clearing baked GI is not part of Unity's Undo — no AuthoringUndoScope.
Lightmapping.Clear();
return new { cleared = true };
}
```
Purely additive, non-destructive commands (e.g. `create_folder`, `add_animator_parameter`)
do not take `confirm`/`dry_run`; they just perform the mutation directly.
### Undo grouping
Scene/object mutations are grouped with `AuthoringUndoScope` so a multi-step command reverts as a
single Ctrl+Z step. It increments Unity's current Undo group on construction and calls
`Undo.CollapseUndoOperations` on dispose, collapsing everything the body registers — via
`Undo.RecordObject`, `Undo.RegisterCreatedObjectUndo`, and similar — into one named step. The command
opts into Undo by calling those APIs inside the scope. See
[Creating authoring commands](authoring-commands.md) for the full pattern and the per-mutation `Undo`
API table.
> **Caveat.** Some operations are not part of Unity's Undo system. `AssetDatabase` operations, UPM
> package changes, and settings backed by native assets may not be undoable; for those the collapse is
> simply a no-op and Ctrl+Z will not revert the change. Don't rely on Undo to clean up such writes —
> validate up front and fail before writing.
### Path confinement
Commands that accept a caller-supplied asset path resolve it through `ProjectPaths.Resolve`, which
normalizes the path, rejects `..` traversal, and confines it to the configurable authoring root
(default `Assets`) so agent-supplied paths cannot escape the project. It returns a project-relative
path, or `null` with an `error` string when the path escapes the root.
```csharp
var normalized = ProjectPaths.Resolve(path, out var error);
if (normalized == null)
throw new ArgumentException(error);
// normalized is project-relative and guaranteed to live under the authoring root.
```
See [Creating authoring commands](authoring-commands.md) for the full path-handling rules and the
`set_authoring_root` / `get_authoring_root` commands that adjust the root.
### For command authors
To make a mutating command safe:
1. If the operation is destructive or overwrites existing content, add `confirm` and `dry_run` boolean
`[CliArg]`s and gate on them inline (preview on `dry_run`, refuse on `!confirm`).
2. Group scene/object mutations in `using (new AuthoringUndoScope("<operation>"))` and register Undo
operations inside it where the mutation is undoable.
3. Resolve any caller-supplied asset path through `ProjectPaths.Resolve` before touching the filesystem
— ideally during validation, so a bad path fails before anything is applied.
See [Creating commands](creating-commands.md) and [Creating authoring commands](authoring-commands.md).
@@ -0,0 +1,95 @@
# Tests architecture
How the package's tests exercise commands. There are two complementary styles — **ViaClient** (over real HTTP, end-to-end) and **CommandDirect** (calling the static command method straight) — backed by a small set of shared test helpers.
## Two test styles
### ViaClient — exercise the full HTTP path
Spin up an isolated `PipelineTestServer` and call `server.Execute(command, params)`. This goes over HTTP through the same routing, parameter coercion, and threading the real server uses — it tests the command *as a client sees it*.
```csharp
[Test]
public void SetTransform_ViaClient_ArrayParams_Apply()
{
var go = Track(new GameObject("Xform219_Arrays"));
using (var server = new PipelineTestServer())
{
var response = server.Execute("set_transform", new
{
target = new { instanceId = PipelineUtils.GetObjectId(go) },
position = new[] { 1f, 2f, 3f },
rotation = new[] { 0f, 90f, 0f },
scale = new[] { 2f, 2f, 2f }
});
Assert.IsTrue(response.IsSuccess, $"set_transform should succeed: {response.Error}");
Assert.AreEqual(new Vector3(1, 2, 3), go.transform.localPosition);
}
}
```
The test server is isolated: it uses the **test editor port range `7850``7899`** and **writes no descriptor**, so it never disturbs the live editor server (which uses `7800``7849`). `Execute` pumps the server's own dispatcher while the HTTP call is in flight, so `MainThreadRequired` commands complete even from a plain `[Test]` that blocks the main thread — no deadlock.
### CommandDirect — call the static method directly
Call the command's static method with typed arguments. This is synchronous, with **no server, HTTP, or dispatcher** involved — fast and ideal for unit-testing command logic and parameter handling.
```csharp
[Test]
public void SetTransform_Direct_TypedArrays_Apply()
{
var go = Track(new GameObject("Xform219_Direct"));
GameObjectCommands.SetTransform(RefTo(go),
position: new[] { 1f, 2f, 3f },
rotation: new[] { 0f, 90f, 0f },
scale: new[] { 2f, 2f, 2f });
Assert.AreEqual(new Vector3(1, 2, 3), go.transform.localPosition);
Assert.AreEqual(new Vector3(2, 2, 2), go.transform.localScale);
}
```
Use **CommandDirect** to verify a command's behavior with strongly-typed inputs; use **ViaClient** to additionally verify wire-level concerns (JSON coercion, required-parameter validation, threading).
## Shared helpers
| Helper | Role |
|--------|------|
| `PipelineTestServer` | Disposable wrapper that starts an isolated `TestEditorPipelineServer`, wires up a `PipelineClient`, and exposes `Execute(command, parameters, timeoutMs = 30000)`. Pumps the server dispatcher during each call to avoid main-thread deadlock. |
| `TestEditorPipelineServer` | `EditorPipelineServer` subclass for tests. Overrides `WritesDescriptor => false`, `GetPortRange() => (7850, 7899)`, and `GetToken()` (from `SecurityTokenManager`) so it is fully isolated from the live server. |
| `PipelineClient` | Test-side HTTP client. Constructed from a URL+token or a server/manager instance. Key methods: `ExecuteCommandAsync` (`/api/exec`), `GetStatusAsync` (`/api/status`), `PostJsonAsync`. Returns a `PipelineResponse` (`IsSuccess`, `StatusCode`, `Error`, `JsonResponse`, `IsCommandSuccess`). |
| `EditorTestUtilities` | Async helpers for editor state, e.g. `WaitFor(Func<bool> condition, timeoutMs)` to poll a condition without busy-waiting. |
| `LiveServerGuard` | Assembly-level `ITestAction` that asserts the **live editor server survives the test run**. |
### `PipelineTestServer` setup
```csharp
public PipelineTestServer()
{
m_Server = new TestEditorPipelineServer();
m_Server.Start(); // auto-assigns a port in 7850-7899; writes no descriptor
m_Client = new PipelineClient($"http://localhost:{m_Server.Port}", SecurityTokenManager.GetOrCreateToken());
}
```
Wrap it in a `using` (it is disposable) so the isolated server is stopped at the end of the test.
### `LiveServerGuard`
Applied once at assembly scope (`[assembly: LiveServerGuard]`), it runs before and after **every** test in the editor suite. If a live editor server was advertising its descriptor before a test, the guard asserts afterwards that the test did not disturb it — the descriptor still exists, the port is unchanged, and the server is still listening:
```csharp
Assert.IsNotNull(after, $"'{test.Name}' deleted the live pipeline server descriptor");
Assert.AreEqual(m_Before.Port, after.Port, $"'{test.Name}' changed the port");
Assert.IsTrue(IsListening(after.Port, after.EvalToken), $"'{test.Name}' left the server not responding");
```
This is what makes it safe to "dogfood" — run the test suite against the very editor you are driving — without a test clobbering the live server. Tests that intentionally start/stop the live server are marked `[Explicit]` and must hand it back intact.
## See also
- [Creating commands](creating-commands.md) — the command API these tests exercise.
- [Connectivity](connectivity.md) — ports, descriptor, and auth (the test server deliberately skips the descriptor).
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5144d7ecd5530424ab66f205802d8c9e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 113bc2bcfe487eb469aa654e6718f78b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,45 @@
using System;
using UnityEditor;
namespace Unity.Pipeline.Editor.Authoring
{
/// <summary>
/// <para>
/// Groups all Undo operations registered during its lifetime into a single, collapsible Editor
/// Undo step, so a multi-step agent action reverts as one. Register mutations inside the scope
/// with Undo.RegisterCreatedObjectUndo / RegisterCompleteObjectUndo / etc.
/// </para>
///
/// <code>
/// using (new AuthoringUndoScope("Create Enemy"))
/// {
/// var go = new GameObject("Enemy");
/// Undo.RegisterCreatedObjectUndo(go, "Create Enemy");
/// // ... further registered mutations collapse into the same step
/// }
/// </code>
///
/// <para>
/// NOTE: minimal seed for the shared safety policy (CAT-2509). AssetDatabase operations
/// (folder/asset creation, import) are NOT part of Unity's Undo system, so this scope only
/// affects scene/object mutations.
/// </para>
/// </summary>
public sealed class AuthoringUndoScope : IDisposable
{
private readonly int m_Group;
public AuthoringUndoScope(string name)
{
Undo.IncrementCurrentGroup();
m_Group = Undo.GetCurrentGroup();
if (!string.IsNullOrEmpty(name))
Undo.SetCurrentGroupName(name);
}
public void Dispose()
{
Undo.CollapseUndoOperations(m_Group);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6ef72626627670a4eadfccdab20cd9ba
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,250 @@
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
using UnityEngine.SceneManagement;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Editor.Authoring
{
/// <summary>
/// Resolves an agent-supplied <see cref="ObjectRef"/> handle to a live UnityEngine.Object, and
/// describes any object back into a canonical <see cref="AuthoringResult"/> identity.
/// Backed by GlobalObjectId, which addresses both assets (GUID + fileId) and scene objects.
/// </summary>
public static class ObjectResolver
{
/// <summary>
/// Try to resolve a handle to a loaded object. Returns false with an <paramref name="error"/>
/// when the handle is empty or does not resolve.
/// </summary>
public static bool TryResolve(ObjectRef handle, out Object obj, out string error)
{
obj = null;
error = null;
if (handle == null || handle.IsEmpty)
{
error = "Empty object reference.";
return false;
}
// 1. Canonical GlobalObjectId.
if (!string.IsNullOrEmpty(handle.GlobalId))
{
if (!GlobalObjectId.TryParse(handle.GlobalId, out var gid))
{
error = $"Invalid globalId '{handle.GlobalId}'.";
return false;
}
obj = GlobalObjectId.GlobalObjectIdentifierToObjectSlow(gid);
if (obj != null)
return true;
error = $"globalId '{handle.GlobalId}' did not resolve to a loaded object.";
return false;
}
// 2. Asset path. Explicit "Assets/"/"Packages/"-rooted paths load as-is; a bare relative
// path (e.g. "Materials/Floor.mat") is normalized under the authoring root first. If it
// does not resolve to an asset, fall back to a hierarchy lookup so dotted GameObject names
// (e.g. "Cube.001") still resolve, and report every strategy that was tried.
if (!string.IsNullOrEmpty(handle.Path))
{
var raw = handle.Path;
var explicitlyRooted = IsExplicitlyRooted(raw);
var assetPath = raw;
if (!explicitlyRooted)
{
var resolved = ProjectPaths.Resolve(raw, out _);
if (!string.IsNullOrEmpty(resolved))
assetPath = resolved;
}
obj = AssetDatabase.LoadMainAssetAtPath(assetPath);
if (obj != null)
return true;
if (explicitlyRooted)
{
error = $"No asset at path '{raw}'.";
return false;
}
// A bare relative string may actually be a scene hierarchy path with a dotted name.
var go = FindByHierarchyPath(raw);
if (go != null)
{
obj = go;
return true;
}
error = $"Could not resolve '{raw}': no asset at '{assetPath}', no GameObject at hierarchy path '{raw}'.";
return false;
}
// 3. GUID (+ optional fileId for sub-assets).
if (!string.IsNullOrEmpty(handle.Guid))
{
var path = AssetDatabase.GUIDToAssetPath(handle.Guid);
if (string.IsNullOrEmpty(path))
{
error = $"Unknown GUID '{handle.Guid}'.";
return false;
}
if (handle.FileId.HasValue)
{
foreach (var candidate in AssetDatabase.LoadAllAssetsAtPath(path))
{
if (candidate != null &&
AssetDatabase.TryGetGUIDAndLocalFileIdentifier(candidate, out _, out long localId) &&
localId == handle.FileId.Value)
{
obj = candidate;
return true;
}
}
error = $"No sub-asset with fileId {handle.FileId} in '{path}'.";
return false;
}
obj = AssetDatabase.LoadMainAssetAtPath(path);
if (obj != null)
return true;
error = $"Could not load asset '{path}'.";
return false;
}
// 4. Instance id (loaded/scene object).
if (handle.InstanceId.HasValue)
{
obj = PipelineUtils.IdToObject(handle.InstanceId.Value);
if (obj != null)
return true;
error = $"No loaded object with instanceId {handle.InstanceId}.";
return false;
}
// 5. Scene hierarchy path.
if (!string.IsNullOrEmpty(handle.HierarchyPath))
{
var go = FindByHierarchyPath(handle.HierarchyPath);
if (go != null)
{
obj = go;
return true;
}
error = $"No GameObject at hierarchy path '{handle.HierarchyPath}'.";
return false;
}
error = "Empty object reference.";
return false;
}
/// <summary>
/// Produce the canonical identity for an object (assets get path/guid/fileId; loaded objects
/// get instanceId/hierarchyPath). Returns null for a null object.
/// </summary>
public static AuthoringResult Describe(Object obj)
{
if (obj == null)
return null;
var result = new AuthoringResult { Type = obj.GetType().Name };
var assetPath = AssetDatabase.GetAssetPath(obj);
if (!string.IsNullOrEmpty(assetPath))
{
result.AssetPath = assetPath;
if (AssetDatabase.TryGetGUIDAndLocalFileIdentifier(obj, out var guid, out long localId))
{
result.Guid = guid;
result.FileId = localId;
}
}
else
{
result.InstanceId = PipelineUtils.GetObjectId(obj);
var go = obj as GameObject ?? (obj as Component)?.gameObject;
if (go != null)
result.HierarchyPath = GetHierarchyPath(go);
}
// Not every object has a global id (e.g. transient objects); ignore failures.
try
{
result.GlobalId = GlobalObjectId.GetGlobalObjectIdSlow(obj).ToString();
}
catch
{
// leave GlobalId null
}
return result;
}
/// <summary>True when a path is explicitly rooted at "Assets/" or "Packages/" (or is exactly one of those).</summary>
private static bool IsExplicitlyRooted(string path) =>
path == "Assets" || path == "Packages"
|| path.StartsWith("Assets/", System.StringComparison.Ordinal)
|| path.StartsWith("Packages/", System.StringComparison.Ordinal);
private static GameObject FindByHierarchyPath(string path)
{
var parts = path.Trim('/').Split('/');
if (parts.Length == 0 || string.IsNullOrEmpty(parts[0]))
return null;
for (int s = 0; s < SceneManager.sceneCount; s++)
{
var scene = SceneManager.GetSceneAt(s);
if (!scene.isLoaded)
continue;
foreach (var root in scene.GetRootGameObjects())
{
if (root.name != parts[0])
continue;
var current = root.transform;
var matched = true;
for (int i = 1; i < parts.Length; i++)
{
var child = current.Find(parts[i]);
if (child == null)
{
matched = false;
break;
}
current = child;
}
if (matched)
return current.gameObject;
}
}
return null;
}
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;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 26becd8c305c973488fa15b9ea058a00
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,145 @@
using System;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Editor.Authoring
{
/// <summary>
/// Project-path handling for authoring commands.
///
/// - Paths resolve relative to the configurable <see cref="AuthoringRoot"/> (default "Assets"),
/// so callers can pass bare paths ("Gameplay/Enemies") without repeating the "Assets/" prefix.
/// - Explicit "Assets/..." (or "Packages/...") paths are treated as project-relative as-is.
/// - Every resolved path is confined to <see cref="AuthoringRoot"/> (the sandbox). With the
/// default root ("Assets") that means full Assets access; set a sub-folder to confine an agent.
/// - "../" traversal and writes outside the project root are always rejected.
///
/// NOTE: minimal seed for the shared safety policy (CAT-2509).
/// </summary>
public static class ProjectPaths
{
private const string DefaultRoot = "Assets";
// Per-project EditorPrefs key so the root doesn't leak across projects on the same machine.
private static string PrefKey => $"Unity.Pipeline.AuthoringRoot:{Application.dataPath}";
/// <summary>Absolute path to the project root (the folder that contains Assets/).</summary>
public static string ProjectRoot => Path.GetDirectoryName(Application.dataPath);
/// <summary>
/// Base folder (project-relative, under Assets/) that bare paths resolve against and that all
/// authoring writes are confined to. Defaults to "Assets". Persisted per project via EditorPrefs.
/// Setting an invalid value (outside Assets/ or containing "..") throws.
/// </summary>
public static string AuthoringRoot
{
get
{
var root = EditorPrefs.GetString(PrefKey, DefaultRoot);
return string.IsNullOrEmpty(root) ? DefaultRoot : root;
}
set
{
var normalized = NormalizeRoot(value, out var error);
if (normalized == null)
throw new ArgumentException(error);
EditorPrefs.SetString(PrefKey, normalized);
}
}
/// <summary>Reset the authoring root to the default ("Assets").</summary>
public static void ResetAuthoringRoot() => EditorPrefs.DeleteKey(PrefKey);
/// <summary>
/// Resolve an agent-supplied path to a normalized, project-relative path confined to
/// <see cref="AuthoringRoot"/>. Bare paths are taken relative to the root; explicit
/// "Assets/..." paths and absolute paths under the project are honored. Returns null with an
/// <paramref name="error"/> when the path escapes the root or the project.
/// </summary>
public static string Resolve(string path, out string error)
{
error = null;
if (string.IsNullOrWhiteSpace(path))
{
error = "Path is required.";
return null;
}
var root = AuthoringRoot;
var normalized = path.Replace('\\', '/').Trim();
// Absolute path: must live under the project root; convert to project-relative.
if (Path.IsPathRooted(normalized))
{
var full = Path.GetFullPath(normalized).Replace('\\', '/');
var projectRoot = ProjectRoot.Replace('\\', '/').TrimEnd('/');
if (!full.Equals(projectRoot, StringComparison.OrdinalIgnoreCase) &&
!full.StartsWith(projectRoot + "/", StringComparison.OrdinalIgnoreCase))
{
error = $"Path '{path}' is outside the project root '{projectRoot}'.";
return null;
}
normalized = full.Length > projectRoot.Length ? full.Substring(projectRoot.Length + 1) : string.Empty;
}
normalized = normalized.TrimStart('/');
if (normalized.Split('/').Contains(".."))
{
error = $"Path '{path}' must not contain '..'.";
return null;
}
// Explicit project-relative paths ("Assets/..." / "Packages/...") are used as-is; anything
// else is a bare path taken relative to the authoring root.
var isProjectRelative =
normalized == "Assets" || normalized.StartsWith("Assets/", StringComparison.Ordinal) ||
normalized == "Packages" || normalized.StartsWith("Packages/", StringComparison.Ordinal);
var resolved = (isProjectRelative ? normalized : CombineUnder(root, normalized)).TrimEnd('/');
// Confine to the authoring root (default "Assets" => full Assets access).
if (!(resolved == root || resolved.StartsWith(root + "/", StringComparison.Ordinal)))
{
error = $"Path '{path}' resolves to '{resolved}', which is outside the authoring root '{root}'.";
return null;
}
return resolved;
}
private static string CombineUnder(string root, string relative)
{
relative = relative.TrimStart('/');
return string.IsNullOrEmpty(relative) ? root : $"{root}/{relative}";
}
private static string NormalizeRoot(string value, out string error)
{
error = null;
if (string.IsNullOrWhiteSpace(value))
{
error = "Authoring root is required.";
return null;
}
var normalized = value.Replace('\\', '/').Trim().TrimStart('/').TrimEnd('/');
if (normalized.Split('/').Contains(".."))
{
error = $"Authoring root '{value}' must not contain '..'.";
return null;
}
if (!(normalized == "Assets" || normalized.StartsWith("Assets/", StringComparison.Ordinal)))
{
error = $"Authoring root '{value}' must be under the project's Assets/ folder.";
return null;
}
return normalized;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 462c39b6cec1060408ca75a32bf31d89
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6a4d02bf29da41d499ea151c638ee920
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,266 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEngine.SceneManagement;
using UnityEditor.SceneManagement;
namespace Unity.Pipeline.Editor.BuildProcessors
{
/// <summary>
/// Build processor for runtime Pipeline support. Two responsibilities:
/// - Validates RuntimePipelineManager components in build scenes (security settings) before
/// allowing builds with runtime Pipeline enabled.
/// - Bakes the project's hot reload scope (Assets + loaded package locations) into the manager
/// in each build scene. A running Player cannot resolve the project layout, so the absolute
/// roots it is allowed to hot reload from must be captured at build time.
/// Components control their behavior directly without conditional compilation.
/// </summary>
#if UNITY_6000_3_OR_NEWER
public class PipelineRuntimeBuildProcessor : IPreprocessBuildWithContext, IProcessSceneWithReport
#else
public class PipelineRuntimeBuildProcessor : IPreprocessBuildWithReport, IProcessSceneWithReport
#endif
{
public int callbackOrder => 0;
#if UNITY_6000_3_OR_NEWER
public void OnPreprocessBuild(BuildCallbackContext ctx)
#else
public void OnPreprocessBuild(BuildReport report)
#endif
{
// Integrity gate: fail the build if a bundled Roslyn DLL was swapped or modified.
VerifyBundledChecksums();
// Note: this opens and closes scene which invalidate all managers.
// Find RuntimePipelineManager components in build scenes
var managers = FindRuntimeManagersInBuildScenes();
if (managers.Count == 0)
{
Debug.LogWarning("Pipeline: No RuntimePipelineManager components found in build scenes. Pipeline will be disabled in Player builds.");
return;
}
if (managers.Count > 1)
{
var sceneNames = string.Join(", ", managers.Select(m => m.gameObject.scene.name + "/" + m.gameObject.name));
Debug.LogWarning($"Pipeline: Multiple RuntimePipelineManager components found in build: {sceneNames}. Only the first enabled component will be used.");
}
// Find enabled managers
var enabledManagers = managers.Where(m => m.enableInBuilds).ToList();
if (enabledManagers.Count == 0)
{
Debug.LogWarning("Pipeline: RuntimePipelineManager components found, but all have enableInBuilds = false. Pipeline will be disabled in Player builds.");
return;
}
if (enabledManagers.Count > 1)
{
var enabledNames = string.Join(", ", enabledManagers.Select(m => m.gameObject.scene.name + "/" + m.gameObject.name));
Debug.LogWarning($"Pipeline: Multiple enabled RuntimePipelineManager components found: {enabledNames}. Using the first one.");
}
var activeManager = enabledManagers[0];
// Validate the active manager configuration
var validationResult = activeManager.ValidateConfiguration();
if (!validationResult.IsValid)
{
throw new BuildFailedException($"Pipeline: Runtime configuration validation failed for {activeManager.gameObject.scene.name}/{activeManager.gameObject.name}: {validationResult.Message}");
}
if (validationResult.Level == "warning")
{
Debug.LogWarning($"Pipeline: Runtime configuration warning for {activeManager.gameObject.scene.name}/{activeManager.gameObject.name}: {validationResult.Message}");
if (!EditorUserBuildSettings.development)
{
Debug.LogWarning("Pipeline: Security warnings detected in release build. Consider reviewing configuration.");
}
}
Debug.Log($"Pipeline: Runtime server ENABLED in build for {activeManager.gameObject.scene.name}/{activeManager.gameObject.name}");
}
/// <summary>
/// Find all RuntimePipelineManager components in scenes that will be included in the build.
/// </summary>
private List<RuntimePipelineManager> FindRuntimeManagersInBuildScenes()
{
var managers = new List<RuntimePipelineManager>();
var buildScenes = EditorBuildSettings.scenes.Where(s => s.enabled).ToArray();
foreach (var buildScene in buildScenes)
{
var scene = EditorSceneManager.OpenScene(buildScene.path, OpenSceneMode.Additive);
var sceneManagers = scene.GetRootGameObjects()
.SelectMany(go => go.GetComponentsInChildren<RuntimePipelineManager>(true))
.ToArray();
managers.AddRange(sceneManagers);
}
return managers;
}
/// <summary>
/// Bake the project's hot reload roots into the RuntimePipelineManager of each build scene.
/// Edits the temporary build copy of the scene, so the user's saved scene is untouched.
/// </summary>
public void OnProcessScene(Scene scene, BuildReport report)
{
// report is null when this runs on entering Play Mode; only bake during real builds.
if (report == null)
{
return;
}
var roots = CollectProjectRoots();
foreach (var go in scene.GetRootGameObjects())
{
foreach (var manager in go.GetComponentsInChildren<RuntimePipelineManager>(true))
{
manager.SetAllowedReloadRoots(roots);
}
}
}
/// <summary>
/// Absolute roots considered in-scope for runtime hot reload: the project's Assets folder
/// plus the resolved location of every package loaded into the project (a local package may
/// live anywhere on disk, not only under Packages).
/// </summary>
public static List<string> CollectProjectRoots()
{
var roots = new List<string> { Path.GetFullPath(Application.dataPath) };
foreach (var package in UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages())
{
if (!string.IsNullOrEmpty(package.resolvedPath))
{
roots.Add(Path.GetFullPath(package.resolvedPath));
}
}
return roots;
}
// Relative location of the bundled Roslyn DLLs + their integrity manifest within the package.
private const string CodeAnalysisRelDir = "Runtime/Plugins/CodeAnalysis";
private const string ChecksumsFileName = "CHECKSUMS";
/// <summary>
/// Verify the bundled Roslyn DLLs against the committed CHECKSUMS manifest, locating them
/// relative to this package on disk. Throws <see cref="BuildFailedException"/> on any
/// mismatch so a tampered/swapped DLL cannot be built into a player.
/// </summary>
public static void VerifyBundledChecksums()
{
var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssembly(
typeof(PipelineRuntimeBuildProcessor).Assembly);
if (packageInfo == null || string.IsNullOrEmpty(packageInfo.resolvedPath))
{
throw new BuildFailedException(
"Pipeline: could not locate the com.unity.pipeline package on disk to verify " +
"bundled Roslyn DLL integrity. Aborting build.");
}
var codeAnalysisDir = Path.Combine(packageInfo.resolvedPath, CodeAnalysisRelDir);
var checksumsPath = Path.Combine(codeAnalysisDir, ChecksumsFileName);
var error = VerifyChecksums(codeAnalysisDir, checksumsPath);
if (error != null)
{
throw new BuildFailedException($"Pipeline: bundled Roslyn DLL integrity check failed. {error}");
}
}
/// <summary>
/// Core, side-effect-free integrity check (so it is directly unit-testable). Returns null
/// when every DLL listed in <paramref name="checksumsPath"/> exists under
/// <paramref name="codeAnalysisDir"/> with a matching SHA-256 and no unlisted DLL is present;
/// otherwise returns a human-readable error describing the first problem found.
/// </summary>
public static string VerifyChecksums(string codeAnalysisDir, string checksumsPath)
{
if (!Directory.Exists(codeAnalysisDir))
return $"DLL directory not found: {codeAnalysisDir}";
if (!File.Exists(checksumsPath))
return $"CHECKSUMS manifest not found: {checksumsPath}";
var expected = ParseChecksums(checksumsPath);
if (expected.Count == 0)
return $"CHECKSUMS manifest has no entries: {checksumsPath}";
// Every listed DLL must exist and match.
foreach (var entry in expected)
{
var dllPath = Path.Combine(codeAnalysisDir, entry.Key);
if (!File.Exists(dllPath))
return $"listed DLL is missing: {entry.Key}";
var actual = ComputeSha256(dllPath);
if (!string.Equals(actual, entry.Value, StringComparison.OrdinalIgnoreCase))
return $"hash mismatch for {entry.Key} (expected {entry.Value}, got {actual})";
}
// No unlisted DLL may sit alongside them (guards against an injected extra assembly).
foreach (var dllPath in Directory.GetFiles(codeAnalysisDir, "*.dll"))
{
var name = Path.GetFileName(dllPath);
if (!expected.ContainsKey(name))
return $"unexpected DLL not listed in CHECKSUMS: {name}";
}
return null;
}
/// <summary>SHA-256 of a file as a lowercase hex string.</summary>
public static string ComputeSha256(string filePath)
{
using (var sha = SHA256.Create())
using (var stream = File.OpenRead(filePath))
{
var hash = sha.ComputeHash(stream);
var sb = new StringBuilder(hash.Length * 2);
foreach (var b in hash)
sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
// Parse "<sha256> <filename> # comment" lines, skipping blanks and '#' comment lines.
private static Dictionary<string, string> ParseChecksums(string checksumsPath)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in File.ReadAllLines(checksumsPath))
{
var line = raw.Trim();
if (line.Length == 0 || line.StartsWith("#"))
continue;
var tokens = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length < 2)
continue;
result[tokens[1]] = tokens[0];
}
return result;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 50df0b5c45692fb4a81122ca6eec4709
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e52bfc361c2ed7846a3c4c69b24828c5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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:

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