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
+1
View File
@@ -0,0 +1 @@
进行 Unity 开发时,必须阅读并遵循UNITY-GUIDE.md。
+52
View File
@@ -0,0 +1,52 @@
# Unity 操作规范
本文规定 Agent 进行 Unity 开发时的工具选择、内容操作边界与验证要求。
## 1. 核心工具
- 统一通过 Unity CLI/Pipeline 与 Unity 交互,不使用 Unity MCP。
- 与 Unity 交互时必须使用 `unity-pipeline` 技能,并遵循其中的命令、参数、状态轮询和操作流程说明。
- 存在多个可连接的 Unity Editor 或开发版 Player 时,必须显式指定目标,避免误操作。
- 使用 `unity command` 查看当前实例支持的命令,不假定所有实例或 Pipeline 版本能力相同。
## 2. 操作边界
### 可直接编辑
- 普通 `.cs` 源码由 Agent 直接创建或修改。
- Shader、CSV、JSON、Markdown 等纯文本也可直接编辑。
- 涉及多个脚本或文本文件时,先批量完成修改,再统一让 Unity 刷新和编译,避免逐文件导入及重复的编译与重载。
- Pipeline 不可达时仅可进行上述纯文本编辑,不得修改序列化资源。若未能完成 Unity 编译、测试或运行验证,须明确说明。
### 必须使用 Pipeline
- 创建或修改场景、Prefab、Material、ScriptableObject、AnimationClip、AnimatorController、Timeline 等由 Unity 管理的序列化内容。
- 导入图片、模型、音频、插件、DLL 等外部资产。
- 移动、重命名、复制或删除 `Assets/` 下的既有文件和文件夹。
- 场景对象操作,包括 GameObject 的创建、删除、层级、Transform、组件及其序列化字段。
- Unity 工程设置,包括 Build Settings、Tags/Layers、Input、Quality、Graphics、Player Settings 等。
- 场景、Prefab、材质和视觉效果等项目内容,应在编辑器中制作并持久化,提供必要的可调参数。不得以运行时代码临时创建来替代资产制作。
### 禁止直接操作
- 禁止手动创建、复制、修改或删除 `.meta` 文件,尤其不得改写其中的 GUID。
- 禁止使用文件系统工具复制、移动、重命名或删除 `Assets/` 下的既有资源。
- 禁止手动修改 Unity 序列化资源文件,如 `.unity``.prefab``.mat``.asset``.controller``.anim` 等。
- 禁止手动修改 Unity 生成内容或本地状态文件,如 `Library/``Temp/``Logs/``UserSettings/``*.csproj``*.sln` 等。
- 禁止以字节方式改写二进制资产。
## 3. 操作原则
- 操作 Unity 对象前先查询并确认目标,仅执行任务所需的最小操作,完成后回读确认。
- 更改序列化字段、组件类或程序集结构时,必须同步维护相关资源的兼容与脚本绑定。
- 大范围重新导入、切换构建目标、构建 Player 或批量改写资源必须获得明确授权。
- 优先使用专用操作及其提供的类型校验、Undo、预览和确认机制。
- 仅在缺少合适专用操作时,才使用 `eval` 类命令在编辑器中执行 C# 代码。代码应限于任务所需的最小范围,并按需处理 Undo、SerializedObject、AssetDatabase、脏标记和保存流程。
## 4. 验证与收尾
- 修改代码或资源后,必须通过相关测试或最小场景验证;涉及视觉改动时,还须检查实际画面。
- 验证时区分历史 Console 日志与本轮新增错误,避免将历史错误归因于当前改动。
- Scene、Prefab 编辑和 Play Mode 等临时状态结束后,必须按意图保存或放弃修改,并恢复 Editor 状态。
+8
View File
@@ -0,0 +1,8 @@
---
name: default
description: Project default instructions for Unity development. Use whenever working on Unity code, Unity assets, Unity packages, Unity Editor automation, build configuration, tests, or any other Unity project changes in this repository.
---
# Default
进行 Unity 开发时,必须阅读并遵循 `.codex\UNITY-GUIDE.md`
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "Default"
short_description: "Read and follow the project Unity guide for Unity development."
default_prompt: "Follow the project Unity development guide."
+116
View File
@@ -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,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; }
}
}

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