Add Unity Pipeline package support
This commit is contained in:
Binary file not shown.
+116
@@ -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:
|
||||
+265
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3eb482c6630e46b98100dd9c13a3f7d3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+21
@@ -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
|
||||
}
|
||||
+7
@@ -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)
|
||||
+319
@@ -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.
|
||||
+191
@@ -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).
|
||||
+150
@@ -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).
|
||||
+188
@@ -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).
|
||||
+115
@@ -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).
|
||||
+169
@@ -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).
|
||||
+55
@@ -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).
|
||||
+32
@@ -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).
|
||||
+70
@@ -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).
|
||||
+239
@@ -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).
|
||||
+162
@@ -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.
|
||||
+189
@@ -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.
|
||||
+90
@@ -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:
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6ef72626627670a4eadfccdab20cd9ba
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+250
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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:
|
||||
+266
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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:
|
||||
+423
@@ -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; }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 764503eaf79a41668748014af111eaf2
|
||||
+885
@@ -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; }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 426c006ffb43461e94ab3dde2caed7be
|
||||
+637
@@ -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; }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dcf5399fb1544ee3a5b069c535bcd002
|
||||
+52
@@ -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");
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f3f8120d06814b2c86d46503ac132022
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 39163f849a3aebd47a3f718fdc0224f9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+602
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 47b49bf20465486fb6ffdf6c03775b16
|
||||
+654
@@ -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; }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cb5b302104dc446d8d94f33252087062
|
||||
+34
@@ -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>();
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7ff032cead554b7f97cd2467a83abd35
|
||||
+51
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b78420627d9cd1e43835d9088975e691
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+126
@@ -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; }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f57a6d794d1e4c40976f65ec3774b8ed
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7512e3b6952430f4b8fb5c67dd22f13f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+29
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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:
|
||||
+42
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cf05be2261cb475c9cf3d7fccbf65ff5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+640
@@ -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 >= 1, bakeTimeMs > 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; }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dd7529c9d1f14ff98b9eb3e9a1e8ba5b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+424
@@ -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; }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5173e39c97c44308ad4b80462eaf7982
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+265
@@ -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; }
|
||||
}
|
||||
}
|
||||
+11
@@ -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:
|
||||
+577
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6e718f193c77f724ea4de47fe8a2a3cb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+337
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e65c0b78842b492d8f9363ec749bc370
|
||||
+271
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 37afada24be667e489d7e90a62f2ca1d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+237
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5b3d3b7a26bf4a5c8f5522cbfdd0574b
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 87f4434ff2c6ed6419e6982345ffe0ef
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+262
@@ -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; }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3247a78c799021c4094dd3f7e3717aeb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
#if UNITY_6000_7_OR_NEWER
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Runtime.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Captures a UI Toolkit <see cref="VisualElement"/> from an <see cref="EditorWindow"/> to a PNG
|
||||
/// and returns it base64-encoded (and writes it to disk). The element is located by a USS-like
|
||||
/// selector within the window's <see cref="EditorWindow.rootVisualElement"/>.
|
||||
///
|
||||
/// Selection, capture, and encoding are shared with the runtime command via
|
||||
/// <see cref="VisualElementCaptureSupport"/>; this command only resolves the target window.
|
||||
/// </summary>
|
||||
public static class CaptureEditorElementCommand
|
||||
{
|
||||
[CliCommand("capture_editor_element",
|
||||
"Capture a UI Toolkit VisualElement (by selector) from an EditorWindow to a PNG; returns path + base64.",
|
||||
MainThreadRequired = true)]
|
||||
public static CaptureElementResponse CaptureEditorElement(
|
||||
[CliArg("window", "EditorWindow type name (e.g. InspectorWindow) or window title to capture from.", Required = true)] string window = "",
|
||||
[CliArg("selector", "Element selector: '#name', '.class', a type name (e.g. Button), descendant (space) / child ('>') chains, optional pseudo-states (:checked,:hover,:focus,:active,:enabled,:disabled,:not(...)).", Required = true)] string selector = "",
|
||||
[CliArg("output", "Output PNG path (absolute, or relative to the project root). Defaults to a timestamped file under <project>/Temp/pipeline-screenshots/.")] string output = "")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(window))
|
||||
return CaptureElementResponse.Fail("A 'window' (EditorWindow type name or title) is required.");
|
||||
if (string.IsNullOrWhiteSpace(selector))
|
||||
return CaptureElementResponse.Fail("A 'selector' is required.");
|
||||
|
||||
var target = ResolveWindow(window);
|
||||
if (target == null)
|
||||
return CaptureElementResponse.Fail(
|
||||
$"No open EditorWindow matched '{window}' (by type name or title). Open the window first.");
|
||||
|
||||
var root = target.rootVisualElement;
|
||||
if (root == null)
|
||||
return CaptureElementResponse.Fail($"EditorWindow '{window}' has no rootVisualElement.");
|
||||
|
||||
var element = VisualElementCaptureSupport.ResolveElement(root, selector);
|
||||
if (element == null)
|
||||
return CaptureElementResponse.Fail(
|
||||
$"No element matched selector '{selector}' in window '{window}'.");
|
||||
|
||||
var projectRoot = Path.GetDirectoryName(Application.dataPath);
|
||||
var defaultDir = Path.Combine(projectRoot, "Temp", "pipeline-screenshots");
|
||||
return VisualElementCaptureSupport.CaptureAndRespond(
|
||||
element, selector, $"window:{window}", output,
|
||||
relativeBaseDir: projectRoot, defaultDir: defaultDir, prefix: "element");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find an open EditorWindow whose runtime type name or title matches <paramref name="window"/>.
|
||||
/// Uses <c>Resources.FindObjectsOfTypeAll</c> so hidden/utility windows are included too, but
|
||||
/// prefers a match whose root is actually attached to a panel — <c>FindObjectsOfTypeAll</c> can
|
||||
/// also return stale/backup window instances whose <c>rootVisualElement</c> has no panel (and
|
||||
/// therefore cannot be captured).
|
||||
/// </summary>
|
||||
static EditorWindow ResolveWindow(string window)
|
||||
{
|
||||
var windows = Resources.FindObjectsOfTypeAll<EditorWindow>();
|
||||
|
||||
bool Matches(EditorWindow w) =>
|
||||
w.GetType().Name == window ||
|
||||
(w.titleContent != null && w.titleContent.text == window);
|
||||
|
||||
return windows.FirstOrDefault(w => Matches(w)
|
||||
&& w.rootVisualElement != null && w.rootVisualElement.panel != null)
|
||||
?? windows.FirstOrDefault(Matches);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d09a03ff1d920b24080d933c3191e192
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Command to get detailed Editor status information.
|
||||
/// Returns rich Editor state including compilation, play mode, and project details.
|
||||
/// Accessible via "unity request editor_status" and "/api/editor_status" endpoint.
|
||||
/// </summary>
|
||||
public static class EditorStatusCommand
|
||||
{
|
||||
[CliCommand("editor_status", "Get detailed Unity Editor status and state information")]
|
||||
public static StatusResponse GetEditorStatus()
|
||||
{
|
||||
return new StatusResponse
|
||||
{
|
||||
Status = GetOverallStatus(),
|
||||
Compiling = EditorApplication.isCompiling,
|
||||
DomainReloadInProgress = EditorApplication.isUpdating,
|
||||
PlayMode = GetPlayModeStatus(),
|
||||
LastHeartbeat = DateTime.UtcNow,
|
||||
ProjectPath = Path.GetDirectoryName(Application.dataPath),
|
||||
UnityVersion = Application.unityVersion
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get overall Editor status based on current state.
|
||||
/// </summary>
|
||||
private static string GetOverallStatus()
|
||||
{
|
||||
if (EditorApplication.isCompiling)
|
||||
return "compiling";
|
||||
|
||||
if (EditorApplication.isUpdating)
|
||||
return "reloading";
|
||||
|
||||
if (EditorApplication.isPlaying || EditorApplication.isPaused)
|
||||
return "playing";
|
||||
|
||||
return "ready";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current play mode status.
|
||||
/// </summary>
|
||||
private static string GetPlayModeStatus()
|
||||
{
|
||||
if (EditorApplication.isPaused)
|
||||
return "paused";
|
||||
|
||||
if (EditorApplication.isPlaying)
|
||||
return "playing";
|
||||
|
||||
return "stopped";
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 953fb97ec3dd48e4aa2ed9607c412f44
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user