diff --git a/.codex/AGENTS.md b/.codex/AGENTS.md new file mode 100644 index 00000000..220862a3 --- /dev/null +++ b/.codex/AGENTS.md @@ -0,0 +1 @@ +进行 Unity 开发时,必须阅读并遵循UNITY-GUIDE.md。 \ No newline at end of file diff --git a/.codex/UNITY-GUIDE.md b/.codex/UNITY-GUIDE.md new file mode 100644 index 00000000..53782132 --- /dev/null +++ b/.codex/UNITY-GUIDE.md @@ -0,0 +1,52 @@ +# Unity 操作规范 + +本文规定 Agent 进行 Unity 开发时的工具选择、内容操作边界与验证要求。 + +## 1. 核心工具 + +- 统一通过 Unity CLI/Pipeline 与 Unity 交互,不使用 Unity MCP。 +- 与 Unity 交互时必须使用 `unity-pipeline` 技能,并遵循其中的命令、参数、状态轮询和操作流程说明。 +- 存在多个可连接的 Unity Editor 或开发版 Player 时,必须显式指定目标,避免误操作。 +- 使用 `unity command` 查看当前实例支持的命令,不假定所有实例或 Pipeline 版本能力相同。 + +## 2. 操作边界 + +### 可直接编辑 + +- 普通 `.cs` 源码由 Agent 直接创建或修改。 +- Shader、CSV、JSON、Markdown 等纯文本也可直接编辑。 +- 涉及多个脚本或文本文件时,先批量完成修改,再统一让 Unity 刷新和编译,避免逐文件导入及重复的编译与重载。 +- Pipeline 不可达时仅可进行上述纯文本编辑,不得修改序列化资源。若未能完成 Unity 编译、测试或运行验证,须明确说明。 + +### 必须使用 Pipeline + +- 创建或修改场景、Prefab、Material、ScriptableObject、AnimationClip、AnimatorController、Timeline 等由 Unity 管理的序列化内容。 +- 导入图片、模型、音频、插件、DLL 等外部资产。 +- 移动、重命名、复制或删除 `Assets/` 下的既有文件和文件夹。 +- 场景对象操作,包括 GameObject 的创建、删除、层级、Transform、组件及其序列化字段。 +- Unity 工程设置,包括 Build Settings、Tags/Layers、Input、Quality、Graphics、Player Settings 等。 +- 场景、Prefab、材质和视觉效果等项目内容,应在编辑器中制作并持久化,提供必要的可调参数。不得以运行时代码临时创建来替代资产制作。 + +### 禁止直接操作 + + +- 禁止手动创建、复制、修改或删除 `.meta` 文件,尤其不得改写其中的 GUID。 +- 禁止使用文件系统工具复制、移动、重命名或删除 `Assets/` 下的既有资源。 + +- 禁止手动修改 Unity 序列化资源文件,如 `.unity`、`.prefab`、`.mat`、`.asset`、`.controller`、`.anim` 等。 +- 禁止手动修改 Unity 生成内容或本地状态文件,如 `Library/`、`Temp/`、`Logs/`、`UserSettings/`、`*.csproj`、`*.sln` 等。 +- 禁止以字节方式改写二进制资产。 + +## 3. 操作原则 + +- 操作 Unity 对象前先查询并确认目标,仅执行任务所需的最小操作,完成后回读确认。 +- 更改序列化字段、组件类或程序集结构时,必须同步维护相关资源的兼容与脚本绑定。 +- 大范围重新导入、切换构建目标、构建 Player 或批量改写资源必须获得明确授权。 +- 优先使用专用操作及其提供的类型校验、Undo、预览和确认机制。 +- 仅在缺少合适专用操作时,才使用 `eval` 类命令在编辑器中执行 C# 代码。代码应限于任务所需的最小范围,并按需处理 Undo、SerializedObject、AssetDatabase、脏标记和保存流程。 + +## 4. 验证与收尾 + +- 修改代码或资源后,必须通过相关测试或最小场景验证;涉及视觉改动时,还须检查实际画面。 +- 验证时区分历史 Console 日志与本轮新增错误,避免将历史错误归因于当前改动。 +- Scene、Prefab 编辑和 Play Mode 等临时状态结束后,必须按意图保存或放弃修改,并恢复 Editor 状态。 diff --git a/.codex/skills/default/SKILL.md b/.codex/skills/default/SKILL.md new file mode 100644 index 00000000..e9f6a043 --- /dev/null +++ b/.codex/skills/default/SKILL.md @@ -0,0 +1,8 @@ +--- +name: default +description: Project default instructions for Unity development. Use whenever working on Unity code, Unity assets, Unity packages, Unity Editor automation, build configuration, tests, or any other Unity project changes in this repository. +--- + +# Default + +进行 Unity 开发时,必须阅读并遵循 `.codex\UNITY-GUIDE.md`。 \ No newline at end of file diff --git a/.codex/skills/default/agents/openai.yaml b/.codex/skills/default/agents/openai.yaml new file mode 100644 index 00000000..88deb80a --- /dev/null +++ b/.codex/skills/default/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Default" + short_description: "Read and follow the project Unity guide for Unity development." + default_prompt: "Follow the project Unity development guide." \ No newline at end of file diff --git a/.codex/skills/unity-pipeline/SKILL.md b/.codex/skills/unity-pipeline/SKILL.md new file mode 100644 index 00000000..f008d4b8 --- /dev/null +++ b/.codex/skills/unity-pipeline/SKILL.md @@ -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 [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 ` +(default 30000), `--assemblyDir ` (persist DLLs instead of in-memory); +`cleanup_hotreload --assemblyDir ` 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 ` + when more than one is running. diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/.attestation.p7m b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/.attestation.p7m new file mode 100644 index 00000000..5dbd0108 Binary files /dev/null and b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/.attestation.p7m differ diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/.claude/skills/unity-pipeline/SKILL.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/.claude/skills/unity-pipeline/SKILL.md new file mode 100644 index 00000000..f008d4b8 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/.claude/skills/unity-pipeline/SKILL.md @@ -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 [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 ` +(default 30000), `--assemblyDir ` (persist DLLs instead of in-memory); +`cleanup_hotreload --assemblyDir ` 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 ` + when more than one is running. diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/.signature b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/.signature new file mode 100644 index 00000000..3e029011 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/.signature @@ -0,0 +1 @@ +{"timestamp":1784898270,"signature":"m1B6AxHNRb3eYS2lmIt0kYUTKJpC60vksUpgeHUJJw2CZquWt5NTve2Qoyqy6rj28gpbtQyivBdokixwRC8tW5lOTj2/FTYzGTL9lRRKJYEeOqH/7f7RbKaydQnwYPkO/KTwDy7ad3tv2abdbvaqtrKSpuBYZfonfz3LFUoTacjds3tU7tZrx7jEyGVEzzha4KrNbFUJtEtHqmePABe4KCGeFo06FaSMxt6PSkJEwdkVJJ0LQaeBvDMAaQ71o63Ue5nPHEbj7TKjcGrmRMDCtsSWLxE2bsVgGX79gclMH2khqJKD6Z2fvsFWd6JUahlKjnDgZ29zkb0Wkrhu3i/GT1IK4zFY0xcs8HjbliTQuqxWhfHVkiSKa3mYntEWOa/xGaoZ9wsm44jP3e2ROCJcjLzPDDa2bWleMk0nIzqwYNvPbfgPF0/3SmrvefA6L2oL8e4DyogrWBOxxUmzxdFKe54SUdu0S0Or8Zua9h68JNoPxIJC+waXkaltmitaFSsr","publicKey":"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQm9qQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FZOEFNSUlCaWdLQ0FZRUFzdUhXYUhsZ0I1cVF4ZEJjTlJKSAordHR4SmoxcVY1NTdvMlZaRE1XaXhYRVBkRTBEMVFkT1JIRXNSS1RscmplUXlERU83ZlNQS0ZwZ1A3MU5TTnJCCkFHM2NFSU45aHNQVDhOVmllZmdWem5QTkVMenFkVmdEbFhpb2VpUnV6OERKWFgvblpmU1JWKytwbk9ySTRibG4KS0twelJlNW14OTc1SjhxZ1FvRktKT0NNRlpHdkJMR2MxSzZZaEIzOHJFODZCZzgzbUovWjBEYkVmQjBxZm13cgo2ZDVFUXFsd0E5Y3JZT1YyV1VpWXprSnBLNmJZNzRZNmM1TmpBcEFKeGNiaTFOaDlRVEhUcU44N0ZtMDF0R1ZwCjVNd1pXSWZuYVRUemEvTGZLelR5U0pka0tldEZMVGdkYXpMYlpzUEE2aHBSK0FJRTJhc0tLTi84UUk1N3UzU2cKL2xyMnZKS1IvU2l5eEN1Q20vQWJkYnJMbXk0WjlSdm1jMGdpclA4T0lLQWxBRWZ2TzV5Z2hSKy8vd1RpTFlzUQp1SllDM0V2UE16ZGdKUzdGR2FscnFLZzlPTCsxVzROY05yNWdveVdSUUJ0cktKaWlTZEJVWmVxb0RvSUY5NHpCCndGbzJJT1JFdXFqcU51M3diMWZIM3p1dGdtalFra3IxVjJhd3hmcExLWlROQWdNQkFBRT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg"} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CHANGELOG.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CHANGELOG.md new file mode 100644 index 00000000..e4aed298 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CHANGELOG.md @@ -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_. diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CHANGELOG.md.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CHANGELOG.md.meta new file mode 100644 index 00000000..5411dc56 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CHANGELOG.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 75cd0dccc034c6543afb4f969beca6af +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CLAUDE.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CLAUDE.md new file mode 100644 index 00000000..1378c9be --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CLAUDE.md @@ -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 +`/Library/Pipeline/.unity-pipeline-port`, sent as `Authorization: Bearer `. + +Edit→verify loop: make a logical change (may span several files) → `command recompile` → +poll `command recompile_status` → `command run_tests --filter `. 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. diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CLAUDE.md.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CLAUDE.md.meta new file mode 100644 index 00000000..f7d39303 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CLAUDE.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6433e36ff8e1bc44fb3cde740ee86080 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CODEOWNERS b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CODEOWNERS new file mode 100644 index 00000000..3c2cce51 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CODEOWNERS @@ -0,0 +1,2 @@ +# see https://github.com/Unity-Technologies/com.unity.pipeline for more information +* @Unity-Technologies/core-authoring \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CODEOWNERS.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CODEOWNERS.meta new file mode 100644 index 00000000..629037eb --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CODEOWNERS.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: cbc57dbad3c6f114d94ced75a1f9737d +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CodeGen.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CodeGen.meta new file mode 100644 index 00000000..1713e89d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CodeGen.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8b1d193bfb6e52d44975ae298a3c060a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CodeGen/HotReloadInPlaceILPostProcessor.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CodeGen/HotReloadInPlaceILPostProcessor.cs new file mode 100644 index 00000000..752a2316 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CodeGen/HotReloadInPlaceILPostProcessor.cs @@ -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 +{ + /// + /// 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). + /// + 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(); + + 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); + } + } + + /// + /// Inject, at the very top of the method: + /// if (HotReloadRegistry.TryInvokeHotReload("Type.Method", this, args)) return; + /// + 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.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(); + } + + /// + /// Resolve HotReloadRegistry.TryInvokeHotReload(string, object, object[]) from the runtime + /// assembly and import it into the target module. + /// + private static MethodReference ResolveDispatchMethod( + ModuleDefinition module, PostProcessorAssemblyResolver resolver, List 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, + }; + } + + /// + /// Minimal IAssemblyResolver that resolves referenced assemblies from the compiled assembly's + /// reference paths (which are absolute file paths supplied by the compilation pipeline). + /// + internal sealed class PostProcessorAssemblyResolver : IAssemblyResolver + { + private readonly string[] _referencePaths; + private readonly Dictionary _cache = new Dictionary(); + + 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(); + } + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CodeGen/HotReloadInPlaceILPostProcessor.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CodeGen/HotReloadInPlaceILPostProcessor.cs.meta new file mode 100644 index 00000000..3bdc2153 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CodeGen/HotReloadInPlaceILPostProcessor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3eb482c6630e46b98100dd9c13a3f7d3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CodeGen/Unity.Pipeline.CodeGen.asmdef b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CodeGen/Unity.Pipeline.CodeGen.asmdef new file mode 100644 index 00000000..535ad309 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CodeGen/Unity.Pipeline.CodeGen.asmdef @@ -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 +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CodeGen/Unity.Pipeline.CodeGen.asmdef.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CodeGen/Unity.Pipeline.CodeGen.asmdef.meta new file mode 100644 index 00000000..2a94d5b9 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/CodeGen/Unity.Pipeline.CodeGen.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3d86918fcc1e40b8a17542a4bacf8dbc +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/TableOfContents.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/TableOfContents.md new file mode 100644 index 00000000..0d31d469 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/TableOfContents.md @@ -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) diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/authoring-commands.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/authoring-commands.md new file mode 100644 index 00000000..42fc2da2 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/authoring-commands.md @@ -0,0 +1,319 @@ +# Creating authoring commands + +*Authoring commands* are the subset of commands that **create or mutate project content** — assets, +scenes, GameObjects, components — on behalf of an agent. They build on the base command API in +[Creating commands](creating-commands.md); this page covers the four concerns that are specific to +content authoring: + +- **The authoring root** — the sandbox that bare paths resolve against and writes are confined to. +- **`ObjectRef`** — how a command *receives* a reference to an existing object. +- **`AuthoringResult`** — how a command *returns* the identity of an object it created or touched. +- **Undo/redo** — grouping a command's scene mutations into a single, revertible step. + +Together these let one command's output feed the next command's input, so an agent can chain calls +(`create_gameobject` → `add_component` → `set_component_properties` → `create_prefab`) without ever +handling a raw Unity object itself. If you want to support a content type the package doesn't cover +yet (materials, audio, lighting, terrain, …), this is the pattern to follow. + +Read [Creating commands](creating-commands.md) first — this page assumes you know how `[CliCommand]`, +`[CliArg]`, `MainThreadRequired`, and the response envelope work. + +## Authoring vs. management commands + +Not every state-changing command is an *authoring* command. This page — and the [checklist](#checklist-for-a-new-authoring-command) +at the end — applies to commands that **create or mutate project content** (assets, scenes, +GameObjects, components) and hand the agent back an object identity. + +**Management commands** change *configuration* or *drive tooling* rather than author content: +project settings (`Editor/Commands/ProjectSettings/`), builds (`Build/`), and packages +(`PackageManager/`). They are a **different category** and deliberately do +**not** follow the full authoring contract. They share only the cross-cutting safety conventions +(the `confirm`/`dry_run` gate and, where relevant, `ObjectResolver`/`ProjectPaths`) — see +[Safety & mutations](safety-and-mutations.md). + +| Concern | Authoring command | Management command | +|---|---|---| +| Object input | `ObjectRef` → `ObjectResolver.TryResolve` | same, when it references an object (e.g. a scene in the build list) | +| Path input | `ProjectPaths.Resolve` (confined to the authoring root) | as needed; may be unconfined by design (e.g. a build output path) | +| Undo | scene/object mutations wrapped in `AuthoringUndoScope` + registered `Undo` APIs | **none** — settings / AssetDatabase / UPM writes don't participate in Undo, so no scope; note `Not undoable via Ctrl+Z.` in the description instead | +| Destructive gate | `confirm`/`dry_run` where destructive | `confirm`/`dry_run` where destructive (same convention) | +| Return value | `AuthoringResult` via `ObjectResolver.Describe` | a domain model (e.g. `ProjectSettingsResponse`, `PackageMutationResponse`) or a plain result object | +| Failure | **throw** (`ArgumentException` / `InvalidOperationException`) | throw, **or** return a structured `{ success = false, code, error }` object — consistent within the area | + +If you're adding a config/build/package command, follow the management column and the shared safety +conventions; the checklist below is for content-authoring commands. + +## The building blocks + +| Type | Namespace | Role | +|------|-----------|------| +| `ProjectPaths` | `Unity.Pipeline.Editor.Authoring` | Resolve/confine agent-supplied paths to the authoring root. | +| `ObjectRef` | `Unity.Pipeline.Models` | Input handle to an existing object (asset or scene object). | +| `AuthoringResult` | `Unity.Pipeline.Models` | Output identity of an object your command created or acted on. | +| `ObjectResolver` | `Unity.Pipeline.Editor.Authoring` | `TryResolve(ObjectRef …)` (handle → object) and `Describe(object)` (object → `AuthoringResult`). | +| `AuthoringUndoScope` | `Unity.Pipeline.Editor.Authoring` | Collapses all `Undo`-registered mutations in its lifetime into one editor undo step. | + +Authoring commands are Editor-only. Put them under `Editor/Commands//` in the +`Unity.Pipeline.Editor.Commands.` 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() : 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 +{ + /// Authoring commands for materials (rendering). + 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() : 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//`, `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. diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/animation.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/animation.md new file mode 100644 index 00000000..e983ada6 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/animation.md @@ -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). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/assets-and-files.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/assets-and-files.md new file mode 100644 index 00000000..50ee6f00 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/assets-and-files.md @@ -0,0 +1,150 @@ +# Asset & file commands + +Commands for creating, importing, moving, copying, renaming, deleting and finding project assets, plus folder and text-file operations. Every agent-supplied path is funnelled through the authoring-root sandbox (it rejects `../` and out-of-project writes); destructive or overwriting operations require `confirm=true` and support `dry_run`. AssetDatabase create/move/copy/rename/delete are not part of Unity's Undo system. + +### `create_asset` +Create a new ScriptableObject (or other UnityEngine.Object) asset of the given type at a path under the authoring root. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `path` | yes | – | Asset path relative to the authoring root, including extension (e.g. Data/Config.asset or Materials/Wall.mat). The Assets/ prefix is optional. | +| `type` | yes | – | Fully-qualified or short type name to instantiate (e.g. UnityEngine.Material, MyGame.GameConfig). Must derive from UnityEngine.Object and be creatable. | +| `shader` | no | `–` | Material-only (ignored otherwise): shader name to assign (e.g. Standard, "Universal Render Pipeline/Lit"). When omitted, defaults to "Universal Render Pipeline/Lit" if a Scriptable Render Pipeline is active, otherwise the built-in "Standard" shader (falling back to "Standard" if URP/Lit is unavailable). | +| `confirm` | no | `false` | Required (true) only when overwriting an existing asset at the path. Ignored when the path is empty. | +| `dry_run` | no | `false` | If true, validate inputs and report what would be created without writing anything. | + +**Returns:** `AuthoringResult` +**Notes:** `confirm=true` required to overwrite an existing asset; supports `dry_run`. + +### `import_asset` +Import an external file (e.g. a texture, model, audio clip) into the project by copying it to a path under the authoring root, then importing it. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `source` | yes | – | Absolute filesystem path to the external file to import. | +| `path` | yes | – | Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional. | +| `confirm` | no | `false` | Required (true) only when overwriting an existing asset at the destination path. | +| `dry_run` | no | `false` | If true, validate inputs and report what would be imported without writing anything. | + +**Returns:** `AuthoringResult` +**Notes:** `confirm=true` required to overwrite an existing asset; supports `dry_run`. + +### `move_asset` +Move (or rename via a new path) an asset to a new location under the authoring root. Preserves the asset's GUID. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `asset` | yes | – | Reference to the asset to move (path / guid / globalId). | +| `destination` | yes | – | Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional. | +| `dry_run` | no | `false` | If true, validate the move (via AssetDatabase.ValidateMoveAsset) without performing it. | + +**Returns:** `AuthoringResult` +**Notes:** Supports `dry_run`. + +### `copy_asset` +Copy an asset to a new path under the authoring root. The copy gets a fresh GUID. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `asset` | yes | – | Reference to the asset to copy (path / guid / globalId). | +| `destination` | yes | – | Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional. | +| `confirm` | no | `false` | Required (true) only when overwriting an existing asset at the destination path. | +| `dry_run` | no | `false` | If true, validate inputs and report what would be copied without writing anything. | + +**Returns:** `AuthoringResult` +**Notes:** `confirm=true` required to overwrite an existing asset; supports `dry_run`. + +### `rename_asset` +Rename an asset in place (keeps it in the same folder, keeps its GUID). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `asset` | yes | – | Reference to the asset to rename (path / guid / globalId). | +| `new_name` | yes | – | New file name WITHOUT a folder path. The extension is preserved if omitted. | +| `dry_run` | no | `false` | If true, validate the rename without performing it. | + +**Returns:** `AuthoringResult` +**Notes:** Supports `dry_run`. + +### `delete_asset` +Delete an asset from the project. Destructive: requires confirm=true. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `asset` | yes | – | Reference to the asset to delete (path / guid / globalId). | +| `confirm` | no | `false` | Must be true to actually delete. Without it the command refuses (destructive guard). | +| `dry_run` | no | `false` | If true, report the asset that would be deleted without deleting it. | + +**Returns:** `AuthoringResult` +**Notes:** Destructive — `confirm=true` required; supports `dry_run`. Not undoable via Unity's Undo. + +### `find_assets` +Find assets by type and/or name and/or label, returning their path, GUID and type. At least one filter is required. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `type` | no | `–` | Type name to filter by (e.g. Material, GameObject, ScriptableObject, MyGame.GameConfig). Resolved to a System.Type and matched against each asset's actual main type. | +| `name` | no | `–` | Name substring to filter by (AssetDatabase name filter). | +| `label` | no | `–` | Asset label to filter by (AssetDatabase 'l:' filter). | +| `search_in` | no | `–` | Folder to scope the search to, relative to the authoring root (default: the authoring root). | +| `limit` | no | `200` | Maximum number of results to return (default 200). | + +**Returns:** `FindAssetsResult` +**Notes:** At least one of `type`, `name`, or `label` is required. + +### `set_import_settings` +Set import settings on an asset's AssetImporter (e.g. a texture's isReadable) and re-import it. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `asset` | yes | – | Reference to the asset whose importer to edit (path / guid / globalId). | +| `settings` | yes | – | JSON object of importer property/field names to values, e.g. {"isReadable": true, "textureType": "NormalMap"}. | +| `dry_run` | no | `false` | If true, validate which settings would apply (and which are unknown) without writing or re-importing. | + +**Returns:** `SetImportSettingsResult` +**Notes:** Supports `dry_run`. Import settings live in the asset's .meta file and are not part of Unity's Undo system. + +### `get_import_settings` +Read an asset's import settings, structured by importer type (texture/model/audio), including the default-platform fields and (for textures/audio) one platform override block. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `asset` | yes | – | Reference to the asset whose importer to read (path / guid / globalId). | +| `platform` | no | `Default` | Platform whose override to read: Default \| Standalone \| iOS \| Android \| WebGL \| tvOS. | + +**Returns:** `GetImportSettingsResult` + +### `create_folder` +Create a folder under the authoring root (creates intermediate folders). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `path` | yes | – | Folder path relative to the authoring root (default Assets/); the Assets/ prefix is optional. e.g. Gameplay/Enemies or Assets/Gameplay/Enemies | + +**Returns:** `AuthoringResult` + +### `read_text_file` +Read a UTF-8 text file under the authoring root and return its contents. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `path` | yes | – | Text file path relative to the authoring root. The Assets/ prefix is optional. | +| `max_bytes` | no | `1048576` | Reject files larger than this many bytes (default 1048576 = 1 MiB) to avoid huge payloads. | + +**Returns:** `ReadTextFileResult` +**Notes:** `MainThreadRequired = true`. + +### `write_text_file` +Write UTF-8 text to a file under the authoring root, then import it. Overwriting an existing file requires confirm=true. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `path` | yes | – | Text file path relative to the authoring root, including extension. The Assets/ prefix is optional. | +| `contents` | yes | – | The full text content to write (replaces the file). | +| `confirm` | no | `false` | Required (true) only when overwriting an existing file at the path. | +| `dry_run` | no | `false` | If true, validate inputs and report what would be written without writing anything. | + +**Returns:** `AuthoringResult` +**Notes:** `confirm=true` required to overwrite an existing file; supports `dry_run`. Filesystem writes are not part of Unity's Undo system. + +See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/baking.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/baking.md new file mode 100644 index 00000000..a7163466 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/baking.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). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/build-and-compilation.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/build-and-compilation.md new file mode 100644 index 00000000..7ae55d4b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/build-and-compilation.md @@ -0,0 +1,188 @@ +# Build, compilation & test commands + +These commands drive the build/compile/test dev loop, plus build configuration: triggering Player builds and reading the resulting BuildReport, switching the active build target, enumerating targets, reading/writing EditorUserBuildSettings, and listing Build Profiles. Several commands are asynchronous: trigger the operation, then poll a matching `*_status` command until it reports completion. The client must tolerate connection errors during a domain reload (recompile, target switch) or a long blocking build. + +### `build` +Trigger an async Player build and report the full BuildReport. Returns immediately (queued); poll build_status until status is 'completed'. DetailedBuildReport is included by default unless 'options' is supplied. Use dry_run to validate without building. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `target` | no | `–` | BuildTarget name (e.g. StandaloneWindows64). Defaults to the active target. Must be installed. | +| `outputPath` | no | `–` | Output path (absolute, or relative to the project root). Defaults to the last/auto path. | +| `profileName` | no | `–` | Build Profile name to activate before building (Unity 6 only; ignored otherwise). | +| `options` | no | `–` | BuildOptions names. Omit to get just DetailedBuildReport; supplying any disables that default. | +| `scenes` | no | `–` | Scene asset paths to build (e.g. Assets/Scenes/Main.unity). Defaults to EditorBuildSettings. | +| `confirm` | no | `false` | Acknowledge and run the build; without it the call is refused. Use `dry_run` to validate only. | +| `dry_run` | no | `false` | Validate target/outputPath/scenes without building. | + +Supported `options` values (case-insensitive): `Development`, `AllowDebugging`, `ConnectWithProfiler`, `EnableHeadlessMode`, `SymlinkSources`, `BuildAdditionalStreamedScenes`, `CleanBuildCache`, `DetailedBuildReport`. Any unrecognized value is a validation error. + +**Returns:** `object` (transient payloads: `queued` / `busy` / `dry_run` / `error`; the finished report is read via `build_status`). +**Notes:** `MainThreadRequired = false`. Async — validates + queues, returns `queued` immediately, then an editor tick runs the (blocking) build; poll `build_status` until status is `completed`. Only one build at a time (returns `busy` otherwise). Mutating: gated by the `confirm`/`dry_run` convention — pass `confirm=true` for a real build, or `dry_run=true` to validate target/outputPath/scenes without building (nothing is queued on a dry run or on validation failure). + +### `build_status` +Status of the current/most recent build: idle | queued | building | completed, with the full BuildReport (files, packedAssets, buildSteps, errors, warnings) once completed. Retained until the next build. + +No parameters. + +**Returns:** `string` (JSON). `idle` when no build has run; `queued`/`building` (the latter with live `elapsedMs`) while in progress; a full `BuildReportResult` once `completed`. + +`BuildReportResult` fields (returned when `status` is `completed`): + +| Field | Description | +|-------|-------------| +| `status` | Always `completed` here. | +| `buildId` | Id returned by the triggering `build` call. | +| `result` | `Succeeded` \| `Failed` \| `Cancelled` \| `Unknown`. | +| `platform` | Build target the report is for. | +| `outputPath` | Final output location. | +| `totalSizeBytes` | Total build size in bytes. | +| `buildTimeMs` | Total build duration in milliseconds. | +| `buildStartedAt` / `buildEndedAt` | ISO-8601 timestamps (omitted when unset). | +| `totalWarnings` / `totalErrors` | Aggregate counts from the report summary. | +| `files` | Output files (path, role, sizeBytes). Present only on success. | +| `packedAssets` | Packed bundles with per-asset breakdown. Present only on success and requires DetailedBuildReport. | +| `buildSteps` | Per-step name, durationMs, depth, and messages. | +| `errors` / `warnings` | Build issues, each with `message` and best-effort `file`. | + +**Notes:** `MainThreadRequired = false`. Reads a Temp status file off-thread, so polling keeps working while a build holds the main thread. The file also survives the domain reload a build can incur, so the last report is retained until the next build. + +### `switch_build_target` +Switch the active build target (destructive, long-running: triggers a full reimport + domain reload). Requires confirm=true. Returns immediately; poll switch_build_target_status. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `target` | yes | `–` | BuildTarget name to switch to (must be installed; see list_build_targets). | +| `confirm` | no | `false` | Apply the switch. Without it the call is refused. | + +**Returns:** `object` (`switching` / `busy` / `completed` / `error`). Returns `completed` immediately if already on the requested target. +**Notes:** `MainThreadRequired = false`. Async — validates + queues, returns `switching` immediately, then an editor tick performs the (blocking) switch; poll `switch_build_target_status` until `completed`. Only one switch at a time (returns `busy` otherwise). Mutating and confirm-gated: without `confirm=true` the call is refused. The status file survives the domain reload the switch causes, and is reconciled against the active target on the next load. + +### `switch_build_target_status` +Status of the last target switch: idle | switching | completed (with success + activeBuildTarget). + +No parameters. + +**Returns:** `string` (JSON). `idle`, `switching`, or `completed` (with `success` and `activeBuildTarget`, or `errors` on failure). +**Notes:** `MainThreadRequired = false`. Reads a Temp status file off-thread, so it keeps answering while the switch holds the main thread. + +### `list_build_targets` +List the known BuildTarget values with their group and whether build support is installed. + +No parameters. + +**Returns:** `object` (list of `BuildTargetInfo`: `name`, `displayName`, `targetGroup`, `isInstalled`), sorted by group then name. Obsolete and sentinel targets are excluded. +**Notes:** `MainThreadRequired = true`. + +### `get_build_settings` +Read the current build configuration from EditorUserBuildSettings / EditorBuildSettings. + +No parameters. + +**Returns:** `object` (`BuildSettingsResult`). + +`BuildSettingsResult` fields: + +| Field | Description | +|-------|-------------| +| `activeBuildTarget` | Active BuildTarget name. | +| `activeBuildTargetGroup` | Active BuildTargetGroup name. | +| `developmentBuild` | Whether a Development Player is configured. | +| `allowDebugging` | Whether script debugging is allowed. | +| `connectWithProfiler` | Whether the Profiler auto-connects. | +| `buildScriptsOnly` | Whether only scripts are built (skip data). | +| `symlinkSources` | Whether runtime/plugin sources are symlinked. | +| `il2CppCodeGeneration` | `OptimizeSpeed` \| `OptimizeSize` for the active target. | +| `scenes` | Build Settings scene list (each: `path`, `guid`, `enabled`). | + +**Notes:** `MainThreadRequired = true`. + +### `set_build_settings` +Set mutable EditorUserBuildSettings fields. Does NOT manage scenes (use add_scene_to_build / remove_scene_from_build) or switch target (use switch_build_target). Use dry_run to preview. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `settings` | no | `–` | Fields to change; omitted fields are left unchanged. | +| `confirm` | no | `false` | Apply the changes. Without it the call is refused. | +| `dry_run` | no | `false` | Preview the change without applying it. | + +`settings` fields (a structured input DTO; every field is optional — only supplied fields change): + +| Field | Description | +|-------|-------------| +| `developmentBuild` | Build a Development Player (enables the debugger/profiler). | +| `allowDebugging` | Allow script debugging (only effective with developmentBuild=true). | +| `connectWithProfiler` | Auto-connect the Profiler (only effective with developmentBuild=true). | +| `buildScriptsOnly` | Build only the scripts (skip data) for faster iteration. | +| `symlinkSources` | Symlink runtime/plugin sources instead of copying (where supported). | +| `il2CppCodeGeneration` | IL2CPP code generation for the active target: OptimizeSpeed \| OptimizeSize. | + +**Returns:** `object` (`SetBuildSettingsResult`: `success`, `dryRun`, `applied` map of fields that changed, `skipped` map of supplied fields that already matched, `message`). +**Notes:** `MainThreadRequired = true`. Mutating: refused unless `confirm=true` (or `dry_run=true` to preview). Fields already at the requested value are reported under `skipped` rather than re-applied. Fails if no `settings` object is provided. + +### `list_build_profiles` +List Build Profile assets in the project (Unity 6 only). Returns feature_unavailable on earlier versions. + +No parameters. + +**Returns:** `object` (list of `BuildProfileInfo`: `name`, `guid`, `platform`, `isActive`), or `{ error, code = "feature_unavailable" }` on editors older than Unity 6. +**Notes:** `MainThreadRequired = true`. + +### `recompile` +Force a script recompile (works while unfocused/minimized). Poll recompile_status for completion. + +No parameters. + +**Returns:** `object` +**Notes:** `MainThreadRequired = true`. Async — poll `recompile_status` until status is `completed` or `up_to_date`. A successful compile triggers a domain reload, so the triggering request cannot stay open. + +### `recompile_status` +Get the status of the last recompile: idle | triggered | compiling | completed | up_to_date. + +No parameters. + +**Returns:** `string` +**Notes:** `MainThreadRequired = false`. + +### `list_tests` +List all available tests (EditMode and/or PlayMode) without running them. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `mode` | no | `all` | Test mode: all, editor, playmode (default: all) | + +**Returns:** `TestListResponse` +**Notes:** `MainThreadRequired = true`. + +### `run_tests` +Execute Unity tests with filtering options. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `mode` | no | `all` | Test mode: all, editor, playmode (default: all) | +| `filter` | no | `–` | Test name filter pattern (case-insensitive partial match) | +| `filter_type` | no | `testName` | Filter type: testName, assembly, category (default: testName) | +| `include_explicit` | no | `false` | Include tests marked with [Explicit] attribute | +| `async_tests` | no | `false` | Run asynchronously - return immediately, poll /test-status for results | +| `timeout` | no | `300` | Test execution timeout in seconds (default: 300) | + +**Returns:** `TestExecutionResponse` +**Notes:** `MainThreadRequired = true`. Synchronous by default; with `async_tests=true` it returns immediately — poll `test_status` for results. + +### `test_status` +Get status of running async test execution. + +No parameters. + +**Returns:** `string` +**Notes:** `MainThreadRequired = false`. + +### `cancel_tests` +Cancel running test execution. + +No parameters. + +**Returns:** `object` +**Notes:** `MainThreadRequired = true`. + +See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/capture.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/capture.md new file mode 100644 index 00000000..9c289c94 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/capture.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 /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). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/editor-lifecycle-and-observability.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/editor-lifecycle-and-observability.md new file mode 100644 index 00000000..a5889b84 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/editor-lifecycle-and-observability.md @@ -0,0 +1,115 @@ +# Editor lifecycle & observability commands + +Commands to control Editor play mode, focus, and menus, and to observe the Editor's state, console, and performance. + +### `editor_play` +Enter Unity Editor play mode. + +No parameters. + +**Returns:** `string` + +### `editor_stop` +Exit Unity Editor play mode. + +No parameters. + +**Returns:** `string` + +### `editor_pause` +Pause Unity Editor play mode. + +No parameters. + +**Returns:** `string` + +### `editor_status` +Get detailed Unity Editor status and state information. + +No parameters. + +**Returns:** `StatusResponse` + +### `editor_focus` +Bring the Unity Editor window to the foreground. + +No parameters. + +**Returns:** `string` +**Notes:** `MainThreadRequired = true`. + +### `menu` +Execute an Editor menu item by path, or list available items when no path is given. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `path` | no | `–` | Menu item path to execute, e.g. "Assets/Reimport All". Omit to list available menu items. | + +**Returns:** `MenuResponse` +**Notes:** `MainThreadRequired = true`. + +### `screenshot` +Capture the Scene or Game view as a PNG and return its file path. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `view` | no | `game` | Which view to capture: 'game' (default) or 'scene' | +| `output` | no | `–` | Output PNG path (absolute, or relative to the project root). Defaults to a timestamped file under /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). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/gameobjects-and-components.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/gameobjects-and-components.md new file mode 100644 index 00000000..ca1e4cc0 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/gameobjects-and-components.md @@ -0,0 +1,169 @@ +# GameObject & component commands + +Commands for creating GameObjects, querying the scene hierarchy, mutating the core GameObject/Transform surface, and adding/removing components and editing their serialized properties. Scene mutations are wrapped in Unity's Undo system (so a multi-step action reverts as one collapsible step) and are blocked during Play mode. Objects are referenced by an `ObjectRef` handle (globalId/path/guid/instanceId/hierarchyPath), and results come back as `AuthoringResult` identities you can chain into a follow-up call. + +### `create_gameobject` +Create an empty GameObject or a built-in primitive (cube/sphere/capsule/cylinder/plane/quad) in the active scene. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `name` | no | `–` | Name for the new GameObject. Defaults to 'GameObject' (or the primitive name). | +| `primitive` | no | `–` | Optional primitive type: cube, sphere, capsule, cylinder, plane, quad. Omit for an empty GameObject. | +| `parent` | no | `–` | Optional parent handle (globalId/path/guid/instanceId/hierarchyPath). The new object becomes a child of it. | + +**Returns:** `AuthoringResult` +**Notes:** Undo-able; blocked during Play mode. + +### `create_gameobjects` +Batch-create N empty GameObjects or primitives in one call. Optional positions/rotations/scales are arrays of [x,y,z] (length must equal count). Returns the created identities. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `name` | no | `–` | Base name. With count>1 and no explicit names, objects are suffixed Name1..NameN. | +| `primitive` | no | `–` | Optional primitive type: cube, sphere, capsule, cylinder, plane, quad. Omit for empty GameObjects. | +| `parent` | no | `–` | Optional parent handle. Every created object becomes a child of it. | +| `count` | no | `1` | How many GameObjects to create. Default 1. | +| `positions` | no | `–` | Local positions, one [x,y,z] per object. Length must equal count when supplied. | +| `rotations` | no | `–` | Local Euler rotations (degrees), one [x,y,z] per object. Length must equal count when supplied. | +| `scales` | no | `–` | Local scales, one [x,y,z] per object. Length must equal count when supplied. | + +**Returns:** `CreateGameObjectsResult` +**Notes:** Undo-able (the whole batch reverts as one step); blocked during Play mode. + +### `find_gameobjects` +Find GameObjects in loaded scenes by name, tag, component type, and/or hierarchy path (filters are combined). Returns structured identities. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `name` | no | `–` | Exact name to match. | +| `tag` | no | `–` | Tag to match (e.g. 'Player'). | +| `type` | no | `–` | Component type name to match (e.g. 'Rigidbody', 'UnityEngine.Camera'). | +| `hierarchy_path` | no | `–` | Exact hierarchy path to match (e.g. '/Root/Child'). | +| `include_inactive` | no | `true` | Include inactive GameObjects. Default true. | + +**Returns:** `FindGameObjectsResult` + +### `set_transform` +Set a GameObject's local position/rotation(euler)/scale. Omitted channels are left unchanged. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `target` | yes | – | Handle of the GameObject to modify. | +| `position` | no | `–` | Local position as [x,y,z]. | +| `rotation` | no | `–` | Local rotation as Euler angles [x,y,z] in degrees. | +| `scale` | no | `–` | Local scale as [x,y,z]. | + +**Returns:** `AuthoringResult` +**Notes:** Undo-able; blocked during Play mode. + +### `set_parent` +Reparent a GameObject under a new parent, or detach it to scene root when no parent is given. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `target` | yes | – | Handle of the GameObject to reparent. | +| `parent` | no | `–` | Handle of the new parent. Omit (or empty) to move the object to the scene root. | +| `world_position_stays` | no | `true` | Keep the object's world position when reparenting. Default true. | + +**Returns:** `AuthoringResult` +**Notes:** Undo-able; blocked during Play mode. + +### `set_active` +Set a GameObject's active self-state (activeSelf). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `target` | yes | – | Handle of the GameObject. | +| `active` | yes | – | Desired active state. | + +**Returns:** `AuthoringResult` +**Notes:** Undo-able; blocked during Play mode. + +### `set_tag` +Set a GameObject's tag (the tag must already exist in the project). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `target` | yes | – | Handle of the GameObject. | +| `tag` | yes | – | Tag to assign (must exist in the Tag Manager). | + +**Returns:** `AuthoringResult` +**Notes:** Undo-able; blocked during Play mode. + +### `set_layer` +Set a GameObject's layer by name or numeric index (0-31). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `target` | yes | – | Handle of the GameObject. | +| `layer` | yes | – | Layer name (e.g. 'UI') or numeric index 0-31. | + +**Returns:** `AuthoringResult` +**Notes:** Undo-able; blocked during Play mode. + +### `rename_gameobject` +Rename a GameObject. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `target` | yes | – | Handle of the GameObject. | +| `name` | yes | – | New name. | + +**Returns:** `AuthoringResult` +**Notes:** Undo-able; blocked during Play mode. + +### `delete_gameobject` +Delete a GameObject from the scene (reversible via Undo). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `target` | yes | – | Handle of the GameObject to delete. | + +**Returns:** `AuthoringResult` +**Notes:** Undo-able; blocked during Play mode. The result describes the object's identity captured before destruction. + +### `add_component` +Add a component (by type name) to a GameObject. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `target` | yes | – | Handle of the GameObject. | +| `type` | yes | – | Component type name (e.g. 'Rigidbody' or 'UnityEngine.Camera'). | + +**Returns:** `AuthoringResult` +**Notes:** Undo-able; blocked during Play mode. + +### `remove_component` +Remove a component from a GameObject. Provide either a component handle (target) or a GameObject handle (target) plus a type name. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `target` | yes | – | Handle of the component to remove, OR of the GameObject when 'type' is given. | +| `type` | no | `–` | Component type name to remove from the target GameObject (omit when 'target' already points at a component). | + +**Returns:** `AuthoringResult` +**Notes:** Undo-able; blocked during Play mode. + +### `get_component_properties` +Get a component's serialized properties as a JSON map. Address the component by handle, or by GameObject handle + type. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `target` | yes | – | Handle of the component, OR of the GameObject when 'type' is given. | +| `type` | no | `–` | Component type name on the target GameObject (omit when 'target' is a component handle). | + +**Returns:** `ComponentPropertiesResult` + +### `set_component_properties` +Set serialized properties on a component (one Undo step). 'properties' maps property name -> value; object references accept an ObjectRef handle. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `target` | yes | – | Handle of the component, OR of the GameObject when 'type' is given. | +| `properties` | yes | – | Map of serialized property name to value. Vectors/colors are arrays; object refs are handle objects. | +| `type` | no | `–` | Component type name on the target GameObject (omit when 'target' is a component handle). | + +**Returns:** `ComponentPropertiesResult` +**Notes:** Undo-able (one Undo step); blocked during Play mode. An unknown property name fails the whole batch (no partial apply). + +See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/materials.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/materials.md new file mode 100644 index 00000000..a81d780f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/materials.md @@ -0,0 +1,55 @@ +# Material & shader commands + +Read and edit material shader properties, and discover/introspect shaders so an agent can pick a valid shader name and property set. + +## Materials + +### `get_material_properties` +Read a material's shader, render queue, enabled keywords, and all shader properties with their current values (Color as [r,g,b,a], Vector as [x,y,z,w], Texture as an object reference). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `material` | yes | `–` | Reference to the .mat asset (or a loaded material) to read (path / guid / globalId / instanceId). | + +**Returns:** `MaterialPropertiesResult` + +### `set_material_properties` +Set shader properties on a material (Float/Range/Int=number; Color=[r,g,b,a] or "#RRGGBBAA" hex; Vector=[x,y,z,w]; Texture=an object reference or null to clear), optionally reassign the shader, set the render queue, and toggle keywords. Unknown names / type mismatches are reported in `unknown[]`. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `material` | yes | `–` | Reference to the .mat asset (or a loaded material) to edit (path / guid / globalId / instanceId). | +| `shader` | no | `–` | Reassign the material's shader by name (e.g. "Standard", "Universal Render Pipeline/Lit", or a Shader Graph shader name). Applied before properties so new property names resolve against the new shader. | +| `properties` | no | `–` | JSON object of shader property name -> value. Names must include the leading underscore (e.g. _BaseColor). Float/Range/Int=number; Color=[r,g,b,a] or hex string; Vector=[x,y,z,w]; Texture=an object reference {guid/path} or null. | +| `renderQueue` | no | `–` | Explicit render queue, or -1 to inherit from the shader. Omit to leave unchanged. | +| `enableKeywords` | no | `–` | Shader keywords to enable (e.g. _NORMALMAP, _EMISSION). | +| `disableKeywords` | no | `–` | Shader keywords to disable. | +| `confirm` | no | `false` | Reserved for parity; editing an existing material is non-destructive and undoable, so it is not required. | +| `dry_run` | no | `false` | If true, validate the shader, resolve property names and texture refs, and report applied[]/unknown[] without writing anything. | + +**Returns:** `SetMaterialPropertiesResult` + +## Shaders + +### `list_shaders` +Discover available shaders so an agent can pick a valid name for set_material_properties / create_asset. Returns `[{ name, assetPath|null, isBuiltin, isSupported }]`. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `filter` | no | `–` | Case-insensitive substring matched against the shader name (e.g. "URP", "Lit"). | +| `includeBuiltin` | no | `true` | Include built-in/engine shaders (those with no project asset path). | +| `limit` | no | `200` | Maximum number of shaders to return. | + +**Returns:** `object` (list of `ShaderInfo`) + +### `get_shader_properties` +Introspect a shader's declared property list (name, description, type Color|Vector|Float|Range|TexEnv|Int, range, textureDimension, flags). Provide `shader` (by name) OR `material` (read the shader off that material). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `shader` | no | `–` | Shader name (e.g. "Universal Render Pipeline/Lit"). Provide this OR 'material'. | +| `material` | no | `–` | Reference to a material to read the shader from instead of naming it. Provide this OR 'shader'. | + +**Returns:** `ShaderPropertiesResult` + +See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/navigation.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/navigation.md new file mode 100644 index 00000000..fed671f9 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/navigation.md @@ -0,0 +1,32 @@ +# Navigation & selection commands + +Commands to read and drive the Editor selection, and to run Unity Search queries. All commands are read-only or non-destructive and run on the main thread. + +### `get_selection` +Read the current Editor selection as structured object identities. + +No parameters. + +**Returns:** `SelectionResult` + +### `set_selection` +Set the Editor selection to the given assets/scene objects. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `instance_ids` | no | `–` | Scene/loaded object instance IDs to select. | +| `paths` | no | `–` | Asset paths to select (e.g. Assets/Foo.prefab). | + +**Returns:** `SelectionResult` + +### `search` +Run a Unity Search query and return structured results. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `query` | yes | `–` | Unity Search query string, e.g. 't:Material', 'p: my asset', 'h: Main Camera'. | +| `limit` | no | `50` | Max results to return (capped 200). | + +**Returns:** `SearchResult` + +See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/package-manager.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/package-manager.md new file mode 100644 index 00000000..d84010e4 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/package-manager.md @@ -0,0 +1,70 @@ +# Package Manager commands + +These commands query and modify the project's UPM package set (list, search, add, remove, resolve, status) over `UnityEditor.PackageManager`. Read-only queries block until the registry responds and return synchronously; mutating commands (`package_add`/`package_remove`) require `confirm=true`, support `dry_run` previews, and are async by default (poll `package_status`) with an optional `wait=true` to block until done. + +### `package_list` +List packages by scope: installed (default) | available (registry) | all (both). Returns the full result synchronously — available/all block until the registry query completes. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `scope` | no | `installed` | Which packages to list: installed (default) | available | all. | +| `include_indirect` | no | `true` | Include indirect (transitive) installed dependencies (applies to scope=installed/all). | +| `offline` | no | `false` | For available/all: query the local cache instead of the registry. | + +**Returns:** `object` +**Notes:** `MainThreadRequired = false`. Synchronous — `available`/`all` start a registry query and block until it completes (up to a 25s timeout); `installed` reads the resolved set inline on the main thread. + +### `package_search` +Search packages available in the registry. Provide a name (e.g. com.unity.foo) or omit to list all. Returns the full result synchronously (blocks until the registry query completes). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `query` | no | `–` | Package name to search for. Omit/empty to list all available packages. | +| `offline` | no | `false` | Search the local cache only. | + +**Returns:** `object` +**Notes:** `MainThreadRequired = false`. Synchronous — blocks until the registry query completes (up to a 25s timeout). Each result is flagged with whether it is currently installed. + +### `package_add` +Add a UPM package by name@version, git URL, or 'file:' local path. Async by default (returns in_progress; poll package_status); pass wait=true to block until added. A recompile/domain reload follows — poll recompile_status. Requires confirm=true; use dry_run to preview. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `identifier` | yes | `–` | Package to add: 'com.unity.foo@1.2.3', a git URL, or 'file:../Path'. | +| `confirm` | no | `false` | Apply the change. Without it the call is refused. | +| `dry_run` | no | `false` | Preview the change without applying it. | +| `wait` | no | `false` | Block until the operation completes and return the result (synchronous). Default: return immediately and poll package_status. | + +**Returns:** `object` +**Notes:** `MainThreadRequired = false`. Mutating — requires `confirm=true`; `dry_run=true` returns a preview without applying. Async by default (returns `in_progress`; poll `package_status`); with `wait=true` it blocks until completion (up to a 25s timeout). A successful add triggers a recompile/domain reload — poll `recompile_status`. Returns `busy` if another package operation is already in progress. + +### `package_remove` +Remove a UPM package by name. Async by default (returns in_progress; poll package_status); pass wait=true to block until removed. A recompile/domain reload follows — poll recompile_status. Requires confirm=true; use dry_run to preview. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `name` | yes | `–` | Package name to remove (e.g. com.unity.foo). | +| `confirm` | no | `false` | Apply the change. Without it the call is refused. | +| `dry_run` | no | `false` | Preview the change without applying it. | +| `wait` | no | `false` | Block until the operation completes and return the result (synchronous). Default: return immediately and poll package_status. | + +**Returns:** `object` +**Notes:** `MainThreadRequired = false`. Mutating — requires `confirm=true`; `dry_run=true` returns a preview without applying. Async by default (returns `in_progress`; poll `package_status`); with `wait=true` it blocks until completion (up to a 25s timeout). A successful remove triggers a recompile/domain reload — poll `recompile_status`; the reload can drop a synchronous reply, so `package_status` confirms the outcome. Returns `busy` if another package operation is already in progress. + +### `package_resolve` +Resolve/refresh packages from the manifest (re-fetch and re-link). May trigger a recompile/domain reload — poll recompile_status. Its outcome is recorded for package_status. + +No parameters. + +**Returns:** `object` +**Notes:** `MainThreadRequired = true`. Fire-and-forget — `Client.Resolve()` schedules a resolve on a later tick, so the response returns before any reload. May trigger a recompile/domain reload — poll `recompile_status`. Its outcome is recorded in the status file, so `package_status` validates it. + +### `package_status` +Status of the last async package operation (add/remove/resolve): idle | in_progress | completed | failed, with the added package, manifest, and any error. + +No parameters. + +**Returns:** `string` +**Notes:** `MainThreadRequired = false`. Reads a Temp status file that survives the domain reload an add/remove/resolve triggers, so a lost synchronous reply or an interrupted async op is recoverable by polling. Returns `{"status":"idle"}` when no operation has run. + +See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/prefabs.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/prefabs.md new file mode 100644 index 00000000..9f5c13aa --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/prefabs.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). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/project-settings.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/project-settings.md new file mode 100644 index 00000000..142cc4fe --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/project-settings.md @@ -0,0 +1,239 @@ +# Project settings commands + +Read and change a representative slice of Unity's project-wide settings (Audio, Graphics, Input, Physics, Player, Quality, Tags & Layers, Time). `get_*` commands read a group's current values into a `ProjectSettingsResponse`; `set_*` commands mutate them through the shared `confirm`/`dry_run` convention, so `confirm=true` is required to apply and `dry_run=true` previews the change without applying it. Every command in this group is `MainThreadRequired = true`. On a `set_*`, omitted fields are left unchanged, and the group is re-read after the write so the response reflects the resulting state. Settings writes are not undoable via Ctrl+Z. + +### `get_audio_settings` +Read project Audio settings (volume, rolloff scale, doppler factor). + +No parameters. + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. + +### `set_audio_settings` +Change project Audio settings. Requires confirm=true; use dry_run to preview. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `settings` | no | `–` | Fields to change; omitted fields are left unchanged (fields below). | +| `confirm` | no | `false` | Apply the change. Without it the call is refused. | +| `dry_run` | no | `false` | Preview the change without applying it. | + +`settings` fields: + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `volume` | no | `–` | Global audio volume (0..1). | +| `rolloffScale` | no | `–` | Global rolloff scale. | +| `dopplerFactor` | no | `–` | Global doppler factor. | + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`. + +### `get_graphics_settings` +Read GraphicsSettings (default render pipeline). + +No parameters. + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. + +### `set_graphics_settings` +Set the default render pipeline asset. Requires confirm=true; use dry_run to preview. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `settings` | no | `–` | Fields to change; omitted fields are left unchanged (fields below). | +| `confirm` | no | `false` | Apply the change. Without it the call is refused. | +| `dry_run` | no | `false` | Preview the change without applying it. | + +`settings` fields: + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `renderPipelineAsset` | no | `–` | Reference (path / guid / globalId) to a RenderPipelineAsset to set as the default. Pass an empty reference (`{}`) to select the built-in pipeline; omit to leave unchanged. | + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`. + +### `get_input_settings` +Read the legacy Input Manager axes (names and count). + +No parameters. + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. + +### `set_input_settings` +Tune a legacy Input Manager axis (sensitivity/gravity/dead) by name. Requires confirm=true; use dry_run to preview. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `settings` | no | `–` | Axis change. 'axis' selects the axis by name; omitted numeric fields are left unchanged (fields below). | +| `confirm` | no | `false` | Apply the change. Without it the call is refused. | +| `dry_run` | no | `false` | Preview the change without applying it. | + +`settings` fields: + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `axis` | yes | – | Name of the axis to modify (e.g. 'Horizontal'). | +| `sensitivity` | no | `–` | New sensitivity. | +| `gravity` | no | `–` | New gravity. | +| `dead` | no | `–` | New dead-zone size. | + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`. + +### `get_physics_settings` +Read Physics settings (gravity, solver iterations, bounce threshold). + +No parameters. + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. + +### `set_physics_settings` +Change Physics settings. Requires confirm=true; use dry_run to preview. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `settings` | no | `–` | Fields to change; omitted fields are left unchanged (fields below). | +| `confirm` | no | `false` | Apply the change. Without it the call is refused. | +| `dry_run` | no | `false` | Preview the change without applying it. | + +`settings` fields: + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `gravityX` | no | `–` | Gravity X component. | +| `gravityY` | no | `–` | Gravity Y component (e.g. -9.81). | +| `gravityZ` | no | `–` | Gravity Z component. | +| `defaultSolverIterations` | no | `–` | Default solver iteration count. | +| `bounceThreshold` | no | `–` | Bounce threshold velocity. | + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`. + +### `get_player_settings` +Read PlayerSettings (company/product/version, scripting backend, API level). + +No parameters. + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. + +### `set_player_settings` +Change PlayerSettings. Requires confirm=true; use dry_run to preview. Scripting backend / API level changes trigger a domain reload. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `settings` | no | `–` | Fields to change; omitted fields are left unchanged (fields below). | +| `confirm` | no | `false` | Apply the change. Without it the call is refused. | +| `dry_run` | no | `false` | Preview the change without applying it. | + +`settings` fields: + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `companyName` | no | `–` | Company name. | +| `productName` | no | `–` | Product name. | +| `bundleVersion` | no | `–` | Bundle/application version string. | +| `scriptingBackend` | no | `–` | Scripting backend (e.g. Mono2x, IL2CPP). Triggers a domain reload. | +| `apiCompatibilityLevel` | no | `–` | API compatibility level (e.g. NET_Standard_2_0, NET_Unity_4_8). Triggers a domain reload. | + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`. Changing `scriptingBackend` or `apiCompatibilityLevel` triggers a domain reload (`requiresDomainReload`); scripting-backend / API-level changes are read and written against the active build target. + +### `get_quality_settings` +Read QualitySettings (current level, level names, vSync, anti-aliasing). + +No parameters. + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. + +### `set_quality_settings` +Change QualitySettings. Requires confirm=true; use dry_run to preview. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `settings` | no | `–` | Fields to change; omitted fields are left unchanged (fields below). | +| `confirm` | no | `false` | Apply the change. Without it the call is refused. | +| `dry_run` | no | `false` | Preview the change without applying it. | + +`settings` fields: + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `level` | no | `–` | Quality level index (see levelNames from get_quality_settings). | +| `vSyncCount` | no | `–` | VSync count (0 = off, 1, 2). | +| `antiAliasing` | no | `–` | MSAA sample count (0, 2, 4, 8). | + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`. + +### `get_tags_layers` +Read the project's tags and (named) layers. + +No parameters. + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. + +### `set_tags_layers` +Add/remove tags and assign user layer names (index 8-31). Requires confirm=true; use dry_run to preview. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `settings` | no | `–` | Tag/layer changes to make (fields below). | +| `confirm` | no | `false` | Apply the change. Without it the call is refused. | +| `dry_run` | no | `false` | Preview the change without applying it. | + +`settings` fields: + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `addTags` | no | `–` | Tag names to add. | +| `removeTags` | no | `–` | Tag names to remove. | +| `setLayers` | no | `–` | User layer assignments (index 8-31); array of layer-assignment objects (fields below). | + +`setLayers[]` (layer-assignment) fields: + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `index` | yes | – | Layer index (8-31 for user layers). | +| `name` | yes | – | Layer name (empty string clears the slot). | + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`. User layers are indices 8-31 (0-7 are reserved). + +### `get_time_settings` +Read Time settings (fixedDeltaTime, maximumDeltaTime, timeScale). + +No parameters. + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. + +### `set_time_settings` +Change Time settings. Requires confirm=true; use dry_run to preview. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `settings` | no | `–` | Fields to change; omitted fields are left unchanged (fields below). | +| `confirm` | no | `false` | Apply the change. Without it the call is refused. | +| `dry_run` | no | `false` | Preview the change without applying it. | + +`settings` fields: + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `fixedDeltaTime` | no | `–` | Fixed timestep in seconds (e.g. 0.02). | +| `maximumDeltaTime` | no | `–` | Maximum allowed timestep in seconds. | +| `timeScale` | no | `–` | Time scale (1 = real-time). | + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`. + +See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/runtime.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/runtime.md new file mode 100644 index 00000000..75d301b4 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/runtime.md @@ -0,0 +1,162 @@ +# Runtime commands + +Commands served by the Player / dev-build server. Many require a Development build running `RuntimePipelineManager` — see [Runtime setup](../runtime-setup.md). Commands marked `RuntimeOnly` are only registered in a player build. + +### `runtime_status` +Get comprehensive runtime application status. + +No parameters. + +**Returns:** `RuntimeStatusResponse` +**Notes:** `MainThreadRequired = true`, `RuntimeOnly`. + +### `quit` +Gracefully quit the Unity application. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `exitCode` | no | `0` | Exit code for the application | + +**Returns:** `string` +**Notes:** `MainThreadRequired = true`, `RuntimeOnly`. + +### `set_target_framerate` +Set the target frame rate for the application. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `frameRate` | yes | `–` | Target frame rate (-1 for platform default, 0 for unlimited) | + +**Returns:** `string` +**Notes:** `MainThreadRequired = true`, `RuntimeOnly`. + +### `set_timescale` +Set the time scale for the application. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `scale` | yes | `–` | Time scale multiplier (0.0 to pause, 1.0 for normal speed) | + +**Returns:** `string` +**Notes:** `MainThreadRequired = true`, `RuntimeOnly`. + +### `simulate_key` +Simulate a keyboard key event (Input System). Drives the running app. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `key` | yes | `–` | Input System Key name, e.g. Space, W, Enter, LeftArrow. | +| `action` | no | `press` | down \| up \| press (down+up). | + +**Returns:** `InputSimulationResponse` +**Notes:** `MainThreadRequired = true`, `RuntimeOnly`. Requires the Input System package (`ENABLE_INPUT_SYSTEM`); reports unavailable otherwise. + +### `simulate_pointer` +Simulate a mouse/pointer event at screen coordinates (Input System). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `x` | yes | `–` | Screen X in pixels (origin bottom-left). | +| `y` | yes | `–` | Screen Y in pixels (origin bottom-left). | +| `action` | no | `click` | move \| down \| up \| click (down+up). | +| `button` | no | `left` | left \| right \| middle. | + +**Returns:** `InputSimulationResponse` +**Notes:** `MainThreadRequired = true`, `RuntimeOnly`. Requires the Input System package (`ENABLE_INPUT_SYSTEM`); reports unavailable otherwise. + +### `log` +Write a message to Unity console. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `message` | yes | `–` | Message to log to console | +| `level` | no | `info` | Log level: info, warning, error | + +**Returns:** `string` +**Notes:** `MainThreadRequired = true`, `RuntimeOnly`. + +### `console` +Get captured Unity console output (Editor or Player; supports tail, level filtering, and follow via a cursor). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `tail` | no | `100` | Maximum number of most-recent entries to return | +| `level` | no | `log` | Minimum severity to include: log | warn | error | +| `since` | no | `-1` | Cursor: only return entries newer than this seq. Use the 'cursor' from a previous response to follow. | + +**Returns:** `ConsoleLogResponse` +**Notes:** `MainThreadRequired = false`. Available in both the Editor and player builds. + +### `eval` +Evaluate C# code dynamically using Roslyn compiler. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `code` | yes | `–` | C# code to evaluate | +| `timeout` | no | `5000` | Timeout in milliseconds | + +**Returns:** `EvalResponse` +**Notes:** `MainThreadRequired = true`. Available on both editor and runtime. + +### `eval_file` +Evaluate C# code read from a `.cs` file on disk. A convenience alternative to `eval` for code +too long to pass inline — edit the file, then evaluate it. The resolved source is run through the +same evaluation path as `eval`. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `file` | yes | `–` | Path to a `.cs` file to evaluate | +| `timeout` | no | `5000` | Timeout in milliseconds | + +The `file` is read on the Unity side (relative paths resolve against the Unity process working +directory) and must end in `.cs`. A missing file, a non-`.cs` extension, or an empty file returns +a `Bad Request`. + +**Returns:** `EvalResponse` +**Notes:** `MainThreadRequired = true`. Available on both editor and runtime. + +### `reload_file` +Compile and apply in-place [HotReload] edits from a source file. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `filename` | yes | `–` | Source file containing [HotReload] methods (e.g. Assets/Scripts/Player.cs) | +| `timeout` | no | `30000` | Compilation timeout in milliseconds | +| `assemblyDir` | no | `–` | Directory to save compiled assemblies to disk (optional, default is in-memory only) | +| `pdb` | no | `false` | Emit debug symbols (portable PDB) mapped to the original source so breakpoints bind in your editor. Compiles unoptimized. | + +**Returns:** `HotReloadResponse` +**Notes:** `MainThreadRequired = true`. See [Hot reload](../hot-reload.md). + +### `reload_file_override` +Compile and apply hot reload file changes immediately. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `filename` | yes | `–` | Hot reload source file to compile (e.g. PlayerTweaks.cs) | +| `timeout` | no | `30000` | Compilation timeout in milliseconds | +| `assemblyDir` | no | `–` | Directory to save compiled assemblies to disk (optional, default is in-memory only) | + +**Returns:** `HotReloadResponse` +**Notes:** `MainThreadRequired = true`. See [Hot reload](../hot-reload.md). + +### `hotreload_status` +Show current hot reload registry status and statistics. + +No parameters. + +**Returns:** `HotReloadResponse` +**Notes:** `MainThreadRequired = true`, `RuntimeOnly`. + +### `cleanup_hotreload` +Remove old hot reload DLL versions and clear registry. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `assemblyDir` | yes | `–` | Directory containing assemblies to cleanup | +| `force_domain_reload` | no | `true` | Force Unity domain reload after cleanup | + +**Returns:** `HotReloadResponse` +**Notes:** `MainThreadRequired = true`, `RuntimeOnly`. + +See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/scenes.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/scenes.md new file mode 100644 index 00000000..5d5e5af9 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/scenes.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). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/scripts.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/scripts.md new file mode 100644 index 00000000..768404f6 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/commands/scripts.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). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/connectivity.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/connectivity.md new file mode 100644 index 00000000..5fe389e1 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/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\\AppData\Local\Unity\Editor\Editor.log` | +| macOS | `~/Library/Logs/Unity/Editor.log` | + +**Player log** (`Player.log`): + +| OS | Path | +|----|------| +| Windows | `C:\Users\\AppData\LocalLow\\\Player.log` | +| macOS | `~/Library/Logs///Player.log` | + +(`` and `` 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** | `/Library/Pipeline/.unity-pipeline-port` | +| **Runtime** | `/.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 `.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": "" +} +``` + +| 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 +``` + +- 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:/...` with `Authorization: Bearer `. + +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. diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/creating-commands.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/creating-commands.md new file mode 100644 index 00000000..8182a542 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/creating-commands.md @@ -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 Handler(...); +``` + +| Argument | Meaning | +|----------|---------| +| `name` | Unique command name used by the client (`unity command `). | +| `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. diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/hot-reload.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/hot-reload.md new file mode 100644 index 00000000..71d4c9aa --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/hot-reload.md @@ -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 "/Spinner.cs" + +# Emit debug symbols so breakpoints bind (compiles unoptimized): +unity command reload_file "/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 "/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 Name( 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`) and void (`Action`) originals: + +```csharp +public static object ExecuteWithHotReload(T instance, string methodName, Func originalMethod, params object[] parameters); +public static void ExecuteWithHotReload(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) diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/index.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/index.md new file mode 100644 index 00000000..bc2e27f6 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/index.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. | diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/runtime-setup.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/runtime-setup.md new file mode 100644 index 00000000..040ebdd1 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/runtime-setup.md @@ -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:/`, 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 `; 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. diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/safety-and-mutations.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/safety-and-mutations.md new file mode 100644 index 00000000..8acfd216 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/safety-and-mutations.md @@ -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(""))` 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). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/testing.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/testing.md new file mode 100644 index 00000000..5825b2f7 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Documentation~/testing.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 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). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor.meta new file mode 100644 index 00000000..ac21aa23 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5144d7ecd5530424ab66f205802d8c9e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring.meta new file mode 100644 index 00000000..266f5869 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 113bc2bcfe487eb469aa654e6718f78b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/AuthoringUndoScope.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/AuthoringUndoScope.cs new file mode 100644 index 00000000..65fb5254 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/AuthoringUndoScope.cs @@ -0,0 +1,45 @@ +using System; +using UnityEditor; + +namespace Unity.Pipeline.Editor.Authoring +{ + /// + /// + /// 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. + /// + /// + /// + /// using (new AuthoringUndoScope("Create Enemy")) + /// { + /// var go = new GameObject("Enemy"); + /// Undo.RegisterCreatedObjectUndo(go, "Create Enemy"); + /// // ... further registered mutations collapse into the same step + /// } + /// + /// + /// + /// 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. + /// + /// + 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); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/AuthoringUndoScope.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/AuthoringUndoScope.cs.meta new file mode 100644 index 00000000..a0ef9b15 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/AuthoringUndoScope.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6ef72626627670a4eadfccdab20cd9ba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/ObjectResolver.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/ObjectResolver.cs new file mode 100644 index 00000000..6dfccac4 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/ObjectResolver.cs @@ -0,0 +1,250 @@ +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine; +using UnityEngine.SceneManagement; +using Object = UnityEngine.Object; + +namespace Unity.Pipeline.Editor.Authoring +{ + /// + /// Resolves an agent-supplied handle to a live UnityEngine.Object, and + /// describes any object back into a canonical identity. + /// Backed by GlobalObjectId, which addresses both assets (GUID + fileId) and scene objects. + /// + public static class ObjectResolver + { + /// + /// Try to resolve a handle to a loaded object. Returns false with an + /// when the handle is empty or does not resolve. + /// + 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; + } + + /// + /// Produce the canonical identity for an object (assets get path/guid/fileId; loaded objects + /// get instanceId/hierarchyPath). Returns null for a null object. + /// + 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; + } + + /// True when a path is explicitly rooted at "Assets/" or "Packages/" (or is exactly one of those). + 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; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/ObjectResolver.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/ObjectResolver.cs.meta new file mode 100644 index 00000000..a33169d2 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/ObjectResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 26becd8c305c973488fa15b9ea058a00 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/ProjectPaths.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/ProjectPaths.cs new file mode 100644 index 00000000..64b9d486 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/ProjectPaths.cs @@ -0,0 +1,145 @@ +using System; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Authoring +{ + /// + /// Project-path handling for authoring commands. + /// + /// - Paths resolve relative to the configurable (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 (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). + /// + 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}"; + + /// Absolute path to the project root (the folder that contains Assets/). + public static string ProjectRoot => Path.GetDirectoryName(Application.dataPath); + + /// + /// 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. + /// + 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); + } + } + + /// Reset the authoring root to the default ("Assets"). + public static void ResetAuthoringRoot() => EditorPrefs.DeleteKey(PrefKey); + + /// + /// Resolve an agent-supplied path to a normalized, project-relative path confined to + /// . Bare paths are taken relative to the root; explicit + /// "Assets/..." paths and absolute paths under the project are honored. Returns null with an + /// when the path escapes the root or the project. + /// + 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; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/ProjectPaths.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/ProjectPaths.cs.meta new file mode 100644 index 00000000..528dcdfb --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Authoring/ProjectPaths.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 462c39b6cec1060408ca75a32bf31d89 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/BuildProcessors.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/BuildProcessors.meta new file mode 100644 index 00000000..b17ac39c --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/BuildProcessors.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6a4d02bf29da41d499ea151c638ee920 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/BuildProcessors/PipelineRuntimeBuildProcessor.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/BuildProcessors/PipelineRuntimeBuildProcessor.cs new file mode 100644 index 00000000..c29c037f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/BuildProcessors/PipelineRuntimeBuildProcessor.cs @@ -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 +{ + /// + /// 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. + /// +#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}"); + } + + /// + /// Find all RuntimePipelineManager components in scenes that will be included in the build. + /// + private List FindRuntimeManagersInBuildScenes() + { + var managers = new List(); + + 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(true)) + .ToArray(); + managers.AddRange(sceneManagers); + } + return managers; + } + + /// + /// 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. + /// + 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(true)) + { + manager.SetAllowedReloadRoots(roots); + } + } + } + + /// + /// 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). + /// + public static List CollectProjectRoots() + { + var roots = new List { 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"; + + /// + /// Verify the bundled Roslyn DLLs against the committed CHECKSUMS manifest, locating them + /// relative to this package on disk. Throws on any + /// mismatch so a tampered/swapped DLL cannot be built into a player. + /// + 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}"); + } + } + + /// + /// Core, side-effect-free integrity check (so it is directly unit-testable). Returns null + /// when every DLL listed in exists under + /// with a matching SHA-256 and no unlisted DLL is present; + /// otherwise returns a human-readable error describing the first problem found. + /// + 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; + } + + /// SHA-256 of a file as a lowercase hex string. + 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 " # comment" lines, skipping blanks and '#' comment lines. + private static Dictionary ParseChecksums(string checksumsPath) + { + var result = new Dictionary(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; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/BuildProcessors/PipelineRuntimeBuildProcessor.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/BuildProcessors/PipelineRuntimeBuildProcessor.cs.meta new file mode 100644 index 00000000..acd03295 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/BuildProcessors/PipelineRuntimeBuildProcessor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 50df0b5c45692fb4a81122ca6eec4709 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands.meta new file mode 100644 index 00000000..0316bec0 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e52bfc361c2ed7846a3c4c69b24828c5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation.meta new file mode 100644 index 00000000..e98352ae --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 881bcacc155b44229761a2e2b2917fae +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/AnimationClipCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/AnimationClipCommands.cs new file mode 100644 index 00000000..067cb5a8 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/AnimationClipCommands.cs @@ -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 +{ + /// + /// Group A of CLI-214: author assets and their float curves. + /// + /// These sit on the CLI-190 authoring foundation, so: + /// - every agent-supplied asset path is funnelled through + /// (sandboxed to the authoring root), + /// - an existing clip is addressed with an resolved by + /// and re-confined to the authoring root, + /// - destructive/overwriting operations require an explicit confirm argument and every + /// command supports dry_run. + /// + /// NOTE: AnimationClip sub-object edits (curves, clip settings) are NOT covered by Unity's Undo, + /// so changes are persisted with + + /// rather than wrapped in an . Only float curves are supported in + /// v1 (object-reference / PPtr curves are out of scope). + /// + 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(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; + } + + /// + /// Resolve a clip handle to a loaded and its sandbox-confined asset + /// path, rejecting handles that don't point at an on-disk AnimationClip or that escape the root. + /// + 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); + } + + /// + /// Resolve a curve-binding component type. Reuses the component , + /// which only accepts subclasses (curves bind to component properties). + /// + 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; + } + + /// Build an from the agent-supplied keyframe array. + 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(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(); + var value = keyObj["value"].ToObject(); + var inTangent = keyObj["inTangent"]?.ToObject() ?? 0f; + var outTangent = keyObj["outTangent"]?.ToObject() ?? 0f; + + var keyframe = new Keyframe(time, value, inTangent, outTangent); + + var weightedToken = keyObj["weightedMode"]; + if (weightedToken != null && weightedToken.Type != JTokenType.Null) + { + var name = weightedToken.ToObject(); + if (!Enum.TryParse(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()); + } + + /// Create the asset's parent folder chain if it does not yet exist. + 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 of set_animation_curve / remove_animation_curve: the clip identity extended + /// with the affected binding and its key count. + /// + [Serializable] + public class SetAnimationCurveResult : AuthoringResult + { + [JsonProperty("binding")] + public CurveBinding Binding { get; set; } + + [JsonProperty("keyCount")] + public int KeyCount { get; set; } + } + + /// A curve binding's address: GameObject path, component type, and property name. + [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; } + } + + /// Result of get_animation_clip: clip metadata and its curve bindings. + [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 Bindings { get; set; } = new List(); + } + + /// A single curve binding in , optionally with its keys. + [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 Keys { get; set; } + } + + /// A single keyframe in a curve binding. + [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; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/AnimationClipCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/AnimationClipCommands.cs.meta new file mode 100644 index 00000000..c5d610e2 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/AnimationClipCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 764503eaf79a41668748014af111eaf2 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/AnimatorControllerCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/AnimatorControllerCommands.cs new file mode 100644 index 00000000..86b1b64d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/AnimatorControllerCommands.cs @@ -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 +{ + /// + /// Group B of CLI-214: author assets — layers, parameters, states + /// and transitions. Built on UnityEditor.Animations, 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 + /// / , and every command supports + /// dry_run. Some AnimatorController API calls register Undo internally, but sub-object edits + /// are not reliably undoable, so changes are persisted with + + /// . + /// + /// Out of scope (v1): BlendTree authoring (assigning an EXISTING BlendTree as a state motion is + /// allowed), StateMachineBehaviours, sub-state machines, and humanoid/avatar configuration. + /// + 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(); + break; + case AnimatorControllerParameterType.Int: + if (defaultValue != null && defaultValue.Type != JTokenType.Null) defInt = defaultValue.ToObject(); + break; + case AnimatorControllerParameterType.Bool: + if (defaultValue != null && defaultValue.Type != JTokenType.Null) defBool = defaultValue.ToObject(); + 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(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(), position[1].ToObject(), 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; + } + + /// + /// 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. + /// + private static List ParseConditions(JArray conditions, AnimatorController ctrl) + { + var parsed = new List(); + 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(); + var modeName = obj["mode"]?.ToObject(); + 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(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() ?? 0f; + + parsed.Add(new ParsedCondition { Parameter = parameterName, Mode = mode, Threshold = threshold }); + } + + return parsed; + } + + /// + /// 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. + /// + 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(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 + } + } + + /// + /// 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'). + /// + 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(); + if (asDouble != Math.Floor(asDouble)) + { + error = $"Layer index must be an integer; got {asDouble}."; + return false; + } + index = (int)asDouble; + } + else + { + index = layer.ToObject(); + } + + 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(); + 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; + } + + /// + /// 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. + /// + 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 ---- + + /// A structured, non-throwing error envelope ({ error, code }) returned at HTTP 200. + [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 Parameters { get; set; } = new List(); + + [JsonProperty("layers")] + public List Layers { get; set; } = new List(); + } + + [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 States { get; set; } = new List(); + + [JsonProperty("transitions")] + public List Transitions { get; set; } = new List(); + } + + [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 Conditions { get; set; } = new List(); + + [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; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/AnimatorControllerCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/AnimatorControllerCommands.cs.meta new file mode 100644 index 00000000..403e7499 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/AnimatorControllerCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 426c006ffb43461e94ab3dde2caed7be diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/TimelineCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/TimelineCommands.cs new file mode 100644 index 00000000..61d152f2 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/TimelineCommands.cs @@ -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 +{ + /// + /// Group C of CLI-214: author TimelineAssets (tracks + clips). Timeline ships in the OPTIONAL + /// com.unity.timeline package, so the Editor assembly does NOT reference Unity.Timeline + /// (that would break compilation when the package is absent). Every command: + /// + /// 1. runs ; if Timeline is absent it returns a structured + /// { error, code: "package_not_found" } at HTTP 200 (no exception thrown), and + /// 2. reaches the Timeline API entirely through reflection. + /// + /// Type names used (all in Unity.Timeline): UnityEngine.Timeline.TimelineAsset, + /// TrackAsset, TimelineClip, and the concrete track types + /// (AnimationTrack, AudioTrack, ActivationTrack, ControlTrack, + /// PlayableTrack, SignalTrack, MarkerTrack). + /// + /// Out of scope (v1): markers, signal emitters/receivers, custom playable tracks, and track bindings + /// to scene objects. + /// + public static class TimelineCommands + { + private const string Asm = "Unity.Timeline"; + + // Friendly track-type name => concrete TrackAsset subclass full name. + private static readonly Dictionary TrackTypeNames = new Dictionary(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); + } + + /// + /// Set the timeline frame rate, preferring the current editorSettings.frameRate (double) + /// and falling back to the obsolete editorSettings.fps (float) on older package versions. + /// + 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 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 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; + } + + /// + /// Find a CreateClip 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). + /// + 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 Tracks { get; set; } = new List(); + } + + [Serializable] + public class TimelineTrackInfo + { + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("type")] + public string TrackType { get; set; } + + [JsonProperty("clips")] + public List Clips { get; set; } = new List(); + } + + [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; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/TimelineCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/TimelineCommands.cs.meta new file mode 100644 index 00000000..538c4fc7 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/TimelineCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dcf5399fb1544ee3a5b069c535bcd002 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/TimelineGuard.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/TimelineGuard.cs new file mode 100644 index 00000000..d4f7ef9c --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/TimelineGuard.cs @@ -0,0 +1,52 @@ +using System; + +namespace Unity.Pipeline.Editor.Commands.Animation +{ + /// + /// Presence guard for the optional com.unity.timeline package (CLI-214, Group C). + /// + /// The Editor assembly deliberately does NOT reference Unity.Timeline (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. + /// + public static class TimelineGuard + { + /// The package id, surfaced in the not-installed error message. + public const string PackageId = "com.unity.timeline"; + + /// Assembly-qualified name of the package's root asset type. + public const string TimelineAssetTypeName = "UnityEngine.Timeline.TimelineAsset, Unity.Timeline"; + + private static bool? s_Installed; + + /// + /// True when the Timeline package is installed (its TimelineAsset type resolves). + /// + public static bool IsInstalled() + { + if (s_Installed.HasValue) + return s_Installed.Value; + + s_Installed = ResolveTimelineAssetType() != null; + return s_Installed.Value; + } + + /// + /// The UnityEngine.Timeline.TimelineAsset , or null if the package is + /// absent. Callers use this as the entry point for further reflection. + /// + public static Type ResolveTimelineAssetType() => Type.GetType(TimelineAssetTypeName, throwOnError: false); + + /// + /// The structured "package not installed" error (HTTP 200, no exception) that every Timeline + /// command returns when is false. + /// + public static ErrorResult NotInstalledError() => + ErrorResult.PackageStyle("package_not_found", $"{PackageId} is not installed"); + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/TimelineGuard.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/TimelineGuard.cs.meta new file mode 100644 index 00000000..607fca66 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/TimelineGuard.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f3f8120d06814b2c86d46503ac132022 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets.meta new file mode 100644 index 00000000..23d44dc8 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 39163f849a3aebd47a3f718fdc0224f9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/AssetCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/AssetCommands.cs new file mode 100644 index 00000000..4bf2e8f4 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/AssetCommands.cs @@ -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 +{ + /// + /// 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 (sandboxed + /// to the authoring root; rejects "../" and out-of-project writes), + /// - results are returned as the canonical envelope so an agent can + /// reference the asset in a follow-up call, + /// - existing assets are addressed with an resolved by + /// . + /// + /// NOTE: AssetDatabase create/move/copy/rename/delete are NOT part of Unity's Undo system, so + /// would have no effect here — these operations are intentionally + /// not wrapped in an undo scope, and destructive/overwriting operations instead require an explicit + /// confirm argument (and support dry_run) so an agent cannot silently lose data. + /// + 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(); + 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; + } + + /// + /// Resolve a user-supplied type name (short or fully-qualified) to a by + /// scanning non-dynamic loaded assemblies. Returns null when no match is found. + /// + 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; + } + + /// + /// Resolve an 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. + /// + 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; + } + + /// Create the asset's parent folder chain if it does not yet exist. + 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); + } + + /// Convert a project-relative asset path to an absolute filesystem path. + private static string ToAbsolute(string projectRelative) + { + return $"{ProjectPaths.ProjectRoot.Replace('\\', '/')}/{projectRelative}"; + } + + /// + /// CLI-221: resolve the shader to assign to a newly-created . + /// - An explicit must resolve via ; + /// 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. + /// + 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."); + } + + /// + /// Resolve a user-supplied type name to a . 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). + /// + 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; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/AssetCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/AssetCommands.cs.meta new file mode 100644 index 00000000..6afcc352 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/AssetCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 47b49bf20465486fb6ffdf6c03775b16 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/AssetImportCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/AssetImportCommands.cs new file mode 100644 index 00000000..e6741dd7 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/AssetImportCommands.cs @@ -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 +{ + /// + /// + /// Import-settings authoring commands (CLI-191 + CLI-212). Reads and edits the + /// for an asset. + /// + /// + /// + /// set_import_settings sets named members on the importer from a JSON object and re-imports. + /// For the Default platform it sets top-level public properties/fields via reflection (works + /// across every importer kind). For a real platform on a / + /// , keys are applied onto the platform-override struct + /// ( / ) which + /// is unreachable by top-level reflection. has no per-platform overrides. + /// + /// + /// + /// get_import_settings reads the importer state structured by importer type (texture / model / + /// audio), including the default-platform fields and, for textures/audio, one platform override block. + /// + /// + /// + /// Unknown keys are reported back rather than silently ignored, so an agent gets actionable feedback. + /// + /// + /// + /// 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 confirm is required. + /// + /// + 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 s_SupportedPlatforms = new HashSet(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(); + 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 + { + ["userData"] = importer.userData, + ["assetBundleName"] = importer.assetBundleName + }; + result.PlatformOverride = null; + break; + } + + return result; + } + + // ------------------------------------------------------------------ resolution / helpers + + /// + /// Resolve an to its , confined to the + /// authoring root. Throws an actionable on any failure. + /// + 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; + } + + /// Normalize a caller platform name to its canonical capitalization (e.g. "ios" -> "iOS"). + private static string Canonical(string platform) + { + foreach (var supported in s_SupportedPlatforms) + { + if (string.Equals(supported, platform, StringComparison.OrdinalIgnoreCase)) + return supported; + } + return platform; + } + + /// + /// 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. + /// + 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 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); + } + } + + /// + /// 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. + /// + 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; + } + + /// + /// Convert a JSON token to (with case-insensitive enum-name support) + /// and, on success, invoke . Returns false when the value cannot convert. + /// + private static bool TryConvertAndSet(Type targetType, JToken value, Action set) + { + try + { + if (!TryConvert(targetType, value, out var converted)) + return false; + set(converted); + return true; + } + catch + { + return false; + } + } + + /// + /// 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 . + /// + 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(); + 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()); + return true; + } + + return false; + } + + converted = value.ToObject(targetType); + return true; + } + + // ------------------------------------------------------------------ texture platform overrides + + private static void ApplyTexturePlatform(TextureImporter texture, JObject settings, List 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); + } + } + + /// + /// 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 =false (dry-run) mode the member is only + /// probed for existence, never mutated. + /// + 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()); + } + + /// True when a settable boolean property or field with that name exists on the type. + 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); + } + + /// + /// Write a boolean property or field (matching '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. + /// + 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 ReadTextureDefaults(TextureImporter texture) + { + return new Dictionary + { + ["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 ReadTexturePlatformOverride(TextureImporter texture, string canonicalPlatform, bool isDefault) + { + var settings = isDefault + ? texture.GetDefaultPlatformTextureSettings() + : texture.GetPlatformTextureSettings(UnityPlatformName(canonicalPlatform)); + + return new Dictionary + { + ["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 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()) + { + 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 ReadAudioDefaults(AudioImporter audio) + { + var sample = audio.defaultSampleSettings; + return new Dictionary + { + ["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 ReadAudioPlatformOverride(AudioImporter audio, string canonicalPlatform, bool isDefault) + { + if (isDefault) + { + var sample = audio.defaultSampleSettings; + return new Dictionary + { + ["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 + { + ["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 ReadModelDefaults(ModelImporter model) + { + return new Dictionary + { + // 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); + } + } + + /// + /// Result of set_import_settings: target platform plus which keys were applied vs. unknown. + /// + [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 Applied { get; set; } = new List(); + + [Newtonsoft.Json.JsonProperty("unknown")] + public List Unknown { get; set; } = new List(); + } + + /// + /// Result of get_import_settings: the importer type, default-platform settings, and (for + /// textures/audio) one platform-override block. platformOverride is null for ModelImporter and + /// non-import assets. + /// + [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 Settings { get; set; } + + [Newtonsoft.Json.JsonProperty("platform")] + public string Platform { get; set; } + + [Newtonsoft.Json.JsonProperty("platformOverride")] + public Dictionary PlatformOverride { get; set; } + } + + /// Structured error payload serialized into the thrown ArgumentException message. + [Serializable] + public class ErrorResult + { + [Newtonsoft.Json.JsonProperty("code")] + public string Code { get; set; } + + [Newtonsoft.Json.JsonProperty("message")] + public string Message { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/AssetImportCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/AssetImportCommands.cs.meta new file mode 100644 index 00000000..f1ef29b3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/AssetImportCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cb5b302104dc446d8d94f33252087062 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/FindAssetsResult.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/FindAssetsResult.cs new file mode 100644 index 00000000..18c9cc53 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/FindAssetsResult.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Unity.Pipeline.Models; + +namespace Unity.Pipeline.Editor.Commands.Assets +{ + /// + /// Structured result for the find_assets command (CLI-191): a list of matched assets, each + /// described as the canonical (path / GUID / type) so an agent can + /// feed any entry straight back into another command as an . + /// + /// 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. + /// + [System.Serializable] + public class FindAssetsResult + { + /// The AssetDatabase filter string that was executed (for diagnostics). + [JsonProperty("filter")] + public string Filter { get; set; } + + /// Number of assets returned (after the limit is applied). + [JsonProperty("count")] + public int Count { get; set; } + + /// True when the result was capped by the limit argument. + [JsonProperty("truncated")] + public bool Truncated { get; set; } + + /// The matched assets, each with path / GUID / type. + [JsonProperty("assets")] + public List Assets { get; set; } = new List(); + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/FindAssetsResult.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/FindAssetsResult.cs.meta new file mode 100644 index 00000000..c9166401 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/FindAssetsResult.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7ff032cead554b7f97cd2467a83abd35 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/FolderCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/FolderCommands.cs new file mode 100644 index 00000000..4d162401 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/FolderCommands.cs @@ -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 +{ + /// + /// Asset-folder authoring commands. Reference exemplar for the authoring foundation (CLI-190): + /// it validates input through the project-path sandbox () and returns + /// the canonical envelope so an agent can reference the created + /// folder in follow-up calls. + /// + 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(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); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/FolderCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/FolderCommands.cs.meta new file mode 100644 index 00000000..25bacd03 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/FolderCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b78420627d9cd1e43835d9088975e691 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/TextFileCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/TextFileCommands.cs new file mode 100644 index 00000000..365a9a5f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/TextFileCommands.cs @@ -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 +{ + /// + /// 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 , 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 confirm argument; dry_run is supported. + /// Filesystem writes are not part of Unity's Undo system. + /// + 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}"; + } + } + + /// Result of read_text_file: the file contents plus its identity and size. + [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; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/TextFileCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/TextFileCommands.cs.meta new file mode 100644 index 00000000..b8375b84 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Assets/TextFileCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f57a6d794d1e4c40976f65ec3774b8ed diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Authoring.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Authoring.meta new file mode 100644 index 00000000..6184fe61 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Authoring.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7512e3b6952430f4b8fb5c67dd22f13f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Authoring/AuthoringConfigCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Authoring/AuthoringConfigCommands.cs new file mode 100644 index 00000000..e941eea7 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Authoring/AuthoringConfigCommands.cs @@ -0,0 +1,29 @@ +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Authoring; + +namespace Unity.Pipeline.Editor.Commands.Authoring +{ + /// + /// 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). + /// + 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 }; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Authoring/AuthoringConfigCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Authoring/AuthoringConfigCommands.cs.meta new file mode 100644 index 00000000..76c13ef8 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Authoring/AuthoringConfigCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c4429604fb18e8347a2e2efdefc49663 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/AutoTickCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/AutoTickCommand.cs new file mode 100644 index 00000000..0801f308 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/AutoTickCommand.cs @@ -0,0 +1,87 @@ +using System; +using System.Reflection; +using Unity.Pipeline.Commands; +using UnityEditor; + +namespace Unity.Pipeline.Editor.Commands +{ + /// + /// 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. + /// + 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"; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/AutoTickCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/AutoTickCommand.cs.meta new file mode 100644 index 00000000..1b29ec1c --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/AutoTickCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 76bae7b5ceb53dd498f891ed7f1e0d19 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking.meta new file mode 100644 index 00000000..131b0325 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 758dc2e598a448e5b80e66443fede0d9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/BakeSceneGuard.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/BakeSceneGuard.cs new file mode 100644 index 00000000..41227768 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/BakeSceneGuard.cs @@ -0,0 +1,42 @@ +using System.Linq; +using UnityEngine.SceneManagement; + +namespace Unity.Pipeline.Editor.Commands.Baking +{ + /// + /// Shared helpers for the CLI-215 bake commands (lighting / NavMesh / occlusion). All three bakes + /// act on the currently open scene(s) — 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 { code: "no_scene" } result (returned, not thrown, + /// to match the CLI-215 spec), and nothing is started. + /// + internal static class BakeSceneGuard + { + /// The structured "no_scene" error payload returned by a trigger with no bakeable scene. + 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." + }; + } + + /// + /// 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 and cannot host a + /// bake (its data has nowhere to live), so it does not count. + /// + internal static bool HasBakeableScene() + { + return OpenScenes().Any(s => s.isLoaded && !string.IsNullOrEmpty(s.path)); + } + + /// Enumerate every open scene by index. + internal static System.Collections.Generic.IEnumerable OpenScenes() + { + for (int i = 0; i < SceneManager.sceneCount; i++) + yield return SceneManager.GetSceneAt(i); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/BakeSceneGuard.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/BakeSceneGuard.cs.meta new file mode 100644 index 00000000..40ddf756 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/BakeSceneGuard.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cf05be2261cb475c9cf3d7fccbf65ff5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/LightingBakeCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/LightingBakeCommands.cs new file mode 100644 index 00000000..0a428efa --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/LightingBakeCommands.cs @@ -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 +{ + /// + /// 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 + /// . + /// + /// Why async (trigger-then-poll, mirroring ): a full lightmap bake runs + /// for seconds-to-minutes. kicks the bake off and returns + /// immediately; bake_lighting therefore returns { status: "baking", bakeId } right + /// away and the caller polls lighting_bake_status until it reports completed. + /// + /// 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 reconciles our recorded + /// state against the live flag and a small status file under + /// Temp/ (which persists across the reload, mirroring RecompileCommand). Completion is detected via + /// the callback and, defensively, by polling + /// flipping false in . + /// + [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(); + var unknown = new System.Collections.Generic.List(); + + 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"); + } + } + + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + static void OnReload() + { + if (!File.Exists(StatusFile)) + return; + + LightingBakeStatus prior; + try + { + prior = JsonConvert.DeserializeObject(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 + + /// + /// The active for the open scene. When the scene has no explicit + /// LightingSettings asset assigned, Unity falls back to a hidden per-scene default that + /// / + /// still exposes; we read/write that. records which we used. + /// + /// When none is available and is true, a new settings asset is + /// created and assigned so a real (non-dry_run) set has a target. When + /// is false (dry_run), this returns null rather than creating/assigning anything, so the + /// dry_run "no writes" contract holds. + /// + 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() + }; + } + + /// + /// Apply one settings key/value to the LightingSettings. Returns false for unrecognized keys so + /// the caller can report them as unknown[]. Enum-valued fields accept their string names + /// (case-insensitive). In dry-run mode we still validate (parse) the value but do not assign. + /// + static bool TryApply(LightingSettings ls, string key, JToken value, bool dryRun) + { + switch (key) + { + case "lightmapper": + return SetEnum(value, dryRun, v => ls.lightmapper = v); + case "bakedGI": + if (!dryRun) ls.bakedGI = value.Value(); + return true; + case "realtimeGI": + if (!dryRun) ls.realtimeGI = value.Value(); + return true; + case "directSampleCount": + if (!dryRun) ls.directSampleCount = value.Value(); + return true; + case "indirectSampleCount": + if (!dryRun) ls.indirectSampleCount = value.Value(); + return true; + case "environmentSampleCount": + if (!dryRun) ls.environmentSampleCount = value.Value(); + return true; + case "bounces": + if (!dryRun) ls.maxBounces = value.Value(); + return true; + case "lightmapResolution": + if (!dryRun) ls.lightmapResolution = value.Value(); + return true; + case "lightmapPadding": + if (!dryRun) ls.lightmapPadding = value.Value(); + return true; + case "maxLightmapSize": + if (!dryRun) ls.lightmapMaxSize = value.Value(); + return true; + case "lightmapCompression": + return SetEnum(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(); + return true; + } + var modeStr = value.Value() ?? 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(); + return true; + case "aoMaxDistance": + if (!dryRun) ls.aoMaxDistance = value.Value(); + return true; + case "filteringMode": + return SetEnum(value, dryRun, v => ls.filteringMode = v); + default: + return false; + } + } + + static bool SetEnum(JToken value, bool dryRun, Action 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(); + 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(); + if (!Enum.TryParse(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 + + /// + /// Collect post-bake lightmap statistics from : 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. + /// + 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; + } + + /// Rough byte estimate for a baked lightmap texture (4 bytes/pixel; good enough for a size signal). + 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}"); + } + } + } + + /// Status/result payload for bake_lighting / lighting_bake_status. + [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; } + } + + /// Post-bake lightmap statistics (CLI-215 acceptance: lightmapCount >= 1, bakeTimeMs > 0). + [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; } + } + + /// Result of get_lighting_settings (and the dry-run payload of bake_lighting). + [Serializable] + public class LightingSettingsResult + { + /// False when no LightingSettings could be read for the active scene. + [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; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/LightingBakeCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/LightingBakeCommands.cs.meta new file mode 100644 index 00000000..e8d6736c --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/LightingBakeCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dd7529c9d1f14ff98b9eb3e9a1e8ba5b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/NavMeshBakeCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/NavMeshBakeCommands.cs new file mode 100644 index 00000000..6b557334 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/NavMeshBakeCommands.cs @@ -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 +{ + /// + /// CLI-215 Group B — NavMesh bake commands, targeting the built-in legacy baker + /// (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 (com.unity.ai.navigation, NavMeshSurface + /// components) is OUT for v1 — we do NOT add an asmdef reference to it. bake_navmesh_surfaces + /// is a guarded stub that probes for the package by reflection and returns + /// { code: "package_not_found" } when it is absent (full support is a follow-up). + /// + /// Async pattern mirrors / : trigger + /// returns immediately, completion is detected by flipping + /// false (polled in ), and in-flight state is held in static + /// fields reconciled against a Temp/ status file across domain reloads. + /// + [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(); + var unknown = new List(); + + 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 }; + } + + /// + /// Guarded stub for the AI Navigation package (com.unity.ai.navigation) NavMeshSurface + /// bake path. We never reference that assembly, so probe for the type by reflection; absent → the + /// documented package_not_found result. Full multi-surface baking is a follow-up. + /// + [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(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(); + 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(); + 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}"); + } + } + } + + /// Status/result payload for bake_navmesh / navmesh_bake_status. + [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; } + } + + /// Result of get_navmesh_settings (default-agent legacy bake settings). + [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; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/NavMeshBakeCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/NavMeshBakeCommands.cs.meta new file mode 100644 index 00000000..a3be55d0 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/NavMeshBakeCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5173e39c97c44308ad4b80462eaf7982 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/OcclusionBakeCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/OcclusionBakeCommands.cs new file mode 100644 index 00000000..d255a94e --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/OcclusionBakeCommands.cs @@ -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 +{ + /// + /// CLI-215 Group C — Occlusion Culling bake commands. Trigger an async occlusion bake of the open + /// scene(s) via , poll its status, cancel + /// it, and clear the baked occlusion data. + /// + /// Async pattern mirrors / : trigger + /// returns immediately; completion is detected by + /// flipping false (polled in ); in-flight state is held in + /// static fields and reconciled against a Temp/ status file across domain reloads. + /// + [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(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}"); + } + } + } + + /// Status/result payload for bake_occlusion_culling / occlusion_bake_status. + [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; } + } + + /// Post-bake occlusion statistics (umbra data size in bytes). + [Serializable] + public class OcclusionBakeStats + { + [JsonProperty("umbraDataSizeBytes")] + public long UmbraDataSizeBytes { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/OcclusionBakeCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/OcclusionBakeCommands.cs.meta new file mode 100644 index 00000000..eb800ab8 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Baking/OcclusionBakeCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 00b06ed57bce4c0bb281527e016889dd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build.meta new file mode 100644 index 00000000..aff88155 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bdb8ef5b0513b0d488ac3be1a9a77185 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildCommand.cs new file mode 100644 index 00000000..51a74d8d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildCommand.cs @@ -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 +{ + /// + /// Triggers a Player build and reports the full (CLI-204). Fills out the + /// original build/build_status 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 / + /// ): + /// runs synchronously and freezes the main thread for the whole build (seconds to minutes). If + /// build ran it inline it would block the server's (serial) request loop and the call could + /// never return. Instead build validates + queues and returns queued immediately; an + /// hook runs the build on the next tick. Poll build_status + /// until status == "completed". + /// + /// build_status is deliberately MainThreadRequired = false: 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. + /// + [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 s_SupportedOptions = + new Dictionary(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(() => 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(); + + // ---- 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; + } + + /// + /// Main-thread pump: picks up a queued request and runs the (blocking) build. Guarded so a build + /// runs at most once at a time. + /// + 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() + }); + } + 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 ProjectFiles(BuildReport report) + { + var files = new List(); + 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 ProjectPackedAssets(BuildReport report) + { + var packed = new List(); + 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() + }; + + 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 ProjectSteps(BuildReport report, + out List errors, out List warnings) + { + errors = new List(); + warnings = new List(); + + var steps = new List(); + 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() + }; + + 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 --------------------------------------------------------------------------- + + /// Case-insensitive parse of a enum name. + 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)); + } + + /// True if some ancestor directory of already exists — a cheap, + /// non-mutating check that the build output folder is creatable. + 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. + } + } + + /// + /// 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. + /// + 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."); + } + } + + /// + /// Run 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 . + /// + static T RunOnMain(Func 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}"); + } + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildCommand.cs.meta new file mode 100644 index 00000000..39abf764 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6e718f193c77f724ea4de47fe8a2a3cb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildConfigCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildConfigCommands.cs new file mode 100644 index 00000000..2cb27174 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildConfigCommands.cs @@ -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 +{ + /// + /// Read/inspect build configuration for agents (CLI-204): enumerate targets, read/write the mutable + /// EditorUserBuildSettings fields, and list Build Profile assets. Scene-list management and + /// target switching are intentionally elsewhere (add_scene_to_build/remove_scene_from_build + /// from CLI-189, and switch_build_target). + /// + 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(); + var seen = new HashSet(); + + 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() != 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 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); + } + + /// Friendly label for common targets; falls back to the enum name. + 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(); + } + } + } + + /// + /// Reflection shim over the Unity 6 Build Profile API (UnityEditor.Build.Profile.BuildProfile), + /// so this assembly compiles and runs on older editors without the type. Used by + /// list_build_profiles and by build's optional profileName activation. + /// + 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 List() + { + var list = new List(); + 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)); + } + + /// Activate the named profile via the API's static setter; returns false if unavailable. + 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; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildConfigCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildConfigCommands.cs.meta new file mode 100644 index 00000000..e1445786 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildConfigCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e65c0b78842b492d8f9363ec749bc370 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildModels.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildModels.cs new file mode 100644 index 00000000..f585f349 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildModels.cs @@ -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 +{ + /// + /// One entry of list_build_targets (CLI-204): a BuildTarget 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. + /// + [Serializable] + public class BuildTargetInfo + { + /// BuildTarget enum name, e.g. "StandaloneWindows64". + [JsonProperty("name")] public string Name { get; set; } + + /// Friendly label, e.g. "Windows 64-bit". + [JsonProperty("displayName")] public string DisplayName { get; set; } + + /// BuildTargetGroup enum name, e.g. "Standalone". + [JsonProperty("targetGroup")] public string TargetGroup { get; set; } + + /// BuildPipeline.IsBuildTargetSupported(group, target) — module installed and usable. + [JsonProperty("isInstalled")] public bool IsInstalled { get; set; } + } + + /// One scene in the Build Settings scene list, projected for get_build_settings. + [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; } + } + + /// + /// Result of get_build_settings (CLI-204): the current build configuration read from + /// EditorUserBuildSettings / EditorBuildSettings plus the active target's IL2CPP code + /// generation mode. Scene-list management lives in add_scene_to_build / + /// remove_scene_from_build (CLI-189); this only reports the list. + /// + [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; } + + /// "OptimizeSpeed" | "OptimizeSize" for the active build target. + [JsonProperty("il2CppCodeGeneration")] public string Il2CppCodeGeneration { get; set; } + + [JsonProperty("scenes")] public List Scenes { get; set; } + } + + /// + /// Mutable build-settings fields for set_build_settings (CLI-204). Every field is nullable; + /// only the ones the caller supplies are changed. Note that and + /// only take effect when is also on. + /// + 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; } + } + + /// + /// Result of set_build_settings (CLI-204): the fields that were actually changed + /// () versus the supplied fields that already had the requested value + /// (). A dry run reports the same set it would + /// write, without changing anything. + /// + [Serializable] + public class SetBuildSettingsResult + { + [JsonProperty("success")] public bool Success { get; set; } + [JsonProperty("dryRun")] public bool DryRun { get; set; } + + /// Field name → new value, for each field that changed (or would change on a dry run). + [JsonProperty("applied")] public Dictionary Applied { get; set; } = new Dictionary(); + + /// Field name → current value, for supplied fields that already matched (no-ops). + [JsonProperty("skipped")] public Dictionary Skipped { get; set; } = new Dictionary(); + + [JsonProperty("message")] public string Message { get; set; } + + public static SetBuildSettingsResult Fail(string message) => + new SetBuildSettingsResult { Success = false, Message = message }; + } + + /// One Build Profile asset, projected for list_build_profiles (Unity 6 only). + [Serializable] + public class BuildProfileInfo + { + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("guid")] public string Guid { get; set; } + + /// The profile's build target name (best-effort; may be empty if unreadable). + [JsonProperty("platform")] public string Platform { get; set; } + + [JsonProperty("isActive")] public bool IsActive { get; set; } + } + + /// One output file produced by the build (a row of BuildReport.GetFiles()). + [Serializable] + public class BuildFileEntry + { + [JsonProperty("path")] public string Path { get; set; } + + /// BuildFile.role verbatim, e.g. "MainData", "AssetBundle", "BootConfig". + [JsonProperty("role")] public string Role { get; set; } + + [JsonProperty("sizeBytes")] public long SizeBytes { get; set; } + } + + /// One asset packed into a build bundle/file (a row of a PackedAssets entry). + [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; } + } + + /// A packed bundle/file with its per-asset size breakdown (requires DetailedBuildReport). + [Serializable] + public class PackedAssetEntry + { + [JsonProperty("bundlePath")] public string BundlePath { get; set; } + [JsonProperty("overheadBytes")] public long OverheadBytes { get; set; } + [JsonProperty("contents")] public List Contents { get; set; } + } + + /// One message emitted during a build step. + [Serializable] + public class BuildStepMessageEntry + { + /// "Log" | "Warning" | "Error" (mapped from LogType). + [JsonProperty("type")] public string Type { get; set; } + [JsonProperty("content")] public string Content { get; set; } + } + + /// One step of the build (a row of BuildReport.steps). + [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 Messages { get; set; } + } + + /// A build error/warning surfaced in build_status, with best-effort source file. + [Serializable] + public class BuildIssue + { + [JsonProperty("message")] public string Message { get; set; } + + /// Source file when the message carries a "File.cs(line,col): ..." prefix; omitted otherwise. + [JsonProperty("file", NullValueHandling = NullValueHandling.Ignore)] public string File { get; set; } + + /// Build a , reusing for file extraction. + 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 + }; + } + } + + /// + /// Full build_status result for a finished build (CLI-204) — a JSON-friendly projection of + /// UnityEditor.Build.Reporting.BuildReport. Optional collections are omitted when null + /// (), 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. + /// + [Serializable] + public class BuildReportResult + { + [JsonProperty("status")] public string Status { get; set; } + [JsonProperty("buildId")] public string BuildId { get; set; } + + /// "Succeeded" | "Failed" | "Cancelled" | "Unknown". + [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 Files { get; set; } + [JsonProperty("packedAssets", NullValueHandling = NullValueHandling.Ignore)] public List PackedAssets { get; set; } + [JsonProperty("buildSteps", NullValueHandling = NullValueHandling.Ignore)] public List BuildSteps { get; set; } + + [JsonProperty("errors")] public List Errors { get; set; } = new List(); + [JsonProperty("warnings")] public List Warnings { get; set; } = new List(); + } + + /// + /// A single build error/warning with best-effort file/line parsed from the message content. + /// Retained from the original build 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. + /// + [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(@"^(?.*?)\((?\d+),\d+\):\s*(?.*)$", RegexOptions.Singleline); + + /// + /// Build a 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. + /// + 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; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildModels.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildModels.cs.meta new file mode 100644 index 00000000..46f86061 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/BuildModels.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 37afada24be667e489d7e90a62f2ca1d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/SwitchBuildTargetCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/SwitchBuildTargetCommand.cs new file mode 100644 index 00000000..af0b8933 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/SwitchBuildTargetCommand.cs @@ -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 +{ + /// + /// Switches the active build target (CLI-204). 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 switching immediately, and an + /// 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 reconciles a left-over switching record + /// against the now-active target. switch_build_target_status reads the file off the main thread, + /// so it keeps answering while the switch holds the main thread. + /// + [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(() => 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\"}"; + } + + /// Main-thread pump: performs the queued switch. Blocks the main thread until it returns. + 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; } + } + } + + /// + /// 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. + /// + 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(Func 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}"); + } + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/SwitchBuildTargetCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/SwitchBuildTargetCommand.cs.meta new file mode 100644 index 00000000..ccb7ec17 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Build/SwitchBuildTargetCommand.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5b3d3b7a26bf4a5c8f5522cbfdd0574b diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Capture.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Capture.meta new file mode 100644 index 00000000..34a04a32 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Capture.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 87f4434ff2c6ed6419e6982345ffe0ef +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Capture/CaptureCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Capture/CaptureCommands.cs new file mode 100644 index 00000000..cea22f35 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Capture/CaptureCommands.cs @@ -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 +{ + /// + /// 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 ) and refreshes the AssetDatabase. + /// + /// Captures require a GPU; in batchmode/headless the device type is + /// and the commands throw so callers get a clear message + /// instead of an empty image. + /// + 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); + } + + /// + /// Shared capture core. Renders at the requested (clamped) size and writes the file when + /// is set. The image is inlined as base64 only when there is no + /// file, or on — a save_path result is otherwise + /// path-only, so agent tool results stay small (AUTHAPI-8). + /// caps the inline image's longest edge (no-op when no inline image is returned); the saved + /// file keeps the requested resolution. + /// + 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 + }; + } + + /// Scale (w, h) down so the longest edge is at most , preserving aspect. + 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))); + } + + /// Throw when no GPU is available (batchmode/headless), where a render would be blank. + private static void GuardHasGpu() + { + if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null) + throw new InvalidOperationException("No GPU available (batchmode/headless); cannot capture."); + } + + /// + /// Resolve the camera to capture: by name (if provided), else , + /// else the first enabled camera. Returns null when nothing matches. + /// + 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().FirstOrDefault(c => c.name == name); + return byName; + } + + if (Camera.main != null) + return Camera.main; + + return Camera.allCameras.FirstOrDefault(); + } + + /// + /// Render 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. + /// + 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); + } + } + + /// + /// When 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. + /// + 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; + } + } + + /// + /// 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). + /// + [Serializable] + public class CaptureResult + { + /// Rendered width in pixels (after clamping). + [JsonProperty("width")] + public int Width { get; set; } + + /// Rendered height in pixels (after clamping). + [JsonProperty("height")] + public int Height { get; set; } + + /// Image encoding; always "png". + [JsonProperty("encoding")] + public string Encoding { get; set; } + + /// Base64-encoded PNG bytes; null (omitted) when save_path is set without include_inline_image. + [JsonProperty("base64", NullValueHandling = NullValueHandling.Ignore)] + public string Base64 { get; set; } + + /// Length of the raw PNG byte array. + [JsonProperty("bytes")] + public int Bytes { get; set; } + + /// What was captured, e.g. "camera:Main Camera" or "sceneView". + [JsonProperty("source")] + public string Source { get; set; } + + /// Project-relative path the PNG was also written to, or null. + [JsonProperty("savedPath")] + public string SavedPath { get; set; } + + /// Inline image width when max_resolution downscaled it below the saved file's; omitted otherwise. + [JsonProperty("inlineWidth", NullValueHandling = NullValueHandling.Ignore)] + public int? InlineWidth { get; set; } + + /// Inline image height when max_resolution downscaled it below the saved file's; omitted otherwise. + [JsonProperty("inlineHeight", NullValueHandling = NullValueHandling.Ignore)] + public int? InlineHeight { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Capture/CaptureCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Capture/CaptureCommands.cs.meta new file mode 100644 index 00000000..bb1dd0e9 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Capture/CaptureCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3247a78c799021c4094dd3f7e3717aeb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/CaptureEditorElementCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/CaptureEditorElementCommand.cs new file mode 100644 index 00000000..0b53583c --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/CaptureEditorElementCommand.cs @@ -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 +{ + /// + /// Captures a UI Toolkit from an 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 . + /// + /// Selection, capture, and encoding are shared with the runtime command via + /// ; this command only resolves the target window. + /// + 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 /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"); + } + + /// + /// Find an open EditorWindow whose runtime type name or title matches . + /// Uses Resources.FindObjectsOfTypeAll so hidden/utility windows are included too, but + /// prefers a match whose root is actually attached to a panel — FindObjectsOfTypeAll can + /// also return stale/backup window instances whose rootVisualElement has no panel (and + /// therefore cannot be captured). + /// + static EditorWindow ResolveWindow(string window) + { + var windows = Resources.FindObjectsOfTypeAll(); + + 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 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/CaptureEditorElementCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/CaptureEditorElementCommand.cs.meta new file mode 100644 index 00000000..1b875f33 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/CaptureEditorElementCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d09a03ff1d920b24080d933c3191e192 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/EditorStatusCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/EditorStatusCommand.cs new file mode 100644 index 00000000..326addbe --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/EditorStatusCommand.cs @@ -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 +{ + /// + /// 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. + /// + 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 + }; + } + + /// + /// Get overall Editor status based on current state. + /// + private static string GetOverallStatus() + { + if (EditorApplication.isCompiling) + return "compiling"; + + if (EditorApplication.isUpdating) + return "reloading"; + + if (EditorApplication.isPlaying || EditorApplication.isPaused) + return "playing"; + + return "ready"; + } + + /// + /// Get current play mode status. + /// + private static string GetPlayModeStatus() + { + if (EditorApplication.isPaused) + return "paused"; + + if (EditorApplication.isPlaying) + return "playing"; + + return "stopped"; + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/EditorStatusCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/EditorStatusCommand.cs.meta new file mode 100644 index 00000000..5699589f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/EditorStatusCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 953fb97ec3dd48e4aa2ed9607c412f44 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/FocusEditorCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/FocusEditorCommand.cs new file mode 100644 index 00000000..3de15aec --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/FocusEditorCommand.cs @@ -0,0 +1,118 @@ +using System; +using System.Reflection; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands +{ + /// + /// Brings the Unity Editor application to the foreground (and restores it if minimized) by + /// focusing the main window's HostView. + /// + /// ContainerWindow / HostView / GUIView are internal, so this uses reflection. GUIView.Focus() + /// maps to the native MonoGUIView::Focus, which activates the OS window. Useful for headless + /// automation: the editor throttles/defers some work (notably script compilation) while + /// unfocused, so a client can call this to bring it forward first. + /// + public static class FocusEditorCommand + { + const BindingFlags k_All = BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + static Type s_ContainerWindowType; + static Type s_HostViewType; + static MethodInfo s_IsMainWindow; + static MethodInfo s_GuiViewFocus; + static bool s_Resolved; + + [CliCommand("editor_focus", "Bring the Unity Editor window to the foreground", MainThreadRequired = true)] + public static string FocusEditor() + { + if (!ResolveReflection(out var error)) + return error; + + try + { + object mainWindow = null; + foreach (var w in Resources.FindObjectsOfTypeAll(s_ContainerWindowType)) + { + if ((bool)s_IsMainWindow.Invoke(w, null)) { mainWindow = w; break; } + } + if (mainWindow == null) + return "Error: main editor window not found"; + + var rootView = GetRootView(mainWindow); + if (rootView == null) + return "Error: main window has no root view"; + + var host = FindHostView(rootView); + if (host == null) + return "Error: no HostView found in main window"; + + s_GuiViewFocus.Invoke(host, null); + return $"Editor focused via {host.GetType().Name}"; + } + catch (Exception ex) + { + return $"Error: failed to focus editor: {ex.Message}"; + } + } + + static bool ResolveReflection(out string error) + { + error = null; + if (s_Resolved) return true; + + var asm = typeof(EditorWindow).Assembly; + s_ContainerWindowType = asm.GetType("UnityEditor.ContainerWindow"); + s_HostViewType = asm.GetType("UnityEditor.HostView"); + var guiViewType = asm.GetType("UnityEditor.GUIView"); + + if (s_ContainerWindowType == null || s_HostViewType == null || guiViewType == null) + { + error = "Error: internal editor window types not found (Unity version mismatch?)"; + return false; + } + + s_IsMainWindow = s_ContainerWindowType.GetMethod("IsMainWindow", k_All); + s_GuiViewFocus = guiViewType.GetMethod("Focus", k_All, null, Type.EmptyTypes, null); + + if (s_IsMainWindow == null || s_GuiViewFocus == null) + { + error = "Error: ContainerWindow.IsMainWindow or GUIView.Focus not found (internal API changed?)"; + return false; + } + + s_Resolved = true; + return true; + } + + static object GetRootView(object mainWindow) + { + var prop = s_ContainerWindowType.GetProperty("rootView", k_All); + if (prop != null) + return prop.GetValue(mainWindow); + return s_ContainerWindowType.GetField("m_RootView", k_All)?.GetValue(mainWindow); + } + + static object FindHostView(object rootView) + { + if (s_HostViewType.IsInstanceOfType(rootView)) + return rootView; + + if (!(rootView.GetType().GetProperty("allChildren", k_All)?.GetValue(rootView) is Array allChildren)) + return null; + + // Prefer a DockArea (an actual docked panel) over the Toolbar, but any HostView works + // to bring the main window — and thus the application — to the foreground. + object firstHost = null; + foreach (var c in allChildren) + { + if (c == null || !s_HostViewType.IsInstanceOfType(c)) continue; + if (firstHost == null) firstHost = c; + if (c.GetType().Name == "DockArea") return c; + } + return firstHost; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/FocusEditorCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/FocusEditorCommand.cs.meta new file mode 100644 index 00000000..77c9f412 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/FocusEditorCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f146c518ed01a1b4a9be8a546cdfbde5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects.meta new file mode 100644 index 00000000..ccc362e2 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c2281ed89e8a4db9a14ec523905221f9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/ComponentCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/ComponentCommands.cs new file mode 100644 index 00000000..30416bc3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/ComponentCommands.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands.GameObjects +{ + /// + /// Component authoring commands (CLI-192): add/remove a component on a GameObject and get/set its + /// serialized properties. + /// + /// WHY property get/set goes exclusively through / + /// : it is the only mutation path that dirties the object, records + /// prefab overrides, and registers a single Undo step (via + + /// ) the way the Inspector does. Direct + /// reflection on backing fields would silently desync the Editor. Component types are resolved by + /// name across all loaded assemblies () so agents can use short or + /// fully-qualified names. + /// + public static class ComponentCommands + { + /// + /// Add a component (resolved by type name) to a GameObject. Registered with + /// via the non-generic overload so the addition + /// is reversible. Returns the new component's identity so its properties can be set next. + /// + [CliCommand("add_component", "Add a component (by type name) to a GameObject.")] + public static AuthoringResult AddComponent( + [CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target, + [CliArg("type", "Component type name (e.g. 'Rigidbody' or 'UnityEngine.Camera').", Required = true)] string type) + { + var go = GameObjectCommands.ResolveGameObject(target, "target"); + var componentType = TypeResolver.ResolveComponentType(type); + if (componentType == null) + throw new ArgumentException($"Could not resolve component type '{type}'."); + + using (new AuthoringUndoScope("Add Component")) + { + var component = Undo.AddComponent(go, componentType); + if (component == null) + throw new InvalidOperationException( + $"Failed to add component '{componentType.Name}' to '{go.name}' (it may be disallowed on this GameObject)."); + + GameObjectCommands.MarkDirty(go); + return ObjectResolver.Describe(component); + } + } + + /// + /// Remove a component from a GameObject. The component is addressed directly by handle (an + /// resolving to a Component), or by GameObject handle plus a type name. + /// Uses so the removal is reversible. + /// + [CliCommand("remove_component", + "Remove a component from a GameObject. Provide either a component handle (target) or a GameObject handle (target) plus a type name.")] + public static AuthoringResult RemoveComponent( + [CliArg("target", "Handle of the component to remove, OR of the GameObject when 'type' is given.", Required = true)] ObjectRef target, + [CliArg("type", "Component type name to remove from the target GameObject (omit when 'target' already points at a component).")] string type = null) + { + var component = ResolveComponent(target, type); + var go = component.gameObject; + var described = ObjectResolver.Describe(component); + + using (new AuthoringUndoScope("Remove Component")) + { + Undo.DestroyObjectImmediate(component); + GameObjectCommands.MarkDirty(go); + } + + return described; + } + + /// + /// Read the serialized properties of a component as a JSON map. Iterates the visible serialized + /// surface (skipping the script reference) and converts each property with + /// . + /// + [CliCommand("get_component_properties", + "Get a component's serialized properties as a JSON map. Address the component by handle, or by GameObject handle + type.")] + public static ComponentPropertiesResult GetComponentProperties( + [CliArg("target", "Handle of the component, OR of the GameObject when 'type' is given.", Required = true)] ObjectRef target, + [CliArg("type", "Component type name on the target GameObject (omit when 'target' is a component handle).")] string type = null) + { + var component = ResolveComponent(target, type); + var so = new SerializedObject(component); + + var properties = new Dictionary(); + var iterator = so.GetIterator(); + var enterChildren = true; + while (iterator.NextVisible(enterChildren)) + { + enterChildren = false; // only iterate top-level visible properties + if (iterator.name == "m_Script") + continue; + + try + { + properties[iterator.name] = SerializedPropertyConverter.Read(iterator.Copy()); + } + catch (Exception ex) + { + properties[iterator.name] = new JValue($""); + } + } + + return new ComponentPropertiesResult + { + Component = ObjectResolver.Describe(component), + Properties = properties + }; + } + + /// + /// Set one or more serialized properties on a component. Each entry in + /// maps a property path (e.g. "m_Mass" or "myField") to a JSON value. The whole batch is one + /// Undo step: snapshots the component, every property is written + /// through , and + /// commits + dirties as one operation. + /// An unknown property name fails the whole batch with a clear error (no partial apply). + /// + [CliCommand("set_component_properties", + "Set serialized properties on a component (one Undo step). 'properties' maps property name -> value; object references accept an ObjectRef handle.")] + public static ComponentPropertiesResult SetComponentProperties( + [CliArg("target", "Handle of the component, OR of the GameObject when 'type' is given.", Required = true)] ObjectRef target, + [CliArg("properties", "Map of serialized property name to value. Vectors/colors are arrays; object refs are handle objects.", Required = true)] JObject properties, + [CliArg("type", "Component type name on the target GameObject (omit when 'target' is a component handle).")] string type = null) + { + if (properties == null || properties.Count == 0) + throw new ArgumentException("'properties' must contain at least one property to set."); + + var component = ResolveComponent(target, type); + + using (new AuthoringUndoScope("Set Component Properties")) + { + Undo.RecordObject(component, "Set Component Properties"); + var so = new SerializedObject(component); + + foreach (var pair in properties) + { + var property = so.FindProperty(pair.Key); + if (property == null) + throw new ArgumentException( + $"Component '{component.GetType().Name}' has no serialized property '{pair.Key}'."); + + SerializedPropertyConverter.Write(property, pair.Value); + } + + // Applies the changes, registers Undo, and dirties the object/scene. + so.ApplyModifiedProperties(); + GameObjectCommands.MarkDirty(component.gameObject); + } + + // Re-read so the caller sees the committed values. + return GetComponentProperties(target, type); + } + + #region Helpers + + /// + /// Resolve a component either directly (the handle points at a Component) or by GameObject + /// handle + type name. Throws a descriptive error when neither resolves. + /// + private static Component ResolveComponent(ObjectRef handle, string type) + { + if (!ObjectResolver.TryResolve(handle, out var obj, out var error)) + throw new ArgumentException($"Could not resolve 'target': {error}"); + + // The handle already points at a specific Component: honour it directly so we never + // mutate/remove the wrong instance when the GameObject has several components of the same + // type. A 'type' here is only a constraint to validate against, NOT a reason to re-fetch + // GetComponent(type) (which returns the first match and would ignore the handle). + if (obj is Component directComponent) + { + if (!string.IsNullOrEmpty(type)) + { + var requestedType = TypeResolver.ResolveComponentType(type); + if (requestedType == null) + throw new ArgumentException($"Could not resolve component type '{type}'."); + if (!requestedType.IsInstanceOfType(directComponent)) + throw new ArgumentException( + $"'target' resolves to a {directComponent.GetType().Name}, which is not a '{requestedType.Name}'."); + } + + return directComponent; + } + + var go = obj as GameObject; + if (go == null) + throw new ArgumentException($"'target' did not resolve to a GameObject or Component (got {obj.GetType().Name})."); + + if (string.IsNullOrEmpty(type)) + throw new ArgumentException("'type' is required when 'target' is a GameObject."); + + var componentType = TypeResolver.ResolveComponentType(type); + if (componentType == null) + throw new ArgumentException($"Could not resolve component type '{type}'."); + + var component = go.GetComponent(componentType); + if (component == null) + throw new ArgumentException($"GameObject '{go.name}' has no component of type '{componentType.Name}'."); + + return component; + } + + #endregion + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/ComponentCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/ComponentCommands.cs.meta new file mode 100644 index 00000000..37d0ba69 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/ComponentCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1a7470ef26f8497595bcbdb84ceb96dc diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/ComponentPropertiesResult.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/ComponentPropertiesResult.cs new file mode 100644 index 00000000..185daa98 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/ComponentPropertiesResult.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Unity.Pipeline.Models; + +namespace Unity.Pipeline.Editor.Commands.GameObjects +{ + /// + /// Structured result for get_component_properties / set_component_properties. + /// WHY it pairs the component identity with the property map: the caller gets back both the + /// canonical handle (to reference the component again) and the + /// current serialized values in one round trip, which also makes set return the committed state + /// for a read-after-write check. + /// + public sealed class ComponentPropertiesResult + { + /// Canonical identity of the component the properties belong to. + [JsonProperty("component")] + public AuthoringResult Component { get; set; } + + /// Map of serialized property name to its JSON value. + [JsonProperty("properties")] + public Dictionary Properties { get; set; } = new Dictionary(); + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/ComponentPropertiesResult.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/ComponentPropertiesResult.cs.meta new file mode 100644 index 00000000..9a9f83cd --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/ComponentPropertiesResult.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e87b3f2873ff4bac9229a28242917ac0 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/CreateGameObjectsResult.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/CreateGameObjectsResult.cs new file mode 100644 index 00000000..76c4387f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/CreateGameObjectsResult.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Unity.Pipeline.Models; + +namespace Unity.Pipeline.Editor.Commands.GameObjects +{ + /// + /// Structured result for the batch create_gameobjects command (CLI-223). WHY a dedicated + /// envelope rather than a bare array: it carries the created alongside the + /// list so an agent can confirm the whole batch landed in one round-trip without inspecting array + /// length, and mirrors so the GameObject command surface stays + /// consistent. Each entry is the same canonical identity returned by + /// the single-object create_gameobject, so any created object can be fed straight back as an + /// in a follow-up call. + /// + public sealed class CreateGameObjectsResult + { + /// Number of GameObjects created in the batch. + [JsonProperty("count")] + public int Count { get; set; } + + /// Canonical identities of the created GameObjects, in creation order. + [JsonProperty("gameObjects")] + public List GameObjects { get; set; } = new List(); + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/CreateGameObjectsResult.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/CreateGameObjectsResult.cs.meta new file mode 100644 index 00000000..d78945ba --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/CreateGameObjectsResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 671f5065c22d6474b80053b6da5a84ef +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/FindGameObjectsResult.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/FindGameObjectsResult.cs new file mode 100644 index 00000000..98ef8745 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/FindGameObjectsResult.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Unity.Pipeline.Models; + +namespace Unity.Pipeline.Editor.Commands.GameObjects +{ + /// + /// Structured result for find_gameobjects. WHY a dedicated envelope rather than a bare + /// array: it carries the match alongside the list so an agent can branch on + /// "found / not found / ambiguous" without inspecting array length, and leaves room to add query + /// metadata later without breaking the shape. Each entry is the same canonical + /// identity returned by the single-object commands, so a result can + /// be fed straight back as an in a follow-up call. + /// + public sealed class FindGameObjectsResult + { + /// Number of GameObjects that matched the query. + [JsonProperty("count")] + public int Count { get; set; } + + /// Canonical identities of the matching GameObjects. + [JsonProperty("gameObjects")] + public List GameObjects { get; set; } = new List(); + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/FindGameObjectsResult.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/FindGameObjectsResult.cs.meta new file mode 100644 index 00000000..23d924c7 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/FindGameObjectsResult.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1d12bf4a53c14a839c26a91e8ec68292 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/GameObjectCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/GameObjectCommands.cs new file mode 100644 index 00000000..68196aaf --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/GameObjectCommands.cs @@ -0,0 +1,533 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using Object = UnityEngine.Object; + +namespace Unity.Pipeline.Editor.Commands.GameObjects +{ + /// + /// GameObject authoring commands (CLI-192): creating GameObjects, querying the scene hierarchy, + /// and mutating the core GameObject/Transform surface (parenting, transform, active state, tag, + /// layer, name, deletion). + /// + /// WHY these go through the same conventions as the rest of the authoring foundation: + /// every mutation is wrapped in an and registered with Unity's + /// Undo system so an agent's multi-step action reverts as a single collapsible step. Each + /// mutated object is marked dirty (and its scene flagged dirty) so the change survives a Save and + /// is visible to the rest of the Editor. Results come back as the canonical + /// envelope (via ) so the + /// agent can address the same object in a follow-up call. + /// + public static class GameObjectCommands + { + /// + /// Create an empty GameObject or a built-in primitive in the active scene. + /// + /// Creation goes through for primitives (so the + /// matching mesh + collider are attached exactly as the Editor would) and a plain + /// new GameObject for the empty case. The object is registered with + /// so it can be reverted, and an optional parent + /// is resolved by handle so a whole hierarchy can be built in one sequence of calls. + /// + [CliCommand("create_gameobject", + "Create an empty GameObject or a built-in primitive (cube/sphere/capsule/cylinder/plane/quad) in the active scene.")] + public static AuthoringResult CreateGameObject( + [CliArg("name", "Name for the new GameObject. Defaults to 'GameObject' (or the primitive name).")] string name = null, + [CliArg("primitive", "Optional primitive type: cube, sphere, capsule, cylinder, plane, quad. Omit for an empty GameObject.")] string primitive = null, + [CliArg("parent", "Optional parent handle (globalId/path/guid/instanceId/hierarchyPath). The new object becomes a child of it.")] ObjectRef parent = null) + { + // Resolve the parent once, outside the scope, so a bad handle fails before any creation. + var parentTransform = ResolveParentTransform(parent); + + using (new AuthoringUndoScope("Create GameObject")) + { + var go = CreateOne(primitive, name, parentTransform); + MarkDirty(go); + return ObjectResolver.Describe(go); + } + } + + /// + /// Create N GameObjects (empty or a primitive) in one server round-trip (CLI-223). + /// + /// WHY a separate plural command rather than overloading : the + /// single command's contract — one call returns one — is relied + /// on by existing callers and tests, so it is left untouched. This plural command is the batch + /// surface and returns a envelope (count + per-item + /// identities). When > 1 and no explicit name array is supplied, + /// each object is suffixed Name1..NameN so they remain individually addressable. + /// + /// Per-index // + /// are arrays of [x,y,z] vectors. When a vectors array is supplied its length MUST equal + /// (a mismatch is a validation error). Omitted channels leave the + /// object at its creation default (origin / identity / unit scale). The whole batch is wrapped + /// in ONE so it reverts as a single Undo step. + /// + [CliCommand("create_gameobjects", + "Batch-create N empty GameObjects or primitives in one call. Optional positions/rotations/scales are arrays of [x,y,z] (length must equal count). Returns the created identities.")] + public static CreateGameObjectsResult CreateGameObjects( + [CliArg("name", "Base name. With count>1 and no explicit names, objects are suffixed Name1..NameN.")] string name = null, + [CliArg("primitive", "Optional primitive type: cube, sphere, capsule, cylinder, plane, quad. Omit for empty GameObjects.")] string primitive = null, + [CliArg("parent", "Optional parent handle. Every created object becomes a child of it.")] ObjectRef parent = null, + [CliArg("count", "How many GameObjects to create. Default 1.")] int count = 1, + [CliArg("positions", "Local positions, one [x,y,z] per object. Length must equal count when supplied.")] float[][] positions = null, + [CliArg("rotations", "Local Euler rotations (degrees), one [x,y,z] per object. Length must equal count when supplied.")] float[][] rotations = null, + [CliArg("scales", "Local scales, one [x,y,z] per object. Length must equal count when supplied.")] float[][] scales = null) + { + if (count < 1) + throw new ArgumentException($"'count' must be at least 1, got {count}."); + + ValidateVectorArray(positions, count, "positions"); + ValidateVectorArray(rotations, count, "rotations"); + ValidateVectorArray(scales, count, "scales"); + + // Resolve the parent once, outside the scope, so a bad handle fails before any creation. + var parentTransform = ResolveParentTransform(parent); + + var result = new CreateGameObjectsResult(); + using (new AuthoringUndoScope("Create GameObjects")) + { + for (int i = 0; i < count; i++) + { + // count==1 keeps the bare name (no "1" suffix) so a single-item batch reads + // naturally; count>1 suffixes 1..N for individual addressability. + var itemName = count > 1 && !string.IsNullOrEmpty(name) ? name + (i + 1) : name; + var go = CreateOne(primitive, itemName, parentTransform); + + // Index the arg name so a null/mis-shaped entry names the offending element + // (a JSON array may contain a null entry even when its length matches count). + if (positions != null) + go.transform.localPosition = ToVector3(positions[i], $"positions[{i}]"); + if (rotations != null) + go.transform.localEulerAngles = ToVector3(rotations[i], $"rotations[{i}]"); + if (scales != null) + go.transform.localScale = ToVector3(scales[i], $"scales[{i}]"); + + MarkDirty(go); + result.GameObjects.Add(ObjectResolver.Describe(go)); + } + } + + result.Count = result.GameObjects.Count; + return result; + } + + /// + /// Find GameObjects in the loaded scenes by name, tag, component type, and/or hierarchy path. + /// All supplied filters are combined (AND). Returns the canonical identity of each match so an + /// agent can act on the results without a second lookup. + /// + /// This is intentionally a structured query rather than a single-object resolve: agents need + /// to discover what exists before they can reference it, and the result set carries the same + /// identities used everywhere else. + /// + [CliCommand("find_gameobjects", + "Find GameObjects in loaded scenes by name, tag, component type, and/or hierarchy path (filters are combined). Returns structured identities.")] + public static FindGameObjectsResult FindGameObjects( + [CliArg("name", "Exact name to match.")] string name = null, + [CliArg("tag", "Tag to match (e.g. 'Player').")] string tag = null, + [CliArg("type", "Component type name to match (e.g. 'Rigidbody', 'UnityEngine.Camera').")] string type = null, + [CliArg("hierarchy_path", "Exact hierarchy path to match (e.g. '/Root/Child').")] string hierarchyPath = null, + [CliArg("include_inactive", "Include inactive GameObjects. Default true.")] bool includeInactive = true) + { + Type componentType = null; + if (!string.IsNullOrEmpty(type)) + { + componentType = TypeResolver.ResolveComponentType(type); + if (componentType == null) + throw new ArgumentException($"Could not resolve component type '{type}'."); + } + + // Resolve the hierarchy filter once so it is comparable to each object's computed path. + var hierarchyFilter = string.IsNullOrEmpty(hierarchyPath) ? null : NormalizeHierarchyPath(hierarchyPath); + + // Validate the tag up front: comparing against an undefined tag (CompareTag / .tag) would + // log a Unity error per object. An unknown tag simply yields no matches. + if (!string.IsNullOrEmpty(tag) && !InternalEditorTagExists(tag)) + return new FindGameObjectsResult { Count = 0, GameObjects = new List() }; + + var matches = new List(); + foreach (var go in EnumerateSceneGameObjects(includeInactive)) + { + if (!string.IsNullOrEmpty(name) && go.name != name) + continue; + + if (!string.IsNullOrEmpty(tag) && go.tag != tag) + continue; + + if (componentType != null && go.GetComponent(componentType) == null) + continue; + + if (hierarchyFilter != null && GetHierarchyPath(go) != hierarchyFilter) + continue; + + var described = ObjectResolver.Describe(go); + if (described != null) + matches.Add(described); + } + + return new FindGameObjectsResult { Count = matches.Count, GameObjects = matches }; + } + + /// + /// Set the local transform (position, rotation in Euler degrees, scale) of a GameObject. + /// Any omitted channel is left unchanged. Goes through + /// on the Transform so the change is reversible and recorded as a prefab override where relevant. + /// + [CliCommand("set_transform", + "Set a GameObject's local position/rotation(euler)/scale. Omitted channels are left unchanged.")] + public static AuthoringResult SetTransform( + [CliArg("target", "Handle of the GameObject to modify.", Required = true)] ObjectRef target, + [CliArg("position", "Local position as [x,y,z].")] float[] position = null, + [CliArg("rotation", "Local rotation as Euler angles [x,y,z] in degrees.")] float[] rotation = null, + [CliArg("scale", "Local scale as [x,y,z].")] float[] scale = null) + { + var go = ResolveGameObject(target, "target"); + using (new AuthoringUndoScope("Set Transform")) + { + var transform = go.transform; + Undo.RegisterCompleteObjectUndo(transform, "Set Transform"); + + if (position != null) + transform.localPosition = ToVector3(position, "position"); + if (rotation != null) + transform.localEulerAngles = ToVector3(rotation, "rotation"); + if (scale != null) + transform.localScale = ToVector3(scale, "scale"); + + MarkDirty(go); + return ObjectResolver.Describe(go); + } + } + + /// + /// Reparent a GameObject (or clear its parent so it becomes a scene root). Uses + /// so the reparent — including the sibling-index change — + /// is a single undoable operation. World position is preserved by default to match the + /// Editor's drag-to-reparent behavior. + /// + [CliCommand("set_parent", + "Reparent a GameObject under a new parent, or detach it to scene root when no parent is given.")] + public static AuthoringResult SetParent( + [CliArg("target", "Handle of the GameObject to reparent.", Required = true)] ObjectRef target, + [CliArg("parent", "Handle of the new parent. Omit (or empty) to move the object to the scene root.")] ObjectRef parent = null, + [CliArg("world_position_stays", "Keep the object's world position when reparenting. Default true.")] bool worldPositionStays = true) + { + var go = ResolveGameObject(target, "target"); + Transform parentTransform = null; + if (parent != null && !parent.IsEmpty) + parentTransform = ResolveGameObject(parent, "parent").transform; + + using (new AuthoringUndoScope("Set Parent")) + { + Undo.SetTransformParent(go.transform, parentTransform, worldPositionStays, "Set Parent"); + MarkDirty(go); + return ObjectResolver.Describe(go); + } + } + + /// Set a GameObject's active self-state. + [CliCommand("set_active", "Set a GameObject's active self-state (activeSelf).")] + public static AuthoringResult SetActive( + [CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target, + [CliArg("active", "Desired active state.", Required = true)] bool active) + { + var go = ResolveGameObject(target, "target"); + using (new AuthoringUndoScope("Set Active")) + { + Undo.RegisterCompleteObjectUndo(go, "Set Active"); + go.SetActive(active); + MarkDirty(go); + return ObjectResolver.Describe(go); + } + } + + /// + /// Set a GameObject's tag. The tag must already exist in the project's Tag Manager; an unknown + /// tag surfaces a clear error rather than silently creating one (tag creation is a project + /// settings mutation outside this command's scope). + /// + [CliCommand("set_tag", "Set a GameObject's tag (the tag must already exist in the project).")] + public static AuthoringResult SetTag( + [CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target, + [CliArg("tag", "Tag to assign (must exist in the Tag Manager).", Required = true)] string tag) + { + var go = ResolveGameObject(target, "target"); + if (!InternalEditorTagExists(tag)) + throw new ArgumentException($"Tag '{tag}' does not exist in the project. Add it via the Tag Manager first."); + + using (new AuthoringUndoScope("Set Tag")) + { + Undo.RegisterCompleteObjectUndo(go, "Set Tag"); + go.tag = tag; + MarkDirty(go); + return ObjectResolver.Describe(go); + } + } + + /// + /// Set a GameObject's layer by name or numeric index. A name is resolved via + /// ; an out-of-range index or unknown name is rejected. + /// + [CliCommand("set_layer", "Set a GameObject's layer by name or numeric index (0-31).")] + public static AuthoringResult SetLayer( + [CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target, + [CliArg("layer", "Layer name (e.g. 'UI') or numeric index 0-31.", Required = true)] string layer) + { + var go = ResolveGameObject(target, "target"); + var layerIndex = ResolveLayer(layer); + + using (new AuthoringUndoScope("Set Layer")) + { + Undo.RegisterCompleteObjectUndo(go, "Set Layer"); + go.layer = layerIndex; + MarkDirty(go); + return ObjectResolver.Describe(go); + } + } + + /// Rename a GameObject. + [CliCommand("rename_gameobject", "Rename a GameObject.")] + public static AuthoringResult RenameGameObject( + [CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target, + [CliArg("name", "New name.", Required = true)] string name) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentException("New name must not be empty."); + + var go = ResolveGameObject(target, "target"); + using (new AuthoringUndoScope("Rename GameObject")) + { + Undo.RegisterCompleteObjectUndo(go, "Rename GameObject"); + go.name = name; + MarkDirty(go); + return ObjectResolver.Describe(go); + } + } + + /// + /// Delete a GameObject from the scene. Uses so the + /// deletion is reversible. The result describes the object's identity captured *before* + /// destruction (the live object is gone afterwards). + /// + [CliCommand("delete_gameobject", "Delete a GameObject from the scene (reversible via Undo).")] + public static AuthoringResult DeleteGameObject( + [CliArg("target", "Handle of the GameObject to delete.", Required = true)] ObjectRef target) + { + var go = ResolveGameObject(target, "target"); + // Capture identity before destruction; the live object is invalid afterwards. + var described = ObjectResolver.Describe(go); + var scene = go.scene; + + using (new AuthoringUndoScope("Delete GameObject")) + { + Undo.DestroyObjectImmediate(go); + if (scene.IsValid()) + EditorSceneManager.MarkSceneDirty(scene); + } + + return described; + } + + #region Helpers + + /// + /// Resolve an to a GameObject. Accepts either a GameObject handle or a + /// Component handle (in which case its owning GameObject is used), matching how agents tend to + /// hold references. Throws a descriptive error rather than returning null so the command + /// surfaces a clear message to the caller. + /// + internal static GameObject ResolveGameObject(ObjectRef handle, string argName) + { + if (!ObjectResolver.TryResolve(handle, out var obj, out var error)) + throw new ArgumentException($"Could not resolve '{argName}': {error}"); + + var go = obj as GameObject ?? (obj as Component)?.gameObject; + if (go == null) + throw new ArgumentException($"'{argName}' did not resolve to a GameObject (got {obj.GetType().Name})."); + + return go; + } + + /// Mark a GameObject and its scene dirty so changes persist and the Editor refreshes. + internal static void MarkDirty(GameObject go) + { + EditorUtility.SetDirty(go); + if (go.scene.IsValid()) + EditorSceneManager.MarkSceneDirty(go.scene); + } + + /// + /// Create a single GameObject (empty or a primitive), apply an optional name, register it for + /// Undo, and parent it under when given. Shared by the + /// single and the batch so both + /// build objects identically. MUST be called inside an . + /// + private static GameObject CreateOne(string primitive, string name, Transform parentTransform) + { + GameObject go; + if (!string.IsNullOrEmpty(primitive)) + { + if (!TryParsePrimitive(primitive, out var primitiveType)) + throw new ArgumentException( + $"Unknown primitive '{primitive}'. Expected one of: cube, sphere, capsule, cylinder, plane, quad."); + + go = GameObject.CreatePrimitive(primitiveType); + } + else + { + go = new GameObject(); + } + + if (!string.IsNullOrEmpty(name)) + go.name = name; + + Undo.RegisterCreatedObjectUndo(go, "Create GameObject"); + + if (parentTransform != null) + Undo.SetTransformParent(go.transform, parentTransform, "Create GameObject"); + + return go; + } + + /// + /// Resolve an optional parent handle to its Transform, or null when no parent is supplied. + /// Resolved before any creation so an invalid handle fails fast (and a batch creates nothing). + /// + private static Transform ResolveParentTransform(ObjectRef parent) + { + if (parent == null || parent.IsEmpty) + return null; + return ResolveGameObject(parent, "parent").transform; + } + + /// + /// Validate a per-item vectors array (positions/rotations/scales) up front, before any object is + /// created. When supplied it must have exactly one entry per object, and every entry must be a + /// non-null [x,y,z] triple (a JSON array can carry a null element even when its length matches + /// count). A bad array is an agent error surfaced as an naming + /// the offending index, so a batch applies fully or creates nothing rather than throwing an + /// NRE part-way through. + /// + private static void ValidateVectorArray(float[][] vectors, int count, string argName) + { + if (vectors == null) + return; + + if (vectors.Length != count) + throw new ArgumentException( + $"'{argName}' has {vectors.Length} entries but count is {count}; they must match."); + + for (int i = 0; i < vectors.Length; i++) + { + if (vectors[i] == null) + throw new ArgumentException($"'{argName}[{i}]' must be an [x,y,z] array but was null."); + if (vectors[i].Length != 3) + throw new ArgumentException( + $"'{argName}[{i}]' must have exactly 3 components [x,y,z], got {vectors[i].Length}."); + } + } + + private static bool TryParsePrimitive(string value, out PrimitiveType primitiveType) + { + switch (value.Trim().ToLowerInvariant()) + { + case "cube": primitiveType = PrimitiveType.Cube; return true; + case "sphere": primitiveType = PrimitiveType.Sphere; return true; + case "capsule": primitiveType = PrimitiveType.Capsule; return true; + case "cylinder": primitiveType = PrimitiveType.Cylinder; return true; + case "plane": primitiveType = PrimitiveType.Plane; return true; + case "quad": primitiveType = PrimitiveType.Quad; return true; + default: primitiveType = default; return false; + } + } + + private static Vector3 ToVector3(float[] values, string argName) + { + if (values == null) + throw new ArgumentException($"'{argName}' must be an [x,y,z] array but was null."); + if (values.Length != 3) + throw new ArgumentException($"'{argName}' must have exactly 3 components [x,y,z], got {values.Length}."); + return new Vector3(values[0], values[1], values[2]); + } + + private static int ResolveLayer(string layer) + { + if (int.TryParse(layer, out var index)) + { + if (index < 0 || index > 31) + throw new ArgumentException($"Layer index {index} is out of range (0-31)."); + return index; + } + + var named = LayerMask.NameToLayer(layer); + if (named < 0) + throw new ArgumentException($"Layer '{layer}' does not exist in the project."); + return named; + } + + private static bool InternalEditorTagExists(string tag) + { + return UnityEditorInternal.InternalEditorUtility.tags.Contains(tag); + } + + private static IEnumerable EnumerateSceneGameObjects(bool includeInactive) + { + for (int s = 0; s < SceneManager.sceneCount; s++) + { + var scene = SceneManager.GetSceneAt(s); + if (!scene.isLoaded) + continue; + + foreach (var root in scene.GetRootGameObjects()) + { + foreach (var go in EnumerateRecursive(root, includeInactive)) + yield return go; + } + } + } + + private static IEnumerable EnumerateRecursive(GameObject go, bool includeInactive) + { + if (!includeInactive && !go.activeInHierarchy) + yield break; + + yield return go; + + var transform = go.transform; + for (int i = 0; i < transform.childCount; i++) + { + foreach (var child in EnumerateRecursive(transform.GetChild(i).gameObject, includeInactive)) + yield return child; + } + } + + private static string GetHierarchyPath(GameObject go) + { + var sb = new System.Text.StringBuilder(go.name); + var parent = go.transform.parent; + while (parent != null) + { + sb.Insert(0, parent.name + "/"); + parent = parent.parent; + } + + return "/" + sb; + } + + private static string NormalizeHierarchyPath(string path) + { + var trimmed = "/" + path.Trim().Trim('/'); + return trimmed; + } + + #endregion + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/GameObjectCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/GameObjectCommands.cs.meta new file mode 100644 index 00000000..eb0a58bc --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/GameObjectCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a1aac051d9234c78b4918a8dd366514a diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/SerializedPropertyConverter.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/SerializedPropertyConverter.cs new file mode 100644 index 00000000..5225659c --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/SerializedPropertyConverter.cs @@ -0,0 +1,224 @@ +using System; +using Newtonsoft.Json.Linq; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace Unity.Pipeline.Editor.Commands.GameObjects +{ + /// + /// Reads and writes a single from/to a JSON value. + /// + /// WHY everything goes through SerializedProperty rather than reflection on the C# field: it is + /// the only path that correctly participates in dirtying, prefab override tracking, and Undo + /// (when the change is applied via ). + /// Reflection would bypass all three and leave the Editor's view stale. + /// + /// Supported surface: primitives (bool/int/long/float/double/string), enums (by name or index), + /// //, , and + /// object references (assigned from an handle resolved by + /// ). + /// + internal static class SerializedPropertyConverter + { + /// + /// Read a property into a JSON-friendly value (primitive, string, or small array/object for + /// vector/color types). Unsupported property kinds return a short type descriptor string so a + /// get_component_properties dump never throws on an exotic field. + /// + public static JToken Read(SerializedProperty property) + { + // Array/list fields surface as Generic with isArray=true; return their elements as a JSON + // array so a get/set round-trips. (String also reports isArray, hence the exclusion.) + if (property.isArray && property.propertyType != SerializedPropertyType.String) + { + var elements = new JArray(); + for (int i = 0; i < property.arraySize; i++) + elements.Add(Read(property.GetArrayElementAtIndex(i))); + return elements; + } + + switch (property.propertyType) + { + case SerializedPropertyType.Boolean: + return new JValue(property.boolValue); + case SerializedPropertyType.Integer: + return new JValue(property.longValue); + case SerializedPropertyType.Float: + return new JValue(property.doubleValue); + case SerializedPropertyType.String: + return new JValue(property.stringValue); + case SerializedPropertyType.Enum: + // enumValueIndex indexes enumNames; guard against -1 (mixed/unknown). + return property.enumValueIndex >= 0 && property.enumValueIndex < property.enumNames.Length + ? new JValue(property.enumNames[property.enumValueIndex]) + : new JValue(property.intValue); + case SerializedPropertyType.Vector2: + return ToArray(property.vector2Value.x, property.vector2Value.y); + case SerializedPropertyType.Vector3: + return ToArray(property.vector3Value.x, property.vector3Value.y, property.vector3Value.z); + case SerializedPropertyType.Vector4: + return ToArray(property.vector4Value.x, property.vector4Value.y, property.vector4Value.z, property.vector4Value.w); + case SerializedPropertyType.Color: + var c = property.colorValue; + return ToArray(c.r, c.g, c.b, c.a); + case SerializedPropertyType.ObjectReference: + return property.objectReferenceValue != null + ? JToken.FromObject(ObjectResolver.Describe(property.objectReferenceValue)) + : JValue.CreateNull(); + default: + // Bounds, Quaternion, AnimationCurve, etc. are out of MVP scope: surface a hint. + return new JValue($""); + } + } + + /// + /// Write a JSON value into a property. Throws with a clear + /// message on a type mismatch so set_component_properties reports exactly which field/value + /// failed. Object references are resolved from an -shaped JSON object + /// (or a bare string treated as an asset path / globalId). + /// + public static void Write(SerializedProperty property, JToken value) + { + // Array/list fields (Generic + isArray) are written from a JSON array: resize, then recurse + // per element so arrays of primitives AND object references (e.g. MeshRenderer.m_Materials) + // both work. (String also reports isArray, hence the exclusion.) + if (property.isArray && property.propertyType != SerializedPropertyType.String) + { + if (!(value is JArray array)) + throw new ArgumentException( + $"Property '{property.name}' is an array; provide a JSON array of elements."); + + property.arraySize = array.Count; + for (int i = 0; i < array.Count; i++) + Write(property.GetArrayElementAtIndex(i), array[i]); + return; + } + + switch (property.propertyType) + { + case SerializedPropertyType.Boolean: + property.boolValue = value.ToObject(); + break; + case SerializedPropertyType.Integer: + property.longValue = value.ToObject(); + break; + case SerializedPropertyType.Float: + property.doubleValue = value.ToObject(); + break; + case SerializedPropertyType.String: + property.stringValue = value.Type == JTokenType.Null ? string.Empty : value.ToObject(); + break; + case SerializedPropertyType.Enum: + WriteEnum(property, value); + break; + case SerializedPropertyType.Vector2: + { + var a = ToFloats(value, 2, property.name); + property.vector2Value = new Vector2(a[0], a[1]); + break; + } + case SerializedPropertyType.Vector3: + { + var a = ToFloats(value, 3, property.name); + property.vector3Value = new Vector3(a[0], a[1], a[2]); + break; + } + case SerializedPropertyType.Vector4: + { + var a = ToFloats(value, 4, property.name); + property.vector4Value = new Vector4(a[0], a[1], a[2], a[3]); + break; + } + case SerializedPropertyType.Color: + { + var a = ToFloats(value, 4, property.name); + property.colorValue = new Color(a[0], a[1], a[2], a[3]); + break; + } + case SerializedPropertyType.ObjectReference: + property.objectReferenceValue = ResolveObjectReference(value, property.name); + break; + default: + throw new ArgumentException( + $"Property '{property.name}' has unsupported type '{property.propertyType}' for set."); + } + } + + private static void WriteEnum(SerializedProperty property, JToken value) + { + // Accept either the enum constant name or its index/value. + if (value.Type == JTokenType.String) + { + var name = value.ToObject(); + var index = Array.IndexOf(property.enumNames, name); + if (index < 0) + { + // Fall back to display names (Editor humanized form), then error. + index = Array.IndexOf(property.enumDisplayNames, name); + if (index < 0) + throw new ArgumentException( + $"Enum value '{name}' is not valid for property '{property.name}'. Valid: [{string.Join(", ", property.enumNames)}]."); + } + + property.enumValueIndex = index; + } + else + { + // A numeric value is the enum's underlying integer, NOT an index into enumNames: + // those only coincide when declaration order matches the constant values. Mirror Read, + // which falls back to property.intValue, so round-tripping a numeric enum is stable. + property.intValue = value.ToObject(); + } + } + + private static Object ResolveObjectReference(JToken value, string propertyName) + { + // An explicit null clears the reference; anything else MUST resolve — never silently no-op + // (a handle that doesn't resolve used to be dropped, so the command reported success while + // assigning nothing). + if (value == null || value.Type == JTokenType.Null) + return null; + + // Route every shape (string handle, handle object, or bare-integer instanceId) through + // ObjectRefConverter so path / guid / globalId / instanceId forms all parse identically. + var handle = value.ToObject(); + if (handle == null || handle.IsEmpty) + throw new ArgumentException( + $"Property '{propertyName}': '{value}' is not a recognized object reference handle. " + + "Provide a path, guid, globalId, or instanceId (or null to clear)."); + + if (!ObjectResolver.TryResolve(handle, out var obj, out var error)) + throw new ArgumentException($"Could not resolve object reference for '{propertyName}': {error}"); + + return obj; + } + + private static float[] ToFloats(JToken value, int expected, string propertyName) + { + if (value is JArray array) + { + if (array.Count != expected) + throw new ArgumentException( + $"Property '{propertyName}' expects {expected} components, got {array.Count}."); + var result = new float[expected]; + for (int i = 0; i < expected; i++) + result[i] = array[i].ToObject(); + return result; + } + + throw new ArgumentException( + $"Property '{propertyName}' expects an array of {expected} numbers."); + } + + private static JArray ToArray(params float[] values) + { + var array = new JArray(); + foreach (var v in values) + array.Add(v); + return array; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/SerializedPropertyConverter.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/SerializedPropertyConverter.cs.meta new file mode 100644 index 00000000..b42d4073 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/SerializedPropertyConverter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 85f605c1109e40898fe47ff1e2a8a4d0 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/TypeResolver.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/TypeResolver.cs new file mode 100644 index 00000000..5bddcca6 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/TypeResolver.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands.GameObjects +{ + /// + /// Resolves a -derived from an + /// agent-supplied name. WHY this is non-trivial: agents pass either a short name ("Rigidbody") + /// or a fully-qualified one ("UnityEngine.Camera"), and the type may live in any loaded assembly + /// (UnityEngine modules, user assemblies, packages). We therefore try, in order: an exact + /// fully-qualified , then a scan of all loaded assemblies for a + /// full-name match, then a short-name match. Only subclasses are accepted + /// so the result is always valid for AddComponent/GetComponent. + /// + public static class TypeResolver + { + /// + /// Resolve a component type by name, or return null if no unambiguous + /// subclass matches. A short name that matches multiple types in different namespaces is + /// treated as ambiguous and returns null (the caller should ask for a fully-qualified name). + /// + public static Type ResolveComponentType(string typeName) + { + if (string.IsNullOrWhiteSpace(typeName)) + return null; + + typeName = typeName.Trim(); + + // 1. Exact, assembly-qualified or fully-qualified lookup. + var direct = Type.GetType(typeName, throwOnError: false, ignoreCase: false); + if (IsComponent(direct)) + return direct; + + var assemblies = PipelineUtils.GetLoadedAssemblies(); + + // 2. Full-name match across loaded assemblies. + var fullNameMatches = new List(); + // 3. Short-name match (collected in the same pass) as a fallback. + var shortNameMatches = new List(); + + foreach (var assembly in assemblies) + { + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (System.Reflection.ReflectionTypeLoadException ex) + { + types = ex.Types.Where(t => t != null).ToArray(); + } + + foreach (var type in types) + { + if (!IsComponent(type)) + continue; + + if (string.Equals(type.FullName, typeName, StringComparison.Ordinal)) + fullNameMatches.Add(type); + else if (string.Equals(type.Name, typeName, StringComparison.Ordinal)) + shortNameMatches.Add(type); + } + } + + if (fullNameMatches.Count == 1) + return fullNameMatches[0]; + if (fullNameMatches.Count > 1) + return null; // genuinely ambiguous full names (rare) — caller must disambiguate. + + // Distinct in case the same type is reachable through multiple assembly references. + var distinctShort = shortNameMatches.Distinct().ToList(); + return distinctShort.Count == 1 ? distinctShort[0] : null; + } + + private static bool IsComponent(Type type) + { + return type != null && typeof(Component).IsAssignableFrom(type) && !type.IsAbstract; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/TypeResolver.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/TypeResolver.cs.meta new file mode 100644 index 00000000..57792669 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/GameObjects/TypeResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f033287d429f4957bfcfd6795f731b48 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials.meta new file mode 100644 index 00000000..64b68572 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4841c84bba2a471eb3fb429f79e3b118 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialCommands.cs new file mode 100644 index 00000000..3c714374 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialCommands.cs @@ -0,0 +1,405 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine; +using UnityEngine.Rendering; +using Object = UnityEngine.Object; + +namespace Unity.Pipeline.Editor.Commands.Materials +{ + /// + /// Materials authoring commands (CLI-213): read and write a material's shader, shader properties + /// (floats, colors, vectors, textures, ints), keywords, and render queue. + /// + /// These sit on the CLI-190 authoring foundation: + /// - the material is addressed with an resolved by + /// and confined to the authoring root (a GUID/globalId handle can't edit a material outside the sandbox), + /// - texture property values are s likewise confined to the root, + /// - writes are wrapped in an with + /// (material property changes ARE covered by Undo.RecordObject, unlike AssetDatabase ops), then + /// + persist them, + /// - unknown property names and type mismatches are reported in unknown[] rather than failing + /// the whole call (as long as at least one input applied). + /// + /// Material CREATION already exists in create_asset (CLI-221); this command set is the + /// read/write surface on top of an existing material. + /// + public static class MaterialCommands + { + [CliCommand("get_material_properties", + "Read a material's shader, render queue, enabled keywords, and all shader properties with their current values (Color as [r,g,b,a], Vector as [x,y,z,w], Texture as an object reference).")] + public static MaterialPropertiesResult GetMaterialProperties( + [CliArg("material", "Reference to the .mat asset (or a loaded material) to read (path / guid / globalId / instanceId).", Required = true)] ObjectRef material) + { + var mat = ResolveMaterial(material, out var assetPath); + + var result = new MaterialPropertiesResult + { + AssetPath = assetPath, + Shader = mat.shader != null ? mat.shader.name : null, + // rawRenderQueue is -1 when the material inherits from the shader and a positive + // integer when explicitly overridden — returning the raw value preserves the + // round-trip contract with set_material_properties renderQueue:-1 (inherit). + RenderQueue = GetRawRenderQueue(mat), + EnabledKeywords = GetEnabledKeywords(mat), + }; + + var shader = mat.shader; + if (shader != null) + { + // Enumerate the shader's declared properties via the Shader instance APIs (single index + // space, matching get_shader_properties), so name/type/range-limits always line up. + int count = shader.GetPropertyCount(); + for (int i = 0; i < count; i++) + { + var name = shader.GetPropertyName(i); + var propType = shader.GetPropertyType(i); + + var entry = new MaterialPropertyValue + { + Name = name, + Type = PublicValueType(propType), + Value = MaterialValueConverter.ReadValue(mat, name, propType), + }; + + if (propType == ShaderPropertyType.Range) + { + var limits = shader.GetPropertyRangeLimits(i); + entry.Range = new MaterialRange + { + Min = limits.x, + Max = limits.y, + }; + } + + result.Properties.Add(entry); + } + } + + return result; + } + + [CliCommand("set_material_properties", + "Set shader properties on a material (Float/Range/Int=number; Color=[r,g,b,a] or \"#RRGGBBAA\" hex; Vector=[x,y,z,w]; Texture=an object reference or null to clear), optionally reassign the shader, set the render queue, and toggle keywords. Unknown names / type mismatches are reported in unknown[].")] + public static SetMaterialPropertiesResult SetMaterialProperties( + [CliArg("material", "Reference to the .mat asset (or a loaded material) to edit (path / guid / globalId / instanceId).", Required = true)] ObjectRef material, + [CliArg("shader", "Reassign the material's shader by name (e.g. \"Standard\", \"Universal Render Pipeline/Lit\", or a Shader Graph shader name). Applied before properties so new property names resolve against the new shader.")] string shader = null, + [CliArg("properties", "JSON object of shader property name -> value. Names must include the leading underscore (e.g. _BaseColor). Float/Range/Int=number; Color=[r,g,b,a] or hex string; Vector=[x,y,z,w]; Texture=an object reference {guid/path} or null.")] JObject properties = null, + [CliArg("renderQueue", "Explicit render queue, or -1 to inherit from the shader. Omit to leave unchanged.")] int? renderQueue = null, + [CliArg("enableKeywords", "Shader keywords to enable (e.g. _NORMALMAP, _EMISSION).")] string[] enableKeywords = null, + [CliArg("disableKeywords", "Shader keywords to disable.")] string[] disableKeywords = null, + [CliArg("confirm", "Reserved for parity; editing an existing material is non-destructive and undoable, so it is not required.")] bool confirm = false, + [CliArg("dry_run", "If true, validate the shader, resolve property names and texture refs, and report applied[]/unknown[] without writing anything.")] bool dryRun = false) + { + var mat = ResolveMaterial(material, out var assetPath); + + // Resolve the (optional) replacement shader UP FRONT so dry_run validates it too, and a + // shader_not_found writes nothing (acceptance #9). Shader.Find only finds compiled/included + // shaders — surface that in the hint. + Shader newShader = null; + if (shader != null) + { + newShader = Shader.Find(shader); + if (newShader == null) + throw new ShaderNotFoundException( + $"Shader '{shader}' not found. Shader.Find only resolves shaders that are compiled/included in the build (or referenced by a material/scene). Use list_shaders to discover valid names."); + } + + var result = new SetMaterialPropertiesResult(); + + // ---- dry_run: validate against the would-be shader, never mutate ---------------------- + if (dryRun) + { + var effectiveShader = newShader ?? mat.shader; + ValidateProperties(effectiveShader, properties, result); + if (renderQueue.HasValue) + result.Applied.Add("renderQueue"); + AddKeywordTokens(result.Applied, enableKeywords, disableKeywords); + + GuardAtLeastOneApplied(result, shaderReassigned: newShader != null); + + CopyIdentity(mat, assetPath, result); + result.Shader = effectiveShader != null ? effectiveShader.name : null; + return result; + } + + // ---- apply -------------------------------------------------------------------------- + using (new AuthoringUndoScope("Set Material Properties")) + { + // Material property/shader/keyword/renderQueue changes are all covered by Undo.RecordObject. + Undo.RecordObject(mat, "Set Material Properties"); + + if (newShader != null) + mat.shader = newShader; + + if (properties != null) + { + foreach (var pair in properties) + { + if (pair.Key == "token") // injected by the server; never a material property. + continue; + + var name = pair.Key; + if (!mat.HasProperty(name)) + { + result.Unknown.Add($"{name}: unknown property (not declared by shader '{(mat.shader != null ? mat.shader.name : "")}')"); + continue; + } + + var propType = GetPropertyType(mat.shader, name); + if (propType == null) + { + // HasProperty was true but the type couldn't be read off the shader (e.g. a + // built-in property like unity_Lightmaps not declared in the shader's list). + result.Unknown.Add($"{name}: property is not a settable shader property on '{(mat.shader != null ? mat.shader.name : "")}'"); + continue; + } + + try + { + MaterialValueConverter.ApplyValue(mat, name, propType.Value, pair.Value); + result.Applied.Add(name); + } + catch (MaterialPropertyTypeMismatch mismatch) + { + result.Unknown.Add(mismatch.Message); + } + } + } + + if (renderQueue.HasValue) + { + mat.renderQueue = renderQueue.Value; + result.Applied.Add("renderQueue"); + } + + ApplyKeywords(mat, enableKeywords, disableKeywords, result.Applied); + + GuardAtLeastOneApplied(result, shaderReassigned: newShader != null); + + EditorUtility.SetDirty(mat); + if (!string.IsNullOrEmpty(assetPath)) + AssetDatabase.SaveAssetIfDirty(mat); + } + + CopyIdentity(mat, assetPath, result); + result.Shader = mat.shader != null ? mat.shader.name : null; + return result; + } + + private static int GetRawRenderQueue(Material mat) + { + var serialized = new SerializedObject(mat); + var customRenderQueue = serialized.FindProperty("m_CustomRenderQueue"); + return customRenderQueue != null ? customRenderQueue.intValue : mat.renderQueue; + } + + /// + /// Resolve an to a , confining any on-disk asset to + /// the authoring root. Returns the project-relative asset path (null for a non-asset/in-memory + /// material). Throws a clear on miss / wrong type / out-of-root. + /// + internal static Material ResolveMaterial(ObjectRef material, out string assetPath) + { + if (material == null || material.IsEmpty) + throw new ArgumentException("'material' is required."); + + if (!ObjectResolver.TryResolve(material, out var obj, out var error)) + throw new ArgumentException($"Could not resolve material: {error}"); + + if (!(obj is Material mat)) + throw new ArgumentException($"Reference '{material}' resolved to a {obj.GetType().Name}, not a Material."); + + assetPath = AssetDatabase.GetAssetPath(mat); + if (!string.IsNullOrEmpty(assetPath)) + { + var confined = ProjectPaths.Resolve(assetPath, out var confineError); + if (confined == null) + throw new ArgumentException( + $"Material '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}"); + assetPath = confined; + } + + return mat; + } + + /// + /// Read the material's enabled keyword set. Uses + /// (LocalKeyword[]) which is the supported API on the package's min Unity version (Unity 6 / + /// 6000.0); the legacy shaderKeywords string[] is deprecated. + /// + private static List GetEnabledKeywords(Material mat) + { + var keywords = new List(); + foreach (var keyword in mat.enabledKeywords) + keywords.Add(keyword.name); + keywords.Sort(StringComparer.Ordinal); + return keywords; + } + + private static void ApplyKeywords(Material mat, string[] enable, string[] disable, List applied) + { + // Use the LocalKeyword API (Unity 2022.1+) so that Enable/Disable and the + // enabledKeywords reader are talking to the same keyword table. The legacy + // string-based EnableKeyword(string) affects global keywords, whereas + // mat.enabledKeywords returns LocalKeyword[], so they would disagree on round-trip. + if (enable != null) + { + foreach (var keyword in enable) + { + if (string.IsNullOrWhiteSpace(keyword) || mat.shader == null) + continue; + mat.EnableKeyword(new LocalKeyword(mat.shader, keyword)); + applied.Add($"+keyword:{keyword}"); + } + } + + if (disable != null) + { + foreach (var keyword in disable) + { + if (string.IsNullOrWhiteSpace(keyword) || mat.shader == null) + continue; + mat.DisableKeyword(new LocalKeyword(mat.shader, keyword)); + applied.Add($"-keyword:{keyword}"); + } + } + } + + private static void AddKeywordTokens(List applied, string[] enable, string[] disable) + { + if (enable != null) + foreach (var keyword in enable.Where(k => !string.IsNullOrWhiteSpace(k))) + applied.Add($"+keyword:{keyword}"); + if (disable != null) + foreach (var keyword in disable.Where(k => !string.IsNullOrWhiteSpace(k))) + applied.Add($"-keyword:{keyword}"); + } + + /// + /// dry_run validation: classify each supplied property as applicable (name resolves AND value + /// shape matches the declared type) or unknown, WITHOUT mutating the material. + /// + private static void ValidateProperties(Shader shader, JObject properties, SetMaterialPropertiesResult result) + { + if (properties == null) + return; + + foreach (var pair in properties) + { + if (pair.Key == "token") + continue; + + var name = pair.Key; + var propType = shader != null ? GetPropertyType(shader, name) : null; + if (propType == null) + { + result.Unknown.Add($"{name}: unknown property (not declared by shader '{(shader != null ? shader.name : "")}')"); + continue; + } + + // Validate the value shape against the type by attempting a conversion on a throwaway + // material instance — never the caller's material (dry_run must leave no trace). + Material probe = null; + try + { + probe = new Material(shader); + MaterialValueConverter.ApplyValue(probe, name, propType.Value, pair.Value); + result.Applied.Add(name); + } + catch (MaterialPropertyTypeMismatch mismatch) + { + result.Unknown.Add(mismatch.Message); + } + finally + { + if (probe != null) + Object.DestroyImmediate(probe); + } + } + } + + /// + /// Look up a property's by name on a shader. + /// Returns null when the shader has no property with that exact name (case-sensitive; names include + /// the leading underscore). + /// + internal static ShaderPropertyType? GetPropertyType(Shader shader, string name) + { + if (shader == null) + return null; + int count = shader.GetPropertyCount(); + for (int i = 0; i < count; i++) + { + if (shader.GetPropertyName(i) == name) + return shader.GetPropertyType(i); + } + return null; + } + + private static void GuardAtLeastOneApplied(SetMaterialPropertiesResult result, bool shaderReassigned) + { + // A shader reassignment is itself a successful change, so it satisfies the "at least one + // thing applied" guard even when every supplied property is unknown for the new shader. + if (!shaderReassigned && result.Applied.Count == 0 && result.Unknown.Count > 0) + throw new ArgumentException( + $"None of the supplied inputs could be applied (unknown property name or type mismatch): {string.Join("; ", result.Unknown)}."); + } + + private static void CopyIdentity(Material mat, string assetPath, SetMaterialPropertiesResult result) + { + var identity = ObjectResolver.Describe(mat); + if (identity != null) + { + result.GlobalId = identity.GlobalId; + result.Guid = identity.Guid; + result.FileId = identity.FileId; + result.InstanceId = identity.InstanceId; + result.HierarchyPath = identity.HierarchyPath; + result.Type = identity.Type; + } + else + { + result.Type = nameof(Material); + } + + // Prefer the confined asset path computed by ResolveMaterial. + if (!string.IsNullOrEmpty(assetPath)) + result.AssetPath = assetPath; + } + + /// Map a to the value-shape label used by get_material_properties. + private static string PublicValueType(ShaderPropertyType type) + { + switch (type) + { + case ShaderPropertyType.Color: return "Color"; + case ShaderPropertyType.Vector: return "Vector"; + case ShaderPropertyType.Float: return "Float"; + case ShaderPropertyType.Range: return "Range"; + case ShaderPropertyType.Texture: return "Texture"; + case ShaderPropertyType.Int: return "Int"; + default: return type.ToString(); + } + } + } + + /// + /// Thrown when a requested shader name does not resolve via . Carries the + /// stable shader_not_found code (also prefixed onto the message, since the server propagates + /// only the exception message over the wire) so a client can distinguish it from other failures. + /// + public sealed class ShaderNotFoundException : Exception + { + public const string CodeValue = "shader_not_found"; + + public string Code => CodeValue; + + public ShaderNotFoundException(string message) : base($"[{CodeValue}] {message}") { } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialCommands.cs.meta new file mode 100644 index 00000000..b68b4f07 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a14c4a97ac9e4d61b904de30d1016d92 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialResults.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialResults.cs new file mode 100644 index 00000000..b1e9fd89 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialResults.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Unity.Pipeline.Models; + +namespace Unity.Pipeline.Editor.Commands.Materials +{ + /// + /// Result of get_material_properties (CLI-213): the material's shader, render queue, enabled + /// keywords, and the full list of shader properties with their current values. + /// + [Serializable] + public class MaterialPropertiesResult + { + [JsonProperty("assetPath")] + public string AssetPath { get; set; } + + /// Shader name, e.g. "Universal Render Pipeline/Lit". + [JsonProperty("shader")] + public string Shader { get; set; } + + /// Render queue; -1 means "inherit from shader". + [JsonProperty("renderQueue")] + public int RenderQueue { get; set; } + + [JsonProperty("enabledKeywords")] + public List EnabledKeywords { get; set; } = new List(); + + [JsonProperty("properties")] + public List Properties { get; set; } = new List(); + } + + /// A single shader property of a material with its current value. + [Serializable] + public class MaterialPropertyValue + { + [JsonProperty("name")] + public string Name { get; set; } + + /// "Float" | "Range" | "Color" | "Vector" | "Texture" | "Int". + [JsonProperty("type")] + public string Type { get; set; } + + /// + /// number | [r,g,b,a] | [x,y,z,w] | { texture: ObjectRef } | null, per the property type. + /// + [JsonProperty("value")] + public object Value { get; set; } + + /// Min/max for a Range property; null otherwise. + [JsonProperty("range", NullValueHandling = NullValueHandling.Ignore)] + public MaterialRange Range { get; set; } + } + + /// Inclusive min/max bounds of a Range shader property. + [Serializable] + public class MaterialRange + { + [JsonProperty("min")] + public float Min { get; set; } + + [JsonProperty("max")] + public float Max { get; set; } + } + + /// + /// Result of set_material_properties (CLI-213): the canonical + /// identity of the edited material, extended with which properties applied vs. were unknown (with a + /// reason), and the material's (possibly reassigned) shader name. + /// + [Serializable] + public class SetMaterialPropertiesResult : AuthoringResult + { + /// Shader name after any reassignment. + [JsonProperty("shader")] + public string Shader { get; set; } + + /// Property names (and keyword/renderQueue tokens) that were applied. + [JsonProperty("applied")] + public List Applied { get; set; } = new List(); + + /// + /// Inputs that could not be applied, each as "name: reason" (unknown property name, or a type + /// mismatch like "_Metallic: expected Float, got array"). + /// + [JsonProperty("unknown")] + public List Unknown { get; set; } = new List(); + } + + /// A single discovered shader entry from list_shaders. + [Serializable] + public class ShaderInfo + { + [JsonProperty("name")] + public string Name { get; set; } + + /// Project asset path for a project shader; null for a built-in/engine shader. + [JsonProperty("assetPath")] + public string AssetPath { get; set; } + + [JsonProperty("isBuiltin")] + public bool IsBuiltin { get; set; } + + /// Reflects Shader.isSupported for the active render pipeline. + [JsonProperty("isSupported")] + public bool IsSupported { get; set; } + } + + /// Result of get_shader_properties (CLI-213): a shader's declared property list. + [Serializable] + public class ShaderPropertiesResult + { + [JsonProperty("shader")] + public string Shader { get; set; } + + [JsonProperty("properties")] + public List Properties { get; set; } = new List(); + } + + /// A single declared shader property, introspected via ShaderUtil. + [Serializable] + public class ShaderPropertyInfo + { + [JsonProperty("name")] + public string Name { get; set; } + + /// ShaderUtil property description (the UI label). + [JsonProperty("description")] + public string Description { get; set; } + + /// "Color" | "Vector" | "Float" | "Range" | "TexEnv" | "Int". + [JsonProperty("type")] + public string Type { get; set; } + + /// Min/max for a Range property; null otherwise. + [JsonProperty("range", NullValueHandling = NullValueHandling.Ignore)] + public MaterialRange Range { get; set; } + + /// "Tex2D" | "Cube" | "Tex3D" | "Tex2DArray" for a TexEnv property; null otherwise. + [JsonProperty("textureDimension", NullValueHandling = NullValueHandling.Ignore)] + public string TextureDimension { get; set; } + + /// Property flags, e.g. "HideInInspector", "Normal", "HDR", "NoScaleOffset". + [JsonProperty("flags")] + public List Flags { get; set; } = new List(); + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialResults.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialResults.cs.meta new file mode 100644 index 00000000..db85ad6b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialResults.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 52b9d1506e9b4a95982ff554d70179b3 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialValueConverter.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialValueConverter.cs new file mode 100644 index 00000000..ae4d5f22 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialValueConverter.cs @@ -0,0 +1,310 @@ +using System; +using System.Globalization; +using Newtonsoft.Json.Linq; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine; +using UnityEngine.Rendering; +using Object = UnityEngine.Object; + +namespace Unity.Pipeline.Editor.Commands.Materials +{ + /// + /// Bridges agent-supplied JSON values and a 's shader properties for the + /// get_material_properties / set_material_properties commands (CLI-213). + /// + /// Value encoding by shader property type: + /// - Float / Range / Int : a JSON number + /// - Color : [r, g, b, a] (floats 0–1) or a "#RRGGBB"/"#RRGGBBAA" hex string + /// - Vector : [x, y, z, w] + /// - Texture (TexEnv) : an object (resolved + confined to the authoring + /// root), or null to clear + /// + /// Reads return the same shapes (Color as [r,g,b,a], Vector as [x,y,z,w], Texture as + /// { texture: ObjectRef } or null) so a read round-trips back through a write. + /// + internal static class MaterialValueConverter + { + /// + /// Read a single shader property's current value off the material into a plain object suitable + /// for JSON serialization. The shape matches the . + /// + public static object ReadValue(Material material, string propertyName, ShaderPropertyType type) + { + switch (type) + { + case ShaderPropertyType.Color: + { + var c = material.GetColor(propertyName); + return new[] { c.r, c.g, c.b, c.a }; + } + case ShaderPropertyType.Vector: + { + var v = material.GetVector(propertyName); + return new[] { v.x, v.y, v.z, v.w }; + } + case ShaderPropertyType.Float: + case ShaderPropertyType.Range: + return material.GetFloat(propertyName); + case ShaderPropertyType.Int: + return material.GetInteger(propertyName); + case ShaderPropertyType.Texture: + { + var tex = material.GetTexture(propertyName); + // A re-usable handle for the assigned texture, or null when no texture is bound. + var handle = ObjectResolver.Describe(tex); + return handle == null ? null : new { texture = handle }; + } + default: + return null; + } + } + + /// + /// Apply a JSON value to a single shader property on the material, converting by the shader's + /// declared property type. Throws when the supplied + /// JSON shape does not match the property's type (so the caller can record it in + /// unknown[] with a reason). Texture refs are resolved via + /// and confined to the authoring root; an out-of-root or unresolved ref throws + /// (a hard failure — never silently dropped). + /// + public static void ApplyValue(Material material, string propertyName, ShaderPropertyType type, JToken value) + { + switch (type) + { + case ShaderPropertyType.Float: + material.SetFloat(propertyName, ToFloat(propertyName, "Float", value)); + break; + + case ShaderPropertyType.Range: + material.SetFloat(propertyName, ToFloat(propertyName, "Range", value)); + break; + + case ShaderPropertyType.Int: + // Unity 6: Material.SetInteger is the typed setter for an Int shader property. + material.SetInteger(propertyName, ToInt(propertyName, value)); + break; + + case ShaderPropertyType.Color: + material.SetColor(propertyName, ToColor(propertyName, value)); + break; + + case ShaderPropertyType.Vector: + material.SetVector(propertyName, ToVector4(propertyName, value)); + break; + + case ShaderPropertyType.Texture: + material.SetTexture(propertyName, ToTexture(propertyName, value)); + break; + + default: + throw new MaterialPropertyTypeMismatch( + $"{propertyName}: unsupported shader property type '{type}'."); + } + } + + private static float ToFloat(string property, string typeLabel, JToken value) + { + if (value == null || value.Type == JTokenType.Null) + throw new MaterialPropertyTypeMismatch($"{property}: expected {typeLabel}, got null."); + if (value.Type != JTokenType.Integer && value.Type != JTokenType.Float) + throw new MaterialPropertyTypeMismatch( + $"{property}: expected {typeLabel} (a number), got {Describe(value)}."); + return value.ToObject(); + } + + private static int ToInt(string property, JToken value) + { + if (value == null || value.Type == JTokenType.Null) + throw new MaterialPropertyTypeMismatch($"{property}: expected Int, got null."); + if (value.Type != JTokenType.Integer && value.Type != JTokenType.Float) + throw new MaterialPropertyTypeMismatch( + $"{property}: expected Int (a number), got {Describe(value)}."); + // Reject fractional JSON numbers (e.g. 1.5) rather than silently rounding/truncating. + // An Int shader property must receive a whole number. + var asDouble = value.ToObject(); + if (asDouble != Math.Truncate(asDouble)) + throw new MaterialPropertyTypeMismatch( + $"{property}: expected Int (a whole number), got the non-integer value {value}."); + return value.ToObject(); + } + + /// + /// Parse a Color from either [r,g,b,a] (3 or 4 floats) or a hex string + /// ("#RRGGBB" / "#RRGGBBAA", leading '#' optional). Default alpha is 1.0. + /// + private static Color ToColor(string property, JToken value) + { + if (value is JArray arr) + { + if (arr.Count != 3 && arr.Count != 4) + throw new MaterialPropertyTypeMismatch( + $"{property}: expected Color as [r,g,b,a] (or [r,g,b]), got an array of length {arr.Count}."); + // Route each component through ToFloat so non-numeric/null elements surface as a clean + // MaterialPropertyTypeMismatch rather than throwing and failing the whole command. + float r = ToFloat(property, "Color", arr[0]); + float g = ToFloat(property, "Color", arr[1]); + float b = ToFloat(property, "Color", arr[2]); + float a = arr.Count == 4 ? ToFloat(property, "Color", arr[3]) : 1f; + return new Color(r, g, b, a); + } + + if (value != null && value.Type == JTokenType.String) + { + if (TryParseHexColor(value.ToObject(), out var color)) + return color; + throw new MaterialPropertyTypeMismatch( + $"{property}: expected Color as [r,g,b,a] or a '#RRGGBB'/'#RRGGBBAA' hex string, got '{value}'."); + } + + throw new MaterialPropertyTypeMismatch( + $"{property}: expected Color as [r,g,b,a] or a hex string, got {Describe(value)}."); + } + + private static Vector4 ToVector4(string property, JToken value) + { + if (!(value is JArray arr)) + throw new MaterialPropertyTypeMismatch( + $"{property}: expected Vector as [x,y,z,w], got {Describe(value)}."); + if (arr.Count == 0 || arr.Count > 4) + throw new MaterialPropertyTypeMismatch( + $"{property}: expected Vector as [x,y,z,w] (1–4 numbers), got an array of length {arr.Count}."); + + // Missing trailing components default to 0 (matches Unity's Vector4 component default), + // so [x,y,z] and [x,y] are accepted as shorthand for a 4-component vector. + // Route each present component through ToFloat so non-numeric/null elements surface as a + // clean MaterialPropertyTypeMismatch rather than throwing and failing the whole command. + float x = arr.Count > 0 ? ToFloat(property, "Vector", arr[0]) : 0f; + float y = arr.Count > 1 ? ToFloat(property, "Vector", arr[1]) : 0f; + float z = arr.Count > 2 ? ToFloat(property, "Vector", arr[2]) : 0f; + float w = arr.Count > 3 ? ToFloat(property, "Vector", arr[3]) : 0f; + return new Vector4(x, y, z, w); + } + + /// + /// Resolve a texture property value: an explicit null clears it, otherwise the value must + /// be an that resolves to a confined to the + /// authoring root. A non-texture object, an out-of-root asset, or an unresolved handle is a hard + /// failure (never silently dropped). + /// + private static Texture ToTexture(string property, JToken value) + { + if (value == null || value.Type == JTokenType.Null) + return null; + + // ReadValue returns textures as { "texture": } so a get->set round-trip works. + // Accept both that wrapper form and a bare ObjectRef supplied directly by the caller. + JToken refToken = value; + if (value is JObject wrapper && wrapper.ContainsKey("texture")) + refToken = wrapper["texture"]; + + ObjectRef handle; + try + { + handle = refToken.ToObject(); + } + catch (Exception) + { + throw new MaterialPropertyTypeMismatch( + $"{property}: expected Texture as an object reference {{guid/path/...}} or null, got {Describe(value)}."); + } + + if (handle == null || handle.IsEmpty) + throw new MaterialPropertyTypeMismatch( + $"{property}: expected Texture as an object reference {{guid/path/...}} or null, got {Describe(value)}."); + + if (!ObjectResolver.TryResolve(handle, out var obj, out var error)) + throw new ArgumentException($"{property}: could not resolve texture reference: {error}"); + + if (!(obj is Texture texture)) + throw new MaterialPropertyTypeMismatch( + $"{property}: expected Texture, got a {obj.GetType().Name}."); + + // Confine to the authoring root: a GUID/globalId/path handle must bind a texture that lives + // on disk under the sandbox. An in-memory/scene Texture has an empty asset path; allowing it + // would bypass the root confinement, so reject any ref that doesn't resolve to a persisted + // asset. Mirrors AssetCommands.ResolveAssetPath's confinement. + var assetPath = AssetDatabase.GetAssetPath(texture); + if (string.IsNullOrEmpty(assetPath)) + throw new ArgumentException( + $"{property}: texture reference does not resolve to an on-disk asset under the authoring root '{ProjectPaths.AuthoringRoot}'; in-memory or scene textures are not allowed."); + + var confined = ProjectPaths.Resolve(assetPath, out var confineError); + if (confined == null) + throw new ArgumentException( + $"{property}: texture '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}"); + + return texture; + } + + /// + /// Parse a hex color string ("#RRGGBB", "#RRGGBBAA", or without the leading '#'). Returns false + /// for any malformed input. Channels are 0–255 mapped to 0–1; default alpha is 1.0. + /// + public static bool TryParseHexColor(string hex, out Color color) + { + color = Color.white; + if (string.IsNullOrWhiteSpace(hex)) + return false; + + var s = hex.Trim(); + if (s.StartsWith("#", StringComparison.Ordinal)) + s = s.Substring(1); + + if (s.Length != 6 && s.Length != 8) + return false; + + if (!byte.TryParse(s.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) || + !byte.TryParse(s.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) || + !byte.TryParse(s.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b)) + return false; + + byte a = 255; + if (s.Length == 8 && + !byte.TryParse(s.Substring(6, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out a)) + return false; + + color = new Color(r / 255f, g / 255f, b / 255f, a / 255f); + return true; + } + + /// Map a to the public type label used in results. + public static string TypeLabel(ShaderPropertyType type) + { + switch (type) + { + case ShaderPropertyType.Color: return "Color"; + case ShaderPropertyType.Vector: return "Vector"; + case ShaderPropertyType.Float: return "Float"; + case ShaderPropertyType.Range: return "Range"; + case ShaderPropertyType.Texture: return "TexEnv"; + case ShaderPropertyType.Int: return "Int"; + default: return type.ToString(); + } + } + + private static string Describe(JToken value) + { + if (value == null) + return "null"; + switch (value.Type) + { + case JTokenType.Array: return "array"; + case JTokenType.Object: return "object"; + case JTokenType.Null: return "null"; + default: return value.Type.ToString().ToLowerInvariant(); + } + } + } + + /// + /// Thrown when a supplied value's JSON shape does not match the shader property's declared type. + /// The command catches this and records the property in unknown[] with the message as the + /// reason, rather than failing the whole call. + /// + internal sealed class MaterialPropertyTypeMismatch : Exception + { + public MaterialPropertyTypeMismatch(string message) : base(message) { } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialValueConverter.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialValueConverter.cs.meta new file mode 100644 index 00000000..4445fb6a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/MaterialValueConverter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc8956be3c264ccb8096787d7146f330 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/ShaderCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/ShaderCommands.cs new file mode 100644 index 00000000..0f37c7d5 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/ShaderCommands.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine; +using UnityEngine.Rendering; + +namespace Unity.Pipeline.Editor.Commands.Materials +{ + /// + /// Shader discovery / introspection commands (CLI-213): list_shaders so an agent can pick a + /// valid shader name, and get_shader_properties so it knows a shader's settable property + /// names / types / ranges / texture dimensions / flags before authoring a material. + /// + /// Property introspection uses instance APIs exclusively: + /// GetPropertyCount, GetPropertyName, GetPropertyType, + /// GetPropertyDescription, GetPropertyRangeLimits, + /// GetPropertyTextureDimension, and GetPropertyFlags. The reported type set is + /// : Color, Vector, Float, Range, Texture, Int. + /// + public static class ShaderCommands + { + [CliCommand("list_shaders", + "Discover available shaders so an agent can pick a valid name for set_material_properties / create_asset. Returns [{ name, assetPath|null, isBuiltin, isSupported }].")] + public static List ListShaders( + [CliArg("filter", "Case-insensitive substring matched against the shader name (e.g. \"URP\", \"Lit\").")] string filter = null, + [CliArg("includeBuiltin", "Include built-in/engine shaders (those with no project asset path). Default true.")] bool includeBuiltin = true, + [CliArg("limit", "Maximum number of shaders to return (default 200).")] int limit = 200) + { + if (limit <= 0) + limit = 200; + + var results = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + // AssetDatabase.FindAssets("t:Shader") enumerates project + package shaders (each with an + // asset path); ShaderUtil.GetAllShaderInfo() additionally surfaces built-in engine shaders + // that have no asset path (e.g. "Standard", "Sprites/Default"). Merge both, dedup by name. + foreach (var guid in AssetDatabase.FindAssets("t:Shader")) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + if (string.IsNullOrEmpty(path)) + continue; + var shader = AssetDatabase.LoadAssetAtPath(path); + if (shader == null) + continue; + + if (!seen.Add(shader.name)) + continue; + + if (!PassesFilter(shader.name, filter)) + continue; + + results.Add(new ShaderInfo + { + Name = shader.name, + AssetPath = path, + IsBuiltin = false, + IsSupported = shader.isSupported, + }); + + if (results.Count >= limit) + return results; + } + + if (includeBuiltin) + { + // GetAllShaderInfo() enumerates every shader the editor knows about, including built-in + // engine shaders that have no project/package asset path. Any name NOT already added by + // the FindAssets("t:Shader") pass above is a built-in (assetPath null). `info.supported` + // already reflects Shader::IsSupported(), so we don't re-run Shader.Find per entry. + foreach (var info in ShaderUtil.GetAllShaderInfo()) + { + if (!seen.Add(info.name)) + continue; + + if (!PassesFilter(info.name, filter)) + continue; + + results.Add(new ShaderInfo + { + Name = info.name, + AssetPath = null, + IsBuiltin = true, + IsSupported = info.supported, + }); + + if (results.Count >= limit) + break; + } + } + + return results; + } + + [CliCommand("get_shader_properties", + "Introspect a shader's declared property list (name, description, type Color|Vector|Float|Range|TexEnv|Int, range, textureDimension, flags). Provide 'shader' (by name) OR 'material' (read the shader off that material).")] + public static ShaderPropertiesResult GetShaderProperties( + [CliArg("shader", "Shader name (e.g. \"Universal Render Pipeline/Lit\"). Provide this OR 'material'.")] string shader = null, + [CliArg("material", "Reference to a material to read the shader from instead of naming it. Provide this OR 'shader'.")] ObjectRef material = null) + { + Shader resolved; + + if (!string.IsNullOrWhiteSpace(shader)) + { + resolved = Shader.Find(shader); + if (resolved == null) + throw new ShaderNotFoundException( + $"Shader '{shader}' not found. Shader.Find only resolves shaders that are compiled/included in the build (or referenced by a material/scene). Use list_shaders to discover valid names."); + } + else if (material != null && !material.IsEmpty) + { + var mat = MaterialCommands.ResolveMaterial(material, out _); + resolved = mat.shader; + if (resolved == null) + throw new ArgumentException($"Material '{material}' has no shader assigned."); + } + else + { + throw new ArgumentException("Provide either 'shader' (a shader name) or 'material' (a material reference)."); + } + + var result = new ShaderPropertiesResult { Shader = resolved.name }; + + int count = resolved.GetPropertyCount(); + for (int i = 0; i < count; i++) + { + var propType = resolved.GetPropertyType(i); + var entry = new ShaderPropertyInfo + { + Name = resolved.GetPropertyName(i), + Description = resolved.GetPropertyDescription(i), + Type = MaterialValueConverter.TypeLabel(propType), + Flags = DescribeFlags(resolved.GetPropertyFlags(i)), + }; + + if (propType == ShaderPropertyType.Range) + { + var limits = resolved.GetPropertyRangeLimits(i); + entry.Range = new MaterialRange + { + Min = limits.x, + Max = limits.y, + }; + } + + if (propType == ShaderPropertyType.Texture) + entry.TextureDimension = resolved.GetPropertyTextureDimension(i).ToString(); + + result.Properties.Add(entry); + } + + return result; + } + + private static bool PassesFilter(string name, string filter) + { + if (string.IsNullOrWhiteSpace(filter)) + return true; + return name.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// + /// Decompose a bitmask into its individual flag names (the + /// enum is a [Flags] type). None is reported as an empty list rather than ["None"]. + /// + private static List DescribeFlags(ShaderPropertyFlags flags) + { + var list = new List(); + if (flags == ShaderPropertyFlags.None) + return list; + + foreach (ShaderPropertyFlags flag in Enum.GetValues(typeof(ShaderPropertyFlags))) + { + if (flag == ShaderPropertyFlags.None) + continue; + if ((flags & flag) == flag) + list.Add(flag.ToString()); + } + + return list; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/ShaderCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/ShaderCommands.cs.meta new file mode 100644 index 00000000..0c5e23bb --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Materials/ShaderCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1007e4472d7b4216a6a866f8aaba76c7 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/MenuItemCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/MenuItemCommand.cs new file mode 100644 index 00000000..704872ea --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/MenuItemCommand.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; +using Unity.Pipeline.Commands; +using UnityEditor; + +namespace Unity.Pipeline.Editor.Commands +{ + /// + /// Executes an Editor menu item by its path (e.g. "Assets/Reimport All"), or — when called with + /// no path — lists the available menu items. Backs the unity menu CLI command (CLI-110): + /// unity menu lists options, unity menu "<path>" executes one. The CLI only + /// forwards the request; all logic lives here, per the two-commands design. + /// + /// Named MenuItemCommand (not MenuCommand) to avoid colliding with the built-in + /// type, which would make unqualified references ambiguous + /// in any file that also imports UnityEditor. + /// + /// Execution wraps , which returns false + /// when the item could not be invoked — either because the path does not exist or because the + /// item is currently disabled (its validation function returned false). The API does not + /// distinguish those cases, so a failure is reported as "not found or disabled"; the CLI maps a + /// failed result to its "menu item does not exist" exit code. + /// + /// Listing reflects the actual Editor menu via the internal + /// UnityEditor.Menu.GetMenuItems API (accessed by reflection, mirroring + /// ). Unlike scanning [MenuItem] attributes, this also + /// covers menus Unity defines natively (much of GameObject/…, Assets/Create/…, + /// File/…, etc.), which carry no attribute and would otherwise be missed. + /// + /// Note: some menu items are heavy, show a modal confirmation, or trigger a domain reload/quit + /// (which tears the server down before it can reply). That is the nature of the invoked item; + /// such cases are a known limitation of driving arbitrary menus over a request/response call. + /// + public static class MenuItemCommand + { + [CliCommand("menu", "Execute an Editor menu item by path, or list available items when no path is given", MainThreadRequired = true)] + public static MenuResponse ExecuteMenu( + [CliArg("path", "Menu item path to execute, e.g. \"Assets/Reimport All\". Omit to list available menu items.")] string path = "") + { + // No path → discovery mode: return the available menu items instead of executing. + if (string.IsNullOrWhiteSpace(path)) + { + List items; + try + { + items = DiscoverMenuItems(); + } + catch (Exception ex) + { + return MenuResponse.Fail(null, $"Failed to list menu items: {ex.Message}"); + } + + return new MenuResponse + { + Success = true, + Items = items, + Message = $"{items.Count} menu item(s) available." + }; + } + + bool executed; + try + { + executed = EditorApplication.ExecuteMenuItem(path); + } + catch (Exception ex) + { + return MenuResponse.Fail(path, $"Failed to execute menu item '{path}': {ex.Message}"); + } + + if (!executed) + return MenuResponse.Fail(path, $"Menu item '{path}' was not found or is currently disabled."); + + return new MenuResponse + { + Success = true, + Path = path, + Message = $"Executed menu item '{path}'" + }; + } + + const BindingFlags k_All = BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + // Reflection handles for the internal UnityEditor.Menu.GetMenuItems(string, bool, bool) API, + // which returns an array of internal UnityEditor.ScriptingMenuItem. Resolved once and cached; + // we read each item's `path` and `isSeparator` member (property or field, depending on the + // Unity version). See FocusEditorCommand for the same reflect-into-internal-API approach. + static MethodInfo s_GetMenuItems; + static Func s_GetPath; + static Func s_GetIsSeparator; + static bool s_Resolved; + + static void EnsureReflection() + { + if (s_Resolved) + return; + s_Resolved = true; + + var editorAsm = typeof(EditorApplication).Assembly; + + var menuType = editorAsm.GetType("UnityEditor.Menu"); + s_GetMenuItems = menuType?.GetMethod( + "GetMenuItems", k_All, null, new[] { typeof(string), typeof(bool), typeof(bool) }, null); + + var itemType = editorAsm.GetType("UnityEditor.ScriptingMenuItem"); + s_GetPath = BuildAccessor(itemType, "path", "m_Path"); + s_GetIsSeparator = BuildAccessor(itemType, "isSeparator", "m_IsSeparator"); + } + + /// + /// Build a getter for a member that may be exposed as a property or a backing field, so the + /// reflection survives the internal type's shape changing across Unity versions. + /// + static Func BuildAccessor(Type type, string propertyName, string fieldName) + { + var prop = type?.GetProperty(propertyName, k_All); + if (prop != null) + return o => (T)prop.GetValue(o); + + var field = type?.GetField(propertyName, k_All) ?? type?.GetField(fieldName, k_All); + if (field != null) + return o => (T)field.GetValue(o); + + return null; + } + + // Standard top-level Editor menus. Unity defines these natively (no [MenuItem]), so they are + // not discoverable from attributes; we seed them so their native children are enumerated. + static readonly string[] k_NativeRoots = + { + "File", "Edit", "Assets", "GameObject", "Component", "Window", "Help" + }; + + /// + /// Collect the distinct menu paths from the live Editor menu. Menu.GetMenuItems returns + /// the full (recursive) set of items under a given root — including ones Unity defines + /// natively — but it does not enumerate the roots themselves, and no internal API does. So we + /// union the native roots above with the first path segment of every [MenuItem] (which + /// surfaces custom/package roots), then expand each root through the internal API. The items + /// always come from the live menu, so native entries are included. + /// + static List DiscoverMenuItems() + { + EnsureReflection(); + + if (s_GetMenuItems == null || s_GetPath == null) + throw new InvalidOperationException( + "UnityEditor.Menu.GetMenuItems is unavailable in this Unity version."); + + var paths = new SortedSet(StringComparer.Ordinal); + foreach (var root in CollectRoots()) + CollectItemsUnder(root, paths); + + return paths.ToList(); + } + + /// + /// The top-level menu names to expand: the native roots, plus the first segment of every + /// attributed menu item (covering custom/package menus). Component context menus + /// (CONTEXT/…) are excluded, as they are not part of the main menu bar. + /// + static IEnumerable CollectRoots() + { + var roots = new SortedSet(StringComparer.Ordinal); + foreach (var r in k_NativeRoots) + roots.Add(r); + + foreach (var method in TypeCache.GetMethodsWithAttribute()) + { + foreach (var attr in method.GetCustomAttributes(typeof(MenuItem), false).Cast()) + { + if (attr.validate || string.IsNullOrEmpty(attr.menuItem)) + continue; + + var slash = attr.menuItem.IndexOf('/'); + var root = slash > 0 ? attr.menuItem.Substring(0, slash) : attr.menuItem; + if (root.StartsWith("CONTEXT", StringComparison.Ordinal)) + continue; + + roots.Add(root); + } + } + + return roots; + } + + /// + /// Append every non-separator menu path under to + /// . A root that isn't present in this Editor simply contributes + /// nothing (the API returns an empty array). + /// + static void CollectItemsUnder(string root, SortedSet paths) + { + if (!(s_GetMenuItems.Invoke(null, new object[] { root, false, false }) is Array items)) + return; + + foreach (var item in items) + { + if (item == null) + continue; + if (s_GetIsSeparator != null && s_GetIsSeparator(item)) + continue; + + var path = s_GetPath(item); + if (string.IsNullOrEmpty(path)) + continue; + + paths.Add(path); + } + } + } + + /// + /// Result of the menu command. On execution, false means the item + /// could not be invoked (not found or disabled). In list mode, holds the + /// available menu paths. + /// + [Serializable] + public class MenuResponse + { + [JsonProperty("success")] + public bool Success { get; set; } + + [JsonProperty("path")] + public string Path { get; set; } + + [JsonProperty("items")] + public List Items { get; set; } + + [JsonProperty("message")] + public string Message { get; set; } + + public static MenuResponse Fail(string path, string message) => new MenuResponse + { + Success = false, + Path = path, + Message = message + }; + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/MenuItemCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/MenuItemCommand.cs.meta new file mode 100644 index 00000000..92571afd --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/MenuItemCommand.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f48719fc11d44809a97c890b8317c0d7 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Navigation.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Navigation.meta new file mode 100644 index 00000000..2dcfd0de --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Navigation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 31cd60e0286e77f4cb5baaaa7a3d6377 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Navigation/NavigationCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Navigation/NavigationCommands.cs new file mode 100644 index 00000000..b917ab9f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Navigation/NavigationCommands.cs @@ -0,0 +1,303 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEditor.Search; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace Unity.Pipeline.Editor.Commands.Navigation +{ + /// + /// Navigation and targeting commands (CLI-200): read and drive the Editor selection, and run + /// Unity Search queries — both returning the canonical object + /// identity (via ) so an agent can reference results in a + /// follow-up call. All commands are read-only or non-destructive (set_selection only changes the + /// Editor selection, which carries no undo/safety policy), and run on the main thread. + /// + public static class NavigationCommands + { + private const int MaxSearchResults = 200; + + [CliCommand("get_selection", "Read the current Editor selection as structured object identities.")] + public static SelectionResult GetSelection() + { + return DescribeSelection(null); + } + + /// + /// Set the Editor selection from instance ids and/or asset paths. + /// + /// We accept simple / rather than full + /// [] handles because nested-object parameter schemas (arrays of objects) + /// are pending CAT-2508. Once that lands this can take an [] and route + /// each through for the full handle surface. + /// + /// + [CliCommand("set_selection", "Set the Editor selection to the given assets/scene objects.")] + public static SelectionResult SetSelection( + [CliArg("instance_ids", "Scene/loaded object instance IDs to select.")] ObjectId[] instanceIds = null, + [CliArg("paths", "Asset paths to select (e.g. Assets/Foo.prefab).")] string[] paths = null) + { + var resolved = new List(); + var unresolved = new List(); + + if (instanceIds != null) + { + foreach (var id in instanceIds) + { + var obj = PipelineUtils.IdToObject(id); + if (obj != null) + resolved.Add(obj); + else + unresolved.Add($"instanceId:{id}"); + } + } + + if (paths != null) + { + foreach (var path in paths) + { + var obj = string.IsNullOrEmpty(path) ? null : AssetDatabase.LoadMainAssetAtPath(path); + if (obj != null) + resolved.Add(obj); + else if (path == null) + unresolved.Add(""); + else if (path.Length == 0) + unresolved.Add(""); + else + unresolved.Add(path); + } + } + + var hadInputs = (instanceIds != null && instanceIds.Length > 0) || + (paths != null && paths.Length > 0); + + // Inputs were given but none resolved: that's a hard error, not a silent clear. + if (resolved.Count == 0 && hadInputs) + throw new ArgumentException($"No objects resolved from the given inputs. Unresolved: {string.Join(", ", unresolved)}"); + + // No inputs at all is a valid request to clear the selection. + var objects = resolved.ToArray(); + Selection.objects = objects; + Selection.activeObject = resolved.FirstOrDefault(); + + return DescribeSelection(unresolved.ToArray()); + } + + [CliCommand("search", "Run a Unity Search query and return structured results.")] + public static SearchResult Search( + [CliArg("query", "Unity Search query string, e.g. 't:Material', 'p: my asset', 'h: Main Camera'.", Required = true)] string query, + [CliArg("limit", "Max results to return (capped 200).")] int limit = 50) + { + if (string.IsNullOrEmpty(query)) + throw new ArgumentException("query must be a non-empty Unity Search query string."); + + var clamped = Mathf.Clamp(limit, 0, MaxSearchResults); + + // A non-positive limit can't return any rows; skip the potentially expensive request. + if (clamped <= 0) + { + return new SearchResult + { + Query = query, + Count = 0, + Results = Array.Empty() + }; + } + + var results = new List(); + + ISearchList list = null; + try + { + // Synchronous request returns the items already resolved on the main thread. + list = SearchService.Request(query, SearchFlags.Synchronous); + + foreach (var item in list) + { + if (results.Count >= clamped) + break; + if (item == null) + continue; + + // One malformed item must not fail the whole call. + try + { + results.Add(MapItem(item)); + } + catch + { + // skip the unmappable item + } + } + } + catch (Exception ex) + { + throw new InvalidOperationException($"Unity Search query '{query}' failed: {ex.Message}", ex); + } + finally + { + // The Synchronous Request overload returns an ISearchList that owns a SearchContext; + // dispose it when present so we don't leak the context. + (list as IDisposable)?.Dispose(); + } + + return new SearchResult + { + Query = query, + Count = results.Count, + Results = results.ToArray() + }; + } + + private static SearchResultItem MapItem(SearchItem item) + { + string label; + try + { + label = item.GetLabel(item.context, true); + } + catch + { + label = null; + } + + if (string.IsNullOrEmpty(label)) + label = item.label ?? item.id; + + string description = null; + try + { + description = item.GetDescription(item.context, true); + } + catch + { + // leave description null + } + + string path = null; + try + { + var obj = item.ToObject(); + if (obj != null) + { + var assetPath = AssetDatabase.GetAssetPath(obj); + if (!string.IsNullOrEmpty(assetPath)) + path = assetPath; + } + } + catch + { + // leave path null + } + + return new SearchResultItem + { + Id = item.id, + Label = label, + Description = description, + Provider = item.provider?.id, + Path = path + }; + } + + /// + /// Snapshot the current as canonical identities, optionally tagging on + /// the list from a set_selection call. + /// + private static SelectionResult DescribeSelection(string[] unresolved) + { + var objects = Selection.objects ?? Array.Empty(); + var described = objects + .Select(ObjectResolver.Describe) + .Where(r => r != null) + .ToArray(); + + return new SelectionResult + { + Count = described.Length, + Active = ObjectResolver.Describe(Selection.activeObject), + Objects = described, + Unresolved = unresolved + }; + } + } + + /// + /// Structured snapshot of the Editor selection: the active object plus every selected object, + /// each as a canonical identity. is only + /// populated by set_selection (inputs that did not resolve to a loaded object/asset). + /// + [Serializable] + public class SelectionResult + { + /// Number of selected objects (excludes nulls). + [JsonProperty("count")] + public int Count { get; set; } + + /// The active selection object, or null when the selection is empty. + [JsonProperty("active")] + public AuthoringResult Active { get; set; } + + /// Every selected object as a canonical identity (nulls skipped). + [JsonProperty("objects")] + public AuthoringResult[] Objects { get; set; } + + /// Inputs that did not resolve (set_selection only); null for get_selection. + [JsonProperty("unresolved")] + public string[] Unresolved { get; set; } + } + + /// + /// Structured result of a Unity Search query: the echoed query, the returned count, and the + /// mapped rows (capped at the requested limit). + /// + [Serializable] + public class SearchResult + { + /// The query that was executed (echoed back). + [JsonProperty("query")] + public string Query { get; set; } + + /// Number of results returned (after the limit cap). + [JsonProperty("count")] + public int Count { get; set; } + + /// The mapped search result rows. + [JsonProperty("results")] + public SearchResultItem[] Results { get; set; } + } + + /// + /// One Unity Search result, flattened to portable fields. is populated only + /// when the item resolves to an asset object. + /// + [Serializable] + public class SearchResultItem + { + /// Provider-specific item id (e.g. an asset path or scene object id). + [JsonProperty("id")] + public string Id { get; set; } + + /// Display label for the item. + [JsonProperty("label")] + public string Label { get; set; } + + /// Longer description, when the provider supplies one (may be null). + [JsonProperty("description")] + public string Description { get; set; } + + /// Id of the search provider that produced this item (e.g. "asset", "scene"). + [JsonProperty("provider")] + public string Provider { get; set; } + + /// Project-relative asset path, when the item resolves to an asset (else null). + [JsonProperty("path")] + public string Path { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Navigation/NavigationCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Navigation/NavigationCommands.cs.meta new file mode 100644 index 00000000..353e826e --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Navigation/NavigationCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a16797feca5bc694cac48b892634bb7a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability.meta new file mode 100644 index 00000000..e92c7b42 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7a7cc3922a14b4741a17d985bb6be540 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/ConsoleCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/ConsoleCommands.cs new file mode 100644 index 00000000..510fe76b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/ConsoleCommands.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Unity.Pipeline.Commands; + +namespace Unity.Pipeline.Editor.Commands.Observability +{ + /// + /// Read-only observability commands over the captured Editor console (CLI-198). Logs are captured + /// continuously by ; these commands let an agent read them back as + /// structured data and clear the buffer (and the Unity console) between runs. + /// + public static class ConsoleCommands + { + [CliCommand("get_console_logs", "Read recently captured Editor console logs (structured).")] + public static object GetConsoleLogs( + [CliArg("severity", "Filter: all | log | warning | error. 'all' = every entry; 'log' = Log only; 'warning' = Warning only; 'error' = Error/Exception/Assert only.")] string severity = "all", + [CliArg("limit", "Max entries to return (most-recent first), capped at 1000.")] int limit = 100) + { + // Snapshot is oldest-first; filter by tier, then take the most-recent `limit` entries + // and present them newest-first. + var snapshot = ConsoleLogBuffer.Snapshot(); + var filtered = new List(snapshot.Count); + foreach (var entry in snapshot) + { + if (MatchesSeverity(entry.Type, severity)) + filtered.Add(entry); + } + + var clampedLimit = Math.Min(Math.Max(limit, 1), ConsoleLogBuffer.MaxEntries); + + var logs = new List(Math.Min(clampedLimit, filtered.Count)); + for (var i = filtered.Count - 1; i >= 0 && logs.Count < clampedLimit; i--) + logs.Add(filtered[i]); + + return new + { + total = filtered.Count, + returned = logs.Count, + logs + }; + } + + [CliCommand("clear_console", "Clear the captured log buffer and the Unity Editor console.")] + public static object ClearConsole() + { + ConsoleLogBuffer.Clear(); + + // Best-effort: also clear Unity's own console window. UnityEditor.LogEntries is internal, + // so reach it via reflection and swallow any failure (API is not part of the public contract). + // LogEntries.Clear is a static, non-public method, so include NonPublic in the binding flags — + // GetMethod("Clear") with no flags only finds public methods and would silently return null. + try + { + Type.GetType("UnityEditor.LogEntries,UnityEditor") + ?.GetMethod("Clear", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static) + ?.Invoke(null, null); + } + catch + { + // Ignore — clearing the Editor console is auxiliary; the buffer is already cleared. + } + + return new { cleared = true }; + } + + /// + /// Map a Unity name onto the requested severity tier. + /// The tiers are distinct: "all" matches every entry; "log" matches Log only; + /// "warning" matches Warning only; "error" matches Error/Exception/Assert only. + /// Any unknown value falls back to "all" (matches everything). + /// + private static bool MatchesSeverity(string type, string severity) + { + switch (severity?.ToLowerInvariant()) + { + case "log": + return type == "Log"; + case "warning": + return type == "Warning"; + case "error": + return type == "Error" || type == "Exception" || type == "Assert"; + case "all": + default: + return true; + } + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/ConsoleCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/ConsoleCommands.cs.meta new file mode 100644 index 00000000..9b739831 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/ConsoleCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b7dd929510db8d248b64b05361bfabb6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/ConsoleLogBuffer.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/ConsoleLogBuffer.cs new file mode 100644 index 00000000..62fe5adc --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/ConsoleLogBuffer.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands.Observability +{ + /// + /// A single captured Editor console entry. Returned (most-recent-first) by the + /// get_console_logs command so an agent can read structured log output without scraping + /// the Editor UI. Mirrors the fields Unity surfaces on . + /// + [Serializable] + public class ConsoleLogEntryDto + { + /// Unity name, e.g. "Log", "Warning", "Error", "Exception", "Assert". + [JsonProperty("type")] + public string Type { get; set; } + + /// The logged message text. + [JsonProperty("message")] + public string Message { get; set; } + + /// Captured stack trace, if Unity provided one for the entry. + [JsonProperty("stackTrace")] + public string StackTrace { get; set; } + + /// Capture time in UTC, ISO-8601 round-trip ("o") format. + [JsonProperty("timestampUtc")] + public string TimestampUtc { get; set; } + } + + /// + /// Captures Editor console output into a bounded, thread-safe ring buffer so it can be read back + /// structurally over the pipeline (CLI-198). Subscribes to + /// on load — the threaded variant is used + /// because Unity may raise log callbacks off the main thread, and the handler is fully lock-guarded. + /// + /// The buffer holds at most entries; the oldest is dropped when full. + /// + public static class ConsoleLogBuffer + { + /// Maximum number of entries retained; oldest entries are dropped past this. + public const int MaxEntries = 1000; + + private static readonly object m_Lock = new object(); + private static readonly Queue m_Entries = new Queue(MaxEntries); + + /// + /// Subscribe to Unity's threaded log callback as soon as the Editor domain loads, so capture + /// is live before any command runs. Idempotent across domain reloads (handler is removed + /// first to avoid double-subscription if Init somehow runs twice). + /// + [InitializeOnLoadMethod] + private static void Init() + { + Application.logMessageReceivedThreaded -= OnLogMessageThreaded; + Application.logMessageReceivedThreaded += OnLogMessageThreaded; + } + + private static void OnLogMessageThreaded(string condition, string stackTrace, LogType type) + { + var entry = new ConsoleLogEntryDto + { + Type = type.ToString(), + Message = condition, + StackTrace = stackTrace, + TimestampUtc = DateTime.UtcNow.ToString("o") + }; + + lock (m_Lock) + { + if (m_Entries.Count >= MaxEntries) + m_Entries.Dequeue(); + m_Entries.Enqueue(entry); + } + } + + /// + /// Take a point-in-time copy of the buffer in chronological (oldest-first) order. The copy is + /// detached from the live buffer, so callers can filter/reverse it without holding the lock. + /// + public static IReadOnlyList Snapshot() + { + lock (m_Lock) + { + return new List(m_Entries); + } + } + + /// Number of entries currently retained in the buffer. + public static int Count + { + get + { + lock (m_Lock) + { + return m_Entries.Count; + } + } + } + + /// Drop all captured entries. + public static void Clear() + { + lock (m_Lock) + { + m_Entries.Clear(); + } + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/ConsoleLogBuffer.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/ConsoleLogBuffer.cs.meta new file mode 100644 index 00000000..d77e6a52 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/ConsoleLogBuffer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d212c06f2c819e04c892f7e41520f975 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/PerformanceCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/PerformanceCommands.cs new file mode 100644 index 00000000..12ab002d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/PerformanceCommands.cs @@ -0,0 +1,171 @@ +using System; +using Newtonsoft.Json; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEngine; +using UnityEngine.Profiling; + +namespace Unity.Pipeline.Editor.Commands.Observability +{ + /// + /// Render statistics for the most recently rendered Editor frame, read from + /// . These reflect the last frame the Editor drew (Game/Scene view), not + /// an averaged measurement — treat them as a snapshot. + /// + [Serializable] + public class RenderStats + { + /// Draw calls issued for the last rendered frame. + [JsonProperty("drawCalls")] + public int DrawCalls { get; set; } + + /// Total batches (dynamic + static + instanced) for the last rendered frame. + [JsonProperty("batches")] + public int Batches { get; set; } + + /// SetPass calls (shader pass switches) for the last rendered frame. + [JsonProperty("setPassCalls")] + public int SetPassCalls { get; set; } + + /// Triangles submitted for the last rendered frame. + [JsonProperty("triangles")] + public int Triangles { get; set; } + + /// Vertices submitted for the last rendered frame. + [JsonProperty("vertices")] + public int Vertices { get; set; } + } + + /// + /// Process memory usage read from . Values are in bytes and are always + /// available at runtime regardless of whether the Profiler window is open. + /// + [Serializable] + public class MemoryStats + { + /// Total memory currently allocated by Unity (bytes). + [JsonProperty("totalAllocatedBytes")] + public long TotalAllocatedBytes { get; set; } + + /// Total memory reserved by Unity (bytes). + [JsonProperty("totalReservedBytes")] + public long TotalReservedBytes { get; set; } + + /// Mono managed heap memory in use (bytes). + [JsonProperty("monoUsedBytes")] + public long MonoUsedBytes { get; set; } + + /// Mono managed heap size reserved (bytes). + [JsonProperty("monoHeapBytes")] + public long MonoHeapBytes { get; set; } + } + + /// + /// Latest CPU/GPU frame timing from . The Frame Timing Manager + /// must have a timing captured for to be true; when unavailable the + /// timing fields are left at zero. + /// + [Serializable] + public class FrameTimingStats + { + /// True when a frame timing was captured and the milliseconds fields are meaningful. + [JsonProperty("available")] + public bool Available { get; set; } + + /// Total CPU frame time in milliseconds. + [JsonProperty("cpuFrameTimeMs")] + public double CpuFrameTimeMs { get; set; } + + /// Total GPU frame time in milliseconds. + [JsonProperty("gpuFrameTimeMs")] + public double GpuFrameTimeMs { get; set; } + + /// CPU main-thread frame time in milliseconds. + [JsonProperty("cpuMainThreadFrameTimeMs")] + public double CpuMainThreadFrameTimeMs { get; set; } + } + + /// + /// Composite performance snapshot returned by get_performance_stats: render counters, + /// process memory, and the latest frame timing. + /// + [Serializable] + public class PerformanceStats + { + /// Render counters for the last rendered Editor frame. + [JsonProperty("render")] + public RenderStats Render { get; set; } + + /// Process memory usage at capture time. + [JsonProperty("memory")] + public MemoryStats Memory { get; set; } + + /// Latest CPU/GPU frame timing, if one was captured. + [JsonProperty("frameTiming")] + public FrameTimingStats FrameTiming { get; set; } + } + + /// + /// Read-only telemetry command (CLI-198) that snapshots render, memory, and frame-timing stats so + /// an agent can reason about Editor performance without opening the Profiler or Stats overlay. + /// + public static class PerformanceCommands + { + [CliCommand("get_performance_stats", "Read render, memory, and frame-timing stats (structured, read-only).")] + public static PerformanceStats GetPerformanceStats() + { + return new PerformanceStats + { + Render = new RenderStats + { + // UnityStats reflects the last frame rendered by the Editor (Game/Scene view). + DrawCalls = UnityStats.drawCalls, + Batches = UnityStats.dynamicBatches + UnityStats.staticBatches + UnityStats.instancedBatches, + SetPassCalls = UnityStats.setPassCalls, + Triangles = UnityStats.triangles, + Vertices = UnityStats.vertices + }, + Memory = new MemoryStats + { + TotalAllocatedBytes = Profiler.GetTotalAllocatedMemoryLong(), + TotalReservedBytes = Profiler.GetTotalReservedMemoryLong(), + MonoUsedBytes = Profiler.GetMonoUsedSizeLong(), + MonoHeapBytes = Profiler.GetMonoHeapSizeLong() + }, + FrameTiming = CaptureFrameTiming() + }; + } + + /// + /// Capture the latest CPU/GPU frame timing. The Frame Timing Manager only yields data once a + /// timing has been captured (and platform support exists), so any failure or empty result is + /// reported as = false rather than throwing. + /// + private static FrameTimingStats CaptureFrameTiming() + { + try + { + FrameTimingManager.CaptureFrameTimings(); + + var timings = new FrameTiming[1]; + uint received = FrameTimingManager.GetLatestTimings(1, timings); + if (received > 0) + { + return new FrameTimingStats + { + Available = true, + CpuFrameTimeMs = timings[0].cpuFrameTime, + GpuFrameTimeMs = timings[0].gpuFrameTime, + CpuMainThreadFrameTimeMs = timings[0].cpuMainThreadFrameTime + }; + } + } + catch + { + // Frame timing is platform-dependent and may be unsupported; report as unavailable. + } + + return new FrameTimingStats { Available = false }; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/PerformanceCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/PerformanceCommands.cs.meta new file mode 100644 index 00000000..1a6dec82 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Observability/PerformanceCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ae9262a38e20733409c3a084cb810bdc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager.meta new file mode 100644 index 00000000..c9a1dc1a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fdbdf731f3ad41fca20634ebcf5f4d7e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageIdentifier.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageIdentifier.cs new file mode 100644 index 00000000..7e325713 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageIdentifier.cs @@ -0,0 +1,161 @@ +using System; + +namespace Unity.Pipeline.Editor.Commands.PackageManager +{ + /// Where an added package comes from, derived from its identifier string (CLI-203). + public enum PackageSourceKind + { + /// Could not be classified (e.g. empty input). + Unknown, + + /// A registry package name, optionally pinned: com.unity.foo or com.unity.foo@1.2.3. + Registry, + + /// A git URL: https://…​.git, git+…, ssh://…, git@host:…, optionally #revision. + Git, + + /// A local package reference: file:../RelativePath or file:/abs/path. + Local + } + + /// A parsed UPM package_add identifier — the normalized string handed to + /// Client.Add plus the pieces extracted for plan/audit text. + public sealed class ParsedPackageId + { + /// How the package will be resolved by UPM. + public PackageSourceKind Kind { get; set; } + + /// The identifier passed verbatim to UnityEditor.PackageManager.Client.Add. + public string Identifier { get; set; } + + /// Registry package name (Registry only); null otherwise. + public string Name { get; set; } + + /// Registry version or git revision when given; null otherwise. + public string Version { get; set; } + + /// Human-readable summary for plan/audit text. + public string Description { get; set; } + } + + /// + /// Pure, editor-independent classification of a package_add identifier into a registry name, + /// git URL, or local path (CLI-203). Kept free of any Unity API so the add-path's input handling is + /// unit-testable without a live editor; the command layer feeds the result to + /// UnityEditor.PackageManager.Client.Add. + /// + public static class PackageIdentifier + { + /// Classify an identifier without parsing out its parts. + public static PackageSourceKind Classify(string identifier) + { + if (string.IsNullOrWhiteSpace(identifier)) + return PackageSourceKind.Unknown; + + var id = identifier.Trim(); + + if (StartsWith(id, "file:")) + return PackageSourceKind.Local; + + // Explicit git forms and any URL scheme (UPM treats http(s) identifiers as git URLs). + if (StartsWith(id, "git+") || StartsWith(id, "ssh://") || id.StartsWith("git@", StringComparison.Ordinal)) + return PackageSourceKind.Git; + if (StartsWith(id, "http://") || StartsWith(id, "https://") || id.Contains("://")) + return PackageSourceKind.Git; + + // A bare "owner/repo.git" (no scheme) is still a git reference. Strip an optional + // "#revision" before testing the suffix. + var core = id; + var hash = core.IndexOf('#'); + if (hash >= 0) + core = core.Substring(0, hash); + if (core.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + return PackageSourceKind.Git; + + return PackageSourceKind.Registry; + } + + /// + /// Classify and split . Returns false with + /// set for empty/unclassifiable input. + /// + public static bool TryParse(string identifier, out ParsedPackageId parsed, out string error) + { + parsed = null; + error = null; + + if (string.IsNullOrWhiteSpace(identifier)) + { + error = "Package identifier is empty. Provide a name (com.unity.foo[@version]), a git URL, or a file: path."; + return false; + } + + var id = identifier.Trim(); + switch (Classify(id)) + { + case PackageSourceKind.Local: + parsed = new ParsedPackageId + { + Kind = PackageSourceKind.Local, + Identifier = id, + Description = $"local package '{id}'" + }; + return true; + + case PackageSourceKind.Git: + { + string revision = null; + var hash = id.IndexOf('#'); + if (hash >= 0 && hash < id.Length - 1) + revision = id.Substring(hash + 1); + parsed = new ParsedPackageId + { + Kind = PackageSourceKind.Git, + Identifier = id, + Version = revision, + Description = revision != null ? $"git package '{id}' @ {revision}" : $"git package '{id}'" + }; + return true; + } + + case PackageSourceKind.Registry: + { + var name = id; + string version = null; + + // Split on the version separator '@'. Use the last '@' so npm-scoped names + // ("@scope/pkg@1.0.0") still split on the version, not the leading scope marker. + var at = id.LastIndexOf('@'); + if (at > 0) + { + name = id.Substring(0, at); + version = id.Substring(at + 1); + } + + if (string.IsNullOrWhiteSpace(name)) + { + error = $"Package name is empty in '{identifier}'."; + return false; + } + + parsed = new ParsedPackageId + { + Kind = PackageSourceKind.Registry, + Identifier = id, + Name = name, + Version = version, + Description = version != null ? $"'{name}' @ {version}" : $"'{name}' (latest compatible)" + }; + return true; + } + + default: + error = $"Could not classify package identifier '{identifier}'."; + return false; + } + } + + private static bool StartsWith(string value, string prefix) => + value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageIdentifier.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageIdentifier.cs.meta new file mode 100644 index 00000000..2e8ec865 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageIdentifier.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b95405e8a4f04c93acbdd564a74f8cc2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageManagerCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageManagerCommand.cs new file mode 100644 index 00000000..edd3a131 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageManagerCommand.cs @@ -0,0 +1,644 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using Newtonsoft.Json; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEditor.PackageManager; +using UnityEditor.PackageManager.Requests; +using UnityEngine; +using PackageInfo = UnityEditor.PackageManager.PackageInfo; + +namespace Unity.Pipeline.Editor.Commands.PackageManager +{ + /// + /// UPM package management over the Client API (CLI-203): list / search / add / remove / resolve. + /// + /// Threading: UPM operations are asynchronous (the only + /// progresses while the editor ticks) and their members are main-thread only. Commands that wait on a + /// request therefore run off the main thread (MainThreadRequired = false) and marshal every + /// UPM touch back onto the main thread via the server dispatcher (), so they + /// can block without freezing the editor. + /// + /// Command surface: + /// - package_list (installed scope) reads the resolved set inline; available/all + /// and package_search query the registry and wait for it — all return synchronously. + /// - package_add/package_remove mutate the manifest and trigger a domain reload + /// that tears down the in-flight request and the HTTP connection. They are dual-mode: + /// + /// async (default) — kick the op off, return in_progress immediately, and let + /// an poller finalize a Temp status file. Poll + /// package_status until completed/failed, then recompile_status. This + /// keeps the (single-request) server responsive and survives the reload. + /// synchronous (wait=true) — block until the request completes and return the + /// full result. The result is captured in one main-thread hop the moment the request completes + /// (before the compile-triggered reload). Reliable for add; for remove the reload can fire fast + /// enough to drop the reply — the status file still settles, so package_status confirms it. + /// + /// Both write the status file, so a lost reply (or a reload mid-wait) is recoverable via + /// package_status; settles an in_progress + /// file on the next load. + /// - package_resolve records its status too, so package_status validates it. + /// + /// Mutating commands (add / remove) follow the shared confirm/dry_run convention. + /// UPM operations are not part of Unity's Undo. + /// + [InitializeOnLoad] + public static class PackageManagerCommand + { + const string StatusFile = "Temp/pipeline_package_status.json"; + + // Bound on a synchronous wait, kept under the CLI's default request timeout so a stuck operation + // surfaces as a clean error rather than a dropped connection. + const int WaitTimeoutSeconds = 25; + const int PollIntervalMs = 50; + + static readonly object s_Lock = new object(); + + // The in-flight mutating request (add/remove) and how to project its result. s_InProgress is a + // plain flag (read off-thread by IsBusy) so the busy check never touches main-thread-only Request + // members. Both are reset by a domain reload; RecoverInterruptedOperation settles the status file. + static volatile bool s_InProgress; + static Request s_Request; + static string s_Operation; + static string s_Argument; + static Func s_ReadPackage; + + static PackageManagerCommand() + { + RecoverInterruptedOperation(); + } + + // ---- List / search (synchronous) ------------------------------------------------------- + + [CliCommand("package_list", + "List packages by scope: installed (default) | available (registry) | all (both). Returns the full " + + "result synchronously — available/all block until the registry query completes.", + MainThreadRequired = false)] + public static object PackageList( + [CliArg("scope", "Which packages to list: installed (default) | available | all.")] string scope = "installed", + [CliArg("include_indirect", "Include indirect (transitive) installed dependencies (applies to scope=installed/all).")] + bool includeIndirect = true, + [CliArg("offline", "For available/all: query the local cache instead of the registry.")] bool offline = false) + { + switch ((scope ?? "installed").Trim().ToLowerInvariant()) + { + case "installed": + return RunOnMain(() => ListInstalled(includeIndirect)); + case "available": + return ListFromRegistry("available", includeIndirect, offline); + case "all": + return ListFromRegistry("all", includeIndirect, offline); + default: + return new PackageListResponse + { + Success = false, + Scope = scope, + Message = $"Unknown scope '{scope}'. Use installed | available | all." + }; + } + } + + /// + /// Installed scope: reflects the resolved + /// set without a registry round-trip. Runs on the main thread (via ). + /// + static PackageListResponse ListInstalled(bool includeIndirect) + { + var summaries = new List(); + foreach (var p in PackageInfo.GetAllRegisteredPackages()) + { + if (includeIndirect || p.isDirectDependency) + summaries.Add(Map(p, isInstalled: true)); + } + + return new PackageListResponse + { + Success = true, + Scope = "installed", + Count = summaries.Count, + Packages = summaries, + Manifest = SafeReadManifest(), + Message = $"{summaries.Count} installed package(s)." + }; + } + + /// + /// available / all scope: these need the registry, so a Client.SearchAll request is + /// started on the main thread and awaited here (on the command's background thread). For + /// all, the installed set is merged in (and marked) so callers see one unified list. + /// + static object ListFromRegistry(string scope, bool includeIndirect, bool offline) + { + var request = RunOnMain(() => Client.SearchAll(offline)); + + if (!WaitForCompletion(request, out var waitError)) + return new PackageListResponse { Success = false, Scope = scope, Message = waitError }; + + return RunOnMain(() => + { + if (request.Status != StatusCode.Success) + return new PackageListResponse + { + Success = false, + Scope = scope, + Message = $"Registry query failed: {request.Error?.message ?? "unknown error"}" + }; + + var packages = BuildScopedList(scope, request.Result, includeIndirect); + return new PackageListResponse + { + Success = true, + Scope = scope, + Count = packages.Count, + Packages = packages, + Manifest = SafeReadManifest(), + Message = $"{packages.Count} package(s)." + }; + }); + } + + /// + /// Combine the registry search result with the installed set per : + /// available returns every registry package (flagged whether it is installed); + /// all returns installed packages (resolved info) plus the registry packages that are + /// not installed. + /// + static List BuildScopedList(string scope, PackageInfo[] available, bool includeIndirect) + { + var installed = new Dictionary(); + foreach (var p in PackageInfo.GetAllRegisteredPackages()) + installed[p.name] = p; + + var result = new List(); + + if (scope == "available") + { + foreach (var p in available ?? Array.Empty()) + result.Add(Map(p, isInstalled: installed.ContainsKey(p.name))); + return result; + } + + // scope == "all": installed entries first (with their resolved info), then registry-only ones. + var seen = new HashSet(); + foreach (var entry in installed.Values) + { + if (!includeIndirect && !entry.isDirectDependency) + continue; + result.Add(Map(entry, isInstalled: true)); + seen.Add(entry.name); + } + foreach (var p in available ?? Array.Empty()) + { + if (!seen.Contains(p.name)) + result.Add(Map(p, isInstalled: false)); + } + return result; + } + + [CliCommand("package_search", + "Search packages available in the registry. Provide a name (e.g. com.unity.foo) or omit to list all. " + + "Returns the full result synchronously (blocks until the registry query completes).", + MainThreadRequired = false)] + public static object PackageSearch( + [CliArg("query", "Package name to search for. Omit/empty to list all available packages.")] string query = "", + [CliArg("offline", "Search the local cache only.")] bool offline = false) + { + var trimmed = (query ?? string.Empty).Trim(); + + var request = RunOnMain(() => string.IsNullOrEmpty(trimmed) + ? Client.SearchAll(offline) + : Client.Search(trimmed, offline)); + + if (!WaitForCompletion(request, out var waitError)) + return new PackageSearchResponse { Success = false, Query = trimmed, Message = waitError }; + + return RunOnMain(() => + { + if (request.Status != StatusCode.Success) + return new PackageSearchResponse + { + Success = false, + Query = trimmed, + Message = $"Search failed: {request.Error?.message ?? "unknown error"}" + }; + + var packages = MapMarkingInstalled(request.Result); + return new PackageSearchResponse + { + Success = true, + Query = trimmed, + Count = packages.Count, + Packages = packages, + Message = $"{packages.Count} package(s)." + }; + }); + } + + // ---- Mutating operations (dual-mode, CAT-2509 gated) ----------------------------------- + + [CliCommand("package_add", + "Add a UPM package by name@version, git URL, or 'file:' local path. Async by default (returns " + + "in_progress; poll package_status); pass wait=true to block until added. A recompile/domain reload " + + "follows — poll recompile_status. Requires confirm=true; use dry_run to preview.", + MainThreadRequired = false)] + public static object PackageAdd( + [CliArg("identifier", "Package to add: 'com.unity.foo@1.2.3', a git URL, or 'file:../Path'.", Required = true)] string identifier = "", + [CliArg("confirm", "Apply the change. Without it the call is refused.")] bool confirm = false, + [CliArg("dry_run", "Preview the change without applying it.")] bool dryRun = false, + [CliArg("wait", "Block until the operation completes and return the result (synchronous). Default: return immediately and poll package_status.")] bool wait = false) + { + if (!PackageIdentifier.TryParse(identifier, out var parsed, out var parseError)) + return PackageMutationResponse.Failed("add", identifier, parseError); + + if (IsBusy()) + return PackageMutationResponse.Busy("add"); + + return ExecuteMutation( + operation: "add", + commandName: "package_add", + argument: parsed.Identifier, + planText: $"Add package {parsed.Description} [{parsed.Kind}]", + confirm: confirm, + dryRun: dryRun, + wait: wait, + start: () => Client.Add(parsed.Identifier), + readPackage: req => + { + var added = (req as AddRequest)?.Result; + return added != null ? Map(added, isInstalled: true) : null; + }); + } + + [CliCommand("package_remove", + "Remove a UPM package by name. Async by default (returns in_progress; poll package_status); pass " + + "wait=true to block until removed. A recompile/domain reload follows — poll recompile_status. " + + "Requires confirm=true; use dry_run to preview.", + MainThreadRequired = false)] + public static object PackageRemove( + [CliArg("name", "Package name to remove (e.g. com.unity.foo).", Required = true)] string name = "", + [CliArg("confirm", "Apply the change. Without it the call is refused.")] bool confirm = false, + [CliArg("dry_run", "Preview the change without applying it.")] bool dryRun = false, + [CliArg("wait", "Block until the operation completes and return the result (synchronous). Default: return immediately and poll package_status.")] bool wait = false) + { + if (string.IsNullOrWhiteSpace(name)) + return PackageMutationResponse.Failed("remove", name, "A package name is required."); + + var packageName = name.Trim(); + + if (IsBusy()) + return PackageMutationResponse.Busy("remove"); + + return ExecuteMutation( + operation: "remove", + commandName: "package_remove", + argument: packageName, + planText: $"Remove package '{packageName}'", + confirm: confirm, + dryRun: dryRun, + wait: wait, + start: () => Client.Remove(packageName), + readPackage: null); + } + + [CliCommand("package_resolve", + "Resolve/refresh packages from the manifest (re-fetch and re-link). May trigger a recompile/domain " + + "reload — poll recompile_status. Its outcome is recorded for package_status.", + MainThreadRequired = true)] + public static object PackageResolve() + { + WriteStatus(new PackageStatus + { + Status = "in_progress", + Operation = "resolve", + StartedAt = NowIso(), + Message = "Resolving packages..." + }); + + try + { + // Client.Resolve() is fire-and-forget (returns void) and schedules a resolve on a later + // tick, so the response flushes before any reload — no request to wait on. + Client.Resolve(); + } + catch (Exception ex) + { + var failed = new PackageStatus + { + Status = "failed", + Operation = "resolve", + Success = false, + Error = ex.Message, + Manifest = SafeReadManifest(), + CompletedAt = NowIso(), + Message = $"Resolve failed: {ex.Message}" + }; + WriteStatus(failed); + return PackageMutationResponse.Failed("resolve", null, failed.Error); + } + + var done = new PackageStatus + { + Status = "completed", + Operation = "resolve", + Success = true, + RequiresRecompile = true, + Manifest = SafeReadManifest(), + CompletedAt = NowIso(), + Message = "Resolve requested. If assemblies changed, a domain reload follows — poll recompile_status." + }; + WriteStatus(done); + return ToMutationResponse(done, plan: null); + } + + // ---- Status ---------------------------------------------------------------------------- + + [CliCommand("package_status", + "Status of the last async package operation (add/remove/resolve): idle | in_progress | completed | " + + "failed, with the added package, manifest, and any error.", + MainThreadRequired = false)] + public static string GetPackageStatus() + { + if (File.Exists(StatusFile)) + return File.ReadAllText(StatusFile); + return "{\"status\":\"idle\"}"; + } + + // ---- Mutation execution ---------------------------------------------------------------- + + /// + /// Gate (confirm / dry-run) and kick off the mutating op, then + /// either return in_progress (async, default) or block until completion (). + /// Either way the operation is tracked and a status file is written, so the result is recoverable + /// via package_status even if the domain reload severs a synchronous reply. + /// + static object ExecuteMutation( + string operation, string commandName, string argument, string planText, + bool confirm, bool dryRun, bool wait, + Func start, Func readPackage) + { + if (dryRun) + return PackageMutationResponse.DryRunPreview(operation, argument, planText, $"Dry run — {planText}"); + if (!confirm) + return PackageMutationResponse.Rejected(operation, argument, + "Refused: this changes project packages. Re-run with confirm=true to apply, or dry_run=true to preview."); + + // UPM operations are not part of Unity's Undo system, so there is no undo scope here. + var request = RunOnMain(() => + { + var r = start(); + // async mode finalizes via the update-loop poller; sync mode finalizes itself. + BeginTracking(operation, argument, r, readPackage, subscribePoll: !wait); + return r; + }); + + if (request == null) + return PackageMutationResponse.Failed(operation, argument, "Operation was confirmed but failed to start."); + + if (!wait) + return PackageMutationResponse.InProgress(operation, argument, planText); + + // Synchronous: poll until complete, capturing status + result atomically on the main thread + // (a reload can only fire between ticks, so the snapshot can't be interleaved). + var deadline = DateTime.UtcNow.AddSeconds(WaitTimeoutSeconds); + while (true) + { + var status = RunOnMain(TryFinalize); + if (status != null) + return ToMutationResponse(status, planText); + + if (DateTime.UtcNow > deadline) + { + // Hand off to the update-loop poller so package_status still settles after we bail. + RunOnMain(() => { SubscribePoll(); return null; }); + return PackageMutationResponse.Failed(operation, argument, + $"Timed out after {WaitTimeoutSeconds}s; the operation may still be in progress — poll package_status."); + } + + Thread.Sleep(PollIntervalMs); + } + } + + /// Record the in-flight op, write the in_progress status, and (async only) start polling. + static void BeginTracking(string operation, string argument, Request request, + Func readPackage, bool subscribePoll) + { + lock (s_Lock) + { + s_Request = request; + s_Operation = operation; + s_Argument = argument; + s_ReadPackage = readPackage; + s_InProgress = true; + } + + WriteStatus(new PackageStatus + { + Status = "in_progress", + Operation = operation, + Argument = argument, + StartedAt = NowIso(), + Message = $"{operation} in progress. Poll package_status." + }); + + if (subscribePoll) + SubscribePoll(); + } + + static void SubscribePoll() + { + EditorApplication.update -= Poll; + EditorApplication.update += Poll; + } + + /// Update-loop poller for async mode (and a timed-out sync wait): finalize when done. + static void Poll() => TryFinalize(); + + /// + /// If the tracked request has completed, write its final status, clear tracking, and return the + /// status; otherwise return null. Main-thread only (reads Request members); guarded so the sync + /// waiter and the update-loop poller can't double-finalize. + /// + static PackageStatus TryFinalize() + { + lock (s_Lock) + { + var request = s_Request; + if (request == null) + { + EditorApplication.update -= Poll; + return null; + } + if (!request.IsCompleted) + return null; + + var status = BuildStatus(s_Operation, s_Argument, request, s_ReadPackage); + WriteStatus(status); + + s_Request = null; + s_Operation = null; + s_Argument = null; + s_ReadPackage = null; + s_InProgress = false; + EditorApplication.update -= Poll; + return status; + } + } + + static PackageStatus BuildStatus(string operation, string argument, Request request, + Func readPackage) + { + var status = new PackageStatus + { + Operation = operation, + Argument = argument, + CompletedAt = NowIso(), + Manifest = SafeReadManifest() + }; + + if (request.Status == StatusCode.Success) + { + status.Status = "completed"; + status.Success = true; + status.RequiresRecompile = true; + try { status.Package = readPackage?.Invoke(request); } + catch (Exception ex) { Debug.LogWarning($"[pipeline] package result projection failed: {ex.Message}"); } + status.Message = $"{operation} completed."; + } + else + { + status.Status = "failed"; + status.Success = false; + status.Error = request.Error?.message ?? "Unknown package manager error."; + status.Message = $"{operation} failed: {status.Error}"; + } + + return status; + } + + static PackageMutationResponse ToMutationResponse(PackageStatus s, string plan) => new PackageMutationResponse + { + Success = s.Success, + Operation = s.Operation, + Argument = s.Argument, + Status = s.Status, + Applied = s.Success, + Plan = plan, + Package = s.Package, + Manifest = s.Manifest, + RequiresRecompile = s.RequiresRecompile, + Message = s.Message + }; + + /// + /// On load, settle a status file left at "in_progress" by a domain reload (the expected outcome of + /// a successful add/remove). The manifest change that triggered the reload has taken effect, so + /// the op is recorded completed; the follow-on recompile is observed via recompile_status. + /// + static void RecoverInterruptedOperation() + { + try + { + if (!File.Exists(StatusFile)) + return; + + var status = JsonConvert.DeserializeObject(File.ReadAllText(StatusFile)); + if (status == null || status.Status != "in_progress") + return; + + status.Status = "completed"; + status.Success = true; + status.RequiresRecompile = true; + status.CompletedAt = NowIso(); + status.Manifest = SafeReadManifest(); + status.Message = $"{status.Operation} completed (domain reload). Manifest updated; poll recompile_status for the recompile."; + WriteStatus(status); + } + catch (Exception ex) + { + Debug.LogWarning($"[pipeline] package status recovery failed: {ex.Message}"); + } + } + + // ---- Helpers --------------------------------------------------------------------------- + + static bool IsBusy() => s_InProgress; + + /// + /// Block until completes, polling its (main-thread-only) state via + /// while this thread sleeps between checks. Returns false with + /// set on timeout. Used by the read-only registry queries. + /// + static bool WaitForCompletion(Request request, out string error) + { + error = null; + var deadline = DateTime.UtcNow.AddSeconds(WaitTimeoutSeconds); + while (!RunOnMain(() => request.IsCompleted)) + { + if (DateTime.UtcNow > deadline) + { + error = $"Timed out after {WaitTimeoutSeconds}s waiting for the package manager."; + return false; + } + Thread.Sleep(PollIntervalMs); + } + return true; + } + + /// + /// Run on the Unity main thread. When invoked off-thread (the normal case + /// for the waiting commands), it marshals through the live server's dispatcher; when already on + /// the main thread (e.g. a direct in-process/test call, or no server running) it runs inline. + /// + static T RunOnMain(Func fn) + { + var dispatcher = PipelineServerStartup.Server?.Dispatcher; + if (dispatcher != null && dispatcher.IsInitialized && !dispatcher.IsMainThread()) + return dispatcher.Invoke(fn); + return fn(); + } + + static Dictionary SafeReadManifest() => + PackageManifest.TryRead(out var deps, out _) ? deps : null; + + static PackageSummary Map(PackageInfo p, bool isInstalled = false) => new PackageSummary + { + Name = p.name, + Version = p.version, + DisplayName = p.displayName, + Source = p.source.ToString(), + ResolvedPath = p.resolvedPath, + IsDirectDependency = p.isDirectDependency, + IsInstalled = isInstalled + }; + + /// Map registry results, flagging which are currently installed. + static List MapMarkingInstalled(IEnumerable packages) + { + var installed = new HashSet(); + foreach (var p in PackageInfo.GetAllRegisteredPackages()) + installed.Add(p.name); + + var list = new List(); + if (packages != null) + foreach (var p in packages) + list.Add(Map(p, installed.Contains(p.name))); + return list; + } + + static string NowIso() => DateTime.UtcNow.ToString("o"); + + static void WriteStatus(PackageStatus status) + { + try + { + File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status)); + } + catch (Exception ex) + { + Debug.LogError($"[Package] Failed to write status file: {ex.Message}"); + } + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageManagerCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageManagerCommand.cs.meta new file mode 100644 index 00000000..d322b20c --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageManagerCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 248529bee7274235a46b64bf3f03da11 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageManifest.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageManifest.cs new file mode 100644 index 00000000..76210b1b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageManifest.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands.PackageManager +{ + /// + /// Reads the project's UPM manifest (Packages/manifest.json) dependencies as a flat + /// name → version map, satisfying CLI-203's "return manifest state". The JSON parsing is split into + /// a pure seam so it is unit-testable without a live project; + /// resolves the project path and is main-thread only (it reads + /// ). + /// + public static class PackageManifest + { + /// Project-relative path to the UPM manifest. + public const string RelativePath = "Packages/manifest.json"; + + /// Absolute path to the manifest for the current project. Main thread only. + public static string ManifestPath + { + get + { + var projectRoot = Path.GetDirectoryName(Application.dataPath); + return Path.Combine(projectRoot, "Packages", "manifest.json"); + } + } + + /// + /// Parse the dependencies object of a manifest.json document into a name → version map. + /// Returns an empty map for null/empty input or a manifest without dependencies. + /// + public static Dictionary ReadDependencies(string manifestJson) + { + var result = new Dictionary(); + if (string.IsNullOrWhiteSpace(manifestJson)) + return result; + + var root = JObject.Parse(manifestJson); + if (root["dependencies"] is JObject deps) + { + foreach (var kv in deps) + result[kv.Key] = kv.Value?.ToString(); + } + + return result; + } + + /// + /// Read the current project's manifest dependencies. Returns false with + /// set when the manifest is missing or unreadable. + /// + public static bool TryRead(out Dictionary dependencies, out string error) + { + dependencies = null; + error = null; + try + { + var path = ManifestPath; + if (!File.Exists(path)) + { + error = $"manifest not found at {path}"; + return false; + } + + dependencies = ReadDependencies(File.ReadAllText(path)); + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageManifest.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageManifest.cs.meta new file mode 100644 index 00000000..8608bd45 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageManifest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0401cb232d5143bbb185ac248086a09f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageModels.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageModels.cs new file mode 100644 index 00000000..fbedf315 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageModels.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Unity.Pipeline.Editor.Commands.PackageManager +{ + /// + /// One package in a list/search/add result — a JSON-friendly projection of + /// UnityEditor.PackageManager.PackageInfo (CLI-203). + /// + [Serializable] + public class PackageSummary + { + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("version")] public string Version { get; set; } + [JsonProperty("displayName")] public string DisplayName { get; set; } + + /// Resolved source: Registry, Embedded, Local, Git, BuiltIn, LocalTarball, Unknown. + [JsonProperty("source")] public string Source { get; set; } + + [JsonProperty("resolvedPath")] public string ResolvedPath { get; set; } + + /// True when the package is a direct manifest dependency (vs. a transitive one). + [JsonProperty("isDirectDependency")] public bool IsDirectDependency { get; set; } + + /// True when the package is currently installed/resolved in the project (vs. only + /// available in the registry). Always true for an installed-scope listing. + [JsonProperty("isInstalled")] public bool IsInstalled { get; set; } + } + + /// + /// Synchronous result of package_list (CLI-203). The command returns the full result in one + /// call for every scope — installed reads the resolved set inline; available/all + /// run a registry query and block until it completes — so listing never uses the trigger-then-poll + /// path that the mutating commands do. + /// + [Serializable] + public class PackageListResponse + { + [JsonProperty("success")] public bool Success { get; set; } + + /// The scope that was listed: installed | available | all. + [JsonProperty("scope")] public string Scope { get; set; } + + [JsonProperty("count")] public int Count { get; set; } + + /// The packages in the requested scope. + [JsonProperty("packages")] public List Packages { get; set; } + + /// Manifest dependencies (name → version). + [JsonProperty("manifest")] public Dictionary Manifest { get; set; } + + [JsonProperty("message")] public string Message { get; set; } + } + + /// + /// Synchronous result of package_search (CLI-203). Like package_list, the registry + /// query is awaited inside the command, so the matches are returned in one call. Each entry carries + /// so callers can tell which results are already installed. + /// + [Serializable] + public class PackageSearchResponse + { + [JsonProperty("success")] public bool Success { get; set; } + + /// The search query (empty when listing all available packages). + [JsonProperty("query")] public string Query { get; set; } + + [JsonProperty("count")] public int Count { get; set; } + + /// The matching packages available in the registry. + [JsonProperty("packages")] public List Packages { get; set; } + + [JsonProperty("message")] public string Message { get; set; } + } + + /// + /// Persisted status of the last async mutating operation (add / remove / resolve), written to a Temp + /// status file that survives the domain reload an add/remove/resolve triggers (CLI-203). Read back + /// verbatim by package_status so a caller can poll an async op — or validate one whose + /// synchronous reply was lost to the reload. + /// + [Serializable] + public class PackageStatus + { + /// idle | in_progress | completed | failed. + [JsonProperty("status")] public string Status { get; set; } + + /// add | remove | resolve. + [JsonProperty("operation")] public string Operation { get; set; } + + /// The operation's subject (identifier/name), when applicable. + [JsonProperty("argument")] public string Argument { get; set; } + + [JsonProperty("success")] public bool Success { get; set; } + + [JsonProperty("error")] public string Error { get; set; } + + [JsonProperty("message")] public string Message { get; set; } + + /// The operation triggers a recompile/domain reload; poll recompile_status. + [JsonProperty("requiresRecompile")] public bool RequiresRecompile { get; set; } + + /// The added package (add only); null for remove/resolve. + [JsonProperty("package")] public PackageSummary Package { get; set; } + + /// Manifest dependencies (name → version) after the operation. + [JsonProperty("manifest")] public Dictionary Manifest { get; set; } + + [JsonProperty("startedAt")] public string StartedAt { get; set; } + [JsonProperty("completedAt")] public string CompletedAt { get; set; } + } + + /// + /// Result of a mutating package command — package_add / package_remove / + /// package_resolve (CLI-203). These are dual-mode: by default the call returns + /// in_progress immediately (poll package_status); with wait=true it blocks and + /// this carries the final completed/failed outcome. It reports the confirm/dry_run + /// gate via / . A successful add/remove + /// leaves a recompile/domain reload in flight () — poll + /// recompile_status to wait for the editor to be ready. + /// + [Serializable] + public class PackageMutationResponse + { + [JsonProperty("success")] public bool Success { get; set; } + + /// add | remove | resolve. + [JsonProperty("operation")] public string Operation { get; set; } + + /// The operation's subject (identifier/name), when applicable. + [JsonProperty("argument")] public string Argument { get; set; } + + /// in_progress | completed | dry_run | rejected | failed | busy. + [JsonProperty("status")] public string Status { get; set; } + + /// True only when the operation actually mutated the project. + [JsonProperty("applied")] public bool Applied { get; set; } + + [JsonProperty("dryRun")] public bool DryRun { get; set; } + + /// Human-readable description of the intended/performed change. + [JsonProperty("plan")] public string Plan { get; set; } + + /// The operation leaves a recompile/domain reload in flight; poll recompile_status. + [JsonProperty("requiresRecompile")] public bool RequiresRecompile { get; set; } + + /// The added package (add only); null for remove/resolve. + [JsonProperty("package")] public PackageSummary Package { get; set; } + + /// Manifest dependencies (name → version) after the operation. + [JsonProperty("manifest")] public Dictionary Manifest { get; set; } + + [JsonProperty("message")] public string Message { get; set; } + + public static PackageMutationResponse InProgress(string operation, string argument, string plan) => + new PackageMutationResponse + { + Success = true, + Operation = operation, + Argument = argument, + Status = "in_progress", + Applied = true, + Plan = plan, + Message = $"{operation} started. Poll package_status until status is 'completed' or 'failed'." + }; + + public static PackageMutationResponse Busy(string operation) => + new PackageMutationResponse + { + Success = false, + Operation = operation, + Status = "busy", + Message = "Another package operation is in progress. Poll package_status, then retry." + }; + + public static PackageMutationResponse DryRunPreview(string operation, string argument, string plan, string message) => + new PackageMutationResponse + { + Success = true, + Operation = operation, + Argument = argument, + Status = "dry_run", + DryRun = true, + Plan = plan, + Message = message + }; + + public static PackageMutationResponse Rejected(string operation, string argument, string message) => + new PackageMutationResponse + { + Success = false, + Operation = operation, + Argument = argument, + Status = "rejected", + Message = message + }; + + public static PackageMutationResponse Failed(string operation, string argument, string message) => + new PackageMutationResponse + { + Success = false, + Operation = operation, + Argument = argument, + Status = "failed", + Message = message + }; + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageModels.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageModels.cs.meta new file mode 100644 index 00000000..01d45bd6 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PackageManager/PackageModels.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8fa665b51bbb4f2d8543fc02de12c377 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PlayModeCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PlayModeCommands.cs new file mode 100644 index 00000000..82dcb4e9 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PlayModeCommands.cs @@ -0,0 +1,55 @@ +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands +{ + /// + /// Commands for controlling Unity Editor play mode state. + /// Enables automation workflows to control Editor play/stop/pause from CLI tools. + /// + public static class PlayModeCommands + { + // TODO Pipeline: should all commands have name with namespace: editor.play build.generate instead of using _ + + [CliCommand("editor_play", "Enter Unity Editor play mode")] + public static string EnterPlayMode() + { + if (EditorApplication.isPlaying) + { + Debug.LogWarning("Editor is already in play mode"); + return "Already in play mode"; + } + + EditorApplication.isPlaying = true; + return "Entered play mode"; + } + + [CliCommand("editor_stop", "Exit Unity Editor play mode")] + public static string ExitPlayMode() + { + if (!EditorApplication.isPlaying) + { + Debug.LogWarning("Editor is already in edit mode"); + return "Already in edit mode"; + } + + EditorApplication.isPlaying = false; + return "Exited play mode"; + } + + [CliCommand("editor_pause", "Pause Unity Editor play mode")] + public static string PausePlayMode() + { + if (!EditorApplication.isPlaying) + { + Debug.LogWarning("Cannot pause - Editor is not in play mode"); + return "Cannot pause - not in play mode"; + } + + EditorApplication.isPaused = !EditorApplication.isPaused; + string state = EditorApplication.isPaused ? "paused" : "unpaused"; + return $"Play mode {state}"; + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PlayModeCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PlayModeCommands.cs.meta new file mode 100644 index 00000000..2eecf4be --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/PlayModeCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c86c7028f3fb8f24691d8a3a207f53ff +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Prefabs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Prefabs.meta new file mode 100644 index 00000000..23a327d5 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Prefabs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1e6ed0649e884a59b7403a2f28a87644 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Prefabs/PrefabCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Prefabs/PrefabCommands.cs new file mode 100644 index 00000000..3498a46e --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Prefabs/PrefabCommands.cs @@ -0,0 +1,416 @@ +using System; +using System.IO; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using Object = UnityEngine.Object; + +namespace Unity.Pipeline.Editor.Commands.Prefabs +{ + /// + /// Prefab authoring commands (CLI-194). These cover 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. + /// + /// Authoring conventions (CLI-190): + /// - Asset paths flow through so writes are sandboxed to the + /// authoring root and "../" traversal is rejected. + /// - Existing objects are referenced by and resolved with + /// ; results are returned as . + /// - Scene/object mutations (instantiation, override apply/revert, unpack) are wrapped in an + /// so they revert as one Editor undo step. + /// + /// IMPORTANT about undo coverage: prefab ASSET writes (, + /// variant creation, and prefab-stage save) are AssetDatabase operations and are NOT part of + /// Unity's Undo system, so the undo scope only reverts the scene-side effects. This mirrors the + /// note in and . + /// + /// Nested-prefab safety: structural edits to a prefab asset go through the prefab stage + /// ( / / + /// ) rather than mutating the asset root directly, + /// which preserves nested-prefab links instead of flattening them. + /// + public static class PrefabCommands + { + /// + /// Save a source GameObject (typically a scene object) as a prefab asset. The source becomes + /// a connected instance of the new prefab (interactionMode: AutomatedAction). + /// + [CliCommand("create_prefab", "Save a GameObject as a prefab asset at a project path; the source becomes a connected instance.")] + public static AuthoringResult CreatePrefab( + [CliArg("source", "Reference to the source GameObject to save as a prefab (globalId/path/guid/instanceId/hierarchyPath).", Required = true)] ObjectRef source, + [CliArg("path", "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", Required = true)] string path) + { + var go = ResolveGameObject(source, "source"); + + var assetPath = ResolvePrefabPath(path); + EnsureParentFolder(assetPath); + + GameObject saved; + using (new AuthoringUndoScope("Create Prefab")) + { + // ConnectToInstance leaves the source as an instance of the saved prefab so the agent + // can keep editing it; SaveAsPrefabAsset itself is an AssetDatabase op (not undoable). + saved = PrefabUtility.SaveAsPrefabAssetAndConnect(go, assetPath, InteractionMode.AutomatedAction, out var success); + if (!success || saved == null) + throw new InvalidOperationException($"Failed to save prefab at '{assetPath}'."); + } + + AssetDatabase.SaveAssets(); + var asset = AssetDatabase.LoadAssetAtPath(assetPath); + var result = ObjectResolver.Describe(asset) ?? new AuthoringResult { Type = nameof(GameObject) }; + result.AssetPath = assetPath; + return result; + } + + /// + /// Instantiate a prefab asset into the active (or specified) loaded scene and return the + /// scene instance's identity. + /// + [CliCommand("instantiate_prefab", "Instantiate a prefab asset into a loaded scene and return the created instance.")] + public static AuthoringResult InstantiatePrefab( + [CliArg("prefab", "Reference to the prefab asset to instantiate (path/guid/globalId).", Required = true)] ObjectRef prefab, + [CliArg("scene_path", "Optional path of a loaded scene to instantiate into; defaults to the active scene.")] string scenePath = null, + [CliArg("name", "Optional name for the created instance; defaults to the prefab name.")] string name = null) + { + var prefabAsset = ResolvePrefabAsset(prefab, "prefab"); + + var scene = ResolveTargetScene(scenePath); + if (!scene.IsValid() || !scene.isLoaded) + throw new InvalidOperationException("No valid loaded scene to instantiate into."); + + AuthoringResult result; + using (new AuthoringUndoScope("Instantiate Prefab")) + { + var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefabAsset, scene); + if (instance == null) + throw new InvalidOperationException($"Failed to instantiate prefab '{AssetDatabase.GetAssetPath(prefabAsset)}'."); + + if (!string.IsNullOrEmpty(name)) + instance.name = name; + + Undo.RegisterCreatedObjectUndo(instance, "Instantiate Prefab"); + EditorSceneManager.MarkSceneDirty(scene); + + result = ObjectResolver.Describe(instance) ?? new AuthoringResult { Type = nameof(GameObject) }; + } + + return result; + } + + /// + /// Create a prefab variant asset from a base prefab. The variant inherits the base and can + /// override it. Implemented by instantiating the base, saving the instance as a new prefab + /// asset (which becomes a variant because its root is a base-prefab instance), then removing + /// the temporary scene instance. + /// + [CliCommand("create_prefab_variant", "Create a prefab variant asset that inherits from a base prefab.")] + public static AuthoringResult CreatePrefabVariant( + [CliArg("base", "Reference to the base prefab asset (path/guid/globalId).", Required = true)] ObjectRef basePrefab, + [CliArg("path", "Variant prefab asset path relative to the authoring root (.prefab added if missing).", Required = true)] string path) + { + var basePrefabAsset = ResolvePrefabAsset(basePrefab, "base"); + + var assetPath = ResolvePrefabPath(path); + EnsureParentFolder(assetPath); + + // Author the variant from a throwaway instance of the base (saving an instance of a prefab + // as a new prefab yields a variant). The temporary instance is created in an isolated + // preview scene and the instance + scene are torn down in the finally, so the user's active + // scene is never touched, dirtied, or subjected to object lifecycle callbacks. + var previewScene = EditorSceneManager.NewPreviewScene(); + GameObject instance = null; + GameObject variant; + try + { + instance = (GameObject)PrefabUtility.InstantiatePrefab(basePrefabAsset, previewScene); + if (instance == null) + throw new InvalidOperationException("Failed to instantiate base prefab for variant creation."); + + variant = PrefabUtility.SaveAsPrefabAsset(instance, assetPath, out var success); + if (!success || variant == null) + throw new InvalidOperationException($"Failed to save prefab variant at '{assetPath}'."); + } + finally + { + if (instance != null) + Object.DestroyImmediate(instance); + EditorSceneManager.ClosePreviewScene(previewScene); + } + + AssetDatabase.SaveAssets(); + var asset = AssetDatabase.LoadAssetAtPath(assetPath); + var result = ObjectResolver.Describe(asset) ?? new AuthoringResult { Type = nameof(GameObject) }; + result.AssetPath = assetPath; + return result; + } + + /// + /// Apply a prefab instance's overrides back to its source prefab asset. By default applies + /// the whole instance; the asset is updated on disk. + /// + [CliCommand("apply_prefab_overrides", "Apply a prefab instance's overrides back to its source prefab asset.")] + public static AuthoringResult ApplyPrefabOverrides( + [CliArg("instance", "Reference to a prefab instance GameObject in a scene (instanceId/hierarchyPath/globalId).", Required = true)] ObjectRef instance) + { + var go = ResolveGameObject(instance, "instance"); + var root = PrefabUtility.GetOutermostPrefabInstanceRoot(go); + if (root == null) + throw new ArgumentException($"Object '{instance}' is not part of a prefab instance."); + + var assetPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(root); + // Applying overrides writes back to the source prefab asset, so confine it to the + // authoring root and reject instances whose source lives outside the sandbox. + ConfineToAuthoringRoot(assetPath, "instance's source prefab asset"); + + using (new AuthoringUndoScope("Apply Prefab Overrides")) + { + Undo.RegisterFullObjectHierarchyUndo(root, "Apply Prefab Overrides"); + PrefabUtility.ApplyPrefabInstance(root, InteractionMode.AutomatedAction); + EditorSceneManager.MarkSceneDirty(root.scene); + } + + AssetDatabase.SaveAssets(); + + var asset = AssetDatabase.LoadAssetAtPath(assetPath); + var result = ObjectResolver.Describe(asset) ?? new AuthoringResult { Type = nameof(GameObject) }; + if (!string.IsNullOrEmpty(assetPath)) + result.AssetPath = assetPath; + return result; + } + + /// + /// Revert a prefab instance's overrides, restoring it to match its source prefab asset. + /// + [CliCommand("revert_prefab_overrides", "Revert a prefab instance's overrides so it matches its source prefab asset.")] + public static AuthoringResult RevertPrefabOverrides( + [CliArg("instance", "Reference to a prefab instance GameObject in a scene (instanceId/hierarchyPath/globalId).", Required = true)] ObjectRef instance) + { + var go = ResolveGameObject(instance, "instance"); + var root = PrefabUtility.GetOutermostPrefabInstanceRoot(go); + if (root == null) + throw new ArgumentException($"Object '{instance}' is not part of a prefab instance."); + + using (new AuthoringUndoScope("Revert Prefab Overrides")) + { + Undo.RegisterFullObjectHierarchyUndo(root, "Revert Prefab Overrides"); + PrefabUtility.RevertPrefabInstance(root, InteractionMode.AutomatedAction); + EditorSceneManager.MarkSceneDirty(root.scene); + } + + var result = ObjectResolver.Describe(root) ?? new AuthoringResult { Type = nameof(GameObject) }; + return result; + } + + /// + /// Unpack a prefab instance, turning it (and optionally its nested instances) back into plain + /// GameObjects. chooses between + /// (one level) and + /// (all nested levels). + /// + [CliCommand("unpack_prefab", "Unpack a prefab instance into plain GameObjects (outermost level or completely).")] + public static AuthoringResult UnpackPrefab( + [CliArg("instance", "Reference to a prefab instance GameObject in a scene (instanceId/hierarchyPath/globalId).", Required = true)] ObjectRef instance, + [CliArg("completely", "If true, unpack all nested prefab levels (Completely); if false, only the outermost level (OutermostRoot).", DefaultValue = false)] bool completely = false) + { + var go = ResolveGameObject(instance, "instance"); + var root = PrefabUtility.GetOutermostPrefabInstanceRoot(go); + if (root == null) + throw new ArgumentException($"Object '{instance}' is not part of a prefab instance."); + + var mode = completely ? PrefabUnpackMode.Completely : PrefabUnpackMode.OutermostRoot; + + using (new AuthoringUndoScope("Unpack Prefab")) + { + PrefabUtility.UnpackPrefabInstance(root, mode, InteractionMode.AutomatedAction); + EditorSceneManager.MarkSceneDirty(root.scene); + } + + var result = ObjectResolver.Describe(root) ?? new AuthoringResult { Type = nameof(GameObject) }; + return result; + } + + /// + /// Edit a prefab asset's contents safely through the prefab stage. Loads the prefab into an + /// isolated, editable copy, optionally renames a child or reparents/adds nothing structural by + /// default, then saves and unloads. This is the nested-prefab-safe path: it never mutates the + /// asset root in-place, so nested prefab links are preserved. + /// + /// The MVP supports a small, declarative set of edits expressed as arguments so an agent can + /// drive common changes without an embedded script: + /// - + : toggle a child's activeSelf. + /// - + : rename a child. + /// A no-op call (no edit args) simply round-trips the asset through the stage, which is a useful + /// integrity check that the open/edit/close cycle does not corrupt the asset. + /// + [CliCommand("save_prefab_contents", "Open a prefab asset in an isolated prefab stage, apply a declarative edit, and save it back (nested-prefab safe).")] + public static AuthoringResult SavePrefabContents( + [CliArg("prefab", "Reference to the prefab asset to edit (path/guid/globalId).", Required = true)] ObjectRef prefab, + [CliArg("rename_child", "Optional child name (relative path under the root, e.g. 'Body/Head') to rename.")] string renameChild = null, + [CliArg("new_name", "New name for the child identified by rename_child.")] string newName = null, + [CliArg("set_active_child", "Optional child name (relative path under the root) whose active state to set.")] string setActiveChild = null, + [CliArg("active", "Active state to apply when set_active_child is provided.", DefaultValue = true)] bool active = true) + { + var prefabAsset = ResolvePrefabAsset(prefab, "prefab"); + var assetPath = AssetDatabase.GetAssetPath(prefabAsset); + if (string.IsNullOrEmpty(assetPath)) + throw new ArgumentException($"'{prefab}' does not resolve to a prefab asset on disk."); + // This command writes the prefab asset back to disk, so confine it to the authoring root. + ConfineToAuthoringRoot(assetPath, "prefab asset"); + + // LoadPrefabContents gives an isolated, fully-editable copy of the prefab (with nested + // prefabs intact). All edits happen on this copy; SaveAsPrefabAsset writes it back without + // flattening nested prefabs. Always unload to avoid leaking the temporary scene. + var contentsRoot = PrefabUtility.LoadPrefabContents(assetPath); + try + { + if (!string.IsNullOrEmpty(renameChild)) + { + var child = FindChild(contentsRoot.transform, renameChild); + if (child == null) + throw new ArgumentException($"No child '{renameChild}' under prefab root '{contentsRoot.name}'."); + if (string.IsNullOrEmpty(newName)) + throw new ArgumentException("new_name is required when rename_child is provided."); + child.name = newName; + } + + if (!string.IsNullOrEmpty(setActiveChild)) + { + var child = FindChild(contentsRoot.transform, setActiveChild); + if (child == null) + throw new ArgumentException($"No child '{setActiveChild}' under prefab root '{contentsRoot.name}'."); + child.gameObject.SetActive(active); + } + + PrefabUtility.SaveAsPrefabAsset(contentsRoot, assetPath, out var success); + if (!success) + throw new InvalidOperationException($"Failed to save prefab contents to '{assetPath}'."); + } + finally + { + PrefabUtility.UnloadPrefabContents(contentsRoot); + } + + AssetDatabase.SaveAssets(); + var asset = AssetDatabase.LoadAssetAtPath(assetPath); + var result = ObjectResolver.Describe(asset) ?? new AuthoringResult { Type = nameof(GameObject) }; + result.AssetPath = assetPath; + return result; + } + + #region Helpers + + /// Resolve an to a GameObject (a component ref maps to its GameObject). + private static GameObject ResolveGameObject(ObjectRef handle, string argName) + { + if (!ObjectResolver.TryResolve(handle, out var obj, out var error)) + throw new ArgumentException($"Could not resolve {argName}: {error}"); + + var go = obj as GameObject ?? (obj as Component)?.gameObject; + if (go == null) + throw new ArgumentException($"{argName} '{handle}' does not resolve to a GameObject (got {obj.GetType().Name})."); + return go; + } + + /// Resolve an to a prefab asset GameObject on disk. + private static GameObject ResolvePrefabAsset(ObjectRef handle, string argName) + { + if (!ObjectResolver.TryResolve(handle, out var obj, out var error)) + throw new ArgumentException($"Could not resolve {argName}: {error}"); + + var go = obj as GameObject; + if (go == null) + throw new ArgumentException($"{argName} '{handle}' does not resolve to a prefab GameObject (got {obj.GetType().Name})."); + + var assetPath = AssetDatabase.GetAssetPath(go); + if (string.IsNullOrEmpty(assetPath) || !assetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException($"{argName} '{handle}' is not a prefab asset."); + return go; + } + + /// Sandbox + normalize a prefab asset path, ensuring the .prefab extension. + private static string ResolvePrefabPath(string path) + { + var resolved = ProjectPaths.Resolve(path, out var error); + if (resolved == null) + throw new ArgumentException(error); + + if (!resolved.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + resolved += ".prefab"; + return resolved; + } + + /// + /// Confine an existing asset path (one discovered from a resolved object, not user-supplied) + /// to the authoring root, rejecting writes outside the sandbox. Runs the path through + /// , which honors explicit "Assets/..." paths as-is and + /// errors when they escape the root. describes the path for the error. + /// + private static void ConfineToAuthoringRoot(string assetPath, string what) + { + if (ProjectPaths.Resolve(assetPath, out var error) == null) + throw new ArgumentException($"The {what} ('{assetPath}') is outside the authoring root: {error}"); + } + + /// Create the parent folder chain for an asset path if it does not yet exist. + private static void EnsureParentFolder(string assetPath) + { + var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/'); + if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent)) + return; + CreateFolderRecursive(parent); + AssetDatabase.Refresh(); + } + + private static void CreateFolderRecursive(string folderPath) + { + if (AssetDatabase.IsValidFolder(folderPath)) + return; + + var parent = Path.GetDirectoryName(folderPath)?.Replace('\\', '/'); + var name = Path.GetFileName(folderPath); + if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name)) + throw new ArgumentException($"Invalid folder path '{folderPath}'."); + + if (!AssetDatabase.IsValidFolder(parent)) + CreateFolderRecursive(parent); + + AssetDatabase.CreateFolder(parent, name); + } + + /// Resolve the scene to instantiate into: a named loaded scene, or the active scene. + private static Scene ResolveTargetScene(string scenePath) + { + if (string.IsNullOrEmpty(scenePath)) + return SceneManager.GetActiveScene(); + + for (int i = 0; i < SceneManager.sceneCount; i++) + { + var scene = SceneManager.GetSceneAt(i); + if (scene.isLoaded && + (string.Equals(scene.path, scenePath, StringComparison.OrdinalIgnoreCase) || + string.Equals(scene.name, scenePath, StringComparison.OrdinalIgnoreCase))) + { + return scene; + } + } + + throw new ArgumentException($"No loaded scene matching '{scenePath}'."); + } + + /// Find a descendant by a '/'-separated relative path under a root transform. + private static Transform FindChild(Transform root, string relativePath) + { + // Transform.Find already supports '/'-separated paths for descendants. Normalize Windows-style + // '\' separators to '/' first (mirrors ProjectPaths.Resolve) so paths like 'Body\Head' work. + return root.Find(relativePath.Replace('\\', '/').Trim('/')); + } + + #endregion + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Prefabs/PrefabCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Prefabs/PrefabCommands.cs.meta new file mode 100644 index 00000000..e0167245 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Prefabs/PrefabCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f7c0d242da0d4deb9c5138d8966c51e2 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings.meta new file mode 100644 index 00000000..6a5e0441 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8fe6d0edfccd2e8429890cf65cb7417c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/AudioSettingsCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/AudioSettingsCommands.cs new file mode 100644 index 00000000..494feebe --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/AudioSettingsCommands.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands.ProjectSettings +{ + /// + /// Get/set the project Audio settings (CLI-202). The AudioManager has no scripting API, so this + /// reads/writes its serialized properties (global volume, rolloff scale, doppler factor) via + /// . Properties absent in a given Unity version are skipped. + /// + public static class AudioSettingsCommands + { + const string Group = "audio"; + const string AssetPath = "ProjectSettings/AudioManager.asset"; + + // Serialized property name -> response/plan label. + const string VolumeProp = "m_Volume"; + const string RolloffProp = "Rolloff Scale"; + const string DopplerProp = "Doppler Factor"; + + [CliCommand("get_audio_settings", "Read project Audio settings (volume, rolloff scale, doppler factor).", MainThreadRequired = true)] + public static ProjectSettingsResponse Get() => ProjectSettingsCommand.Get(Group, Read); + + [CliCommand("set_audio_settings", "Change project Audio settings. Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z.", MainThreadRequired = true)] + public static ProjectSettingsResponse Set( + [CliArg("settings", "Fields to change; omitted fields are left unchanged.")] AudioSettingsInput 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) + { + if (settings == null) + return ProjectSettingsResponse.Fail(Group, "No 'settings' object provided."); + + var so = ProjectSettingsAsset.Load(AssetPath); + var changes = new List(); + AddFloatChange(so, VolumeProp, "volume", settings.Volume, changes); + AddFloatChange(so, RolloffProp, "rolloffScale", settings.RolloffScale, changes); + AddFloatChange(so, DopplerProp, "dopplerFactor", settings.DopplerFactor, changes); + + if (changes.Count == 0) + return NoChanges(); + + var planText = "Set audio settings: " + string.Join("; ", changes); + + void Apply() + { + var obj = ProjectSettingsAsset.Load(AssetPath); + SetFloat(obj, VolumeProp, settings.Volume); + SetFloat(obj, RolloffProp, settings.RolloffScale); + SetFloat(obj, DopplerProp, settings.DopplerFactor); + obj.ApplyModifiedProperties(); + AssetDatabase.SaveAssets(); + } + + return ProjectSettingsCommand.Apply(Group, confirm, dryRun, settings, () => planText, Apply, Read); + } + + static ProjectSettingsResponse NoChanges() + { + var response = ProjectSettingsCommand.Get(Group, Read); + response.Message = "No changes specified; nothing to apply."; + return response; + } + + static Dictionary Read() + { + var so = ProjectSettingsAsset.Load(AssetPath); + var values = new Dictionary(); + AddFloat(so, VolumeProp, "volume", values); + AddFloat(so, RolloffProp, "rolloffScale", values); + AddFloat(so, DopplerProp, "dopplerFactor", values); + return values; + } + + static void AddFloat(SerializedObject so, string prop, string label, Dictionary values) + { + var p = so.FindProperty(prop); + if (p != null) + values[label] = p.floatValue; + } + + static void AddFloatChange(SerializedObject so, string prop, string label, float? newValue, List changes) + { + if (!newValue.HasValue) + return; + var p = so.FindProperty(prop); + if (p == null) + return; // not present in this Unity version + if (!Mathf.Approximately(p.floatValue, newValue.Value)) + changes.Add($"{label} {p.floatValue} -> {newValue.Value}"); + } + + static void SetFloat(SerializedObject so, string prop, float? value) + { + if (!value.HasValue) + return; + var p = so.FindProperty(prop); + if (p != null) + p.floatValue = value.Value; + } + } + + /// Audio settings to change. Null/omitted fields are left unchanged. + public class AudioSettingsInput : IStructuredCommandInput + { + [CliArg("volume", "Global audio volume (0..1).")] + public float? Volume { get; set; } + + [CliArg("rolloffScale", "Global rolloff scale.")] + public float? RolloffScale { get; set; } + + [CliArg("dopplerFactor", "Global doppler factor.")] + public float? DopplerFactor { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/AudioSettingsCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/AudioSettingsCommands.cs.meta new file mode 100644 index 00000000..ebc81077 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/AudioSettingsCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1a7abd0c1722ba34dae96408746d2c9a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/GraphicsSettingsCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/GraphicsSettingsCommands.cs new file mode 100644 index 00000000..8b8a306a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/GraphicsSettingsCommands.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine.Rendering; + +namespace Unity.Pipeline.Editor.Commands.ProjectSettings +{ + /// + /// Get/set the project's default render pipeline (CLI-202). The settable field is a handle to a + /// ; an empty reference selects the built-in pipeline (no SRP). + /// + public static class GraphicsSettingsCommands + { + const string Group = "graphics"; + + [CliCommand("get_graphics_settings", "Read GraphicsSettings (default render pipeline).", MainThreadRequired = true)] + public static ProjectSettingsResponse Get() => ProjectSettingsCommand.Get(Group, Read); + + [CliCommand("set_graphics_settings", "Set the default render pipeline asset. Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z.", MainThreadRequired = true)] + public static ProjectSettingsResponse Set( + [CliArg("settings", "Fields to change; omitted fields are left unchanged.")] GraphicsSettingsInput 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) + { + if (settings == null) + return ProjectSettingsResponse.Fail(Group, "No 'settings' object provided."); + + // omitted (null) = leave unchanged; an empty reference = built-in pipeline; otherwise a + // handle to a RenderPipelineAsset resolved through the shared ObjectResolver. + if (settings.RenderPipelineAsset == null) + return NoChanges(); + + RenderPipelineAsset asset = null; + if (!settings.RenderPipelineAsset.IsEmpty) + { + if (!ObjectResolver.TryResolve(settings.RenderPipelineAsset, out var obj, out var resolveError)) + return ProjectSettingsResponse.Fail(Group, resolveError); + asset = obj as RenderPipelineAsset; + if (asset == null) + return ProjectSettingsResponse.Fail(Group, $"'{settings.RenderPipelineAsset}' is not a RenderPipelineAsset."); + } + + var current = GraphicsSettings.defaultRenderPipeline; + if (current == asset) + return NoChanges(); + + var currentName = current != null ? current.name : ""; + var newName = asset != null ? asset.name : ""; + var planText = $"Set defaultRenderPipeline {currentName} -> {newName}"; + + void Apply() + { + GraphicsSettings.defaultRenderPipeline = asset; + AssetDatabase.SaveAssets(); + } + + return ProjectSettingsCommand.Apply(Group, confirm, dryRun, settings, () => planText, Apply, Read); + } + + static ProjectSettingsResponse NoChanges() + { + var response = ProjectSettingsCommand.Get(Group, Read); + response.Message = "No changes specified; nothing to apply."; + return response; + } + + static Dictionary Read() + { + var srp = GraphicsSettings.defaultRenderPipeline; + return new Dictionary + { + ["defaultRenderPipeline"] = srp != null ? srp.name : null, + ["defaultRenderPipelinePath"] = srp != null ? AssetDatabase.GetAssetPath(srp) : null, + ["usingScriptableRenderPipeline"] = srp != null + }; + } + } + + /// Graphics settings to change. Null/omitted fields are left unchanged. + public class GraphicsSettingsInput : IStructuredCommandInput + { + [CliArg("renderPipelineAsset", "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.")] + public ObjectRef RenderPipelineAsset { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/GraphicsSettingsCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/GraphicsSettingsCommands.cs.meta new file mode 100644 index 00000000..f14c08db --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/GraphicsSettingsCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5095c024b3a03e94996b73e4e42c4e4c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/InputSettingsCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/InputSettingsCommands.cs new file mode 100644 index 00000000..7b54215a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/InputSettingsCommands.cs @@ -0,0 +1,150 @@ +using System.Collections.Generic; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands.ProjectSettings +{ + /// + /// Get/set the legacy Input Manager (CLI-202). The InputManager has no scripting API, so this + /// reads/writes ProjectSettings/InputManager.asset via serialized properties: + /// get lists the configured axis names; set tunes a named axis's + /// sensitivity/gravity/dead values. + /// + /// "Input (legacy / Input System where feasible)" per the ticket: the legacy InputManager is + /// always present, so it is the portable target. Projects on the new Input System package store + /// their config in a separate input-actions asset, which would be a follow-up. + /// + public static class InputSettingsCommands + { + const string Group = "input"; + const string AssetPath = "ProjectSettings/InputManager.asset"; + + [CliCommand("get_input_settings", "Read the legacy Input Manager axes (names and count).", MainThreadRequired = true)] + public static ProjectSettingsResponse Get() => ProjectSettingsCommand.Get(Group, Read); + + [CliCommand("set_input_settings", "Tune a legacy Input Manager axis (sensitivity/gravity/dead) by name. Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z.", MainThreadRequired = true)] + public static ProjectSettingsResponse Set( + [CliArg("settings", "Axis change. 'axis' selects the axis by name; omitted numeric fields are left unchanged.")] InputAxisInput 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) + { + if (settings == null) + return ProjectSettingsResponse.Fail(Group, "No 'settings' object provided."); + if (string.IsNullOrEmpty(settings.Axis)) + return ProjectSettingsResponse.Fail(Group, "'axis' is required."); + + var so = ProjectSettingsAsset.Load(AssetPath); + var axes = so.FindProperty("m_Axes"); + var index = FindAxisIndex(axes, settings.Axis); + if (index < 0) + return ProjectSettingsResponse.Fail(Group, $"No axis named '{settings.Axis}'."); + + var axis = axes.GetArrayElementAtIndex(index); + var changes = new List(); + AddFloatChange(axis, "sensitivity", settings.Sensitivity, changes); + AddFloatChange(axis, "gravity", settings.Gravity, changes); + AddFloatChange(axis, "dead", settings.Dead, changes); + + if (changes.Count == 0) + return NoChanges(); + + var planText = $"Set input axis '{settings.Axis}': " + string.Join("; ", changes); + + void Apply() + { + var obj = ProjectSettingsAsset.Load(AssetPath); + var arr = obj.FindProperty("m_Axes"); + var idx = FindAxisIndex(arr, settings.Axis); + if (idx < 0) + return; + + var ax = arr.GetArrayElementAtIndex(idx); + SetFloat(ax, "sensitivity", settings.Sensitivity); + SetFloat(ax, "gravity", settings.Gravity); + SetFloat(ax, "dead", settings.Dead); + obj.ApplyModifiedProperties(); + AssetDatabase.SaveAssets(); + } + + return ProjectSettingsCommand.Apply(Group, confirm, dryRun, settings, () => planText, Apply, Read); + } + + static ProjectSettingsResponse NoChanges() + { + var response = ProjectSettingsCommand.Get(Group, Read); + response.Message = "No changes specified; nothing to apply."; + return response; + } + + static int FindAxisIndex(SerializedProperty axes, string name) + { + if (axes == null) + return -1; + for (var i = 0; i < axes.arraySize; i++) + { + var nameProp = axes.GetArrayElementAtIndex(i).FindPropertyRelative("m_Name"); + if (nameProp != null && nameProp.stringValue == name) + return i; + } + return -1; + } + + static void AddFloatChange(SerializedProperty axis, string prop, float? newValue, List changes) + { + if (!newValue.HasValue) + return; + var p = axis.FindPropertyRelative(prop); + if (p == null) + return; + if (!Mathf.Approximately(p.floatValue, newValue.Value)) + changes.Add($"{prop} {p.floatValue} -> {newValue.Value}"); + } + + static void SetFloat(SerializedProperty axis, string prop, float? value) + { + if (!value.HasValue) + return; + var p = axis.FindPropertyRelative(prop); + if (p != null) + p.floatValue = value.Value; + } + + static Dictionary Read() + { + var so = ProjectSettingsAsset.Load(AssetPath); + var axes = so.FindProperty("m_Axes"); + var names = new List(); + if (axes != null) + { + for (var i = 0; i < axes.arraySize; i++) + { + var nameProp = axes.GetArrayElementAtIndex(i).FindPropertyRelative("m_Name"); + names.Add(nameProp != null ? nameProp.stringValue : null); + } + } + + return new Dictionary + { + ["axisCount"] = names.Count, + ["axes"] = names + }; + } + } + + /// Selects a legacy input axis by name and the numeric fields to change (omitted = unchanged). + public class InputAxisInput : IStructuredCommandInput + { + [CliArg("axis", "Name of the axis to modify (e.g. 'Horizontal').", Required = true)] + public string Axis { get; set; } + + [CliArg("sensitivity", "New sensitivity.")] + public float? Sensitivity { get; set; } + + [CliArg("gravity", "New gravity.")] + public float? Gravity { get; set; } + + [CliArg("dead", "New dead-zone size.")] + public float? Dead { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/InputSettingsCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/InputSettingsCommands.cs.meta new file mode 100644 index 00000000..582c4a0a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/InputSettingsCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 27a9903daaf869948aa6dd45e9735f6f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/PhysicsSettingsCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/PhysicsSettingsCommands.cs new file mode 100644 index 00000000..196ec85a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/PhysicsSettingsCommands.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands.ProjectSettings +{ + /// + /// Get/set a representative slice of 3D settings (CLI-202): gravity (by + /// component), default solver iterations, and the bounce threshold. + /// + public static class PhysicsSettingsCommands + { + const string Group = "physics"; + + [CliCommand("get_physics_settings", "Read Physics settings (gravity, solver iterations, bounce threshold).", MainThreadRequired = true)] + public static ProjectSettingsResponse Get() => ProjectSettingsCommand.Get(Group, Read); + + [CliCommand("set_physics_settings", "Change Physics settings. Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z.", MainThreadRequired = true)] + public static ProjectSettingsResponse Set( + [CliArg("settings", "Fields to change; omitted fields are left unchanged.")] PhysicsSettingsInput 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) + { + if (settings == null) + return ProjectSettingsResponse.Fail(Group, "No 'settings' object provided."); + + var changes = new List(); + + var gravity = Physics.gravity; + var newGravity = gravity; + if (settings.GravityX.HasValue) newGravity.x = settings.GravityX.Value; + if (settings.GravityY.HasValue) newGravity.y = settings.GravityY.Value; + if (settings.GravityZ.HasValue) newGravity.z = settings.GravityZ.Value; + if (newGravity != gravity) + changes.Add($"gravity {gravity} -> {newGravity}"); + + if (settings.DefaultSolverIterations.HasValue && settings.DefaultSolverIterations.Value != Physics.defaultSolverIterations) + changes.Add($"defaultSolverIterations {Physics.defaultSolverIterations} -> {settings.DefaultSolverIterations.Value}"); + if (settings.BounceThreshold.HasValue && !Mathf.Approximately(settings.BounceThreshold.Value, Physics.bounceThreshold)) + changes.Add($"bounceThreshold {Physics.bounceThreshold} -> {settings.BounceThreshold.Value}"); + + if (changes.Count == 0) + return NoChanges(); + + var planText = "Set physics settings: " + string.Join("; ", changes); + + void Apply() + { + Physics.gravity = newGravity; + if (settings.DefaultSolverIterations.HasValue) Physics.defaultSolverIterations = settings.DefaultSolverIterations.Value; + if (settings.BounceThreshold.HasValue) Physics.bounceThreshold = settings.BounceThreshold.Value; + AssetDatabase.SaveAssets(); + } + + return ProjectSettingsCommand.Apply(Group, confirm, dryRun, settings, () => planText, Apply, Read); + } + + static ProjectSettingsResponse NoChanges() + { + var response = ProjectSettingsCommand.Get(Group, Read); + response.Message = "No changes specified; nothing to apply."; + return response; + } + + static Dictionary Read() + { + var g = Physics.gravity; + return new Dictionary + { + ["gravityX"] = g.x, + ["gravityY"] = g.y, + ["gravityZ"] = g.z, + ["defaultSolverIterations"] = Physics.defaultSolverIterations, + ["bounceThreshold"] = Physics.bounceThreshold + }; + } + } + + /// Physics settings to change. Null/omitted fields are left unchanged. + public class PhysicsSettingsInput : IStructuredCommandInput + { + [CliArg("gravityX", "Gravity X component.")] + public float? GravityX { get; set; } + + [CliArg("gravityY", "Gravity Y component (e.g. -9.81).")] + public float? GravityY { get; set; } + + [CliArg("gravityZ", "Gravity Z component.")] + public float? GravityZ { get; set; } + + [CliArg("defaultSolverIterations", "Default solver iteration count.")] + public int? DefaultSolverIterations { get; set; } + + [CliArg("bounceThreshold", "Bounce threshold velocity.")] + public float? BounceThreshold { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/PhysicsSettingsCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/PhysicsSettingsCommands.cs.meta new file mode 100644 index 00000000..6ce0c1b3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/PhysicsSettingsCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 06de808693ce8c24891baa1026490d3d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/PlayerSettingsCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/PlayerSettingsCommands.cs new file mode 100644 index 00000000..827a1328 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/PlayerSettingsCommands.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEditor.Build; + +namespace Unity.Pipeline.Editor.Commands.ProjectSettings +{ + /// + /// Get/set a representative slice of (CLI-202): identity + /// (company/product/version) and the two domain-reload-triggering toggles — scripting backend and + /// API compatibility level. Scripting-backend / API-level changes are read and written against the + /// active build target's . + /// + /// Icons and splash are intentionally out of this MVP set: they take Texture2D assets, not + /// scalar JSON, so they need a dedicated asset-reference convention. + /// + public static class PlayerSettingsCommands + { + const string Group = "player"; + + [CliCommand("get_player_settings", "Read PlayerSettings (company/product/version, scripting backend, API level).", MainThreadRequired = true)] + public static ProjectSettingsResponse Get() => ProjectSettingsCommand.Get(Group, Read); + + [CliCommand("set_player_settings", "Change PlayerSettings. Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z. Scripting backend / API level changes trigger a domain reload.", MainThreadRequired = true)] + public static ProjectSettingsResponse Set( + [CliArg("settings", "Fields to change; omitted fields are left unchanged.")] PlayerSettingsInput 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) + { + if (settings == null) + return ProjectSettingsResponse.Fail(Group, "No 'settings' object provided."); + + var target = NamedBuildTarget.FromBuildTargetGroup(EditorUserBuildSettings.selectedBuildTargetGroup); + var changes = new List(); + var reload = false; + + if (settings.CompanyName != null && settings.CompanyName != PlayerSettings.companyName) + changes.Add($"companyName '{PlayerSettings.companyName}' -> '{settings.CompanyName}'"); + if (settings.ProductName != null && settings.ProductName != PlayerSettings.productName) + changes.Add($"productName '{PlayerSettings.productName}' -> '{settings.ProductName}'"); + if (settings.BundleVersion != null && settings.BundleVersion != PlayerSettings.bundleVersion) + changes.Add($"bundleVersion '{PlayerSettings.bundleVersion}' -> '{settings.BundleVersion}'"); + + if (settings.ScriptingBackend.HasValue) + { + var current = PlayerSettings.GetScriptingBackend(target); + if (settings.ScriptingBackend.Value != current) + { + changes.Add($"scriptingBackend {current} -> {settings.ScriptingBackend.Value} (domain reload)"); + reload = true; + } + } + + if (settings.ApiCompatibilityLevel.HasValue) + { + var current = PlayerSettings.GetApiCompatibilityLevel(target); + if (settings.ApiCompatibilityLevel.Value != current) + { + changes.Add($"apiCompatibilityLevel {current} -> {settings.ApiCompatibilityLevel.Value} (domain reload)"); + reload = true; + } + } + + if (changes.Count == 0) + return NoChanges(); + + var planText = "Set player settings: " + string.Join("; ", changes); + + void Apply() + { + if (settings.CompanyName != null) PlayerSettings.companyName = settings.CompanyName; + if (settings.ProductName != null) PlayerSettings.productName = settings.ProductName; + if (settings.BundleVersion != null) PlayerSettings.bundleVersion = settings.BundleVersion; + if (settings.ScriptingBackend.HasValue) PlayerSettings.SetScriptingBackend(target, settings.ScriptingBackend.Value); + if (settings.ApiCompatibilityLevel.HasValue) PlayerSettings.SetApiCompatibilityLevel(target, settings.ApiCompatibilityLevel.Value); + AssetDatabase.SaveAssets(); + } + + return ProjectSettingsCommand.Apply(Group, confirm, dryRun, settings, () => planText, Apply, Read, requiresDomainReload: reload); + } + + static ProjectSettingsResponse NoChanges() + { + var response = ProjectSettingsCommand.Get(Group, Read); + response.Message = "No changes specified; nothing to apply."; + return response; + } + + static Dictionary Read() + { + var target = NamedBuildTarget.FromBuildTargetGroup(EditorUserBuildSettings.selectedBuildTargetGroup); + return new Dictionary + { + ["companyName"] = PlayerSettings.companyName, + ["productName"] = PlayerSettings.productName, + ["bundleVersion"] = PlayerSettings.bundleVersion, + ["buildTarget"] = target.TargetName, + ["scriptingBackend"] = PlayerSettings.GetScriptingBackend(target).ToString(), + ["apiCompatibilityLevel"] = PlayerSettings.GetApiCompatibilityLevel(target).ToString() + }; + } + } + + /// Player settings to change. Null/omitted fields are left unchanged. + public class PlayerSettingsInput : IStructuredCommandInput + { + [CliArg("companyName", "Company name.")] + public string CompanyName { get; set; } + + [CliArg("productName", "Product name.")] + public string ProductName { get; set; } + + [CliArg("bundleVersion", "Bundle/application version string.")] + public string BundleVersion { get; set; } + + [CliArg("scriptingBackend", "Scripting backend (e.g. Mono2x, IL2CPP). Triggers a domain reload.")] + public ScriptingImplementation? ScriptingBackend { get; set; } + + [CliArg("apiCompatibilityLevel", "API compatibility level (e.g. NET_Standard_2_0, NET_Unity_4_8). Triggers a domain reload.")] + public ApiCompatibilityLevel? ApiCompatibilityLevel { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/PlayerSettingsCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/PlayerSettingsCommands.cs.meta new file mode 100644 index 00000000..1ef2ad2b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/PlayerSettingsCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4fcf409834ac0bf4bb18a7b7fadad8dd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsAsset.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsAsset.cs new file mode 100644 index 00000000..0c8bcb5a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsAsset.cs @@ -0,0 +1,23 @@ +using System; +using UnityEditor; + +namespace Unity.Pipeline.Editor.Commands.ProjectSettings +{ + /// + /// Loads a for a singleton settings asset under + /// ProjectSettings/ (e.g. InputManager.asset, TagManager.asset, + /// AudioManager.asset) that has no first-class scripting API. Used by the settings-group + /// commands that must read/write those managers via serialized properties. + /// + internal static class ProjectSettingsAsset + { + public static SerializedObject Load(string assetPath) + { + var objects = AssetDatabase.LoadAllAssetsAtPath(assetPath); + if (objects == null || objects.Length == 0 || objects[0] == null) + throw new InvalidOperationException($"Could not load settings asset at '{assetPath}'."); + + return new SerializedObject(objects[0]); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsAsset.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsAsset.cs.meta new file mode 100644 index 00000000..5f9d9338 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsAsset.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5646d5f43f10f344ab42eecab65f08c8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsCommand.cs new file mode 100644 index 00000000..519a953e --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsCommand.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands.ProjectSettings +{ + /// + /// Shared plumbing the per-group project-settings commands (CLI-202) wrap, so each group's + /// get/set handler stays thin and the policy is applied uniformly: + /// + /// — read a group's values into a . + /// — gate a write with the confirm/dry_run convention and + /// apply it (project-settings writes are not part of Unity's Undo), then re-read the group so the response + /// reflects the post-change state, and surface domain-reload / reimport signals. + /// + /// + /// Group handlers supply two callbacks: readValues (snapshot the group's current values) + /// and, for writes, apply (perform the mutation) plus a plan describing the intended + /// effect. All run on the main thread (these commands are MainThreadRequired = true). + /// + public static class ProjectSettingsCommand + { + /// + /// Read-only path: build a success response carrying the group's current values. + /// + public static ProjectSettingsResponse Get(string group, Func> readValues) + { + if (readValues == null) + throw new ArgumentNullException(nameof(readValues)); + + if (!TryReadValues(readValues, out var values, out var readError)) + return ProjectSettingsResponse.Fail(group, $"Failed to read {group} settings: {readError}"); + + return new ProjectSettingsResponse + { + Success = true, + Group = group, + Values = values, + Message = $"Read {group} settings." + }; + } + + /// + /// Write path: gate the change with the confirm/dry_run convention and apply it + /// (project-settings writes are not undoable via Ctrl+Z), then re-read the + /// group. / declare + /// side effects of this specific change (Unity defers them, so the response returns first); + /// they are reported for an applied change and for a dry-run preview alike, and suppressed when + /// the operation is refused or fails. + /// + public static ProjectSettingsResponse Apply( + string group, + bool confirm, + bool dryRun, + object auditParams, + Func plan, + Action apply, + Func> readValues, + bool requiresDomainReload = false, + bool requiresReimport = false) + { + if (plan == null) + throw new ArgumentNullException(nameof(plan)); + if (apply == null) + throw new ArgumentNullException(nameof(apply)); + if (readValues == null) + throw new ArgumentNullException(nameof(readValues)); + + string planText; + try + { + planText = plan(); + } + catch (Exception ex) + { + return ProjectSettingsResponse.Fail(group, $"Failed to compute the operation preview: {ex.Message}"); + } + + // Inline confirm / dry_run gate + Undo grouping (the codebase-wide mutating convention). + var response = new ProjectSettingsResponse { Group = group }; + if (dryRun) + { + response.Success = true; + response.DryRun = true; + response.Applied = false; + response.Message = $"Dry run — no changes made. Intended effect: {planText}"; + } + else if (!confirm) + { + response.Success = false; + response.Applied = false; + response.Message = + "Refused: this operation mutates the project. Re-run with confirm=true to apply, " + + "or dry_run=true to preview the change."; + } + else + { + try + { + apply(); + response.Success = true; + response.Applied = true; + response.Message = planText; + } + catch (Exception ex) + { + response.Success = false; + response.Applied = false; + response.Message = $"Operation failed: {ex.Message}"; + } + } + + // Side-effect signals are only meaningful when the operation went through (applied) or was + // previewed (dry run); suppress them for a refusal or failure. + response.RequiresDomainReload = response.Success && requiresDomainReload; + response.RequiresReimport = response.Success && requiresReimport; + + // Re-read so the caller sees the resulting (or, for a dry run / refusal, the unchanged) + // state. A read failure must not mask the write outcome, so it is non-fatal here. + if (TryReadValues(readValues, out var values, out var readError)) + response.Values = values; + else + Debug.LogWarning($"[pipeline] Read-back of {group} settings failed: {readError}"); + + return response; + } + + private static bool TryReadValues(Func> readValues, + out Dictionary values, out string error) + { + try + { + values = readValues() ?? new Dictionary(); + error = null; + return true; + } + catch (Exception ex) + { + values = null; + error = ex.Message; + return false; + } + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsCommand.cs.meta new file mode 100644 index 00000000..87ad6797 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 00e8d8eb6857c9d40babc72bb711b9c4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsResponse.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsResponse.cs new file mode 100644 index 00000000..1842bde4 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsResponse.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Unity.Pipeline.Editor.Commands.ProjectSettings +{ + /// + /// + /// Shared response for the project-settings get/set commands (CLI-202). One type backs every + /// settings group (PlayerSettings, Quality, Graphics, Physics, Time, Input, Tags & Layers, + /// Audio) so agents get a uniform shape across them. + /// + /// + /// carries the current settings for the group as a flat + /// name → value map — returned by a get, and re-read after a set so the caller can + /// confirm the change without a second round-trip. + /// + /// Writes follow the shared confirm/dry_run convention, so + /// / mirror its semantics: a change only mutates when + /// it actually ran (not a preview, not refused). and + /// flag side effects an agent must wait out — e.g. switching the + /// scripting backend or API level triggers a domain reload; some graphics/texture changes trigger + /// an asset reimport. They describe the operation, so a dry_run still reports them. + /// + [Serializable] + public class ProjectSettingsResponse + { + [JsonProperty("success")] + public bool Success { get; set; } + + /// Settings group this response is for (e.g. "player", "quality", "time"). + [JsonProperty("group")] + public string Group { get; set; } + + /// True only when a write actually mutated the project (false for get/dry-run/refused/failed). + [JsonProperty("applied")] + public bool Applied { get; set; } + + /// True when a write was a preview only (no mutation). + [JsonProperty("dryRun")] + public bool DryRun { get; set; } + + /// The operation triggers a domain reload; the agent should wait for the editor to settle. + [JsonProperty("requiresDomainReload")] + public bool RequiresDomainReload { get; set; } + + /// The operation triggers an asset reimport; the agent should wait for it to finish. + [JsonProperty("requiresReimport")] + public bool RequiresReimport { get; set; } + + /// Current settings for the group as a name → value map. + [JsonProperty("values")] + public Dictionary Values { get; set; } + + [JsonProperty("message")] + public string Message { get; set; } + + public static ProjectSettingsResponse Fail(string group, string message) => new ProjectSettingsResponse + { + Success = false, + Group = group, + Message = message + }; + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsResponse.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsResponse.cs.meta new file mode 100644 index 00000000..200fa823 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/ProjectSettingsResponse.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8f8c1356c54a05248b34aff8e0490c0c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/QualitySettingsCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/QualitySettingsCommands.cs new file mode 100644 index 00000000..8bac9b56 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/QualitySettingsCommands.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands.ProjectSettings +{ + /// + /// Get/set (CLI-202): the active quality level plus a couple of + /// representative per-level toggles (vSync, anti-aliasing). Level changes apply expensive changes + /// so the switch takes full effect. + /// + public static class QualitySettingsCommands + { + const string Group = "quality"; + + [CliCommand("get_quality_settings", "Read QualitySettings (current level, level names, vSync, anti-aliasing).", MainThreadRequired = true)] + public static ProjectSettingsResponse Get() => ProjectSettingsCommand.Get(Group, Read); + + [CliCommand("set_quality_settings", "Change QualitySettings. Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z.", MainThreadRequired = true)] + public static ProjectSettingsResponse Set( + [CliArg("settings", "Fields to change; omitted fields are left unchanged.")] QualitySettingsInput 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) + { + if (settings == null) + return ProjectSettingsResponse.Fail(Group, "No 'settings' object provided."); + + var names = QualitySettings.names; + var changes = new List(); + + if (settings.Level.HasValue) + { + if (settings.Level.Value < 0 || settings.Level.Value >= names.Length) + return ProjectSettingsResponse.Fail(Group, $"level {settings.Level.Value} out of range (0..{names.Length - 1})."); + if (settings.Level.Value != QualitySettings.GetQualityLevel()) + changes.Add($"level {QualitySettings.GetQualityLevel()} -> {settings.Level.Value}"); + } + + if (settings.VSyncCount.HasValue && settings.VSyncCount.Value != QualitySettings.vSyncCount) + changes.Add($"vSyncCount {QualitySettings.vSyncCount} -> {settings.VSyncCount.Value}"); + if (settings.AntiAliasing.HasValue && settings.AntiAliasing.Value != QualitySettings.antiAliasing) + changes.Add($"antiAliasing {QualitySettings.antiAliasing} -> {settings.AntiAliasing.Value}"); + + if (changes.Count == 0) + return NoChanges(); + + var planText = "Set quality settings: " + string.Join("; ", changes); + + void Apply() + { + if (settings.Level.HasValue) QualitySettings.SetQualityLevel(settings.Level.Value, true); + if (settings.VSyncCount.HasValue) QualitySettings.vSyncCount = settings.VSyncCount.Value; + if (settings.AntiAliasing.HasValue) QualitySettings.antiAliasing = settings.AntiAliasing.Value; + AssetDatabase.SaveAssets(); + } + + return ProjectSettingsCommand.Apply(Group, confirm, dryRun, settings, () => planText, Apply, Read); + } + + static ProjectSettingsResponse NoChanges() + { + var response = ProjectSettingsCommand.Get(Group, Read); + response.Message = "No changes specified; nothing to apply."; + return response; + } + + static Dictionary Read() + { + var names = QualitySettings.names; + var level = QualitySettings.GetQualityLevel(); + return new Dictionary + { + ["level"] = level, + ["levelName"] = (level >= 0 && level < names.Length) ? names[level] : null, + ["levelNames"] = names, + ["vSyncCount"] = QualitySettings.vSyncCount, + ["antiAliasing"] = QualitySettings.antiAliasing + }; + } + } + + /// Quality settings to change. Null/omitted fields are left unchanged. + public class QualitySettingsInput : IStructuredCommandInput + { + [CliArg("level", "Quality level index (see levelNames from get_quality_settings).")] + public int? Level { get; set; } + + [CliArg("vSyncCount", "VSync count (0 = off, 1, 2).")] + public int? VSyncCount { get; set; } + + [CliArg("antiAliasing", "MSAA sample count (0, 2, 4, 8).")] + public int? AntiAliasing { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/QualitySettingsCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/QualitySettingsCommands.cs.meta new file mode 100644 index 00000000..359020a6 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/QualitySettingsCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b4bc1a976dcac514796a70fd1b91a2be +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/TagsLayersCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/TagsLayersCommands.cs new file mode 100644 index 00000000..cd773782 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/TagsLayersCommands.cs @@ -0,0 +1,143 @@ +using System.Collections.Generic; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEditorInternal; + +namespace Unity.Pipeline.Editor.Commands.ProjectSettings +{ + /// + /// Get/set project tags and layers (CLI-202). Tags use the + /// add/remove API; user layers (indices 8..31; 0..7 are + /// reserved) are assigned via the TagManager's serialized layers array. + /// + public static class TagsLayersCommands + { + const string Group = "tags_layers"; + const string TagManagerPath = "ProjectSettings/TagManager.asset"; + const int FirstUserLayer = 8; + const int LastUserLayer = 31; + + [CliCommand("get_tags_layers", "Read the project's tags and (named) layers.", MainThreadRequired = true)] + public static ProjectSettingsResponse Get() => ProjectSettingsCommand.Get(Group, Read); + + [CliCommand("set_tags_layers", "Add/remove tags and assign user layer names (index 8-31). Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z.", MainThreadRequired = true)] + public static ProjectSettingsResponse Set( + [CliArg("settings", "Tag/layer changes to make.")] TagsLayersInput 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) + { + if (settings == null) + return ProjectSettingsResponse.Fail(Group, "No 'settings' object provided."); + + if (settings.SetLayers != null) + { + foreach (var layer in settings.SetLayers) + { + if (layer == null) + continue; + if (layer.Index < FirstUserLayer || layer.Index > LastUserLayer) + return ProjectSettingsResponse.Fail(Group, + $"layer index {layer.Index} is reserved or out of range; user layers are {FirstUserLayer}..{LastUserLayer}."); + } + } + + var existingTags = new HashSet(InternalEditorUtility.tags); + var existingLayers = InternalEditorUtility.layers; + var changes = new List(); + + if (settings.AddTags != null) + foreach (var tag in settings.AddTags) + if (!string.IsNullOrEmpty(tag) && !existingTags.Contains(tag)) + changes.Add($"add tag '{tag}'"); + + if (settings.RemoveTags != null) + foreach (var tag in settings.RemoveTags) + if (!string.IsNullOrEmpty(tag) && existingTags.Contains(tag)) + changes.Add($"remove tag '{tag}'"); + + if (settings.SetLayers != null) + foreach (var layer in settings.SetLayers) + if (layer != null) + { + var desired = layer.Name ?? string.Empty; + var current = (existingLayers != null && layer.Index >= 0 && layer.Index < existingLayers.Length) + ? (existingLayers[layer.Index] ?? string.Empty) + : string.Empty; + if (current != desired) + changes.Add($"layer[{layer.Index}] = '{desired}'"); + } + + if (changes.Count == 0) + return NoChanges(); + + var planText = "Set tags/layers: " + string.Join("; ", changes); + + void Apply() + { + // Remove before add so a remove+re-add in one call behaves predictably. + if (settings.RemoveTags != null) + foreach (var tag in settings.RemoveTags) + if (!string.IsNullOrEmpty(tag)) + InternalEditorUtility.RemoveTag(tag); + + if (settings.AddTags != null) + foreach (var tag in settings.AddTags) + if (!string.IsNullOrEmpty(tag)) + InternalEditorUtility.AddTag(tag); + + if (settings.SetLayers != null && settings.SetLayers.Length > 0) + { + var so = ProjectSettingsAsset.Load(TagManagerPath); + var layersProp = so.FindProperty("layers"); + foreach (var layer in settings.SetLayers) + { + if (layer == null) + continue; + layersProp.GetArrayElementAtIndex(layer.Index).stringValue = layer.Name ?? string.Empty; + } + so.ApplyModifiedProperties(); + } + + AssetDatabase.SaveAssets(); + } + + return ProjectSettingsCommand.Apply(Group, confirm, dryRun, settings, () => planText, Apply, Read); + } + + static ProjectSettingsResponse NoChanges() + { + var response = ProjectSettingsCommand.Get(Group, Read); + response.Message = "No changes specified; nothing to apply."; + return response; + } + + static Dictionary Read() => new Dictionary + { + ["tags"] = InternalEditorUtility.tags, + ["layers"] = InternalEditorUtility.layers + }; + } + + /// Tag/layer changes. All fields optional; omitted ones make no change. + public class TagsLayersInput : IStructuredCommandInput + { + [CliArg("addTags", "Tag names to add.")] + public string[] AddTags { get; set; } + + [CliArg("removeTags", "Tag names to remove.")] + public string[] RemoveTags { get; set; } + + [CliArg("setLayers", "User layer assignments (index 8-31).")] + public LayerAssignment[] SetLayers { get; set; } + } + + /// Assigns a name to a single user layer slot. + public class LayerAssignment : IStructuredCommandInput + { + [CliArg("index", "Layer index (8-31 for user layers).", Required = true)] + public int Index { get; set; } + + [CliArg("name", "Layer name (empty string clears the slot).", Required = true)] + public string Name { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/TagsLayersCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/TagsLayersCommands.cs.meta new file mode 100644 index 00000000..c68e6471 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/TagsLayersCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2c6cb0f1d0a407f498fc141814f2d329 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/TimeSettingsCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/TimeSettingsCommands.cs new file mode 100644 index 00000000..749a2a4a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/TimeSettingsCommands.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands.ProjectSettings +{ + /// + /// Get/set the project Time settings (CLI-202): fixed timestep, maximum allowed timestep, and the + /// time scale. + /// + public static class TimeSettingsCommands + { + const string Group = "time"; + + [CliCommand("get_time_settings", "Read Time settings (fixedDeltaTime, maximumDeltaTime, timeScale).", MainThreadRequired = true)] + public static ProjectSettingsResponse Get() => ProjectSettingsCommand.Get(Group, Read); + + [CliCommand("set_time_settings", "Change Time settings. Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z.", MainThreadRequired = true)] + 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) + { + if (settings == null) + return ProjectSettingsResponse.Fail(Group, "No 'settings' object provided."); + + var changes = new List(); + + if (settings.FixedDeltaTime.HasValue && !Mathf.Approximately(settings.FixedDeltaTime.Value, Time.fixedDeltaTime)) + changes.Add($"fixedDeltaTime {Time.fixedDeltaTime} -> {settings.FixedDeltaTime.Value}"); + if (settings.MaximumDeltaTime.HasValue && !Mathf.Approximately(settings.MaximumDeltaTime.Value, Time.maximumDeltaTime)) + changes.Add($"maximumDeltaTime {Time.maximumDeltaTime} -> {settings.MaximumDeltaTime.Value}"); + if (settings.TimeScale.HasValue && !Mathf.Approximately(settings.TimeScale.Value, Time.timeScale)) + changes.Add($"timeScale {Time.timeScale} -> {settings.TimeScale.Value}"); + + if (changes.Count == 0) + return NoChanges(); + + var planText = "Set time settings: " + string.Join("; ", changes); + + void Apply() + { + if (settings.FixedDeltaTime.HasValue) Time.fixedDeltaTime = settings.FixedDeltaTime.Value; + if (settings.MaximumDeltaTime.HasValue) Time.maximumDeltaTime = settings.MaximumDeltaTime.Value; + if (settings.TimeScale.HasValue) Time.timeScale = settings.TimeScale.Value; + AssetDatabase.SaveAssets(); + } + + return ProjectSettingsCommand.Apply(Group, confirm, dryRun, settings, () => planText, Apply, Read); + } + + static ProjectSettingsResponse NoChanges() + { + var response = ProjectSettingsCommand.Get(Group, Read); + response.Message = "No changes specified; nothing to apply."; + return response; + } + + static Dictionary Read() => new Dictionary + { + ["fixedDeltaTime"] = Time.fixedDeltaTime, + ["maximumDeltaTime"] = Time.maximumDeltaTime, + ["timeScale"] = Time.timeScale + }; + } + + /// Time settings to change. Null/omitted fields are left unchanged. + public class TimeSettingsInput : IStructuredCommandInput + { + [CliArg("fixedDeltaTime", "Fixed timestep in seconds (e.g. 0.02).")] + public float? FixedDeltaTime { get; set; } + + [CliArg("maximumDeltaTime", "Maximum allowed timestep in seconds.")] + public float? MaximumDeltaTime { get; set; } + + [CliArg("timeScale", "Time scale (1 = real-time).")] + public float? TimeScale { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/TimeSettingsCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/TimeSettingsCommands.cs.meta new file mode 100644 index 00000000..c467654b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ProjectSettings/TimeSettingsCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a5973463d405c064687fe56fee5d011a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/RecompileCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/RecompileCommand.cs new file mode 100644 index 00000000..a0fc4ff2 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/RecompileCommand.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEditor.Compilation; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands +{ + /// + /// Forces a script recompile that works even when the editor is unfocused or minimized. + /// + /// Why this is non-trivial: + /// - Unity only runs script compilation while the editor is the active OS application. Pass + /// focus=true to bring it to the foreground (editor_focus) before AssetDatabase.Refresh(); + /// it is off by default because the server keeps the editor ticking while unfocused, so + /// compilation still proceeds without stealing the user's foreground window. + /// - A successful compile triggers a domain reload, which destroys the managed AppDomain + /// (HTTP server, in-flight requests, statics). The triggering request therefore cannot stay + /// open and return when done. + /// + /// Pattern (mirrors the test runner): completion is reported via a status file that survives the + /// domain reload. Call "recompile" to trigger, then poll "recompile_status" until status is + /// "completed" or "up_to_date". The client must tolerate connection errors during the reload. + /// + [InitializeOnLoad] + public static class RecompileCommand + { + const string StatusFile = "Temp/pipeline_recompile_status.json"; + + static readonly List s_Errors = new List(); + + // Focus action, indirected so tests can observe whether focus was performed without + // actually stealing the OS foreground window. + internal static Action s_FocusAction = () => FocusEditorCommand.FocusEditor(); + + static RecompileCommand() + { + CompilationPipeline.compilationStarted += OnCompilationStarted; + CompilationPipeline.assemblyCompilationFinished += OnAssemblyCompilationFinished; + CompilationPipeline.compilationFinished += OnCompilationFinished; + } + + [CliCommand("recompile", "Force a script recompile (works while unfocused/minimized). Poll recompile_status for completion.", MainThreadRequired = true)] + public static object Recompile( + [CliArg("focus", "If true, bring the Editor to the foreground before compiling. Off by default.")] bool focus = false) + { + // Unity compiles only while it is the active application. Optionally bring it to the + // foreground first; off by default (the server keeps the editor ticking while unfocused, + // so compilation still proceeds). + if (focus) + s_FocusAction(); + + WriteStatus("triggered", false, null); + + // Trigger asset import + compilation. + AssetDatabase.Refresh(); + + if (EditorApplication.isCompiling) + { + // compilationStarted has written "compiling"; a domain reload will follow on success. + return new { status = "compiling", message = "Recompilation started. Poll recompile_status until completed." }; + } + + // Nothing needed recompilation. + WriteStatus("up_to_date", false, null); + return new { status = "up_to_date", message = "No scripts needed recompilation." }; + } + + [CliCommand("recompile_status", "Get the status of the last recompile: idle | triggered | compiling | completed | up_to_date.", MainThreadRequired = false)] + public static string RecompileStatus() + { + if (File.Exists(StatusFile)) + return File.ReadAllText(StatusFile); + return "{\"status\":\"idle\"}"; + } + + static void OnCompilationStarted(object _) + { + s_Errors.Clear(); + WriteStatus("compiling", false, null); + } + + static void OnAssemblyCompilationFinished(string assembly, CompilerMessage[] messages) + { + if (messages == null) return; + foreach (var m in messages) + if (m.type == CompilerMessageType.Error) + s_Errors.Add(m.message); + } + + static void OnCompilationFinished(object _) + { + // Written before the domain reload, so it persists for pollers after the reload. + WriteStatus("completed", s_Errors.Count > 0, s_Errors.ToArray()); + } + + static void WriteStatus(string status, bool failed, string[] errors) + { + try + { + var payload = new { status, failed, errors = errors ?? Array.Empty() }; + File.WriteAllText(StatusFile, JsonConvert.SerializeObject(payload)); + } + catch (Exception ex) + { + Debug.LogError($"[Recompile] Failed to write status file: {ex.Message}"); + } + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/RecompileCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/RecompileCommand.cs.meta new file mode 100644 index 00000000..287a05cd --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/RecompileCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c0ded562e1968c342b40922dbb32f3f5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scenes.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scenes.meta new file mode 100644 index 00000000..e7459cb0 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scenes.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d7cf2e413ccd43f0b9dfb4db22c6cb29 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scenes/SceneCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scenes/SceneCommands.cs new file mode 100644 index 00000000..7492a79d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scenes/SceneCommands.cs @@ -0,0 +1,395 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace Unity.Pipeline.Editor.Commands.Scenes +{ + /// + /// Scene-management authoring commands (CLI-193): create / open / save scenes, inspect open + /// scenes, control the active scene, snapshot a scene's hierarchy, and manage the build scene + /// list. They build on the same foundation as the asset commands — paths go through the + /// sandbox and results come back as the canonical + /// envelope so an agent can chain follow-up calls. + /// + /// WHY the play-mode guard: mutations (new/open/save/setActive) + /// are undefined and destructive while the editor is entering or in play mode — they can discard + /// in-flight play-mode state or leave the scene manager inconsistent. Rather than risk corrupting + /// state, every mutating command refuses up front with a clear, *recoverable* error (an + /// , surfaced by the server as a 400 "Command Execution + /// Failed" without touching scene state). The caller can exit play mode and retry. + /// + public static class SceneCommands + { + private const string SceneExtension = ".unity"; + + // create_scene --template values. "empty" is the historical default (blank scene); "default" + // seeds Unity's built-in "3D" new-scene contents (Main Camera + Directional Light). + private const string TemplateEmpty = "empty"; + private const string TemplateDefault = "default"; + + #region Create / Open / Save + + [CliCommand("create_scene", "Create a new scene and save it to the given path under the authoring root.")] + public static AuthoringResult CreateScene( + [CliArg("path", "Scene path relative to the authoring root (default Assets/); the Assets/ prefix and the .unity extension are optional. e.g. Scenes/Level1", Required = true)] string path, + [CliArg("additive", "Open the new scene additively alongside currently open scenes instead of replacing them.", DefaultValue = false)] bool additive = false, + [CliArg("template", "Initial contents: 'empty' (default) for a blank scene, or 'default' to seed a Main Camera + Directional Light matching Unity's built-in 3D template.", DefaultValue = TemplateEmpty)] string template = TemplateEmpty) + { + GuardNotPlaying("create_scene"); + + // Validate the template up front (before touching scene state) so an unknown value fails + // recoverably with a clear, enumerated error rather than silently doing the wrong thing. + var sceneSetup = ResolveSceneSetup(template); + + var normalized = ResolveScenePath(path); + EnsureParentFolder(normalized); + + // NewSceneMode.Single replaces all open scenes; Additive keeps them. NewSceneSetup.EmptyScene + // gives a blank scene (no default camera/light) — predictable for programmatic population; + // NewSceneSetup.DefaultGameObjects gives exactly a Main Camera (tagged MainCamera) + a + // Directional Light, identical to Unity's built-in "3D" new-scene template. + var setup = additive ? NewSceneMode.Additive : NewSceneMode.Single; + var scene = EditorSceneManager.NewScene(sceneSetup, setup); + + if (!EditorSceneManager.SaveScene(scene, normalized)) + throw new InvalidOperationException($"Failed to save new scene to '{normalized}'."); + + AssetDatabase.Refresh(); + return DescribeSceneAsset(normalized); + } + + [CliCommand("open_scene", "Open an existing scene from the given path.")] + public static AuthoringResult OpenScene( + [CliArg("path", "Scene path relative to the authoring root (default Assets/); the Assets/ prefix and the .unity extension are optional.", Required = true)] string path, + [CliArg("additive", "Open additively alongside currently open scenes instead of replacing them.", DefaultValue = false)] bool additive = false) + { + GuardNotPlaying("open_scene"); + + var normalized = ResolveScenePath(path); + if (string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(normalized))) + throw new InvalidOperationException($"No scene asset at '{normalized}'."); + + var mode = additive ? OpenSceneMode.Additive : OpenSceneMode.Single; + var scene = EditorSceneManager.OpenScene(normalized, mode); + if (!scene.IsValid()) + throw new InvalidOperationException($"Failed to open scene '{normalized}'."); + + return DescribeSceneAsset(normalized); + } + + [CliCommand("save_scene", "Save an open scene. Saves the active scene when no path is given.")] + public static AuthoringResult SaveScene( + [CliArg("path", "Path of the open scene to save (authoring-root relative; Assets/ prefix and .unity optional). Omit to save the active scene.")] string path = null) + { + GuardNotPlaying("save_scene"); + + var scene = string.IsNullOrEmpty(path) + ? EditorSceneManager.GetActiveScene() + : FindOpenScene(ResolveScenePath(path)); + + if (!scene.IsValid() || !scene.isLoaded) + throw new InvalidOperationException( + string.IsNullOrEmpty(path) + ? "No valid active scene to save." + : $"Scene '{path}' is not open."); + + // An untitled/never-saved scene has an empty path. SaveScene(scene) with no explicit path + // would either trigger a modal "Save Scene" dialog (hangs a headless editor) or fail + // non-deterministically. Refuse up front with a recoverable error so the caller picks a path. + if (string.IsNullOrEmpty(scene.path)) + throw new InvalidOperationException( + $"Scene '{scene.name}' has never been saved (no path). " + + "Create or save it with an explicit path first (create_scene ) — no scene state was changed."); + + if (!EditorSceneManager.SaveScene(scene)) + throw new InvalidOperationException($"Failed to save scene '{scene.name}'."); + + AssetDatabase.Refresh(); + return DescribeSceneAsset(scene.path); + } + + [CliCommand("save_all", "Save all open scenes that have unsaved changes.")] + public static object SaveAll() + { + GuardNotPlaying("save_all"); + + // SaveOpenScenes saves every loaded, dirty scene. A dirty untitled scene (empty path) would + // make it pop a modal "Save Scene" dialog, which hangs a headless editor. Fail fast with a + // recoverable error before saving anything so the caller saves that scene with an explicit path. + var dirty = OpenScenes().Where(s => s.isDirty).ToList(); + var untitled = dirty.Where(s => string.IsNullOrEmpty(s.path)).Select(s => s.name).ToList(); + if (untitled.Count > 0) + throw new InvalidOperationException( + $"Cannot save all: {untitled.Count} dirty scene(s) have never been saved ({string.Join(", ", untitled)}). " + + "Save each with an explicit path first (create_scene ) — no scenes were saved."); + + // Report which scenes it touched. + var dirtyBefore = dirty.Select(s => s.path).ToList(); + + var saved = EditorSceneManager.SaveOpenScenes(); + if (!saved && dirtyBefore.Count > 0) + throw new InvalidOperationException("Failed to save one or more open scenes."); + + AssetDatabase.Refresh(); + return new { saved, scenes = dirtyBefore }; + } + + #endregion + + #region Inspect / Active + + [CliCommand("list_open_scenes", "List all currently open scenes with their load/active/dirty state.")] + public static object ListOpenScenes() + { + // Read-only; safe in play mode, so no guard here. + var active = SceneManager.GetActiveScene(); + var scenes = OpenScenes() + .Select(s => new + { + name = s.name, + path = s.path, + isLoaded = s.isLoaded, + isDirty = s.isDirty, + isActive = s.handle == active.handle, + rootCount = s.isLoaded ? s.rootCount : 0 + }) + .ToList(); + + return new { count = scenes.Count, scenes }; + } + + [CliCommand("set_active_scene", "Set which open scene is the active scene (new objects are created in the active scene).")] + public static AuthoringResult SetActiveScene( + [CliArg("path", "Path of an already-open scene to make active (authoring-root relative; Assets/ prefix and .unity optional).", Required = true)] string path) + { + GuardNotPlaying("set_active_scene"); + + var normalized = ResolveScenePath(path); + var scene = FindOpenScene(normalized); + if (!scene.IsValid() || !scene.isLoaded) + throw new InvalidOperationException($"Scene '{path}' is not open; open it before making it active."); + + if (!SceneManager.SetActiveScene(scene)) + throw new InvalidOperationException($"Failed to set '{normalized}' as the active scene."); + + return DescribeSceneAsset(scene.path); + } + + [CliCommand("get_scene_hierarchy", "Return the GameObject tree of an open scene (or the active scene). Each node carries instanceId + hierarchyPath usable by GameObject commands.")] + public static SceneHierarchy GetSceneHierarchy( + [CliArg("path", "Path of the open scene to snapshot (authoring-root relative; Assets/ prefix and .unity optional). Omit for the active scene.")] string path = null) + { + // Read-only; safe in play mode. + var scene = string.IsNullOrEmpty(path) + ? SceneManager.GetActiveScene() + : FindOpenScene(ResolveScenePath(path)); + + if (!scene.IsValid() || !scene.isLoaded) + throw new InvalidOperationException( + string.IsNullOrEmpty(path) + ? "No valid active scene to read." + : $"Scene '{path}' is not open."); + + var active = SceneManager.GetActiveScene(); + var hierarchy = new SceneHierarchy + { + SceneName = scene.name, + ScenePath = scene.path, + IsDirty = scene.isDirty, + IsActive = scene.handle == active.handle + }; + + foreach (var root in scene.GetRootGameObjects()) + hierarchy.Roots.Add(BuildNode(root, "/" + root.name)); + + return hierarchy; + } + + #endregion + + #region Build settings + + [CliCommand("add_scene_to_build", "Add a scene to the Build Settings scene list (idempotent). Optionally enable it.")] + public static object AddSceneToBuild( + [CliArg("path", "Scene path to add (authoring-root relative; Assets/ prefix and .unity optional).", Required = true)] string path, + [CliArg("enabled", "Whether the scene is enabled in the build list.", DefaultValue = true)] bool enabled = true) + { + GuardNotPlaying("add_scene_to_build"); + + var normalized = ResolveScenePath(path); + if (string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(normalized))) + throw new InvalidOperationException($"No scene asset at '{normalized}'."); + + var scenes = EditorBuildSettings.scenes.ToList(); + var existing = scenes.FindIndex(s => PathsEqual(s.path, normalized)); + if (existing >= 0) + { + // Idempotent: just reconcile the enabled flag. + if (scenes[existing].enabled != enabled) + scenes[existing] = new EditorBuildSettingsScene(normalized, enabled); + } + else + { + scenes.Add(new EditorBuildSettingsScene(normalized, enabled)); + } + + EditorBuildSettings.scenes = scenes.ToArray(); + return new { path = normalized, enabled, buildIndex = SceneUtility.GetBuildIndexByScenePath(normalized), count = scenes.Count }; + } + + [CliCommand("remove_scene_from_build", "Remove a scene from the Build Settings scene list (idempotent).")] + public static object RemoveSceneFromBuild( + [CliArg("path", "Scene path to remove (authoring-root relative; Assets/ prefix and .unity optional).", Required = true)] string path) + { + GuardNotPlaying("remove_scene_from_build"); + + var normalized = ResolveScenePath(path); + var scenes = EditorBuildSettings.scenes.ToList(); + var removed = scenes.RemoveAll(s => PathsEqual(s.path, normalized)); + EditorBuildSettings.scenes = scenes.ToArray(); + return new { path = normalized, removed = removed > 0, count = scenes.Count }; + } + + #endregion + + #region Helpers + + /// + /// Refuse a mutating scene op while entering or in play mode. We deliberately throw a + /// recoverable *before* touching any state, so the + /// command surfaces as a clean failure rather than leaving the scene manager inconsistent. + /// + private static void GuardNotPlaying(string command) + { + if (EditorApplication.isPlayingOrWillChangePlaymode) + throw new InvalidOperationException( + $"'{command}' cannot run while the editor is in (or entering) play mode. " + + "Exit play mode (editor_stop) and retry — no scene state was changed."); + } + + /// + /// Map the create_scene template argument to a . "empty" + /// (or a null/blank value) keeps the historical blank-scene behavior; "default" seeds Unity's + /// built-in "3D" template (Main Camera tagged MainCamera + Directional Light). An unknown value + /// throws a recoverable listing the valid options. + /// + private static NewSceneSetup ResolveSceneSetup(string template) + { + // Treat a null/blank template as the default ("empty") for parity with an omitted argument. + var value = string.IsNullOrWhiteSpace(template) ? TemplateEmpty : template.Trim(); + + if (string.Equals(value, TemplateEmpty, StringComparison.OrdinalIgnoreCase)) + return NewSceneSetup.EmptyScene; + if (string.Equals(value, TemplateDefault, StringComparison.OrdinalIgnoreCase)) + return NewSceneSetup.DefaultGameObjects; + + throw new ArgumentException( + $"Unknown template '{template}'. Valid values: '{TemplateEmpty}', '{TemplateDefault}'."); + } + + /// Resolve an agent path through the sandbox and normalize it to a "*.unity" asset path. + private static string ResolveScenePath(string path) + { + var normalized = ProjectPaths.Resolve(path, out var error); + if (normalized == null) + throw new ArgumentException(error); + + if (!normalized.EndsWith(SceneExtension, StringComparison.OrdinalIgnoreCase)) + normalized += SceneExtension; + + return normalized; + } + + /// Create the scene's parent folder chain if missing (mirrors FolderCommands). + private static void EnsureParentFolder(string scenePath) + { + var parent = Path.GetDirectoryName(scenePath)?.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); + } + + /// Build the canonical identity envelope for a scene asset path. + private static AuthoringResult DescribeSceneAsset(string scenePath) + { + var asset = AssetDatabase.LoadAssetAtPath(scenePath); + var result = ObjectResolver.Describe(asset) ?? new AuthoringResult { Type = nameof(SceneAsset) }; + result.AssetPath = scenePath; + return result; + } + + /// Enumerate every open scene (loaded or not) by index. + private static IEnumerable OpenScenes() + { + for (int i = 0; i < SceneManager.sceneCount; i++) + yield return SceneManager.GetSceneAt(i); + } + + /// Find an open scene by its (normalized) asset path. Returns an invalid Scene when not open. + private static Scene FindOpenScene(string normalizedPath) + { + foreach (var scene in OpenScenes()) + { + if (PathsEqual(scene.path, normalizedPath)) + return scene; + } + + return default; + } + + private static bool PathsEqual(string a, string b) => + string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + + private static SceneHierarchyNode BuildNode(GameObject go, string hierarchyPath) + { + var node = new SceneHierarchyNode + { + Name = go.name, + InstanceId = PipelineUtils.GetObjectId(go), + HierarchyPath = hierarchyPath, + ActiveSelf = go.activeSelf, + Components = go.GetComponents() + // A missing/broken script serializes as a null component; skip it rather than NRE. + .Where(c => c != null) + .Select(c => c.GetType().Name) + .ToList() + }; + + var transform = go.transform; + for (int i = 0; i < transform.childCount; i++) + { + var child = transform.GetChild(i).gameObject; + node.Children.Add(BuildNode(child, hierarchyPath + "/" + child.name)); + } + + return node; + } + + #endregion + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scenes/SceneCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scenes/SceneCommands.cs.meta new file mode 100644 index 00000000..94e4e995 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scenes/SceneCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2c7b859adf794e05969586b070d0ab79 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scenes/SceneHierarchy.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scenes/SceneHierarchy.cs new file mode 100644 index 00000000..c83adbca --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scenes/SceneHierarchy.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Unity.Pipeline.Editor.Commands.Scenes +{ + /// + /// Serializable snapshot of an open scene's GameObject tree, returned by + /// get_scene_hierarchy. + /// + /// WHY a dedicated tree type (rather than reusing ): + /// the hierarchy is meant to be the *input* to GameObject-targeting commands. Every node carries + /// a stable and a , + /// either of which is directly accepted by + /// / . + /// That lets an agent fetch the tree once and then address any node without a second round-trip. + /// The shape is kept intentionally small and JSON-friendly (no UnityEngine references) so it + /// survives the HTTP boundary cleanly. + /// + [Serializable] + public class SceneHierarchy + { + /// Scene name (the file name without extension for a saved scene). + [JsonProperty("sceneName")] + public string SceneName { get; set; } + + /// Project-relative scene asset path, or empty for an unsaved scene. + [JsonProperty("scenePath")] + public string ScenePath { get; set; } + + /// Whether this scene currently has unsaved changes. + [JsonProperty("isDirty")] + public bool IsDirty { get; set; } + + /// Whether this scene is the active scene. + [JsonProperty("isActive")] + public bool IsActive { get; set; } + + /// Root GameObjects of the scene, each with their descendants. + [JsonProperty("roots")] + public List Roots { get; set; } = new List(); + } + + /// + /// One GameObject node in a , with enough identity to be used as an + /// handle (instanceId or hierarchyPath) by + /// follow-up GameObject commands. + /// + [Serializable] + public class SceneHierarchyNode + { + /// GameObject name. + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// Runtime instance id of the GameObject. Stable for the lifetime of the loaded scene and + /// directly usable as ObjectRef.instanceId. + /// + [JsonProperty("instanceId")] + public ObjectId InstanceId { get; set; } + + /// + /// Slash-delimited scene path (e.g. "/Root/Child/Leaf"), directly usable as + /// ObjectRef.hierarchyPath. + /// + [JsonProperty("hierarchyPath")] + public string HierarchyPath { get; set; } + + /// Whether the GameObject is active in the hierarchy. + [JsonProperty("activeSelf")] + public bool ActiveSelf { get; set; } + + /// Component type names attached to this GameObject (e.g. "Transform", "Camera"). + [JsonProperty("components")] + public List Components { get; set; } = new List(); + + /// Child nodes. + [JsonProperty("children")] + public List Children { get; set; } = new List(); + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scenes/SceneHierarchy.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scenes/SceneHierarchy.cs.meta new file mode 100644 index 00000000..5c3d8203 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scenes/SceneHierarchy.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a93c6751c9564baca2addf1deb479da4 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ScreenshotCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ScreenshotCommand.cs new file mode 100644 index 00000000..3b60fd8b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ScreenshotCommand.cs @@ -0,0 +1,198 @@ +using System; +using System.Globalization; +using System.IO; +using Newtonsoft.Json; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands +{ + /// + /// Captures the Scene or Game view of a running Editor as a PNG and returns the file path. + /// Backs the unity screenshot CLI command (CLI-112): the CLI only forwards the request + /// via unity request screenshot — all capture logic lives here in the package, per the + /// two-commands design. + /// + /// Capture renders the relevant view camera into an offscreen RenderTexture and reads it back, + /// rather than using ScreenCapture.CaptureScreenshot. CaptureScreenshot only targets the Game + /// view and writes asynchronously on the next frame — a single request/response can't reliably + /// wait for that, and it does nothing for the Scene view in edit mode. Camera.Render() into a + /// RenderTexture is synchronous, needs no play mode, and handles both views identically. + /// + public static class ScreenshotCommand + { + const int k_DefaultWidth = 1920; + const int k_DefaultHeight = 1080; + + [CliCommand("screenshot", "Capture the Scene or Game view as a PNG and return its file path", MainThreadRequired = true)] + public static ScreenshotResponse CaptureScreenshot( + [CliArg("view", "Which view to capture: 'game' (default) or 'scene'")] string view = "game", + [CliArg("output", "Output PNG path (absolute, or relative to the project root). Defaults to a timestamped file under /Temp/pipeline-screenshots/.")] string output = "", + [CliArg("width", "Output width in pixels. 0 (default) uses the view camera's current width.")] int width = 0, + [CliArg("height", "Output height in pixels. 0 (default) uses the view camera's current height.")] int height = 0) + { + var normalizedView = string.IsNullOrWhiteSpace(view) ? "game" : view.Trim().ToLowerInvariant(); + if (normalizedView != "game" && normalizedView != "scene") + return ScreenshotResponse.Fail($"Invalid view '{view}'. Expected 'game' or 'scene'."); + + if (width < 0 || height < 0) + return ScreenshotResponse.Fail("width and height must be >= 0 (0 = use the view's current size)."); + + var camera = ResolveCamera(normalizedView, out var resolveError); + if (camera == null) + return ScreenshotResponse.Fail(resolveError); + + // Default the size to the camera's current pixel size, falling back to 1080p when the + // camera reports nothing useful (can happen for an off-screen scene camera). + var w = width > 0 ? width : camera.pixelWidth; + var h = height > 0 ? height : camera.pixelHeight; + if (w <= 1) w = k_DefaultWidth; + if (h <= 1) h = k_DefaultHeight; + + var path = ResolveOutputPath(output, normalizedView); + + try + { + var png = RenderToPng(camera, w, h); + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + File.WriteAllBytes(path, png); + } + catch (Exception ex) + { + return ScreenshotResponse.Fail($"Failed to capture {normalizedView} view: {ex.Message}"); + } + + return new ScreenshotResponse + { + Success = true, + Path = path, + View = normalizedView, + Width = w, + Height = h, + Message = $"Captured {normalizedView} view to {path}" + }; + } + + /// + /// Resolve the camera to render for the requested view, or null with an explanatory error. + /// + static Camera ResolveCamera(string view, out string error) + { + error = null; + + if (view == "scene") + { + var sv = SceneView.lastActiveSceneView; + if (sv == null || sv.camera == null) + { + error = "No active Scene view to capture. Open a Scene view window and try again."; + return null; + } + return sv.camera; + } + + // Game view: prefer the main camera, otherwise the first enabled camera in the open scenes. + var cam = Camera.main; + if (cam == null && Camera.allCamerasCount > 0) + { + var all = Camera.allCameras; + if (all.Length > 0) + cam = all[0]; + } + + if (cam == null) + error = "No camera found to capture the Game view (no MainCamera and no enabled cameras in the open scenes)."; + + return cam; + } + + /// + /// Render into an offscreen RenderTexture at the given size and + /// encode it to PNG. Restores the camera's previous target and the active RenderTexture so + /// the capture leaves no side effects on the live editor. + /// + static byte[] RenderToPng(Camera camera, int width, int height) + { + var rt = new RenderTexture(width, height, 24); + var prevTarget = camera.targetTexture; + var prevActive = RenderTexture.active; + Texture2D tex = null; + + try + { + camera.targetTexture = rt; + camera.Render(); + + RenderTexture.active = rt; + tex = new Texture2D(width, height, TextureFormat.RGB24, false); + tex.ReadPixels(new Rect(0, 0, width, height), 0, 0); + tex.Apply(); + + return tex.EncodeToPNG(); + } + finally + { + camera.targetTexture = prevTarget; + RenderTexture.active = prevActive; + rt.Release(); + UnityEngine.Object.DestroyImmediate(rt); + if (tex != null) + UnityEngine.Object.DestroyImmediate(tex); + } + } + + /// + /// Resolve the output path: an explicit path is used as-is (rooted) or relative to the + /// project root; an empty path defaults to a timestamped file under Temp. + /// + static string ResolveOutputPath(string output, string view) + { + var projectRoot = Path.GetDirectoryName(Application.dataPath); + + if (!string.IsNullOrWhiteSpace(output)) + { + return Path.IsPathRooted(output) + ? output + : Path.GetFullPath(Path.Combine(projectRoot, output)); + } + + var stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff", CultureInfo.InvariantCulture); + return Path.Combine(projectRoot, "Temp", "pipeline-screenshots", $"screenshot_{view}_{stamp}.png"); + } + } + + /// + /// Result of the screenshot command. Serialized to JSON for the CLI's --format json + /// output; the human formatter reads . + /// + [Serializable] + public class ScreenshotResponse + { + [JsonProperty("success")] + public bool Success { get; set; } + + [JsonProperty("path")] + public string Path { get; set; } + + [JsonProperty("view")] + public string View { get; set; } + + [JsonProperty("width")] + public int Width { get; set; } + + [JsonProperty("height")] + public int Height { get; set; } + + [JsonProperty("message")] + public string Message { get; set; } + + public static ScreenshotResponse Fail(string message) => new ScreenshotResponse + { + Success = false, + Message = message + }; + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ScreenshotCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ScreenshotCommand.cs.meta new file mode 100644 index 00000000..1dcce16f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/ScreenshotCommand.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ee6b23eed8dc4ff096cf01b461985fcc diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts.meta new file mode 100644 index 00000000..5c91cfb9 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c4d83f412a414f419f97bac28b65f25b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/AttachScriptCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/AttachScriptCommand.cs new file mode 100644 index 00000000..01f1461d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/AttachScriptCommand.cs @@ -0,0 +1,138 @@ +using System; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace Unity.Pipeline.Editor.Commands.Scripts +{ + /// + /// Authoring command that adds a MonoBehaviour component to a GameObject, addressing the script + /// either by its (compiled) type name OR by its source asset path (CLI-195, CLI-224). + /// + /// COMPILE-AWARE: the type must already be compiled. A script created via create_script does not + /// have a compiled until a domain reload runs, so attaching it before that + /// fails with a CLEAR, RECOVERABLE error (no crash, no silent no-op) telling the agent to + /// recompile and retry. The full flow is: + /// create_script -> recompile -> poll recompile_status (completed/up_to_date) -> attach_script. + /// + /// Addressing by asset path resolves the backing class through + /// , which correctly handles a class whose name differs from + /// the filename. A path whose MonoScript has no class yet (not compiled, or no single matching + /// top-level type) surfaces the same recoverable "not yet compiled / ambiguous" error. + /// + /// The component add is wrapped in an and registered with the + /// Undo system so it reverts as one step and the owning object/prefab is marked dirty. + /// + public static class AttachScriptCommand + { + [CliCommand("attach_script", + "Add a MonoBehaviour to a GameObject by its (compiled) type name OR by its script asset path. " + + "Provide exactly one of 'type' or 'script'. " + + "If the type isn't compiled yet, returns a recoverable error: recompile, poll recompile_status, then retry.")] + public static AuthoringResult AttachScript( + [CliArg("target", "Reference to the GameObject to add the component to (globalId/path/guid/instanceId/hierarchyPath).", Required = true)] ObjectRef target, + [CliArg("type", "Component type name to add, e.g. PlayerController or Game.Player.PlayerController. Must already be compiled. Mutually exclusive with 'script'.")] string type = null, + [CliArg("script", "Script asset path, e.g. 'Assets/Pool/Scripts/CueShooter.cs'. The backing class is resolved via MonoScript.GetClass(), so the class name may differ from the filename. Mutually exclusive with 'type'.")] string script = null) + { + if (target == null || target.IsEmpty) + throw new ArgumentException("attach_script 'target' is required."); + + // Exactly one addressing form must be supplied. Both is ambiguous; neither is incomplete. + var hasType = !string.IsNullOrWhiteSpace(type); + var hasScript = !string.IsNullOrWhiteSpace(script); + if (hasType && hasScript) + throw new ArgumentException( + "Provide either 'type' (class name) or 'script' (asset path), not both."); + if (!hasType && !hasScript) + throw new ArgumentException( + "Provide either 'type' (class name) or 'script' (asset path)."); + + if (!ObjectResolver.TryResolve(target, out var obj, out var resolveError)) + throw new ArgumentException($"Could not resolve target: {resolveError}"); + + var go = obj as GameObject ?? (obj as Component)?.gameObject; + if (go == null) + throw new ArgumentException( + $"Target '{target}' resolved to a {obj.GetType().Name}, which is not a GameObject. " + + "attach_script needs a GameObject."); + + // Resolve the component Type from whichever addressing form was given. A type that exists but + // isn't compiled yet (or is the wrong kind) surfaces as InvalidOperationException so the + // server returns a 400 "Command Execution Failed" with the recovery instructions intact. + // ArgumentException is reserved for caller input mistakes — both/neither args, or a script + // path that points at no asset — which the server reports as "Parameter Validation Failed". + var componentType = hasScript + ? ResolveTypeFromScriptPath(script) + : ResolveTypeFromName(type); + + using (new AuthoringUndoScope($"Attach {componentType.Name}")) + { + // Undo.AddComponent registers the add for undo and returns the new component. + var component = Undo.AddComponent(go, componentType); + if (component == null) + throw new InvalidOperationException( + $"Failed to add component '{componentType.FullName}' to '{go.name}'. " + + "It may conflict with [DisallowMultipleComponent] or a required-component rule."); + + EditorUtility.SetDirty(go); + + var result = ObjectResolver.Describe(component) ?? new AuthoringResult { Type = componentType.Name }; + return result; + } + } + + /// + /// Resolve a component type by its compiled type name. A missing type is the expected, + /// recoverable "created but not yet compiled" case. + /// + private static Type ResolveTypeFromName(string type) + { + if (!ScriptTypeResolver.TryResolveComponentType(type, out var componentType, out var typeError)) + throw new InvalidOperationException(typeError); + + return componentType; + } + + /// + /// Resolve a component type from a script asset path. Loads the at the + /// path and resolves its backing class via — which correctly + /// handles a class whose name differs from the filename. Validates the class is a concrete + /// MonoBehaviour. A null class is the recoverable "not yet compiled / ambiguous" case and + /// mirrors the type-not-found message style. A path that resolves to no MonoScript asset is a + /// caller input mistake (ArgumentException → "Parameter Validation Failed"), not a recompile- + /// recoverable failure. + /// + private static Type ResolveTypeFromScriptPath(string path) + { + var trimmed = path.Trim(); + + var mono = AssetDatabase.LoadAssetAtPath(trimmed); + if (mono == null) + throw new ArgumentException( + $"No MonoScript at '{trimmed}'. Check the asset path (it should be a project-relative " + + "path to a .cs file, e.g. 'Assets/Scripts/PlayerController.cs')."); + + var cls = mono.GetClass(); + if (cls == null) + throw new InvalidOperationException( + $"Script at '{trimmed}' has no resolvable class. " + + "If you just created or edited this script it is not compiled yet: run 'recompile', poll " + + "'recompile_status' until it reports completed/up_to_date, then retry attach_script. " + + "If it should already exist, ensure the file has a single top-level type matching the " + + "script and that it compiled without errors."); + + if (!typeof(MonoBehaviour).IsAssignableFrom(cls)) + throw new InvalidOperationException( + $"Type '{cls.FullName}' (from '{trimmed}') does not derive from MonoBehaviour and cannot be added as a component."); + + if (cls.IsAbstract) + throw new InvalidOperationException( + $"Type '{cls.FullName}' (from '{trimmed}') is abstract and cannot be instantiated as a component."); + + return cls; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/AttachScriptCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/AttachScriptCommand.cs.meta new file mode 100644 index 00000000..2c4aca08 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/AttachScriptCommand.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 488a53ecbf5c4984bdc009bf1f2f9a45 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/CreateScriptCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/CreateScriptCommand.cs new file mode 100644 index 00000000..9d092698 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/CreateScriptCommand.cs @@ -0,0 +1,163 @@ +using System; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; + +namespace Unity.Pipeline.Editor.Commands.Scripts +{ + /// + /// Authoring command that writes a new C# MonoBehaviour (or other base-class) script from a + /// template into the project under the authoring root (CLI-195). + /// + /// IMPORTANT — the compile/domain-reload boundary: + /// Writing a .cs file does NOT make the type available. Unity must import and compile the new + /// file, which triggers a domain reload, before the type exists and can be attached. The agent + /// workflow is therefore: + /// 1. create_script (this command — writes the file) + /// 2. recompile (triggers compilation; see ) + /// 3. poll recompile_status (wait until "completed" / "up_to_date") + /// 4. attach_script (now the type exists; see ) + /// This command intentionally does its part only: it creates the file and returns the asset + /// identity. It does not trigger a recompile itself — the agent owns that step so it can batch + /// multiple authoring writes before paying the domain-reload cost once. + /// + public static class CreateScriptCommand + { + /// + /// Default class body (the Start/Update stubs) written inside the generated class, matching + /// Unity's own new-MonoBehaviour template so the output reads like a hand-authored script. + /// wraps it with the using/class declaration and an optional namespace. + /// + private const string DefaultBody = + " // Use this for initialization\n" + + " void Start()\n" + + " {\n\n" + + " }\n\n" + + " // Update is called once per frame\n" + + " void Update()\n" + + " {\n\n" + + " }\n"; + + [CliCommand("create_script", + "Create a new C# script (default base class MonoBehaviour) from a template under the authoring root. " + + "NOTE: the type does not exist until a recompile completes — to attach it, call recompile, poll recompile_status, then attach_script.")] + public static AuthoringResult CreateScript( + [CliArg("name", "Class/file name without extension, e.g. PlayerController. Must be a valid C# identifier.", Required = true)] string name, + [CliArg("path", "Folder (relative to the authoring root; the Assets/ prefix is optional) to write the .cs into. Defaults to the authoring root.")] string path = null, + [CliArg("namespace", "Optional namespace to wrap the class in. Omit for the global namespace.")] string @namespace = null, + [CliArg("base_class", "Base class to derive from. Defaults to MonoBehaviour.")] string baseClass = "MonoBehaviour", + [CliArg("overwrite", "Overwrite the file if it already exists. Defaults to false (an existing file is an error).")] bool overwrite = false) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Script 'name' is required."); + + var className = name.Trim(); + if (className.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + className = className.Substring(0, className.Length - 3); + + if (!IsValidIdentifier(className)) + throw new ArgumentException($"Script name '{className}' is not a valid C# class identifier."); + + if (string.IsNullOrWhiteSpace(baseClass)) + baseClass = "MonoBehaviour"; + + // Resolve & sandbox the destination folder through the project-path policy. A null/empty + // path means "the authoring root itself" — Resolve treats an empty path as an error, so + // fall back to the configured root in that case (it is already a confined, valid path). + string folder; + if (string.IsNullOrWhiteSpace(path)) + { + folder = ProjectPaths.AuthoringRoot; + } + else + { + folder = ProjectPaths.Resolve(path, out var error); + if (folder == null) + throw new ArgumentException(error); + } + + if (!AssetDatabase.IsValidFolder(folder)) + throw new ArgumentException( + $"Destination folder '{folder}' does not exist. Create it first with create_folder."); + + var assetPath = $"{folder}/{className}.cs"; + if (File.Exists(ToAbsolute(assetPath)) && !overwrite) + throw new ArgumentException( + $"A script already exists at '{assetPath}'. Pass overwrite=true to replace it."); + + var source = BuildSource(className, @namespace, baseClass); + + // Write through the filesystem then import: AssetDatabase has no "create text asset" API, + // and CreateAsset is for UnityEngine.Object instances, not source files. + File.WriteAllText(ToAbsolute(assetPath), source, new UTF8Encoding(false)); + AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate); + + // The MonoScript may already be loadable (its asset entry exists), but its compiled Type + // will not exist until the next domain reload — see the class doc. + var script = AssetDatabase.LoadAssetAtPath(assetPath); + var result = ObjectResolver.Describe(script) ?? new AuthoringResult { Type = "MonoScript" }; + result.AssetPath = assetPath; + return result; + } + + /// + /// Build the script source from the template, substituting the class name, optional namespace + /// and base class. When a namespace is supplied the class body is indented one extra level. + /// + private static string BuildSource(string className, string @namespace, string baseClass) + { + var usings = "using UnityEngine;\n\n"; + var classDecl = $"public class {className} : {baseClass}\n"; + + var sb = new StringBuilder(); + sb.Append(usings); + + if (!string.IsNullOrWhiteSpace(@namespace)) + { + sb.Append($"namespace {@namespace}\n{{\n"); + sb.Append(Indent(classDecl, " ")); + sb.Append(" {\n"); + sb.Append(Indent(DefaultBody, " ")); + sb.Append(" }\n"); + sb.Append("}\n"); + } + else + { + sb.Append(classDecl); + sb.Append("{\n"); + sb.Append(DefaultBody); + sb.Append("}\n"); + } + + return sb.ToString(); + } + + private static string Indent(string text, string prefix) + { + var sb = new StringBuilder(); + foreach (var line in text.Split('\n')) + sb.Append(line.Length == 0 ? "\n" : prefix + line + "\n"); + // Split adds a trailing empty element for a trailing newline; trim the extra newline we + // appended for it. + if (text.EndsWith("\n") && sb.Length > 0) + sb.Length -= 1; + return sb.ToString(); + } + + private static bool IsValidIdentifier(string value) + { + // A C# identifier: starts with a letter or underscore, then letters/digits/underscores. + return Regex.IsMatch(value, @"^[A-Za-z_][A-Za-z0-9_]*$"); + } + + private static string ToAbsolute(string assetPath) + { + // assetPath is project-relative ("Assets/..."); ProjectRoot is the folder containing Assets/. + return Path.Combine(ProjectPaths.ProjectRoot, assetPath).Replace('\\', '/'); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/CreateScriptCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/CreateScriptCommand.cs.meta new file mode 100644 index 00000000..8884c31e --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/CreateScriptCommand.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 34d285de0757475796fcecc889996421 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/ScriptTypeResolver.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/ScriptTypeResolver.cs new file mode 100644 index 00000000..bae855a8 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/ScriptTypeResolver.cs @@ -0,0 +1,161 @@ +using System; +using System.Linq; +using System.Reflection; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands.Scripts +{ + /// + /// Resolves an agent-supplied type name (or a reference) to a compiled + /// , searching the currently-loaded assemblies. + /// + /// This is the heart of the compile-aware behaviour for CLI-195: a script that was just created + /// has no compiled type until a domain reload runs, so resolution here will fail until the agent + /// recompiles. Callers translate that failure into a clear, recoverable error rather than a crash. + /// + public static class ScriptTypeResolver + { + /// + /// Try to resolve a MonoBehaviour-derived type by its name. Accepts either a bare class name + /// ("PlayerController") or a fully-qualified name ("Game.Player.PlayerController"). When a + /// bare name is ambiguous across namespaces the first match wins; callers should prefer the + /// fully-qualified name. Returns false with a human-readable when no + /// loaded type matches (typically: not yet compiled). + /// + public static bool TryResolveComponentType(string typeName, out Type type, out string error) + { + type = null; + error = null; + + if (string.IsNullOrWhiteSpace(typeName)) + { + error = "Type name is required."; + return false; + } + + var name = typeName.Trim(); + var match = FindType(name); + + if (match == null) + { + error = + $"Type '{name}' was not found in any loaded assembly. " + + "If you just created this script it is not compiled yet: run 'recompile', poll " + + "'recompile_status' until it reports completed/up_to_date, then retry attach_script. " + + "If it should already exist, check the class name (and namespace) and that the file compiled without errors."; + return false; + } + + if (!typeof(MonoBehaviour).IsAssignableFrom(match)) + { + error = $"Type '{match.FullName}' does not derive from MonoBehaviour and cannot be added as a component."; + return false; + } + + if (match.IsAbstract) + { + error = $"Type '{match.FullName}' is abstract and cannot be instantiated as a component."; + return false; + } + + type = match; + return true; + } + + /// + /// Resolve the compiled backing a asset reference + /// (e.g. one returned by create_script). Returns false when the script has no class yet + /// (uncompiled) or the asset is not a MonoScript. + /// + public static bool TryResolveFromMonoScript(MonoScript script, out Type type, out string error) + { + type = null; + error = null; + + if (script == null) + { + error = "MonoScript reference is null."; + return false; + } + + var scriptClass = script.GetClass(); + if (scriptClass == null) + { + error = + $"Script '{script.name}' has no compiled class. It is likely not compiled yet: run 'recompile', " + + "poll 'recompile_status', then retry."; + return false; + } + + type = scriptClass; + return true; + } + + /// + /// Search all loaded assemblies for a type by full name first, then by bare class name. Done + /// over rather than TypeCache so it also finds types in + /// non-Unity-managed assemblies and matches what Activator/AddComponent can actually load. + /// + private static Type FindType(string name) + { + var assemblies = PipelineUtils.GetLoadedAssemblies(); + + // 1. Fully-qualified name (exact). + foreach (var asm in assemblies) + { + var t = asm.GetType(name, throwOnError: false, ignoreCase: false); + if (t != null) + return t; + } + + // 2. Bare class name — match on Name across loaded types. GetTypes() is expensive, so skip + // dynamic and system/Unity assemblies (which never hold user MonoBehaviours). This mirrors + // CommandRegistry.IsSystemAssembly's predicate (replicated locally rather than shared so we + // don't widen that internal helper's surface). + foreach (var asm in assemblies) + { + if (IsSkippableAssembly(asm)) + continue; + + Type[] types; + try + { + types = asm.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + types = ex.Types.Where(t => t != null).ToArray(); + } + + var hit = types.FirstOrDefault(t => t != null && t.Name == name); + if (hit != null) + return hit; + } + + return null; + } + + /// + /// Assemblies we never need to scan for a user MonoBehaviour: dynamic (in-memory, can't be + /// reflected for types reliably) and core system/Unity assemblies. Mirrors the predicate in + /// CommandRegistry.IsSystemAssembly. + /// + private static bool IsSkippableAssembly(Assembly assembly) + { + if (assembly.IsDynamic) + return true; + + var name = assembly.GetName().Name; + if (string.IsNullOrEmpty(name)) + return false; + + return name.StartsWith("System.", StringComparison.Ordinal) || + name.StartsWith("Microsoft.", StringComparison.Ordinal) || + name.StartsWith("mscorlib", StringComparison.Ordinal) || + name.StartsWith("netstandard", StringComparison.Ordinal) || + name.Equals("UnityEngine", StringComparison.Ordinal) || + name.Equals("UnityEditor", StringComparison.Ordinal); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/ScriptTypeResolver.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/ScriptTypeResolver.cs.meta new file mode 100644 index 00000000..79f33389 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/ScriptTypeResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6b1131a6a27648b1829e78c8c0bd69a7 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/SerializedFieldCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/SerializedFieldCommands.cs new file mode 100644 index 00000000..f8b742bf --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/SerializedFieldCommands.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Newtonsoft.Json.Linq; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Editor.Commands.GameObjects; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace Unity.Pipeline.Editor.Commands.Scripts +{ + /// + /// Read and write serialized ([SerializeField] / public) fields on a component or asset through + /// Unity's model (CLI-195). + /// + /// Why SerializedObject and not plain reflection: it honours Unity's serialization rules (private + /// [SerializeField] fields, property drawers, prefab overrides), marks the object dirty correctly, + /// and integrates with Undo. Writes are wrapped in an so an + /// agent's change reverts as one step. + /// + /// Field addressing uses Unity's native SerializedProperty path syntax, so array elements are + /// reachable directly: "myArray.Array.data[2]" sets the third element; "myArray.Array.size" sets + /// the array length. Nested fields use dotted paths ("settings.speed"). + /// + public static class SerializedFieldCommands + { + [CliCommand("set_serialized_field", + "Set a serialized field on a component/asset. Supports primitives, enums, Vector/Color/Rect/Bounds, " + + "object references (value = an ObjectRef: asset by guid/fileId/path or scene object by instanceId/hierarchyPath), " + + "and array elements via 'name.Array.data[i]' (or 'name.Array.size' to resize).")] + public static AuthoringResult SetSerializedField( + [CliArg("target", "Reference to the component or asset to modify (globalId/path/guid/instanceId/hierarchyPath). May be a GameObject when 'component' is given.", Required = true)] ObjectRef target, + [CliArg("field", "SerializedProperty path, e.g. 'speed', 'settings.speed', or 'waypoints.Array.data[0]'.", Required = true)] string field, + [CliArg("value", "JSON value to assign. For object references pass an ObjectRef object (or null to clear). For enums pass the value name.", Required = true)] JToken value, + [CliArg("component", "Component type name on the target GameObject (e.g. 'Rigidbody'). Use when 'target' is a GameObject; omit when 'target' is already a component handle.")] string component = null) + { + if (target == null || target.IsEmpty) + throw new ArgumentException("set_serialized_field 'target' is required."); + if (string.IsNullOrWhiteSpace(field)) + throw new ArgumentException("set_serialized_field 'field' is required."); + + var obj = ResolveSerializable(target, component); + + using (new AuthoringUndoScope($"Set {field}")) + { + // RegisterCompleteObjectUndo captures the pre-change state for revert; the + // SerializedObject below then records the change for prefab/asset dirtying. + Undo.RegisterCompleteObjectUndo(obj, $"Set {field}"); + + var so = new SerializedObject(obj); + var prop = so.FindProperty(field); + if (prop == null) + throw new ArgumentException( + $"Field '{field}' was not found on '{obj.GetType().Name}'. " + + "Use a SerializedProperty path (e.g. 'speed', 'settings.speed', 'items.Array.data[0]')."); + + SerializedPropertyConverter.SetValue(prop, value); + so.ApplyModifiedProperties(); + EditorUtility.SetDirty(obj); + } + + var result = ObjectResolver.Describe(obj) ?? new AuthoringResult { Type = obj.GetType().Name }; + return result; + } + + [CliCommand("get_serialized_fields", + "Read serialized fields of a component/asset. Returns each top-level field's name, type and value " + + "(object references are returned as re-usable handles). Pass 'field' to read a single SerializedProperty path.")] + public static object GetSerializedFields( + [CliArg("target", "Reference to the component or asset to read (globalId/path/guid/instanceId/hierarchyPath). May be a GameObject when 'component' is given.", Required = true)] ObjectRef target, + [CliArg("field", "Optional single SerializedProperty path to read (e.g. 'speed' or 'items.Array.data[0]'). Omit to read all top-level fields.")] string field = null, + [CliArg("component", "Component type name on the target GameObject (e.g. 'Rigidbody'). Use when 'target' is a GameObject; omit when 'target' is already a component handle.")] string component = null) + { + if (target == null || target.IsEmpty) + throw new ArgumentException("get_serialized_fields 'target' is required."); + + var obj = ResolveSerializable(target, component); + var so = new SerializedObject(obj); + + if (!string.IsNullOrWhiteSpace(field)) + { + var prop = so.FindProperty(field); + if (prop == null) + throw new ArgumentException($"Field '{field}' was not found on '{obj.GetType().Name}'."); + + return new + { + type = obj.GetType().Name, + fields = new[] { DescribeProperty(prop) } + }; + } + + var fields = new List(); + var iterator = so.GetIterator(); + // enterChildren=true on the first MoveNext to step into the top level; then false to stay + // at the top level and skip nested children (callers drill in via an explicit 'field'). + var enterChildren = true; + while (iterator.NextVisible(enterChildren)) + { + enterChildren = false; + + // m_Script is Unity's hidden back-reference to the MonoScript; not a user field. + if (iterator.propertyPath == "m_Script") + continue; + + fields.Add(DescribeProperty(iterator)); + } + + return new { type = obj.GetType().Name, fields }; + } + + /// + /// Resolve a handle to an object that a SerializedObject can wrap: a Component or an asset. + /// + /// Two addressing forms (CLI-225): + /// * The handle already points at a Component (or asset) → use it directly. An optional + /// type name is validated against it as a guard. + /// * The handle points at a GameObject AND is given → look up + /// the matching component(s) on that GameObject. Exactly one match is used; MULTIPLE + /// same-type components are an error that lists each instanceId so the agent can re-address + /// by instanceId; zero matches is a clear error. + /// + /// A GameObject with no is rejected (a GameObject's "fields" are + /// its components), with a hint to pass --component or a specific component handle. + /// + /// This mirrors 's GO-path+type pattern, but + /// deliberately errors on MULTIPLE matches (that helper returns the first) so a set/get never + /// silently targets the wrong instance. + /// + private static Object ResolveSerializable(ObjectRef target, string component = null) + { + if (!ObjectResolver.TryResolve(target, out var obj, out var error)) + throw new ArgumentException($"Could not resolve target: {error}"); + + // The handle already points at a specific Component: honour it directly. A 'component' here + // is only a constraint to validate against, never a reason to re-fetch (which could pick a + // different same-type instance and ignore the handle). + if (obj is Component directComponent) + { + if (!string.IsNullOrWhiteSpace(component)) + { + var requestedType = TypeResolver.ResolveComponentType(component); + if (requestedType == null) + throw new ArgumentException($"Could not resolve component type '{component}'."); + if (!requestedType.IsInstanceOfType(directComponent)) + throw new ArgumentException( + $"Target resolves to a {directComponent.GetType().Name}, which is not a '{requestedType.Name}'."); + } + + return directComponent; + } + + if (obj is GameObject go) + { + if (string.IsNullOrWhiteSpace(component)) + throw new ArgumentException( + $"Target '{target}' is a GameObject. Pass --component to pick a component on it, " + + "or target a specific component directly (use its instanceId/globalId), " + + "to read or set serialized fields."); + + var componentType = TypeResolver.ResolveComponentType(component); + if (componentType == null) + throw new ArgumentException($"Could not resolve component type '{component}'."); + + var matches = go.GetComponents(componentType).Where(c => c != null).ToArray(); + if (matches.Length == 0) + throw new ArgumentException( + $"GameObject '{go.name}' has no component of type '{componentType.Name}'."); + + if (matches.Length > 1) + { + var sb = new StringBuilder(); + sb.Append($"GameObject '{go.name}' has {matches.Length} components of type '{componentType.Name}'. ") + .Append("Re-target a specific one by its instanceId: "); + for (int i = 0; i < matches.Length; i++) + { + if (i > 0) sb.Append(", "); + sb.Append($"instanceId {PipelineUtils.GetObjectId(matches[i])}"); + } + sb.Append('.'); + throw new ArgumentException(sb.ToString()); + } + + return matches[0]; + } + + // Not a Component, not a GameObject — an asset (e.g. ScriptableObject) handled directly. + // A 'component' filter is meaningless for an asset; reject it rather than silently ignore it + // (a supplied component here signals a misrouted or misspelled request). + if (!string.IsNullOrWhiteSpace(component)) + throw new ArgumentException( + $"Target '{target}' resolved to a {obj.GetType().Name} (an asset), which has no components. " + + "Omit --component when targeting an asset directly."); + + return obj; + } + + private static object DescribeProperty(SerializedProperty prop) + { + return new + { + name = prop.name, + path = prop.propertyPath, + propertyType = prop.propertyType.ToString(), + isArray = prop.isArray && prop.propertyType != SerializedPropertyType.String, + arrayLength = (prop.isArray && prop.propertyType != SerializedPropertyType.String) ? prop.arraySize : (int?)null, + value = SerializedPropertyConverter.GetValue(prop) + }; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/SerializedFieldCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/SerializedFieldCommands.cs.meta new file mode 100644 index 00000000..161e82f3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/SerializedFieldCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 974b8c0dcec74d388bbf2061fc3aa3dc diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/SerializedPropertyConverter.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/SerializedPropertyConverter.cs new file mode 100644 index 00000000..3e044207 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/SerializedPropertyConverter.cs @@ -0,0 +1,237 @@ +using System; +using Newtonsoft.Json.Linq; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace Unity.Pipeline.Editor.Commands.Scripts +{ + /// + /// Bridges agent-supplied JSON values and Unity's model for the + /// set_serialized_field / get_serialized_fields commands (CLI-195). + /// + /// Supported property kinds: integers, floats, bools, strings, enums, Vector2/3/4, Color, + /// Quaternion, Rect, Bounds, and object references. Object references accept an + /// handle (resolved via ) — assets by GUID/fileId or path, and scene + /// objects by instanceId/hierarchyPath. Arrays are addressed element-by-element via a path like + /// "myArray.Array.data[2]" (Unity's native SerializedProperty path syntax) — see the commands. + /// + public static class SerializedPropertyConverter + { + /// + /// Assign a JSON value to a serialized property, converting by the property's type. Throws + /// with a clear message when the value can't be coerced or the + /// property type isn't supported. The caller is responsible for ApplyModifiedProperties. + /// + public static void SetValue(SerializedProperty prop, JToken value) + { + // Whole-array assignment: an array/list field (Generic + isArray) set from a JSON array — + // resize, then recurse per element (handles primitive and object-reference arrays alike). + // Element-by-element addressing via "field.Array.data[i]" / "field.Array.size" still works. + if (prop.isArray && prop.propertyType != SerializedPropertyType.String) + { + if (!(value is JArray array)) + throw new ArgumentException( + $"Field '{prop.propertyPath}' is an array; provide a JSON array of elements."); + + prop.arraySize = array.Count; + for (int i = 0; i < array.Count; i++) + SetValue(prop.GetArrayElementAtIndex(i), array[i]); + return; + } + + switch (prop.propertyType) + { + case SerializedPropertyType.Integer: + prop.longValue = value.ToObject(); + break; + case SerializedPropertyType.Boolean: + prop.boolValue = value.ToObject(); + break; + case SerializedPropertyType.Float: + prop.doubleValue = value.ToObject(); + break; + case SerializedPropertyType.String: + prop.stringValue = value.ToObject(); + break; + case SerializedPropertyType.Enum: + SetEnum(prop, value); + break; + case SerializedPropertyType.Vector2: + prop.vector2Value = ToVector2(value); + break; + case SerializedPropertyType.Vector3: + prop.vector3Value = ToVector3(value); + break; + case SerializedPropertyType.Vector4: + prop.vector4Value = ToVector4(value); + break; + case SerializedPropertyType.Quaternion: + var v4 = ToVector4(value); + prop.quaternionValue = new Quaternion(v4.x, v4.y, v4.z, v4.w); + break; + case SerializedPropertyType.Color: + prop.colorValue = ToColor(value); + break; + case SerializedPropertyType.Rect: + prop.rectValue = ToRect(value); + break; + case SerializedPropertyType.Bounds: + prop.boundsValue = ToBounds(value); + break; + case SerializedPropertyType.ObjectReference: + prop.objectReferenceValue = ResolveObjectReference(value); + break; + case SerializedPropertyType.ArraySize: + // Resizing an array via ".Array.size" — the property is an int under the hood. + prop.intValue = value.ToObject(); + break; + default: + throw new ArgumentException( + $"Field '{prop.propertyPath}' has unsupported type '{prop.propertyType}'. " + + "Supported: int, float, bool, string, enum, Vector2/3/4, Quaternion, Color, Rect, Bounds, object reference, array size."); + } + } + + /// + /// Read a serialized property into a plain object suitable for JSON serialization. Object + /// references are described via so the agent gets a + /// re-usable handle; arrays are returned as a length plus per-element values. + /// + public static object GetValue(SerializedProperty prop) + { + // Array/list fields are returned as a JSON array of their elements so they round-trip with + // SetValue. (String also reports isArray, hence the exclusion.) + if (prop.isArray && prop.propertyType != SerializedPropertyType.String) + { + var elements = new JArray(); + for (int i = 0; i < prop.arraySize; i++) + { + var v = GetValue(prop.GetArrayElementAtIndex(i)); + elements.Add(v == null ? JValue.CreateNull() : JToken.FromObject(v)); + } + + return elements; + } + + switch (prop.propertyType) + { + case SerializedPropertyType.Integer: + return prop.longValue; + case SerializedPropertyType.Boolean: + return prop.boolValue; + case SerializedPropertyType.Float: + return prop.doubleValue; + case SerializedPropertyType.String: + return prop.stringValue; + case SerializedPropertyType.Enum: + return prop.enumValueIndex >= 0 && prop.enumValueIndex < prop.enumNames.Length + ? prop.enumNames[prop.enumValueIndex] + : (object)prop.intValue; + case SerializedPropertyType.Vector2: + return new { x = prop.vector2Value.x, y = prop.vector2Value.y }; + case SerializedPropertyType.Vector3: + return new { x = prop.vector3Value.x, y = prop.vector3Value.y, z = prop.vector3Value.z }; + case SerializedPropertyType.Vector4: + return new { x = prop.vector4Value.x, y = prop.vector4Value.y, z = prop.vector4Value.z, w = prop.vector4Value.w }; + case SerializedPropertyType.Quaternion: + return new { x = prop.quaternionValue.x, y = prop.quaternionValue.y, z = prop.quaternionValue.z, w = prop.quaternionValue.w }; + case SerializedPropertyType.Color: + return new { r = prop.colorValue.r, g = prop.colorValue.g, b = prop.colorValue.b, a = prop.colorValue.a }; + case SerializedPropertyType.Rect: + return new { x = prop.rectValue.x, y = prop.rectValue.y, width = prop.rectValue.width, height = prop.rectValue.height }; + case SerializedPropertyType.Bounds: + var b = prop.boundsValue; + return new { center = new { x = b.center.x, y = b.center.y, z = b.center.z }, size = new { x = b.size.x, y = b.size.y, z = b.size.z } }; + case SerializedPropertyType.ObjectReference: + return ObjectResolver.Describe(prop.objectReferenceValue); + case SerializedPropertyType.ArraySize: + return prop.intValue; + default: + // Unknown/unsupported types are reported by kind rather than failing a whole read. + return new { unsupported = prop.propertyType.ToString() }; + } + } + + private static void SetEnum(SerializedProperty prop, JToken value) + { + // Accept either the enum name (preferred, stable across reordering) or the numeric index. + if (value.Type == JTokenType.String) + { + var name = value.ToObject(); + var idx = Array.IndexOf(prop.enumNames, name); + if (idx < 0) + idx = Array.IndexOf(prop.enumDisplayNames, name); + if (idx < 0) + throw new ArgumentException( + $"Enum '{name}' is not a valid value for '{prop.propertyPath}'. Valid: {string.Join(", ", prop.enumNames)}."); + prop.enumValueIndex = idx; + } + else + { + var idx = value.ToObject(); + if (idx < 0 || idx >= prop.enumNames.Length) + throw new ArgumentException( + $"Enum index {idx} is out of range for '{prop.propertyPath}' (valid 0..{prop.enumNames.Length - 1}). " + + $"Valid values: {string.Join(", ", prop.enumNames)}."); + prop.enumValueIndex = idx; + } + } + + private static Object ResolveObjectReference(JToken value) + { + // An explicit null clears the reference; anything else MUST resolve — never silently no-op + // (an unresolved handle used to be dropped, so the command reported success while assigning + // nothing). + if (value == null || value.Type == JTokenType.Null) + return null; + + var handle = value.ToObject(); + if (handle == null || handle.IsEmpty) + throw new ArgumentException( + $"'{value}' is not a recognized object reference handle. " + + "Provide a path, guid, globalId, or instanceId (or null to clear)."); + + if (!ObjectResolver.TryResolve(handle, out var obj, out var error)) + throw new ArgumentException($"Could not resolve object reference: {error}"); + + return obj; + } + + private static float F(JToken t, string key, float fallback = 0f) + { + // Indexing a non-object token (e.g. the agent passed a number or array for a Vector3/Color) + // with a string key throws an opaque InvalidOperationException — validate first so the + // caller gets a clear, actionable ArgumentException instead. + if (!(t is JObject obj)) + throw new ArgumentException( + $"Expected a JSON object with named components (e.g. {{ \"{key}\": 0 }}) " + + $"but received a '{t?.Type.ToString() ?? "null"}'."); + + var token = obj[key]; + return token != null ? token.ToObject() : fallback; + } + + private static Vector2 ToVector2(JToken t) => new Vector2(F(t, "x"), F(t, "y")); + private static Vector3 ToVector3(JToken t) => new Vector3(F(t, "x"), F(t, "y"), F(t, "z")); + private static Vector4 ToVector4(JToken t) => new Vector4(F(t, "x"), F(t, "y"), F(t, "z"), F(t, "w")); + + private static Color ToColor(JToken t) => new Color(F(t, "r"), F(t, "g"), F(t, "b"), F(t, "a", 1f)); + + private static Rect ToRect(JToken t) => new Rect(F(t, "x"), F(t, "y"), F(t, "width"), F(t, "height")); + + private static Bounds ToBounds(JToken t) + { + if (!(t is JObject obj)) + throw new ArgumentException( + $"Expected a JSON object with 'center' and 'size' components but received a " + + $"'{t?.Type.ToString() ?? "null"}'."); + + var center = obj["center"] != null ? ToVector3(obj["center"]) : Vector3.zero; + var size = obj["size"] != null ? ToVector3(obj["size"]) : Vector3.zero; + return new Bounds(center, size); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/SerializedPropertyConverter.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/SerializedPropertyConverter.cs.meta new file mode 100644 index 00000000..35a6b3dc --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Scripts/SerializedPropertyConverter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ce4000d573a64d76a63e42b2b40c60a5 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/TestCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/TestCommands.cs new file mode 100644 index 00000000..88612ea8 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/TestCommands.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Testing; +using Unity.Pipeline.Models; +using UnityEditor.TestTools.TestRunner.Api; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands +{ + /// + /// Commands for running Unity tests programmatically + /// + public static class TestCommands + { + /// + /// Execute Unity tests with filtering options. + /// Synchronous by default (blocks until completion), async mode available with --async-tests flag. + /// + [CliCommand("run_tests", "Execute Unity tests with filtering options", MainThreadRequired = true)] + public static async Task RunTests( + [CliArg("mode", "Test mode: all, editor, playmode (default: all)")] string mode = "all", + [CliArg("filter", "Test name filter pattern (case-insensitive partial match)")] string filter = "", + [CliArg("filter_type", "Filter type: testName, assembly, category (default: testName)")] string filterType = "testName", + [CliArg("include_explicit", "Include tests marked with [Explicit] attribute")] bool includeExplicit = false, + [CliArg("async_tests", "Run asynchronously - return immediately, poll /test-status for results")] bool asyncTests = false, + [CliArg("timeout", "Test execution timeout in seconds (default: 300)")] int timeout = 300) + { + // Return the full structured response (including failures and the Error field on a + // failed run) rather than throwing: the server now awaits this Task and serializes the + // unwrapped response, so the client receives complete, structured reporting. Throwing + // would only surface an opaque message and discard the per-test results. + return await PipelineTestRunner.ExecuteTestsAsync( + mode, + filter, + filterType, + includeExplicit, + asyncTests, + timeout); + } + + /// + /// List all available tests without executing any of them. + /// Enumerates the test tree via TestRunnerApi.RetrieveTestList for the requested mode(s). + /// + [CliCommand("list_tests", "List all available tests (EditMode and/or PlayMode) without running them", MainThreadRequired = true)] + public static async Task ListTests( + [CliArg("mode", "Test mode: all, editor, playmode (default: all)")] string mode = "all") + { + try + { + if (!TryGetModes(mode, out var modes)) + { + return new TestListResponse + { + Success = false, + Command = "list_tests", + Error = $"Invalid mode '{mode}'. Use 'editor', 'playmode', or 'all'.", + ExecutedAt = DateTime.UtcNow + }; + } + + var tests = new List(); + foreach (var testMode in modes) + { + tests.AddRange(await RetrieveTestsAsync(testMode)); + } + + return new TestListResponse + { + Success = true, + Command = "list_tests", + Mode = modes.Count == 2 ? "All" : modes[0].ToString(), + Count = tests.Count, + Tests = tests, + Message = $"Found {tests.Count} test(s).", + ExecutedAt = DateTime.UtcNow + }; + } + catch (Exception ex) + { + Debug.LogError($"[TestCommands] list_tests failed: {ex.Message}"); + return new TestListResponse + { + Success = false, + Command = "list_tests", + Error = "Failed to list tests", + ErrorDetails = ex.ToString(), + ExecutedAt = DateTime.UtcNow + }; + } + } + + /// + /// Get current test status for async test execution + /// + [CliCommand("test_status", "Get status of running async test execution", MainThreadRequired = false)] + public static string GetTestStatus() + { + var status = PipelineTestRunner.GetTestStatus(); + return status ?? "{\"status\":\"no_tests\",\"message\":\"No test run in progress\"}"; + } + + /// + /// Cancel running test execution + /// + [CliCommand("cancel_tests", "Cancel running test execution", MainThreadRequired = true)] + public static object CancelTests() + { + return PipelineTestRunner.CancelTests(); + } + + /// + /// Resolve a mode string to the concrete TestMode(s) to enumerate. + /// + private static bool TryGetModes(string mode, out List modes) + { + switch (mode?.ToLowerInvariant()) + { + case null: + case "": + case "all": + modes = new List { TestMode.EditMode, TestMode.PlayMode }; + return true; + case "editor": + case "editmode": + modes = new List { TestMode.EditMode }; + return true; + case "playmode": + case "play": + modes = new List { TestMode.PlayMode }; + return true; + default: + modes = null; + return false; + } + } + + /// + /// Retrieve the test list for a single mode. RetrieveTestList enumerates the test tree (no + /// execution) and invokes the callback on the main thread; we bridge it to a Task. + /// + private static Task> RetrieveTestsAsync(TestMode testMode) + { + var api = ScriptableObject.CreateInstance(); + var tcs = new TaskCompletionSource>(); + + api.RetrieveTestList(testMode, root => + { + try + { + var items = new List(); + CollectLeafTests(root, testMode, items); + tcs.SetResult(items); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }); + + return tcs.Task; + } + + /// + /// Walk the test tree and collect leaf tests (actual test methods, not suites/fixtures). + /// + private static void CollectLeafTests(ITestAdaptor test, TestMode testMode, List items) + { + if (test == null) + return; + + if (!test.HasChildren && !test.IsSuite) + { + items.Add(new TestListItem + { + FullName = test.FullName, + Mode = testMode.ToString(), + Assembly = test.TypeInfo?.Assembly?.GetName()?.Name, + Categories = test.Categories?.ToList() ?? new List(), + Explicit = test.RunState == RunState.Explicit + }); + return; + } + + if (test.Children != null) + { + foreach (var child in test.Children) + CollectLeafTests(child, testMode, items); + } + } + } + + /// + /// Response for the list_tests command: the available tests, without execution results. + /// + [Serializable] + public class TestListResponse : CommandExecutionResponse + { + public string Mode { get; set; } // EditMode, PlayMode, or All + public int Count { get; set; } + public List Tests { get; set; } = new List(); + } + + /// + /// A single available test (no run state / outcome — this is a listing, not a result). + /// + [Serializable] + public class TestListItem + { + public string FullName { get; set; } + public string Mode { get; set; } + public string Assembly { get; set; } + public List Categories { get; set; } = new List(); + public bool Explicit { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/TestCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/TestCommands.cs.meta new file mode 100644 index 00000000..e43426e3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/TestCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 00cbe51faaafd2d488d88f72394a97d3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Console.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Console.meta new file mode 100644 index 00000000..6b510e36 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Console.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 98aa20a2d767cba16cc6922b9e5bb32b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Console/EditorConsoleCaptureBootstrap.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Console/EditorConsoleCaptureBootstrap.cs new file mode 100644 index 00000000..9fde3e5d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Console/EditorConsoleCaptureBootstrap.cs @@ -0,0 +1,43 @@ +using UnityEditor; +using Unity.Pipeline.Console; + +namespace Unity.Pipeline.Editor.Console +{ + /// + /// Editor-only bootstrap for . The capture itself (the shared + /// buffer and the log-callback subscription) lives in the runtime assembly so it also works in + /// player builds; this type adds the two things that only make sense in the Editor: + /// + /// - [InitializeOnLoad] starts capture on every editor load and after every domain reload. + /// - Persistence to a Temp file across domain reloads, so entries and the cursor survive a + /// recompile. Players have no domain reloads, so the runtime path skips persistence. + /// + /// The static constructor restores the persisted buffer first, then starts capture, so logs are + /// never appended ahead of a restore. + /// + [InitializeOnLoad] + public static class EditorConsoleCaptureBootstrap + { + // Lives under Temp/ like the recompile status file: cleared by Unity on project-level cleanup, + // survives domain reloads, and never committed. + internal const string PersistencePath = "Temp/pipeline_console_log.json"; + + static EditorConsoleCaptureBootstrap() + { + // Restore before capture starts so restored entries precede any newly captured ones. + ConsoleLogCapture.Buffer.Load(PersistencePath); + ConsoleLogCapture.EnsureCapturing(); + + AssemblyReloadEvents.beforeAssemblyReload -= Persist; + AssemblyReloadEvents.beforeAssemblyReload += Persist; + + EditorApplication.quitting -= Persist; + EditorApplication.quitting += Persist; + } + + static void Persist() + { + ConsoleLogCapture.Buffer.Save(PersistencePath); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Console/EditorConsoleCaptureBootstrap.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Console/EditorConsoleCaptureBootstrap.cs.meta new file mode 100644 index 00000000..623e9333 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Console/EditorConsoleCaptureBootstrap.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1fb443a79f9bd099f5894c23de206319 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineManager.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineManager.cs new file mode 100644 index 00000000..f5f1f433 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineManager.cs @@ -0,0 +1,74 @@ +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor +{ + /// + /// Inspectable configuration and control surface for the live editor pipeline server. This is a + /// settings asset, NOT the server's owner — owns the server + /// instance (a static owner survives domain reloads cleanly, whereas a ScriptableObject's + /// lifetime does not track the server across editor events). The owner reads this asset's config + /// when starting; the custom inspector drives Start/Stop through the owner and shows live status. + /// + /// The asset is optional: without it the owner uses the defaults below. "Pipeline/Settings" + /// creates it on demand. + /// + public class EditorPipelineManager : ScriptableObject + { + [Tooltip("HTTP port for the editor server. 0 = auto-assign from the 7800-7849 range. Applies on next start.")] + [SerializeField] private ushort m_Port = 0; + + [Tooltip("Start the server automatically when the editor loads. Applies on next editor load.")] + [SerializeField] private bool m_AutoStart = true; + + [Tooltip("Self-heal: if the HTTP listener dies without a Stop(), re-open it on a timer. " + + "Keeps auto-tick on so the editor keeps ticking while unfocused (required for the watchdog).")] + [SerializeField] private bool m_WatchdogEnabled = true; + + [Tooltip("How often the watchdog checks the listener, between 1 and 60 seconds.")] + [SerializeField] private int m_WatchdogIntervalSeconds = 5; + + [Tooltip("Log every command request/response (raw JSON) handled by the editor server to " + + "/Logs/pipeline.log. Editor only; applies live.")] + [SerializeField] private bool m_LogRequestsResponses = false; + + public int Port => m_Port; + public bool AutoStart => m_AutoStart; + public bool WatchdogEnabled => m_WatchdogEnabled; + public int WatchdogIntervalSeconds => m_WatchdogIntervalSeconds; + public bool LogRequestsResponses => m_LogRequestsResponses; + + /// Whether the live server (owned by PipelineServerStartup) is actually running. + public bool IsServerRunning => PipelineServerStartup.Server != null && PipelineServerStartup.Server.IsRunning; + + /// The port the live server is actually listening on, or 0 when stopped. + public int ActualPort => PipelineServerStartup.Server?.Port ?? 0; + + /// Start the live server (delegates to the static owner, which reads this config). + public void StartServer() => PipelineServerStartup.EnsureServerStarted(); + + /// Stop the live server. + public void StopServer() => PipelineServerStartup.StopServer(); + + /// Restart the live server. + public void RestartServer() => PipelineServerStartup.RestartServer(); + + /// + /// Load the single settings asset if one exists, otherwise null (the owner falls back to + /// defaults). No caching — assets are cheap to look up and this is only read at start/inspect. + /// + public static EditorPipelineManager Load() + { + var guids = AssetDatabase.FindAssets("t:EditorPipelineManager"); + if (guids.Length == 0) + return null; + return AssetDatabase.LoadAssetAtPath( + AssetDatabase.GUIDToAssetPath(guids[0])); + } + + private void OnValidate() + { + m_WatchdogIntervalSeconds = Mathf.Clamp(m_WatchdogIntervalSeconds, 1, 60); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineManager.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineManager.cs.meta new file mode 100644 index 00000000..1196d74a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9e6f601455c0e0549b841c969d30c2b7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineManagerEditor.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineManagerEditor.cs new file mode 100644 index 00000000..5ca7aede --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineManagerEditor.cs @@ -0,0 +1,63 @@ +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor +{ + /// + /// Custom inspector for the settings asset: shows live server + /// status (accurate running check + actual port, read from the static owner) and offers + /// Start/Stop/Restart buttons, alongside the editable configuration. Watchdog edits are pushed to + /// a running server immediately; port/autoStart apply on the next start. + /// + [CustomEditor(typeof(EditorPipelineManager))] + public class EditorPipelineManagerEditor : UnityEditor.Editor + { + public override void OnInspectorGUI() + { + var mgr = (EditorPipelineManager)target; + + EditorGUI.BeginChangeCheck(); + DrawDefaultInspector(); + if (EditorGUI.EndChangeCheck()) + { + // Push edited watchdog config to the live server (owned by PipelineServerStartup), so + // changes take effect without a restart. + var server = PipelineServerStartup.Server; + if (server != null) + { + server.WatchdogEnabled = mgr.WatchdogEnabled; + server.WatchdogIntervalSeconds = mgr.WatchdogIntervalSeconds; + server.LogRequestsResponses = mgr.LogRequestsResponses; + } + } + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Status", EditorStyles.boldLabel); + using (new EditorGUI.DisabledScope(true)) + { + EditorGUILayout.Toggle("Running", mgr.IsServerRunning); + EditorGUILayout.IntField("Actual Port", mgr.ActualPort); + } + + EditorGUILayout.Space(); + using (new EditorGUILayout.HorizontalScope()) + { + using (new EditorGUI.DisabledScope(mgr.IsServerRunning)) + { + if (GUILayout.Button("Start")) + mgr.StartServer(); + } + using (new EditorGUI.DisabledScope(!mgr.IsServerRunning)) + { + if (GUILayout.Button("Stop")) + mgr.StopServer(); + } + if (GUILayout.Button("Restart")) + mgr.RestartServer(); + } + + // Keep the live status display current while the inspector is open. + Repaint(); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineManagerEditor.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineManagerEditor.cs.meta new file mode 100644 index 00000000..9d89150b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineManagerEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8a6ccaaae4fdc6f429517e759dbad721 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineServer.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineServer.cs new file mode 100644 index 00000000..4268982f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineServer.cs @@ -0,0 +1,101 @@ +using System; +using Unity.Pipeline.Models; +using Unity.Pipeline.Security; +using UnityEngine; + +namespace Unity.Pipeline.Editor +{ + public class EditorPipelineServer : BasePipelineServer + { + private InstanceDescriptor m_InstanceDescriptor; + + /// When true, every command transaction is written to the project transaction log. + public bool LogRequestsResponses { get; set; } + + public override DateTime StartedAt => m_InstanceDescriptor == null ? new DateTime() : m_InstanceDescriptor.StartedAt; + + protected override void ServerStarted() + { + var isAutomated = false; + foreach (var arg in System.Environment.GetCommandLineArgs()) + { + if (arg == "-automated") + { + isAutomated = true; + break; + } + } + // Only warn in an interactive Editor: batchmode (CI, upm-pvp, UTR) can't show modal + // popups, so the warning is pure noise there. + if (!isAutomated && !Application.isBatchMode) + { + Debug.LogWarning("Editor is not in automated mode. Modal Pop up might break continuous command workflow. Start the editor with -automated"); + } + } + + protected override void OnTransactionProcessed(string requestJson, string responseJson) + { + if (!LogRequestsResponses) + return; + + PipelineTransactionLog.Append(requestJson, responseJson); + } + + // Runtime-only commands (eval, reload_file_override, …) are part of the Player surface; don't + // advertise them when a client is connected to the Editor. + protected override bool IncludeRuntimeOnlyCommands => false; + protected override void CreateInstanceDescriptor() + { + // Create and write instance descriptor for CLI discovery + m_InstanceDescriptor = InstanceDescriptor.CreateCurrent(Port); + InstanceDescriptor.WriteToProjectRoot(m_InstanceDescriptor); + } + + protected override void DeleteInstanceDescriptor() + { + // Clean up instance descriptor file + if (m_InstanceDescriptor != null) + { + InstanceDescriptor.RemoveFromProjectRoot(m_InstanceDescriptor.ProjectPath); + } + m_InstanceDescriptor = null; + } + + protected override void UpdateHeartBeat() + { + // Update heartbeat in instance descriptor + if (m_InstanceDescriptor != null) + { + // Keep the port file's token current for discovery only — GetToken() validates the + // live token, so this just catches up to a rotation on the next heartbeat. + m_InstanceDescriptor.EvalToken = SecurityTokenManager.GetOrCreateToken(); + m_InstanceDescriptor.LastHeartbeat = DateTime.UtcNow; + try + { + InstanceDescriptor.WriteToProjectRoot(m_InstanceDescriptor); + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to update instance descriptor: {ex.Message}"); + } + } + } + + protected override object GetServerStatus() + { + UpdateHeartBeat(); + return new + { + status = m_InstanceDescriptor == null ? "error" : "ready", + lastHeartbeat = m_InstanceDescriptor?.LastHeartbeat + }; + } + + protected override string GetToken() + { + // Validate the live token so a rotation/revocation takes effect on the next request (not + // the next heartbeat). The descriptor's EvalToken is discovery-only. + return SecurityTokenManager.GetOrCreateToken(); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineServer.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineServer.cs.meta new file mode 100644 index 00000000..8fb37eb3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineServer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 11ba9c53c356c2c469bc37d0d370db29 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineStartup.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineStartup.cs new file mode 100644 index 00000000..68c3b86e --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineStartup.cs @@ -0,0 +1,267 @@ +using UnityEditor; +using UnityEngine; +using Unity.Pipeline.Models; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Threading; +using UnityEditor.MPE; + +namespace Unity.Pipeline.Editor +{ + /// + /// Automatically starts Pipeline HTTP server when Unity Editor loads. + /// Handles startup, domain reload persistence, and cleanup. + /// + /// Static owner of the live editor server: a static owner survives domain reloads cleanly + /// (re-created by [InitializeOnLoad]), whereas a ScriptableObject's lifetime does not track the + /// server across editor events. is an optional, inspectable + /// settings asset whose config is read here at start. + /// + [InitializeOnLoad] + public static class PipelineServerStartup + { + private static EditorPipelineServer m_Server; + + /// + /// The live editor pipeline server instance (null when stopped). Exposed so the test guard + /// can disable its watchdog for a test run and the EditorPipelineManager inspector can read + /// live status. + /// + public static EditorPipelineServer Server => m_Server; + + static PipelineServerStartup() + { + // Don't start server in AssetImportWorker processes + if (!IsMainProcess()) + return; + // Setup command discovery using TypeCache for fast Editor performance + CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery()); + + // Clean up any stale instance descriptor files from previous sessions + CleanupStaleDescriptors(); + + // Start the pipeline server (respecting the settings asset's autoStart if one exists) + if (EditorPipelineManager.Load()?.AutoStart ?? true) + StartServer(); + + // Handle domain reloads and editor shutdown + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + EditorApplication.quitting += OnEditorQuitting; + + // Handle domain reload detection + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; + AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; + } + + public static void EnsureServerStarted() + { + StartServer(); + } + + /// + /// Force a clean restart of the editor pipeline server. Unlike EnsureServerStarted, this + /// works even when the current server's listener has died but still reports IsRunning + /// (e.g. after a test disrupted it). Used by tests to revive the live server they disrupted. + /// + public static void RestartServer() + { + StopServer(); + StartServer(); + } + + [MenuItem("Pipeline/Start Server")] + private static void MenuStartServer() + { + StartServer(); + if (m_Server != null && m_Server.IsRunning) + Debug.Log($"Pipeline Server started on port {m_Server.Port}"); + else + Debug.LogWarning("Pipeline Server failed to start"); + } + + [MenuItem("Pipeline/Start Server", true)] + private static bool MenuStartServerValidate() => m_Server == null || !m_Server.IsRunning; + + [MenuItem("Pipeline/Stop Server")] + private static void MenuStopServer() + { + StopServer(); + } + + [MenuItem("Pipeline/Stop Server", true)] + private static bool MenuStopServerValidate() => m_Server != null && m_Server.IsRunning; + + /// + /// Select the EditorPipelineManager settings asset, creating it under Assets/Settings/Pipeline + /// on first use (the live server otherwise runs from built-in defaults). + /// + [MenuItem("Pipeline/Settings...")] + private static void OpenSettings() + { + var mgr = EditorPipelineManager.Load(); + if (mgr == null) + { + const string folder = "Assets/Settings/Pipeline"; + if (!AssetDatabase.IsValidFolder(folder)) + { + if (!AssetDatabase.IsValidFolder("Assets/Settings")) + AssetDatabase.CreateFolder("Assets", "Settings"); + AssetDatabase.CreateFolder("Assets/Settings", "Pipeline"); + } + + mgr = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(mgr, folder + "/EditorPipelineManager.asset"); + AssetDatabase.SaveAssets(); + } + Selection.activeObject = mgr; + EditorGUIUtility.PingObject(mgr); + } + + internal static bool IsMainProcess() + { + // Check command line arguments for asset import worker indicators + var args = System.Environment.GetCommandLineArgs(); + for (int i = 0; i < args.Length; i++) + { + if (args[i] == "-readonly" || args[i] == "--virtual-project-clone") + return true; + } + + if (AssetDatabase.IsAssetImportWorkerProcess()) + return false; + + if (ProcessService.level != ProcessLevel.Main) + return false; + + return true; + } + + /// + /// Start the Pipeline HTTP server, reading configuration from the EditorPipelineManager + /// settings asset if one exists (otherwise using defaults). + /// + private static void StartServer() + { + if (m_Server != null && m_Server.IsRunning) + return; + + try + { + var cfg = EditorPipelineManager.Load(); + m_Server = new EditorPipelineServer + { + // Self-healing watchdog: if the listener dies without a Stop() (an unexpected fault + // outside a domain reload), the watchdog re-opens it so the dogfood loop doesn't + // wedge. Set before Start() (Start arms the watchdog). + WatchdogEnabled = cfg?.WatchdogEnabled ?? true, + WatchdogIntervalSeconds = cfg?.WatchdogIntervalSeconds ?? 5, + LogRequestsResponses = cfg?.LogRequestsResponses ?? false + }; + + // Rotate the transaction log once per Unity session (main thread; SessionState-gated). + // The append path runs off-thread and can't touch SessionState. + PipelineTransactionLog.RotateForNewSession(); + + m_Server.Start(cfg?.Port ?? 0); // 0 auto-assigns from the 7800-7849 range. + + if (m_Server.WatchdogEnabled) + { + // The watchdog rides EditorApplication.update, but a backgrounded/idle editor stops + // ticking once the listener dies (no requests left to wake it) — the exact moment the + // watchdog must run. Keep auto-tick on so the update loop keeps spinning regardless of + // focus, which keeps both the watchdog AND the dispatcher message pump alive. + Commands.AutoTickCommand.SetAutoTick(true); + } + } + catch (System.Exception ex) + { + Debug.LogError($"Failed to start Pipeline Server: {ex.Message}"); + } + } + + /// + /// Stop the Pipeline HTTP server. + /// + public static void StopServer() + { + if (m_Server != null) + { + try + { + m_Server.Stop(); + } + catch (System.Exception ex) + { + Debug.LogWarning($"Error stopping Pipeline Server: {ex.Message}"); + } + finally + { + m_Server = null; + } + } + } + + /// + /// Clean up stale instance descriptor files from previous Editor sessions. + /// + private static void CleanupStaleDescriptors() + { + var projectPath = System.IO.Path.GetDirectoryName(Application.dataPath); + + // Try to read existing descriptor + var existing = InstanceDescriptor.ReadFromProjectRoot(projectPath); + if (existing != null) + { + // Check if process is still running + try + { + var process = System.Diagnostics.Process.GetProcessById(existing.Pid); + if (process.HasExited) + { + // Process is dead, remove stale file + InstanceDescriptor.RemoveFromProjectRoot(projectPath); + } + } + catch + { + // Process doesn't exist or access denied, remove stale file + InstanceDescriptor.RemoveFromProjectRoot(projectPath); + } + } + } + + /// + /// Handle play mode state changes. + /// + private static void OnPlayModeStateChanged(PlayModeStateChange state) + { + // Server continues running through play mode changes + // Status endpoint will reflect current play mode via EditorApplication.isPlaying + } + + /// + /// Handle Editor shutdown. + /// + private static void OnEditorQuitting() + { + StopServer(); // Stop() shuts down the server's own dispatcher. + } + + /// + /// Handle before assembly reload (domain reload). + /// + private static void OnBeforeAssemblyReload() + { + // Server will be automatically recreated after reload due to [InitializeOnLoad] + // Instance descriptor file will be cleaned up and recreated + } + + /// + /// Handle after assembly reload (domain reload). + /// + private static void OnAfterAssemblyReload() + { + // Server should already be restarted via [InitializeOnLoad] + // This is mainly for logging/verification + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineStartup.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineStartup.cs.meta new file mode 100644 index 00000000..ef3bd7b8 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/EditorPipelineStartup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4f85dcf6fb33a5047be5b7790e736674 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/PipelineTransactionLog.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/PipelineTransactionLog.cs new file mode 100644 index 00000000..c56b8e3b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/PipelineTransactionLog.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +[assembly: InternalsVisibleTo("Unity.Pipeline.Tests.Editor")] + +namespace Unity.Pipeline.Editor +{ + /// + /// Appends editor pipeline request/response transactions to <project>/Logs/pipeline.log as a + /// JSON array. rotates any existing log to pipeline_old.log on + /// the first server start of a Unity session: SessionState survives domain reloads but resets on + /// editor restart, so the log accumulates across a session's server start/stop cycles and starts + /// fresh each new session. + /// + /// Rotation must run on the main thread (SessionState is main-thread only), hence it lives in + /// RotateForNewSession (called from server startup), NOT in Append — which runs on the background + /// HTTP thread and is restricted to thread-safe file I/O. + /// + internal static class PipelineTransactionLog + { + private const string SessionRotatedKey = "Unity.Pipeline.TransactionLog.SessionRotated"; + private const string LogFileName = "pipeline.log"; + private const string OldLogFileName = "pipeline_old.log"; + + // Field names are the JSON keys, so they intentionally stay lowercase. request/response are + // JToken so the raw JSON is embedded as real JSON (not a stringified blob) in the log array. + private struct Entry + { + public JToken request; + public JToken response; + public string time; + } + + /// + /// On the first call of a Unity session, back up any existing pipeline.log to pipeline_old.log + /// so the session starts with a fresh log. Gated by SessionState (resets on editor restart), + /// so domain-reload server restarts within a session do not rotate. Main thread only. + /// + public static void RotateForNewSession() + { + try + { + if (SessionState.GetBool(SessionRotatedKey, false)) + return; + + Rotate(GetLogsDirectory()); + SessionState.SetBool(SessionRotatedKey, true); + } + catch (Exception ex) + { + Debug.LogWarning($"Pipeline transaction log rotation failed: {ex.Message}"); + } + } + + /// + /// Append one transaction to the JSON-array log. Runs on the background HTTP thread, so it + /// uses only thread-safe file I/O (no Unity editor APIs). + /// + public static void Append(string requestJson, string responseJson) + { + try + { + Append(GetLogsDirectory(), requestJson, responseJson); + } + catch (Exception ex) + { + // Logging must never break request handling. + Debug.LogWarning($"Pipeline transaction log write failed: {ex.Message}"); + } + } + + /// + /// Back up an existing pipeline.log in to pipeline_old.log, + /// replacing any prior backup. No SessionState gate — exposed as the testable rotation seam. + /// + internal static void Rotate(string logsDir) + { + Directory.CreateDirectory(logsDir); + + var logPath = Path.Combine(logsDir, LogFileName); + if (!File.Exists(logPath)) + return; + + var oldPath = Path.Combine(logsDir, OldLogFileName); + if (File.Exists(oldPath)) + File.Delete(oldPath); + File.Move(logPath, oldPath); + } + + /// + /// Append a transaction to the JSON-array log in an explicit directory. Testable seam behind + /// the public . + /// + internal static void Append(string logsDir, string requestJson, string responseJson) + { + Directory.CreateDirectory(logsDir); + var logPath = Path.Combine(logsDir, LogFileName); + + var entries = ReadEntries(logPath); + entries.Add(new Entry + { + request = ToJsonToken(requestJson), + response = ToJsonToken(responseJson), + time = DateTime.UtcNow.ToString("o") + }); + + File.WriteAllText(logPath, JsonConvert.SerializeObject(entries, Formatting.Indented)); + } + + /// + /// Embed a raw JSON payload as a real JSON token. Falls back to a JSON string for empty or + /// non-JSON payloads, so the log file always stays valid JSON. + /// + private static JToken ToJsonToken(string raw) + { + if (string.IsNullOrEmpty(raw)) + return JValue.CreateNull(); + + try + { + return JToken.Parse(raw); + } + catch (JsonException) + { + return new JValue(raw); + } + } + + private static string GetLogsDirectory() + { + var projectPath = Path.GetDirectoryName(Application.dataPath); + return Path.Combine(projectPath, "Logs"); + } + + private static List ReadEntries(string logPath) + { + if (!File.Exists(logPath)) + return new List(); + + var json = File.ReadAllText(logPath); + if (string.IsNullOrEmpty(json)) + return new List(); + + return JsonConvert.DeserializeObject>(json) ?? new List(); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/PipelineTransactionLog.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/PipelineTransactionLog.cs.meta new file mode 100644 index 00000000..90622168 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/PipelineTransactionLog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7e3c9a4b21d54f8d8b6e0c1f2a3d4e5b diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Testing.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Testing.meta new file mode 100644 index 00000000..4f35249c --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Testing.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 487c1aaaab92fe74884c8b7b0442128a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Testing/PipelineTestRunner.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Testing/PipelineTestRunner.cs new file mode 100644 index 00000000..809ffbf9 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Testing/PipelineTestRunner.cs @@ -0,0 +1,804 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using UnityEditor; +using UnityEditor.TestTools.TestRunner.Api; +using UnityEngine; +using Newtonsoft.Json; + +namespace Unity.Pipeline.Editor.Testing +{ + /// + /// Core test execution engine supporting both synchronous and asynchronous execution modes. + /// Adapted from unity-tools with domain reload handling and dual mode support. + /// + public static class PipelineTestRunner + { + private const string TestRequestFile = "Temp/pipeline_test_request.json"; + private const string TestStatusFile = "Temp/pipeline_test_status.json"; + + private static TestRunnerApi m_ActiveApi; + private static TestResultCollector m_ActiveCollector; + + /// + /// Execute tests with the given parameters + /// + public static async Task ExecuteTestsAsync( + string mode, + string filter, + string filterType, + bool includeExplicit, + bool asyncMode, + int timeoutSeconds) + { + try + { + var parsedMode = ParseTestMode(mode); + if (!parsedMode.IsValid) + { + return CreateErrorResponse($"Invalid mode '{mode}'. Use 'editor', 'playmode', or 'all'"); + } + + // Validate filter type + var normalizedFilterType = filterType?.ToLower() ?? "testname"; + if (!string.IsNullOrEmpty(filter) && + normalizedFilterType != "testname" && normalizedFilterType != "assembly" && normalizedFilterType != "category") + { + return CreateErrorResponse($"Invalid filterType '{filterType}'. Valid options: testName, assembly, category"); + } + + // Cancel any previous run + InvalidatePreviousRun(); + + if (parsedMode.IsAll) + { + // Handle "all" mode by running both editor and playmode tests + return await ExecuteAllModesAsync(filter, normalizedFilterType, includeExplicit, asyncMode, timeoutSeconds); + } + else if (asyncMode) + { + return await ExecuteAsyncMode(parsedMode.TestMode, filter, normalizedFilterType, includeExplicit, timeoutSeconds); + } + else if (parsedMode.TestMode == TestMode.PlayMode) + { + // PlayMode entry triggers a domain reload that drops the in-flight HTTP request, + // so a synchronous playmode run can never return results over one call. Fail fast + // with guidance instead of hanging until the connection resets. + return CreateErrorResponse( + "PlayMode tests cannot run synchronously over HTTP: entering play mode triggers a " + + "domain reload that drops the request. Re-run asynchronously (run_tests --mode " + + "playmode --async_tests) and poll the test_status command for results."); + } + else + { + return await ExecuteSyncMode(parsedMode.TestMode, filter, normalizedFilterType, includeExplicit, timeoutSeconds); + } + } + catch (Exception ex) + { + Debug.LogError($"[PipelineTestRunner] Error: {ex.Message}"); + return CreateErrorResponse($"Failed to run tests: {ex.Message}"); + } + } + + /// + /// Execute tests in all modes (editor + playmode) + /// + private static async Task ExecuteAllModesAsync( + string filter, + string filterType, + bool includeExplicit, + bool asyncMode, + int timeoutSeconds) + { + if (asyncMode) + { + return CreateErrorResponse("Async mode is not supported for 'all' mode. Use 'editor' or 'playmode' for async execution."); + } + + var startTime = DateTime.UtcNow; + + try + { + // EditMode runs synchronously over HTTP (no domain reload), so its results are + // returned in this response. + Debug.Log("[PipelineTestRunner] Running editor tests (all mode)..."); + var editorResponse = await ExecuteSyncMode(TestMode.EditMode, filter, filterType, includeExplicit, timeoutSeconds); + + if (!editorResponse.Success) + { + return editorResponse; // Return error immediately + } + + // PlayMode can't run synchronously over HTTP (entering play mode triggers a domain + // reload that drops the request), so start it asynchronously and hand back a + // StatusPath. The caller polls the test_status command for the playmode results. + Debug.Log("[PipelineTestRunner] Starting playmode tests asynchronously (all mode)..."); + var playmodeStart = await ExecuteAsyncMode(TestMode.PlayMode, filter, filterType, includeExplicit, timeoutSeconds); + + if (!playmodeStart.Success) + { + return playmodeStart; // Return error immediately + } + + // Build combined response: editor results inline, playmode running async. + var duration = (DateTime.UtcNow - startTime).TotalSeconds; + return new TestExecutionResponse + { + Success = true, + Command = "run_tests", + Duration = Math.Round(duration, 2), + Mode = "All", + FilterApplied = string.IsNullOrEmpty(filter) ? null : $"{filterType}: {filter}", + ExecutionTimeMs = (int)(duration * 1000), + Summary = editorResponse.Summary, + Results = editorResponse.Results, + Result = "playmode_running", + StatusPath = playmodeStart.StatusPath, + Message = $"EditMode complete: {editorResponse.Summary.Passed}/{editorResponse.Summary.Total} passed. " + + "PlayMode tests started asynchronously — poll the test_status command for their results." + }; + } + catch (Exception ex) + { + Debug.LogError($"[PipelineTestRunner] All modes execution error: {ex.Message}"); + return CreateErrorResponse($"Failed to execute tests in all modes: {ex.Message}"); + } + } + + /// + /// Synchronous execution - blocks until completion + /// + private static async Task ExecuteSyncMode( + TestMode testMode, + string filter, + string filterType, + bool includeExplicit, + int timeoutSeconds) + { + var startTime = DateTime.UtcNow; + var api = ScriptableObject.CreateInstance(); + var collector = new TestResultCollector(); + + m_ActiveApi = api; + m_ActiveCollector = collector; + + // Set up timeout cancellation + using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds))) + { + cts.Token.Register(() => + { + Debug.Log("[PipelineTestRunner] Test execution timed out, cancelling..."); + InvalidatePreviousRun(); + }); + + try + { + // For PlayMode tests, we need to handle domain reloads + if (testMode == TestMode.PlayMode) + { + // Save request for domain reload recovery + var request = new TestRequest + { + filter = filter, + filterType = filterType, + mode = testMode.ToString(), + includeExplicit = includeExplicit, + isSync = true + }; + File.WriteAllText(TestRequestFile, JsonConvert.SerializeObject(request)); + } + + // Execute tests and wait for completion + var completionTask = ExecuteTestsInternal(api, collector, testMode, filter, filterType, includeExplicit); + var resultAdaptor = await completionTask; + + // Build response + var duration = (DateTime.UtcNow - startTime).TotalSeconds; + var response = new TestExecutionResponse + { + Success = true, + Command = "run_tests", + Duration = Math.Round(duration, 2), + Mode = testMode.ToString(), + FilterApplied = string.IsNullOrEmpty(filter) ? null : $"{filterType}: {filter}", + ExecutionTimeMs = (int)(duration * 1000), + Summary = resultAdaptor != null ? new TestSummary + { + Total = resultAdaptor.PassCount + resultAdaptor.FailCount + resultAdaptor.SkipCount + resultAdaptor.InconclusiveCount, + Passed = resultAdaptor.PassCount, + Failed = resultAdaptor.FailCount, + Skipped = resultAdaptor.SkipCount, + Inconclusive = resultAdaptor.InconclusiveCount + } : new TestSummary(), + Results = collector.Results + }; + + // Clean up PlayMode request file + CleanupRequestFile(); + + return response; + } + catch (OperationCanceledException) + { + return CreateErrorResponse("Test execution timed out"); + } + catch (Exception ex) + { + Debug.LogError($"[PipelineTestRunner] Sync execution error: {ex.Message}"); + CleanupRequestFile(); + return CreateErrorResponse($"Test execution failed: {ex.Message}"); + } + } + } + + /// + /// Asynchronous execution - returns immediately + /// + // Not async: the work here is synchronous (file I/O + a main-thread call that returns + // immediately). Returns a completed Task so callers can still await it. + private static Task ExecuteAsyncMode( + TestMode testMode, + string filter, + string filterType, + bool includeExplicit, + int timeoutSeconds) + { + try + { + // Save request to disk (survives domain reloads for PlayMode) + var request = new TestRequest + { + filter = filter, + filterType = filterType, + mode = testMode.ToString(), + includeExplicit = includeExplicit, + isSync = false + }; + File.WriteAllText(TestRequestFile, JsonConvert.SerializeObject(request)); + + // Clear any previous status + if (File.Exists(TestStatusFile)) + File.Delete(TestStatusFile); + + // Start test execution now, on the main thread (run_tests is MainThreadRequired, + // so we are inside the editor update tick that dispatched this command). Must NOT + // run on a background thread: the test runner creates a TestRunnerApi via + // ScriptableObject.CreateInstance, which is main-thread-only. RetrieveTestList + // returns immediately, so the HTTP "running" response below is still sent promptly. + StartAsyncTestExecution(testMode, filter, filterType, includeExplicit); + + return Task.FromResult(new TestExecutionResponse + { + Success = true, + Command = "run_tests", + Result = "running", + StatusPath = TestStatusFile, + Mode = testMode.ToString(), + FilterApplied = string.IsNullOrEmpty(filter) ? null : $"{filterType}: {filter}", + Message = "Tests started in async mode. Poll /test-status for results." + }); + } + catch (Exception ex) + { + Debug.LogError($"[PipelineTestRunner] Async setup error: {ex.Message}"); + return Task.FromResult(CreateErrorResponse($"Failed to start async test execution: {ex.Message}")); + } + } + + /// + /// Internal test execution logic + /// + private static async Task ExecuteTestsInternal( + TestRunnerApi api, + TestResultCollector collector, + TestMode testMode, + string filter, + string filterType, + bool includeExplicit) + { + var tcs = new TaskCompletionSource(); + + // Retrieve test list and filter + api.RetrieveTestList(testMode, (ITestAdaptor rootTest) => + { + try + { + var testNames = CollectTestNames(rootTest, filter, filterType, includeExplicit); + + if (testNames.Count == 0) + { + Debug.Log("[PipelineTestRunner] No tests to run after filtering"); + var emptyResult = CreateEmptyTestResult(); + tcs.SetResult(emptyResult); + return; + } + + Debug.Log($"[PipelineTestRunner] Running {testNames.Count} tests (explicit tests {(includeExplicit ? "included" : "excluded")})"); + + var testFilter = new Filter + { + testMode = testMode, + testNames = testNames.ToArray() + }; + + // Set up completion handling + collector.OnRunFinished = () => tcs.SetResult(collector.RootResult); + + api.RegisterCallbacks(collector); + api.Execute(new ExecutionSettings(testFilter)); + } + catch (Exception ex) + { + Debug.LogError($"[PipelineTestRunner] Error during test execution: {ex.Message}"); + tcs.SetException(ex); + } + }); + + return await tcs.Task; + } + + /// + /// Start async test execution on the main thread. Registers callbacks and kicks off the + /// run, then returns; completion is reported by the collector writing the status file. + /// Unlike the sync path, this does NOT reuse ExecuteTestsInternal (which sets its own + /// OnRunFinished to complete a Task, clobbering ours), and does NOT await. + /// + private static void StartAsyncTestExecution(TestMode testMode, string filter, string filterType, bool includeExplicit) + { + try + { + var api = ScriptableObject.CreateInstance(); + var collector = new TestResultCollector(); + + m_ActiveApi = api; + m_ActiveCollector = collector; + + // Async completion: write results for pollers. Set once, never overwritten. + collector.OnRunFinished = () => + { + WriteCompletedResults(collector); + RestoreLiveServerIfDisrupted(); // B2: self-heal if a test broke the live server. + CleanupRequestFile(); + m_ActiveApi = null; + m_ActiveCollector = null; + }; + + api.RetrieveTestList(testMode, (ITestAdaptor rootTest) => + { + try + { + var testNames = CollectTestNames(rootTest, filter, filterType, includeExplicit); + + if (testNames.Count == 0) + { + WriteStatusFile(new + { + status = "completed", + duration = 0.0, + summary = new { total = 0, passed = 0, failed = 0, skipped = 0, inconclusive = 0 }, + results = new object[0] + }); + CleanupRequestFile(); + m_ActiveApi = null; + m_ActiveCollector = null; + return; + } + + api.RegisterCallbacks(collector); + api.Execute(new ExecutionSettings(new Filter + { + testMode = testMode, + testNames = testNames.ToArray() + })); + } + catch (Exception ex) + { + Debug.LogError($"[PipelineTestRunner] Async run setup error: {ex.Message}"); + WriteStatusFile(new { status = "error", message = ex.Message }); + CleanupRequestFile(); + m_ActiveApi = null; + m_ActiveCollector = null; + } + }); + } + catch (Exception ex) + { + Debug.LogError($"[PipelineTestRunner] Async execution error: {ex.Message}"); + WriteStatusFile(new { status = "error", message = ex.Message }); + CleanupRequestFile(); + m_ActiveApi = null; + m_ActiveCollector = null; + } + } + + /// + /// Re-register a result collector to capture the completion of a PlayMode run that the + /// Unity Test Framework auto-resumes after a play-mode domain reload (see TestJobDataHolder + /// in the test-framework package). The previous collector did not survive the reload. + /// Deliberately does NOT call RetrieveTestList/api.Execute: the run is already in flight, so + /// starting another one double-executes the task list (running scene tasks during play + /// mode) and trips the runner's "too many instant steps" guard. The collector rebuilds its + /// per-test results from the RootResult tree at RunFinished, since the incremental + /// TestFinished callbacks were delivered to a collector from the previous domain. + /// + private static void ReattachResultCollector() + { + try + { + var api = ScriptableObject.CreateInstance(); + var collector = new TestResultCollector(); + + m_ActiveApi = api; + m_ActiveCollector = collector; + + collector.OnRunFinished = () => + { + WriteCompletedResults(collector); + RestoreLiveServerIfDisrupted(); + CleanupRequestFile(); + m_ActiveApi = null; + m_ActiveCollector = null; + }; + + api.RegisterCallbacks(collector); + } + catch (Exception ex) + { + Debug.LogError($"[PipelineTestRunner] Failed to reattach result collector: {ex.Message}"); + WriteStatusFile(new { status = "error", message = ex.Message }); + CleanupRequestFile(); + m_ActiveApi = null; + m_ActiveCollector = null; + } + } + + /// + /// Called after domain reload to resume pending tests + /// + [InitializeOnLoad] + public static class TestReloader + { + static TestReloader() + { + EditorApplication.delayCall += CheckForPendingTests; + } + } + + public static void CheckForPendingTests() + { + if (!File.Exists(TestRequestFile)) return; + if (File.Exists(TestStatusFile)) return; // Already completed + + try + { + var json = File.ReadAllText(TestRequestFile); + var request = JsonConvert.DeserializeObject(json); + + Debug.Log("[PipelineTestRunner] Reattaching result collector after domain reload"); + if (request.isSync) + { + // For sync mode, we can't really resume after domain reload + // The command has already timed out or failed + CleanupRequestFile(); + return; + } + + // The Unity Test Framework (TestJobDataHolder) auto-resumes the in-flight PlayMode + // run after a play-mode domain reload. We must NOT start a new run here: re-calling + // api.Execute double-starts the job, which re-runs the task list (executing scene + // tasks during play mode) and trips the runner's "too many instant steps" guard. + // We only need to re-register a collector — our previous one did not survive the + // reload — to capture the resumed run's completion. Runs on the main thread because + // CheckForPendingTests is invoked via EditorApplication.delayCall. + ReattachResultCollector(); + } + catch (Exception ex) + { + Debug.LogError($"[PipelineTestRunner] Failed to resume tests: {ex.Message}"); + WriteStatusFile(new { status = "error", message = $"Failed to resume tests: {ex.Message}" }); + CleanupRequestFile(); + } + } + + #region Utility Methods + + private struct ParsedTestMode + { + public bool IsValid { get; set; } + public bool IsAll { get; set; } + public TestMode TestMode { get; set; } + + public static ParsedTestMode Invalid => new ParsedTestMode { IsValid = false }; + public static ParsedTestMode All => new ParsedTestMode { IsValid = true, IsAll = true }; + public static ParsedTestMode Single(TestMode mode) => new ParsedTestMode { IsValid = true, IsAll = false, TestMode = mode }; + } + + private static ParsedTestMode ParseTestMode(string mode) + { + switch (mode?.ToLower()) + { + case "editor": + case "editmode": + return ParsedTestMode.Single(TestMode.EditMode); + case "playmode": + case "play": + return ParsedTestMode.Single(TestMode.PlayMode); + case "all": + return ParsedTestMode.All; + default: + return ParsedTestMode.Invalid; + } + } + + private static List CollectTestNames(ITestAdaptor test, string filter, string filterType, bool includeExplicit) + { + var testNames = new List(); + CollectTestNamesRecursive(test, testNames, filter, filterType, includeExplicit); + return testNames; + } + + private static void CollectTestNamesRecursive(ITestAdaptor test, List testNames, string filter, string filterType, bool includeExplicit) + { + if (test == null) return; + + // Skip [Explicit] tests AND [Explicit] fixtures (the whole subtree) unless includeExplicit. + // Checked at EVERY node — including suite/fixture nodes — because a class-level [Explicit] + // is not reliably propagated to [UnityTest] leaf RunState by Unity's adaptor; honoring the + // declaring type's attribute here is what makes class-level [Explicit] actually exclude. + if (!includeExplicit && IsExplicit(test)) + return; + + // Only collect leaf tests (actual test methods, not fixtures/suites) + if (!test.HasChildren && test.IsSuite == false) + { + // Apply user filter + if (!ShouldIncludeTest(test, filter, filterType)) + return; + + testNames.Add(test.FullName); + } + + // Recurse into children + if (test.Children != null) + { + foreach (var child in test.Children) + { + CollectTestNamesRecursive(child, testNames, filter, filterType, includeExplicit); + } + } + } + + /// + /// True if the test, its method, or its declaring fixture type is marked [Explicit]. + /// Class-level [Explicit] must be honored here because Unity does not always propagate it + /// to leaf RunState (notably for [UnityTest]). + /// + private static bool IsExplicit(ITestAdaptor test) + { + if (test.RunState == RunState.Explicit) + return true; + + var method = test.Method?.MethodInfo; + if (method != null && method.GetCustomAttributes(true).Any(a => a.GetType().Name == "ExplicitAttribute")) + return true; + + var type = test.TypeInfo?.Type; + if (type != null && type.GetCustomAttributes(true).Any(a => a.GetType().Name == "ExplicitAttribute")) + return true; + + return false; + } + + private static bool ShouldIncludeTest(ITestAdaptor test, string filter, string filterType) + { + if (string.IsNullOrEmpty(filter)) + return true; + + switch (filterType) + { + case "assembly": + var assemblyName = test.TypeInfo?.Assembly?.GetName()?.Name; + return assemblyName != null && + assemblyName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0; + + case "testname": + return test.FullName != null && + test.FullName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0; + + case "category": + return test.Categories != null && + test.Categories.Any(c => c.Equals(filter, StringComparison.OrdinalIgnoreCase)); + + default: + return true; + } + } + + private static void InvalidatePreviousRun() + { + if (m_ActiveCollector != null) + { + m_ActiveCollector.Cancel(); + + if (m_ActiveApi != null) + { + try { m_ActiveApi.UnregisterCallbacks(m_ActiveCollector); } + catch (Exception ex) + { + Debug.LogWarning($"[PipelineTestRunner] Could not unregister old collector: {ex.Message}"); + } + } + + Debug.Log("[PipelineTestRunner] Invalidated previous test run"); + } + + m_ActiveApi = null; + m_ActiveCollector = null; + } + + private static TestExecutionResponse CreateErrorResponse(string message) + { + return new TestExecutionResponse + { + Success = false, + Command = "run_tests", + Error = message, + Results = new List(), + Summary = new TestSummary() + }; + } + + private static ITestResultAdaptor CreateEmptyTestResult() + { + // This is a bit of a hack since ITestResultAdaptor is an interface + // In practice, this case should be rare (no tests matching filter) + return null; + } + + /// + /// B2 self-heal: after a test run, if a test left the live editor server disrupted (its + /// listener died, or its discovery descriptor was deleted/clobbered), restart it so the + /// dogfood loop and subsequent commands keep working instead of wedging the session. No-op + /// when the server is healthy. Runs at run completion — the watchdog is disabled during the + /// run (PipelineWatchdogTestGuard), so this is what revives the live server afterwards. + /// + private static void RestoreLiveServerIfDisrupted() + { + try + { + var server = PipelineServerStartup.Server; + var root = Path.GetDirectoryName(Application.dataPath); + var descriptor = Unity.Pipeline.Models.InstanceDescriptor.ReadFromProjectRoot(root); + + var disrupted = server == null + || !server.IsRunning + || descriptor == null + || descriptor.Port != server.Port; + + if (disrupted) + { + Debug.Log("[PipelineTestRunner] Live server was disrupted during the test run — restarting it."); + PipelineServerStartup.RestartServer(); + } + } + catch (Exception ex) + { + Debug.LogWarning($"[PipelineTestRunner] Live server restore check failed: {ex.Message}"); + } + } + + private static void WriteCompletedResults(TestResultCollector collector) + { + try + { + if (!collector.IsComplete) + { + WriteStatusFile(new { status = "error", message = "Tests did not complete" }); + return; + } + + var root = collector.RootResult; + var result = new + { + status = "completed", + duration = Math.Round(root.Duration, 2), + summary = new + { + total = root.PassCount + root.FailCount + root.SkipCount + root.InconclusiveCount, + passed = root.PassCount, + failed = root.FailCount, + skipped = root.SkipCount, + inconclusive = root.InconclusiveCount + }, + results = collector.Results.ToArray() + }; + + WriteStatusFile(result); + } + catch (Exception ex) + { + Debug.LogError($"[PipelineTestRunner] Failed to write results: {ex.Message}"); + WriteStatusFile(new { status = "error", message = ex.Message }); + } + } + + private static void WriteStatusFile(object data) + { + try + { + File.WriteAllText(TestStatusFile, JsonConvert.SerializeObject(data, Formatting.Indented)); + } + catch (Exception ex) + { + Debug.LogError($"[PipelineTestRunner] Failed to write status file: {ex.Message}"); + } + } + + private static void CleanupRequestFile() + { + try { if (File.Exists(TestRequestFile)) File.Delete(TestRequestFile); } catch { } + } + + /// + /// Get current test status (for async mode polling) + /// + public static string GetTestStatus() + { + if (File.Exists(TestStatusFile)) + return File.ReadAllText(TestStatusFile); + if (File.Exists(TestRequestFile)) + return JsonConvert.SerializeObject(new { status = "running" }); + return null; + } + + /// + /// Cancel running tests + /// + public static object CancelTests() + { + if (m_ActiveCollector == null && !File.Exists(TestRequestFile) && !File.Exists(TestStatusFile)) + return new { status = "no_tests", message = "No test run in progress." }; + + bool wasPlayMode = false; + try + { + if (File.Exists(TestRequestFile)) + { + var json = File.ReadAllText(TestRequestFile); + wasPlayMode = json.Contains("PlayMode"); + } + } + catch { } + + InvalidatePreviousRun(); + WriteStatusFile(new { status = "cancelled", message = "Test run cancelled." }); + CleanupRequestFile(); + + if (wasPlayMode && EditorApplication.isPlaying) + { + Debug.Log("[PipelineTestRunner] Exiting play mode to cancel PlayMode tests"); + EditorApplication.ExitPlaymode(); + } + + Debug.Log("[PipelineTestRunner] Test run cancelled"); + return new { status = "cancelled", message = "Test run cancelled." }; + } + + #endregion + + [Serializable] + private class TestRequest + { + public string filter; + public string filterType; + public string mode; + public bool includeExplicit; + public bool isSync; + } + } +} + diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Testing/PipelineTestRunner.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Testing/PipelineTestRunner.cs.meta new file mode 100644 index 00000000..2992d00f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Testing/PipelineTestRunner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b9212c4c36abc384e8bdbefe0d9892df +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Testing/TestResultCollector.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Testing/TestResultCollector.cs new file mode 100644 index 00000000..da5d736d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Testing/TestResultCollector.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using UnityEditor.TestTools.TestRunner.Api; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Testing +{ + /// + /// Collects test results from Unity TestRunner API callbacks. + /// Adapted from unity-tools implementation with sync/async mode support. + /// + public class TestResultCollector : ICallbacks + { + public bool IsComplete { get; private set; } + public bool IsCancelled { get; set; } + public List Results { get; } = new List(); + public ITestResultAdaptor RootResult { get; private set; } + + // For async mode + public Action OnRunFinished { get; set; } + + // For sync mode + private TaskCompletionSource m_CompletionSource; + + public TestResultCollector() + { + m_CompletionSource = new TaskCompletionSource(); + } + + /// + /// Wait for test completion (sync mode) + /// + public Task WaitForCompletionAsync() + { + return m_CompletionSource.Task; + } + + public void RunStarted(ITestAdaptor testsToRun) + { + if (IsCancelled) return; + + IsComplete = false; + Results.Clear(); + Debug.Log($"[TestResultCollector] Run started: {testsToRun.TestCaseCount} test(s)"); + } + + public void TestStarted(ITestAdaptor test) + { + // No action needed for test start + } + + public void TestFinished(ITestResultAdaptor result) + { + if (IsCancelled) return; + if (result.Test.IsSuite) return; + + Results.Add(BuildTestResult(result)); + } + + public void RunFinished(ITestResultAdaptor result) + { + if (IsCancelled) + { + Debug.Log("[TestResultCollector] Ignoring RunFinished from cancelled run"); + return; + } + + RootResult = result; + IsComplete = true; + + // If we attached after the tests already ran (PlayMode re-registers a fresh collector + // after each play-mode domain reload, so the incremental TestFinished callbacks were + // delivered to a collector from a previous domain), rebuild from the authoritative + // result tree. The normal in-domain path leaves Results already populated. + if (Results.Count == 0) + { + CollectLeafResults(result); + } + + var total = result.PassCount + result.FailCount + result.SkipCount + result.InconclusiveCount; + Debug.Log($"[TestResultCollector] Run finished: {total} total, {result.PassCount} passed, {result.FailCount} failed"); + + // Notify async mode + OnRunFinished?.Invoke(); + + // Notify sync mode + m_CompletionSource.SetResult(result); + } + + /// + /// Cancel the test collection and notify sync waiters + /// + public void Cancel() + { + IsCancelled = true; + if (!m_CompletionSource.Task.IsCompleted) + { + m_CompletionSource.SetCanceled(); + } + } + + /// + /// Set error state and notify sync waiters + /// + public void SetError(Exception exception) + { + if (!m_CompletionSource.Task.IsCompleted) + { + m_CompletionSource.SetException(exception); + } + } + + /// + /// Recursively collect leaf (non-suite) test results from the result tree. Used at + /// RunFinished to rebuild Results when this collector was registered too late to receive + /// the incremental TestFinished callbacks (PlayMode resume after a domain reload). + /// + private void CollectLeafResults(ITestResultAdaptor result) + { + if (result == null) return; + + if (!result.Test.IsSuite && !result.Test.HasChildren) + { + Results.Add(BuildTestResult(result)); + return; + } + + if (result.Children != null) + { + foreach (var child in result.Children) + { + CollectLeafResults(child); + } + } + } + + private static TestResult BuildTestResult(ITestResultAdaptor result) + { + return new TestResult + { + FullName = result.Test.FullName, + Status = result.TestStatus.ToString(), + Duration = result.Duration, + Message = Truncate(result.Message, 10000), + StackTrace = Truncate(result.StackTrace, 10000) + }; + } + + private static string Truncate(string s, int maxLength) + { + if (s == null || s.Length <= maxLength) return s; + return s.Substring(0, maxLength) + "\n... (truncated)"; + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Testing/TestResultCollector.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Testing/TestResultCollector.cs.meta new file mode 100644 index 00000000..97f68e1c --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Testing/TestResultCollector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f4541ff42ef502f4989e8ab059df20f3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/TypeCacheCommandDiscovery.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/TypeCacheCommandDiscovery.cs new file mode 100644 index 00000000..aaf1162e --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/TypeCacheCommandDiscovery.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Unity.Pipeline.Commands; +using UnityEditor; + +namespace Unity.Pipeline.Editor +{ + /// + /// Editor implementation of command discovery using Unity's TypeCache. + /// Provides fast command discovery in Editor mode via Unity's optimized caching system. + /// + public class TypeCacheCommandDiscovery : ICommandDiscovery + { + /// + /// Find all methods marked with specified attribute using TypeCache. + /// + public IEnumerable GetMethodsWithAttribute() where T : Attribute + { + return TypeCache.GetMethodsWithAttribute(); + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/TypeCacheCommandDiscovery.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/TypeCacheCommandDiscovery.cs.meta new file mode 100644 index 00000000..15b703c4 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/TypeCacheCommandDiscovery.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f7f7b0f5ced14e548afee892a4014ce9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Unity.Pipeline.Editor.asmdef b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Unity.Pipeline.Editor.asmdef new file mode 100644 index 00000000..70d41e8f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Unity.Pipeline.Editor.asmdef @@ -0,0 +1,19 @@ +{ + "name": "Unity.Pipeline.Editor", + "rootNamespace": "Unity.Pipeline.Editor", + "references": [ + "Unity.Pipeline", + "Unity.Nuget.Newtonsoft-Json" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Unity.Pipeline.Editor.asmdef.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Unity.Pipeline.Editor.asmdef.meta new file mode 100644 index 00000000..0b423f33 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Unity.Pipeline.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a3ca9b2d97442e0499ea28b681f751d9 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/LICENSE.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/LICENSE.md new file mode 100644 index 00000000..d786c29a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/LICENSE.md @@ -0,0 +1,5 @@ +com.unity.pipeline copyright © 2026 Unity Technologies + +Licensed under the Unity Package Distribution License (see https://unity3d.com/legal/licenses/Unity_Package_Distribution_License ). + +Unless expressly provided otherwise, the software under this license is made available strictly on an “AS IS” BASIS WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. Please review the license for details on these and other terms and conditions.” diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/LICENSE.md.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/LICENSE.md.meta new file mode 100644 index 00000000..6b4c589d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/LICENSE.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c07017e56bf04bf4992412964a20140c +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/README.md b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/README.md new file mode 100644 index 00000000..700c1a2e --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/README.md @@ -0,0 +1,91 @@ +# Unity Pipeline Package + +[![Unity Version](https://img.shields.io/badge/Unity-6.0%2B-blue.svg)](https://unity3d.com/unity/whats-new/2023.3.0) + +Transform Unity Editor into a programmable automation element for CI/CD pipelines and development workflows. The package exposes a running Unity Editor (or development Player) over a local HTTP API so external tools, scripts, or agents can execute commands remotely. + +## Install the Unity CLI + +The Pipeline package is driven through the `unity` CLI. + +macOS / Linux +``` +curl -fsSL https://public-cdn.cloud.unity3d.com/hub/prod/cli/install.sh | UNITY_CLI_CHANNEL=beta bash +``` + +Windows (PowerShell) +``` +$env:UNITY_CLI_CHANNEL='beta'; irm https://public-cdn.cloud.unity3d.com/hub/prod/cli/install.ps1 | iex +``` + +For more details, see the [CLI documentation](https://github.com/Unity-Technologies/unity-hub/tree/dev/src/cli). + +## Install the Pipeline package + +Install the package into a Unity project with: + +```bash +unity pipeline install +``` + +By default it targets the current directory (or the running Unity instance). Use `--project-path` to target a specific project: + +```bash +unity pipeline install --project-path /path/to/your/unity/project +``` + +Then open the project in the Unity Editor. The package automatically starts its HTTP server and registers the built-in commands. + +## Connect to a running Editor + +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 +``` + +Click [here](Documentation~/connectivity.md) for more details on Connectivity troubleshooting. + +## Connect to a running Player (Runtime) + +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 path where the runtime port file is located +unity command --runtime-path runtime_status +``` + +`--runtime-path` points to where the `.unity-pipeline-runtime-port` file lives: + +- **Windows** — the port file sits next to the Player executable: + + ```bash + unity command --runtime-path "C:\Builds\MyGame" runtime_status + ``` + +- **macOS** — pass the path to the `.app` bundle: + + ```bash + unity command --runtime-path "/Users/me/Builds/MyGame.app" runtime_status + ``` + +Click [here](Documentation~/connectivity.md) for more details on Connectivity troubleshooting. + +## Documentation + +For the full command reference, connectivity details, runtime setup, and hot-reload guides, see the [Pipeline documentation](Documentation~/index.md). + +## License + +com.unity.package is licensed under the Unity Companion License. See [LICENSE.md](LICENSE.md) for more legal information. + +## Contributions to this repository + +We are not accepting pull requests at this time. If you find an issue with the package or would like to request a new feature, please submit a [GitHub issue](https://github.com/Unity-Technologies/com.unity.pipeline/issues). diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/README.md.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/README.md.meta new file mode 100644 index 00000000..786d10ff --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d1a2488a30b969941afb4af17c063ad3 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime.meta new file mode 100644 index 00000000..4b3402e4 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a9bf7426ce9ccf4448467e47c849c567 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/AssemblyInfo.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/AssemblyInfo.cs new file mode 100644 index 00000000..ff198f09 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Runtime.CompilerServices; + +// Lets the runtime test assembly's PipelineClient read a server's internal Token to authenticate. +[assembly: InternalsVisibleTo("Unity.Pipeline.Tests.Runtime")] +// Lets the EditMode test assembly unit-test internal runtime utilities (e.g. MaxLengthStream). +[assembly: InternalsVisibleTo("Unity.Pipeline.Tests.Editor")] diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/AssemblyInfo.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/AssemblyInfo.cs.meta new file mode 100644 index 00000000..00451891 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 87863085291527c4581eb05fcc20b47c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands.meta new file mode 100644 index 00000000..ed4d6133 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 67704748e63416c4c959bd6de99b4c4f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/CaptureRuntimeElementCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/CaptureRuntimeElementCommand.cs new file mode 100644 index 00000000..a588fd57 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/CaptureRuntimeElementCommand.cs @@ -0,0 +1,143 @@ +#if UNITY_6000_7_OR_NEWER +using System.Collections.Generic; +using System.Linq; +using Unity.Pipeline.Commands; +using UnityEngine; +using UnityEngine.UIElements; +using Object = UnityEngine.Object; + +namespace Unity.Pipeline.Runtime.Commands +{ + /// + /// Captures a UI Toolkit from a live runtime panel to a PNG and + /// returns it base64-encoded (and writes it to disk). Runtime-only so it is available in a + /// development Player as well as the editor's Play mode. + /// + /// A runtime panel is hosted by either a or a . + /// The panel is identified by name (the asset name or the host + /// GameObject name) rather than an asset path, because a Player has no AssetDatabase. + /// + public static class CaptureRuntimeElementCommand + { + [CliCommand("capture_runtime_element", + "Capture a UI Toolkit VisualElement (by selector) from a live runtime panel (UIDocument or PanelRenderer) to a PNG; returns path + base64.", + MainThreadRequired = true, RuntimeOnly = true)] + public static CaptureElementResponse CaptureRuntimeElement( + [CliArg("panel", "Name of the target panel: matches the PanelSettings asset name or the host GameObject name (UIDocument or PanelRenderer). Optional when exactly one panel exists.")] string panel = "", + [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 Application.persistentDataPath). Defaults to a timestamped file under Application.persistentDataPath.")] string output = "") + { + if (string.IsNullOrWhiteSpace(selector)) + return CaptureElementResponse.Fail("A 'selector' is required."); + + var hosts = GatherHosts(); + if (hosts.Count == 0) + return CaptureElementResponse.Fail("No live runtime UI panels found (UIDocument or PanelRenderer). Show the UI first."); + + List matched; + string resolvedName; + if (string.IsNullOrWhiteSpace(panel)) + { + if (hosts.Count > 1) + { + var names = string.Join(", ", hosts.Select(h => h.DisplayName).Distinct()); + return CaptureElementResponse.Fail( + $"Multiple runtime panels exist; specify --panel as one of: {names}."); + } + + matched = hosts; + resolvedName = hosts[0].DisplayName; + } + else + { + matched = hosts.Where(h => h.PanelSettingsName == panel || h.GameObjectName == panel).ToList(); + if (matched.Count == 0) + { + var names = string.Join(", ", hosts.Select(h => h.DisplayName).Distinct()); + return CaptureElementResponse.Fail( + $"No live runtime panel named '{panel}'. Available: {names}."); + } + + resolvedName = panel; + } + + // Lower sorting order is drawn first (further back); query in that order so the topmost + // match is found last only if earlier panels miss. Roots that can't be resolved are skipped. + var roots = matched + .OrderBy(h => h.SortingOrder) + .Select(h => h.GetRoot()) + .Where(r => r != null) + .ToList(); + + if (roots.Count == 0) + return CaptureElementResponse.Fail( + $"Panel '{resolvedName}' has no initialized root VisualElement yet. Show the UI first."); + + var element = VisualElementCaptureSupport.ResolveElement(roots, selector, out _); + if (element == null) + return CaptureElementResponse.Fail( + $"No element matched selector '{selector}' in panel '{resolvedName}'."); + + var dir = Application.persistentDataPath; + return VisualElementCaptureSupport.CaptureAndRespond( + element, selector, $"panel:{resolvedName}", output, + relativeBaseDir: dir, defaultDir: dir, prefix: "element"); + } + + /// A live runtime UI host (UIDocument or PanelRenderer) and how to reach its root. + class Host + { + public string PanelSettingsName; + public string GameObjectName; + public float SortingOrder; + public System.Func GetRoot; + + public string DisplayName => PanelSettingsName ?? GameObjectName; + } + + static List GatherHosts() + { + var hosts = new List(); + + foreach (var doc in PipelineUtils.FindObjectsByType()) + { + var d = doc; + hosts.Add(new Host + { + PanelSettingsName = d.panelSettings != null ? d.panelSettings.name : null, + GameObjectName = d.gameObject.name, + SortingOrder = d.sortingOrder, + GetRoot = () => d.rootVisualElement + }); + } + + // PanelRenderer is a 6000.5+ UI Toolkit API; on older editors only UIDocument panels exist. + foreach (var renderer in PipelineUtils.FindObjectsByType()) + { + var r = renderer; + hosts.Add(new Host + { + PanelSettingsName = r.panelSettings != null ? r.panelSettings.name : null, + GameObjectName = r.gameObject.name, + SortingOrder = r.sortingOrder, + GetRoot = () => GetPanelRendererRoot(r) + }); + } + + return hosts; + } + + // PanelRenderer.rootVisualElement is internal; the public route to its root is a UI-reload + // callback, which fires synchronously when the panel is already initialized. Register, capture + // the root, and immediately unregister. PanelRenderer is a 6000.5+ API. + static VisualElement GetPanelRendererRoot(PanelRenderer renderer) + { + VisualElement captured = null; + PanelRenderer.VersionedUIReloadCallback callback = (pr, root, version) => captured = root; + renderer.RegisterUIReloadCallback(callback); + renderer.UnregisterUIReloadCallback(callback); + return captured; + } + } +} +#endif diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/CaptureRuntimeElementCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/CaptureRuntimeElementCommand.cs.meta new file mode 100644 index 00000000..44372415 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/CaptureRuntimeElementCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 54dc75c59cc13424c9e6e0e7b237e412 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/CodeEvalCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/CodeEvalCommand.cs new file mode 100644 index 00000000..5727a766 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/CodeEvalCommand.cs @@ -0,0 +1,129 @@ +using System; +using Unity.Pipeline.Models; +using Unity.Pipeline.Compilation; +using UnityEngine; +using Unity.Pipeline.Commands; + +namespace Unity.Pipeline.Runtime.Commands +{ + /// + /// Command for evaluating C# code dynamically using Roslyn compilation. + /// Requires security token for authorization. + /// + 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"); + } + + return EvaluateSource(code, timeout); + } + + [CliCommand("eval_file", "Evaluate C# code read from a .cs file on disk", MainThreadRequired = true)] + public static EvalResponse EvaluateFile( + [CliArg("file", "Path to a .cs file to evaluate", Required = true)] string file, + [CliArg("timeout", "Timeout in milliseconds")] int timeout = 5000) + { + if (string.IsNullOrWhiteSpace(file)) + { + return EvalResponse.EvalFailure( + "Bad Request", + "File parameter is required and cannot be empty"); + } + + if (!file.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + { + return EvalResponse.EvalFailure( + "Bad Request", + "File must have a .cs extension"); + } + + if (!System.IO.File.Exists(file)) + { + return EvalResponse.EvalFailure( + "Bad Request", + $"File not found: {file}"); + } + + string code; + try + { + code = System.IO.File.ReadAllText(file); + } + catch (Exception ex) + { + return EvalResponse.EvalFailure( + "Bad Request", + $"Failed to read file: {ex.Message}"); + } + + if (string.IsNullOrWhiteSpace(code)) + { + return EvalResponse.EvalFailure( + "Bad Request", + $"File is empty: {file}"); + } + + return EvaluateSource(code, timeout); + } + + /// + /// Shared evaluation path: compiles and executes the given C# source and returns the + /// response. Both the eval and eval_file commands funnel their resolved + /// source string into here. + /// + private static EvalResponse EvaluateSource(string code, int timeout) + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + try + { + // Validate timeout + if (timeout <= 0 || timeout > 30000) + { + stopwatch.Stop(); + return EvalResponse.EvalFailure( + "Bad Request", + "Timeout must be between 1ms and 30000ms", + stopwatch.ElapsedMilliseconds); + } + + // Execute compilation and evaluation. We're already on the main thread + // (MainThreadRequired = true), so call the synchronous path directly. + var result = EvalCodeCompiler.CompileAndExecuteOnMainThread(code, timeout, null); + + stopwatch.Stop(); + + // Update execution time + if (result != null) + { + result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds; + } + + return result ?? EvalResponse.EvalFailure( + "Unknown Error", + "Compilation returned null result", + stopwatch.ElapsedMilliseconds); + } + catch (Exception ex) + { + stopwatch.Stop(); + Debug.LogError($"Pipeline: Eval command failed: {ex.Message}"); + Debug.LogError($"Pipeline: Stack trace: {ex.StackTrace}"); + + return EvalResponse.EvalFailure( + "Execution Failed", + ex.ToString(), + stopwatch.ElapsedMilliseconds); + } + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/CodeEvalCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/CodeEvalCommand.cs.meta new file mode 100644 index 00000000..2d9d5843 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/CodeEvalCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bc1d3406462d2c246a5f84dd30dd02c7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/ConsoleCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/ConsoleCommand.cs new file mode 100644 index 00000000..a51a1c2b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/ConsoleCommand.cs @@ -0,0 +1,45 @@ +using Unity.Pipeline.Commands; +using Unity.Pipeline.Console; +using Unity.Pipeline.Models; + +namespace Unity.Pipeline.Runtime.Commands +{ + /// + /// Returns captured Unity console output. Backs the CLI's + /// console [--tail N] [--level log|warn|error] [--follow]. + /// + /// Lives in the runtime assembly, so it is available from both the Editor and player builds — + /// console capture is driven by , which runs in both contexts. + /// + /// The package has no streaming transport (the HTTP server handles one request at a time), so + /// --follow is realized client-side: the CLI polls this command, prints new entries, and + /// passes the returned back as since on the next + /// call. The flag mapping is: + /// --tail N -> tail + /// --level X -> level (minimum severity threshold) + /// --follow -> repeated calls with since = previous cursor + /// + /// Reads only the in-memory buffer (no Unity main-thread APIs), so it is marked + /// MainThreadRequired = false for fast polling. + /// + public static class ConsoleCommand + { + /// Default number of entries returned when tail is not supplied. + public const int DefaultTail = 100; + + [CliCommand("console", "Get captured Unity console output (Editor or Player; supports tail, level filtering, and follow via a cursor)", MainThreadRequired = false)] + public static ConsoleLogResponse GetConsole( + [CliArg("tail", "Maximum number of most-recent entries to return")] int tail = DefaultTail, + [CliArg("level", "Minimum severity to include: log | warn | error")] string level = ConsoleLogBuffer.LevelLog, + [CliArg("since", "Cursor: only return entries newer than this seq. Use the 'cursor' from a previous response to follow.")] long since = -1) + { + // Guard against nonsensical tail values; <= 0 would otherwise mean "unlimited" in the + // buffer, which is not what a caller passing e.g. --tail 0 expects. + if (tail <= 0) + tail = DefaultTail; + + var minSeverity = ConsoleLogBuffer.SeverityFromLevelName(level); + return ConsoleLogCapture.Buffer.Query(since, tail, minSeverity); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/ConsoleCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/ConsoleCommand.cs.meta new file mode 100644 index 00000000..597d4a81 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/ConsoleCommand.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 92be7cfe398a263a7b06a4a8a00a802c diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/HotReloadCommands.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/HotReloadCommands.cs new file mode 100644 index 00000000..941b9113 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/HotReloadCommands.cs @@ -0,0 +1,563 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Compilation; +using Unity.Pipeline.HotReload; +using Unity.Pipeline.Models; +using UnityEngine; + +namespace Unity.Pipeline.Runtime.Commands +{ + /// + /// CLI commands for hot reload operations. + /// Provides runtime compilation and management of hot reload files. + /// Follows existing [CliCommand] patterns and integrates with Pipeline Server. + /// + public static class HotReloadCommands + { + [CliCommand("reload_file_override", "Compile and apply hot reload file changes immediately", MainThreadRequired = true)] + internal static HotReloadResponse ReloadFileOverride( + [CliArg("filename", "Hot reload source file to compile (e.g. PlayerTweaks.cs)", Required = true)] string filename, + [CliArg("timeout", "Compilation timeout in milliseconds")] int timeout = 30000, + [CliArg("assemblyDir", "Directory to save compiled assemblies to disk (optional, default is in-memory only)")] string assemblyDir = null) + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + try + { + // Validate filename input + if (string.IsNullOrWhiteSpace(filename)) + { + stopwatch.Stop(); + return HotReloadResponse.CmdFailure( + "Bad Request", + "Filename parameter is required and cannot be empty", + stopwatch.ElapsedMilliseconds); + } + + // Ensure filename ends with .cs + if (!filename.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + { + filename += ".cs"; + } + + // Validate timeout + if (timeout <= 0 || timeout > 300000) // Max 5 minutes + { + stopwatch.Stop(); + return HotReloadResponse.CmdFailure( + "Bad Request", + "Timeout must be between 1ms and 300000ms (5 minutes)", + stopwatch.ElapsedMilliseconds); + } + + Debug.Log($"HotReload: Executing reload_file_override command (helper workflow): {filename}, timeout: {timeout}ms, assemblyDir: {assemblyDir ?? "in-memory"}"); + + // Resolve the override file path (absolute, or relative to the project root). + var fullPath = Path.IsPathRooted(filename) ? filename : Path.GetFullPath(filename); + + // In a Player build, the file must be inside the project's build-baked roots. The + // project layout cannot be resolved from a running build, so this is skipped in the + // editor (which resolves files against the live project). + if (!Application.isEditor) + { + var scopeCheck = ValidateReloadPath(fullPath, HotReloadRegistry.AllowedReloadRoots); + if (!scopeCheck.IsValid) + { + stopwatch.Stop(); + return HotReloadResponse.CmdFailure( + scopeCheck.Error, + scopeCheck.ErrorDetails, + stopwatch.ElapsedMilliseconds); + } + } + + if (!File.Exists(fullPath)) + { + stopwatch.Stop(); + return HotReloadResponse.CmdFailure( + "File Not Found", + $"Override file not found: {fullPath}", + stopwatch.ElapsedMilliseconds); + } + + // Up-front validation so a misconfigured override file produces a clear, actionable + // message instead of a confusing compiler error from generated/duplicate code. + var validation = OverrideFileValidator.Validate(File.ReadAllText(fullPath), Path.GetFileName(fullPath)); + if (!validation.IsValid) + { + stopwatch.Stop(); + return HotReloadResponse.CmdFailure( + "Invalid Override File", + validation.GetFormattedErrorMessage(), + stopwatch.ElapsedMilliseconds, + validation.Errors); + } + + // Compile and apply (helper / separate override file workflow). + var task = ExecuteHotReloadAsync(fullPath, timeout, assemblyDir); + task.Wait(); + var result = task.Result; + + stopwatch.Stop(); + + if (result != null) + { + result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds; + } + + Debug.Log($"HotReload: reload_file_override completed in {stopwatch.ElapsedMilliseconds}ms, success: {result?.Success}"); + + return result ?? HotReloadResponse.CmdFailure( + "Unknown Error", + "Hot reload compilation returned null result", + stopwatch.ElapsedMilliseconds); + } + catch (Exception ex) + { + stopwatch.Stop(); + Debug.LogError($"HotReload: reload_file_override command failed: {ex.Message}"); + Debug.LogError($"HotReload: Stack trace: {ex.StackTrace}"); + + return HotReloadResponse.CmdFailure( + "Execution Failed", + ex.ToString(), + stopwatch.ElapsedMilliseconds); + } + } + + [CliCommand("reload_file", "Compile and apply in-place [HotReload] edits from a source file", MainThreadRequired = true)] + public static HotReloadResponse ReloadFile( + [CliArg("filename", "Source file containing [HotReload] methods (e.g. Assets/Scripts/Player.cs)", Required = true)] string filename, + [CliArg("timeout", "Compilation timeout in milliseconds")] int timeout = 30000, + [CliArg("assemblyDir", "Directory to save compiled assemblies to disk (optional, default is in-memory only)")] string assemblyDir = null, + [CliArg("pdb", "Emit debug symbols (portable PDB) mapped to the original source so breakpoints bind in your editor. Compiles unoptimized.")] bool pdb = false) + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + try + { + if (string.IsNullOrWhiteSpace(filename)) + { + stopwatch.Stop(); + return HotReloadResponse.CmdFailure( + "Bad Request", + "Filename parameter is required and cannot be empty", + stopwatch.ElapsedMilliseconds); + } + + if (!filename.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + { + filename += ".cs"; + } + + var fullPath = ResolveSourceFilePath(filename); + if (string.IsNullOrEmpty(fullPath)) + { + stopwatch.Stop(); + return HotReloadResponse.CmdFailure( + "File Not Found", + $"Could not locate source file: {filename}", + stopwatch.ElapsedMilliseconds); + } + + // In a Player build, the file must be inside the project's build-baked roots. The + // project layout cannot be resolved from a running build, so this is skipped in the + // editor (which resolves files against the live project). + if (!Application.isEditor) + { + var scopeCheck = ValidateReloadPath(fullPath, HotReloadRegistry.AllowedReloadRoots); + if (!scopeCheck.IsValid) + { + stopwatch.Stop(); + return HotReloadResponse.CmdFailure( + scopeCheck.Error, + scopeCheck.ErrorDetails, + stopwatch.ElapsedMilliseconds); + } + } + + Debug.Log($"HotReload: Executing reload_file command: {fullPath}, timeout: {timeout}ms, assemblyDir: {assemblyDir ?? "in-memory"}, pdb: {pdb}"); + + var task = InPlaceReloadProcessor.ProcessSourceFileAsync(fullPath, assemblyDir, pdb); + task.Wait(); + var result = task.Result; + + stopwatch.Stop(); + + if (result.Success) + { + return HotReloadResponse.CmdSuccess( + result.AssemblyName, + $"In-place hot reload successful: {result.AssemblyName} with {result.RegisteredMethods.Count} methods", + result.RegisteredMethods, + stopwatch.ElapsedMilliseconds); + } + + return HotReloadResponse.CmdFailure( + "In-Place Reload Failed", + result.ErrorMessage ?? "In-place hot reload processing failed", + stopwatch.ElapsedMilliseconds, + result.CompilationDiagnostics); + } + catch (Exception ex) + { + stopwatch.Stop(); + Debug.LogError($"HotReload: reload_file command failed: {ex.Message}"); + Debug.LogError($"HotReload: Stack trace: {ex.StackTrace}"); + + return HotReloadResponse.CmdFailure( + "Execution Failed", + ex.ToString(), + stopwatch.ElapsedMilliseconds); + } + } + + [CliCommand("cleanup_hotreload", "Remove old hot reload DLL versions and clear registry", MainThreadRequired = true, RuntimeOnly = true)] + public static HotReloadResponse CleanupHotReload( + [CliArg("assemblyDir", "Directory containing assemblies to cleanup", Required = true)] string assemblyDir, + [CliArg("force_domain_reload", "Force Unity domain reload after cleanup")] bool forceDomainReload = true) + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + try + { + Debug.Log($"HotReload: Executing cleanup_hotreload command for directory: {assemblyDir}"); + + // Execute cleanup + var cleanupResult = HotReloadCompiler.CleanupHotReloadDlls(assemblyDir); + + stopwatch.Stop(); + + if (cleanupResult.Success) + { + var message = $"Cleanup successful: {cleanupResult.Message}"; + if (cleanupResult.DeletedFiles.Count > 0) + { + message += $" Files removed: {string.Join(", ", cleanupResult.DeletedFiles)}"; + } + + // Force domain reload if requested (interrupts gameplay but ensures clean state) + if (forceDomainReload && Application.isEditor) + { +#if UNITY_EDITOR + Debug.Log("HotReload: Requesting Unity domain reload to clean memory state"); + UnityEditor.EditorUtility.RequestScriptReload(); + message += " Unity domain reload requested."; +#endif + } + + Debug.Log($"HotReload: cleanup_hotreload completed successfully in {stopwatch.ElapsedMilliseconds}ms"); + + return HotReloadResponse.CmdSuccess( + "cleanup_completed", + message, + cleanupResult.DeletedFiles, + stopwatch.ElapsedMilliseconds); + } + else + { + return HotReloadResponse.CmdFailure( + "Cleanup Failed", + cleanupResult.Message, + stopwatch.ElapsedMilliseconds); + } + } + catch (Exception ex) + { + stopwatch.Stop(); + Debug.LogError($"HotReload: cleanup_hotreload command failed: {ex.Message}"); + + return HotReloadResponse.CmdFailure( + "Execution Failed", + ex.ToString(), + stopwatch.ElapsedMilliseconds); + } + } + + [CliCommand("hotreload_status", "Show current hot reload registry status and statistics", MainThreadRequired = true, RuntimeOnly = true)] + public static HotReloadResponse HotReloadStatus() + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + try + { + // Get current registry stats + var stats = HotReloadRegistry.GetStats(); + + stopwatch.Stop(); + + var statusMessage = $"Hot Reload Status - " + + $"Reloadable Methods: {stats.ReloadableMethodCount}, " + + $"Active Overrides: {stats.ActiveOverrideCount}, " + + $"Loaded Types: {stats.LoadedTypeCount}"; + + Debug.Log($"HotReload: {statusMessage}"); + + return HotReloadResponse.CmdSuccess( + "status_retrieved", + statusMessage, + stats.ActiveOverrideIds, + stopwatch.ElapsedMilliseconds); + } + catch (Exception ex) + { + stopwatch.Stop(); + Debug.LogError($"HotReload: hotreload_status command failed: {ex.Message}"); + + return HotReloadResponse.CmdFailure( + "Execution Failed", + ex.ToString(), + stopwatch.ElapsedMilliseconds); + } + } + + /// + /// Validate that a resolved file path lives inside one of the allowed project roots and + /// exists on disk. "In scope" means under the Assets folder or a loaded package's location. + /// The path is normalized (collapsing any .. segments) before the scope check, so a + /// path that escapes the allowed roots via traversal is rejected. A null or empty root set + /// matches nothing, so the path is rejected as out of scope. This is a security boundary — + /// it prevents files outside the project from being compiled and injected into the assembly. + /// + public static PathValidationResult ValidateReloadPath(string resolvedPath, System.Collections.Generic.IReadOnlyCollection allowedRoots) + { + if (string.IsNullOrWhiteSpace(resolvedPath)) + { + return PathValidationResult.Invalid("Bad Request", "Resolved file path is empty."); + } + + string fullPath; + try + { + fullPath = Path.GetFullPath(resolvedPath); + } + catch (Exception ex) + { + return PathValidationResult.Invalid("Bad Request", $"Invalid path: {ex.Message}"); + } + + var underAnyRoot = false; + if (allowedRoots != null) + { + foreach (var root in allowedRoots) + { + if (string.IsNullOrWhiteSpace(root)) + { + continue; + } + + string fullRoot; + try + { + fullRoot = Path.GetFullPath(root); + } + catch + { + continue; + } + + if (IsUnderDirectory(fullPath, fullRoot)) + { + underAnyRoot = true; + break; + } + } + } + + if (!underAnyRoot) + { + return PathValidationResult.Invalid( + "Out Of Project Scope", + $"Hot reload only accepts files inside the project's baked roots (Assets/ or a loaded " + + $"package). '{fullPath}' is outside the project scope and will not be compiled."); + } + + if (!File.Exists(fullPath)) + { + return PathValidationResult.Invalid("File Not Found", $"Source file not found: {fullPath}"); + } + + return PathValidationResult.Valid(); + } + + /// + /// True when is contained within . + /// Both are expected to be normalized absolute paths. A trailing separator is appended to + /// the directory so that a sibling such as "AssetsExtra" is not treated as being under "Assets". + /// + private static bool IsUnderDirectory(string path, string directory) + { + var prefix = directory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + + // Windows file systems are case-insensitive; Unix file systems are case-sensitive. + var comparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + return path.StartsWith(prefix, comparison); + } + + /// + /// Resolve source file path from various possible locations (used by the in-place command). + /// + private static string ResolveSourceFilePath(string filename) + { + if (Path.IsPathRooted(filename) && File.Exists(filename)) + { + return filename; + } + + var potentialPaths = new[] + { + Path.Combine("Assets", filename), + Path.Combine("Assets", "Scripts", filename), + filename // Project root + }; + + foreach (var path in potentialPaths) + { + if (File.Exists(path)) + { + return Path.GetFullPath(path); + } + } + + return null; + } + + /// + /// Execute hot reload compilation and application asynchronously (for separate override files). + /// + private static async Task ExecuteHotReloadAsync(string filename, int timeoutMs, string assemblyDir) + { + try + { + // Use the HotReloadCompiler to compile and apply the changes + var compileResult = await HotReloadCompiler.CompileAndApplyAsync(filename, assemblyDir); + + if (!compileResult.IsSuccess) + { + return HotReloadResponse.CmdFailure( + compileResult.Error ?? "Compilation Failed", + compileResult.ErrorDetails ?? "Hot reload compilation failed", + compileResult.ExecutionTimeMs, + compileResult.Diagnostics); + } + + // Compiled, but no override actually bound (e.g. the target is not [HotReloadWithOverrides], + // or the component is not in the scene / not in play mode). Report it rather than + // claiming success when nothing changed. + if (compileResult.RegisteredMethods.Count == 0) + { + return HotReloadResponse.CmdFailure( + "No Overrides Applied", + compileResult.Diagnostics.Count > 0 + ? "No hot reload overrides were applied:\n- " + string.Join("\n- ", compileResult.Diagnostics) + : "No [HotReloadOverrideMethod] overrides were applied. Ensure the target component is in the scene and in play mode.", + compileResult.ExecutionTimeMs, + compileResult.Diagnostics); + } + + var message = $"Hot reload successful: {compileResult.AssemblyName} with {compileResult.RegisteredMethods.Count} methods"; + var response = HotReloadResponse.CmdSuccess( + compileResult.AssemblyName, + message, + compileResult.RegisteredMethods, + compileResult.ExecutionTimeMs); + response.Diagnostics = compileResult.Diagnostics; // surface any partially-skipped overrides + return response; + } + catch (TimeoutException) + { + return HotReloadResponse.CmdFailure( + "Timeout", + $"Hot reload compilation exceeded {timeoutMs}ms timeout", + timeoutMs); + } + catch (Exception ex) + { + return HotReloadResponse.CmdFailure( + "Execution Failed", + ex.ToString(), + 0); + } + } + } + + /// + /// Response model for hot reload CLI commands. + /// Provides consistent response format for all hot reload operations. + /// + public class HotReloadResponse : CommandExecutionResponse + { + /// + /// Assembly name or operation identifier for successful operations. + /// + public string AssemblyName { get; set; } + + /// + /// List of registered method IDs or files processed. + /// + public System.Collections.Generic.List Items { get; set; } = new System.Collections.Generic.List(); + + /// + /// Compilation or processing diagnostics. + /// + public System.Collections.Generic.List Diagnostics { get; set; } = new System.Collections.Generic.List(); + + /// + /// Create a successful hot reload response. + /// + public static HotReloadResponse CmdSuccess(string assemblyName, string message, System.Collections.Generic.List items, long executionTimeMs) + { + return new HotReloadResponse + { + Success = true, + AssemblyName = assemblyName, + Message = message, + Items = items ?? new System.Collections.Generic.List(), + ExecutionTimeMs = executionTimeMs + }; + } + + /// + /// Create a failed hot reload response. + /// + public static HotReloadResponse CmdFailure(string error, string errorDetails, long executionTimeMs, System.Collections.Generic.List diagnostics = null) + { + return new HotReloadResponse + { + Success = false, + Error = error, + ErrorDetails = errorDetails, + ExecutionTimeMs = executionTimeMs, + Diagnostics = diagnostics ?? new System.Collections.Generic.List() + }; + } + } + + /// + /// Result of validating a hot reload source path. is a short category and + /// the human-readable explanation, matching the shape consumed by + /// . + /// + public class PathValidationResult + { + public bool IsValid { get; private set; } + public string Error { get; private set; } + public string ErrorDetails { get; private set; } + + public static PathValidationResult Valid() + { + return new PathValidationResult { IsValid = true }; + } + + public static PathValidationResult Invalid(string error, string errorDetails) + { + return new PathValidationResult { IsValid = false, Error = error, ErrorDetails = errorDetails }; + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/HotReloadCommands.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/HotReloadCommands.cs.meta new file mode 100644 index 00000000..74077bc3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/HotReloadCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 33ec6e594cb07434cb51fbc751ca37f2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeApplicationCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeApplicationCommand.cs new file mode 100644 index 00000000..a937df13 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeApplicationCommand.cs @@ -0,0 +1,89 @@ +using Unity.Pipeline.Commands; +using UnityEngine; + +namespace Unity.Pipeline.Runtime.Commands +{ + /// + /// Runtime application control commands for Unity Player builds. + /// Provides basic application lifecycle and performance control. + /// + public static class RuntimeApplicationCommand + { + [CliCommand("quit", "Gracefully quit the Unity application", MainThreadRequired = true, RuntimeOnly = true)] + public static string QuitApplication( + [CliArg("exitCode", "Exit code for the application")] int exitCode = 0) + { + // Schedule quit on next frame to allow HTTP response to be sent + var quitObject = new GameObject("PipelineQuitScheduler"); + var quitScheduler = quitObject.AddComponent(); + quitScheduler.Initialize(exitCode); + + return $"Application quit scheduled with exit code {exitCode}"; + } + + [CliCommand("set_target_framerate", "Set the target frame rate for the application", MainThreadRequired = true, RuntimeOnly = true)] + public static string SetTargetFrameRate( + [CliArg("frameRate", "Target frame rate (-1 for platform default, 0 for unlimited)", Required = true)] int frameRate) + { + var previousFrameRate = Application.targetFrameRate; + Application.targetFrameRate = frameRate; + + var frameRateDescription = frameRate switch + { + -1 => "platform default", + 0 => "unlimited (VSync)", + _ => $"{frameRate} FPS" + }; + + return $"Target frame rate set to {frameRateDescription} (was {previousFrameRate})"; + } + + [CliCommand("set_timescale", "Set the time scale for the application", MainThreadRequired = true, RuntimeOnly = true)] + public static string SetTimeScale( + [CliArg("scale", "Time scale multiplier (0.0 to pause, 1.0 for normal speed)", Required = true)] float scale) + { + if (scale < 0f) + { + return "Error: Time scale cannot be negative"; + } + + var previousScale = Time.timeScale; + Time.timeScale = scale; + + var scaleDescription = scale switch + { + 0f => "paused", + 1f => "normal speed", + _ => $"{scale}x speed" + }; + + return $"Time scale set to {scaleDescription} (was {previousScale}x)"; + } + + /// + /// MonoBehaviour to handle delayed application quit. + /// Allows HTTP response to be sent before application terminates. + /// + private class QuitScheduler : MonoBehaviour + { + private int m_ExitCode; + private float m_QuitTime; + + public void Initialize(int exitCode) + { + m_ExitCode = exitCode; + m_QuitTime = Time.realtimeSinceStartup + 0.1f; // 100ms delay + DontDestroyOnLoad(gameObject); + } + + private void Update() + { + if (Time.realtimeSinceStartup >= m_QuitTime) + { + Debug.Log($"Pipeline: Quitting application with exit code {m_ExitCode}"); + Application.Quit(m_ExitCode); + } + } + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeApplicationCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeApplicationCommand.cs.meta new file mode 100644 index 00000000..d952319c --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeApplicationCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e508f97d464c66347816d5281c68e276 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeInputCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeInputCommand.cs new file mode 100644 index 00000000..526de177 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeInputCommand.cs @@ -0,0 +1,180 @@ +using System; +using Unity.Pipeline.Commands; +using UnityEngine; +#if ENABLE_INPUT_SYSTEM +using UnityEngine.InputSystem; +using UnityEngine.InputSystem.Controls; +using UnityEngine.InputSystem.LowLevel; +#endif + +namespace Unity.Pipeline.Runtime.Commands +{ + /// + /// + /// Input-simulation commands that drive a running Player by injecting synthetic input — the enabler + /// for automated play-testing (CLI-208). Keyboard and pointer events are injected through the Input + /// System (InputSystem.QueueEvent with a state event captured from the live device), which also + /// drives uGUI / UI Toolkit through the Input System UI module — so a simulated pointer click lands on + /// on-screen UI the same way a real one does. + /// + /// + /// Input stack: device injection requires the Input System package to be present and active + /// (ENABLE_INPUT_SYSTEM). Legacy UnityEngine.Input is poll-only and exposes no injection + /// API, so it is intentionally not supported; when the Input System is absent these commands report an + /// "unavailable" result rather than silently no-op'ing. + /// + /// Safety: these are write/destructive commands. The package has no safety-policy gate yet + /// (CAT-2509 is not implemented), so they follow the existing unguarded write-command model (cf. + /// set_target_framerate). TODO(CAT-2509): route input commands through the safety policy when it + /// lands. + /// + public static class RuntimeInputCommand + { + [CliCommand("simulate_key", "Simulate a keyboard key event (Input System). Drives the running app.", + MainThreadRequired = true, RuntimeOnly = true)] + public static InputSimulationResponse SimulateKey( + [CliArg("key", "Input System Key name, e.g. Space, W, Enter, LeftArrow", Required = true)] string key, + [CliArg("action", "down | up | press (down+up). Default: press")] string action = "press") + { +#if ENABLE_INPUT_SYSTEM + var keyboard = Keyboard.current; + if (keyboard == null) + return InputSimulationResponse.Fail("simulate_key", "No keyboard device is present."); + + if (!Enum.TryParse(key, ignoreCase: true, out var parsedKey) || parsedKey == Key.None) + return InputSimulationResponse.Fail("simulate_key", $"Unknown key '{key}'. Use an Input System Key name (e.g. Space, W, Enter)."); + + var control = keyboard[parsedKey]; + var act = Normalize(action); + switch (act) + { + case "down": + QueueButton(control, true); + break; + case "up": + QueueButton(control, false); + break; + case "press": + QueueButton(control, true); + QueueButton(control, false); + break; + default: + return InputSimulationResponse.Fail("simulate_key", $"Unknown action '{action}'. Use down | up | press."); + } + + // Flush queued events so the effect is observable synchronously to the caller. + InputSystem.Update(); + return InputSimulationResponse.Ok("simulate_key", $"key={parsedKey} action={act}"); +#else + return InputSimulationResponse.Unavailable("simulate_key"); +#endif + } + + [CliCommand("simulate_pointer", "Simulate a mouse/pointer event at screen coordinates (Input System).", + MainThreadRequired = true, RuntimeOnly = true)] + public static InputSimulationResponse SimulatePointer( + [CliArg("x", "Screen X in pixels (origin bottom-left)", Required = true)] float x, + [CliArg("y", "Screen Y in pixels (origin bottom-left)", Required = true)] float y, + [CliArg("action", "move | down | up | click (down+up). Default: click")] string action = "click", + [CliArg("button", "left | right | middle. Default: left")] string button = "left") + { +#if ENABLE_INPUT_SYSTEM + var mouse = Mouse.current; + if (mouse == null) + return InputSimulationResponse.Fail("simulate_pointer", "No mouse/pointer device is present."); + + var position = new Vector2(x, y); + var act = Normalize(action); + + ButtonControl btn; + switch (Normalize(button)) + { + case "left": btn = mouse.leftButton; break; + case "right": btn = mouse.rightButton; break; + case "middle": btn = mouse.middleButton; break; + default: + return InputSimulationResponse.Fail("simulate_pointer", $"Unknown button '{button}'. Use left | right | middle."); + } + + switch (act) + { + case "move": + QueuePointer(mouse, position, null, false); + break; + case "down": + QueuePointer(mouse, position, btn, true); + break; + case "up": + QueuePointer(mouse, position, btn, false); + break; + case "click": + QueuePointer(mouse, position, btn, true); + QueuePointer(mouse, position, btn, false); + break; + default: + return InputSimulationResponse.Fail("simulate_pointer", $"Unknown action '{action}'. Use move | down | up | click."); + } + + InputSystem.Update(); + return InputSimulationResponse.Ok("simulate_pointer", $"pos=({x},{y}) action={act} button={Normalize(button)}"); +#else + return InputSimulationResponse.Unavailable("simulate_pointer"); +#endif + } + +#if ENABLE_INPUT_SYSTEM + // Capture the device's current state into a state event, override a single control, then queue it. + // Capturing current state (rather than sending a zeroed full state) preserves any other controls + // that are already held — so chained down/up calls compose correctly. + private static void QueueButton(InputControl control, bool pressed) + { + using (StateEvent.From(control.device, out var eventPtr)) + { + control.WriteValueIntoEvent(pressed ? 1f : 0f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + } + + private static void QueuePointer(Mouse mouse, Vector2 position, ButtonControl button, bool pressed) + { + using (StateEvent.From(mouse, out var eventPtr)) + { + mouse.position.WriteValueIntoEvent(position, eventPtr); + if (button != null) + button.WriteValueIntoEvent(pressed ? 1f : 0f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + } +#endif + + private static string Normalize(string s) => (s ?? string.Empty).Trim().ToLowerInvariant(); + } + + /// + /// Structured result for an input-simulation command: whether the event was injected, and a short + /// human-readable detail (or an error / unavailable reason). + /// + [Serializable] + public class InputSimulationResponse + { + public bool Success { get; set; } + public string Command { get; set; } + public string Detail { get; set; } + public string Error { get; set; } + + public static InputSimulationResponse Ok(string command, string detail) => + new InputSimulationResponse { Success = true, Command = command, Detail = detail }; + + public static InputSimulationResponse Fail(string command, string error) => + new InputSimulationResponse { Success = false, Command = command, Error = error }; + + public static InputSimulationResponse Unavailable(string command) => + new InputSimulationResponse + { + Success = false, + Command = command, + Error = "Input simulation requires the Input System package to be present and active " + + "(ENABLE_INPUT_SYSTEM). Legacy input injection is not supported." + }; + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeInputCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeInputCommand.cs.meta new file mode 100644 index 00000000..788ed9dd --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeInputCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fa9e0d957acd4b1293e4d40e83bca73f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeLogCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeLogCommand.cs new file mode 100644 index 00000000..8c4e3192 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeLogCommand.cs @@ -0,0 +1,49 @@ +using Unity.Pipeline.Commands; +using UnityEngine; + +namespace Unity.Pipeline.Runtime.Commands +{ + /// + /// Runtime logging commands for Unity Player builds. + /// Provides console logging capabilities from CLI. + /// + public static class RuntimeLogCommand + { + [CliCommand("log", "Write a message to Unity console", MainThreadRequired = true, RuntimeOnly = true)] + public static string LogMessage( + [CliArg("message", "Message to log to console", Required = true)] string message, + [CliArg("level", "Log level: info, warning, error")] string level = "info") + { + if (string.IsNullOrWhiteSpace(message)) + { + return "Error: Message cannot be empty"; + } + + Debug.developerConsoleVisible = false; + var logLevel = level?.ToLowerInvariant() ?? "info"; + + switch (logLevel) + { + case "info": + case "log": + Debug.Log($"[Pipeline] {message}"); + return $"Logged info message: {message}"; + + case "warning": + case "warn": + Debug.LogWarning($"[Pipeline] {message}"); + return $"Logged warning message: {message}"; + + case "error": + case "err": + Debug.LogError($"[Pipeline] {message}"); + return $"Logged error message: {message}"; + + default: + Debug.LogWarning($"[Pipeline] Unknown log level '{level}', defaulting to info: {message}"); + Debug.Log($"[Pipeline] {message}"); + return $"Logged message with default level: {message}"; + } + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeLogCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeLogCommand.cs.meta new file mode 100644 index 00000000..876ab94d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeLogCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8e82ea92b18fc264faf68d664212e013 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeStatusCommand.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeStatusCommand.cs new file mode 100644 index 00000000..2e006d7b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeStatusCommand.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Runtime.Telemetry; +using UnityEngine; +using UnityEngine.Profiling; + +namespace Unity.Pipeline.Runtime.Commands +{ + /// + /// Runtime status information for Unity Player builds. + /// Provides Player-appropriate status without Editor-specific APIs. + /// + public static class RuntimeStatusCommand + { + [CliCommand("runtime_status", "Get comprehensive runtime application status", MainThreadRequired = true, RuntimeOnly = true)] + public static RuntimeStatusResponse GetRuntimeStatus() + { + try + { + return new RuntimeStatusResponse + { + // Application info + UnityVersion = Application.unityVersion, + Platform = Application.platform.ToString(), + BuildGuid = Application.buildGUID, + Version = Application.version, + + // Runtime state + IsPlaying = true, // Always true in Player builds + TargetFrameRate = Application.targetFrameRate, + TimeScale = Time.timeScale, + RealTimeSinceStartup = Time.realtimeSinceStartup, + + // Performance info + FrameCount = Time.frameCount, + LoadedLevelName = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name, + LoadedLevelBuildIndex = UnityEngine.SceneManagement.SceneManager.GetActiveScene().buildIndex, + + // Live performance telemetry (fps, frame time, memory, profiler counters) + Performance = BuildPerformanceTelemetry(), + + // Timestamp + Timestamp = DateTime.UtcNow + }; + } + catch (Exception ex) + { + Debug.LogError($"Pipeline: Runtime status command failed: {ex.Message}"); + throw; + } + } + + /// + /// Assemble the live performance section from two independent sources: + /// the per-frame (fps / frame time / profiler counters), which only + /// exists while a is feeding it, and the Profiler memory APIs, + /// which can be read on demand at any time. Frame-stat fields stay at their defaults (and + /// stays false) when no sampler is + /// running — e.g. in EditMode tests — so a missing sampler degrades gracefully rather than throwing. + /// + private static RuntimePerformanceTelemetry BuildPerformanceTelemetry() + { + var perf = new RuntimePerformanceTelemetry(); + + var sampler = FrameStatsSampler.Shared; + if (sampler != null) + { + var snap = sampler.GetSnapshot(); + perf.FrameStatsAvailable = snap.Available; + perf.SampleWindow = snap.SampleWindow; + perf.Fps = snap.Fps; + perf.AverageFrameTimeMs = snap.AverageFrameTimeMs; + perf.MinFrameTimeMs = snap.MinFrameTimeMs; + perf.MaxFrameTimeMs = snap.MaxFrameTimeMs; + perf.LastFrameTimeMs = snap.LastFrameTimeMs; + perf.GpuTimingAvailable = snap.GpuTimingAvailable; + perf.CpuFrameTimeMs = snap.CpuFrameTimeMs; + perf.GpuFrameTimeMs = snap.GpuFrameTimeMs; + perf.Counters = snap.Counters; + } + else + { + perf.Counters = new Dictionary(); + } + + // Memory is sampled directly (no per-frame accumulation needed). These return 0 in a + // non-development build where the profiler is stripped, which is reported faithfully. + perf.TotalAllocatedMemory = Profiler.GetTotalAllocatedMemoryLong(); + perf.TotalReservedMemory = Profiler.GetTotalReservedMemoryLong(); + perf.MonoUsedSize = Profiler.GetMonoUsedSizeLong(); + perf.MonoHeapSize = Profiler.GetMonoHeapSizeLong(); + perf.GcMemory = GC.GetTotalMemory(false); + + return perf; + } + } + + /// + /// Response model for runtime status information. + /// + [Serializable] + public class RuntimeStatusResponse + { + // Application Information + public string UnityVersion { get; set; } + public string Platform { get; set; } + public string BuildGuid { get; set; } + public string Version { get; set; } + + // Runtime State + public bool IsPlaying { get; set; } // Always true for Player builds + public int TargetFrameRate { get; set; } + public float TimeScale { get; set; } + public float RealTimeSinceStartup { get; set; } + + // Performance Information + public int FrameCount { get; set; } + public string LoadedLevelName { get; set; } + public int LoadedLevelBuildIndex { get; set; } + + // Live performance telemetry + public RuntimePerformanceTelemetry Performance { get; set; } + + // Metadata + public DateTime Timestamp { get; set; } + } + + /// + /// Live performance telemetry for a running Player: rolling fps / frame-time statistics, optional + /// CPU/GPU frame timing, memory usage, and a map of profiler counters. Frame-stat and counter fields + /// are populated from the per-frame ; memory fields are read on demand. + /// + [Serializable] + public class RuntimePerformanceTelemetry + { + // Frame timing — sampled over a rolling window by the RuntimePipelineManager. + /// False when no sampler is running; the fps / frame-time fields below are then meaningless. + public bool FrameStatsAvailable { get; set; } + public int SampleWindow { get; set; } + public float Fps { get; set; } + public float AverageFrameTimeMs { get; set; } + public float MinFrameTimeMs { get; set; } + public float MaxFrameTimeMs { get; set; } + public float LastFrameTimeMs { get; set; } + + // CPU/GPU frame timing via FrameTimingManager (not available on every platform). + public bool GpuTimingAvailable { get; set; } + public double CpuFrameTimeMs { get; set; } + public double GpuFrameTimeMs { get; set; } + + // Memory (bytes) via the Profiler API; 0 when the profiler is stripped (non-development build). + public long TotalAllocatedMemory { get; set; } + public long TotalReservedMemory { get; set; } + public long MonoUsedSize { get; set; } + public long MonoHeapSize { get; set; } + public long GcMemory { get; set; } + + // Profiler counters by reported name (e.g. DrawCalls, Triangles, GcAllocInFrame). + public Dictionary Counters { get; set; } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeStatusCommand.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeStatusCommand.cs.meta new file mode 100644 index 00000000..3ae73653 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/RuntimeStatusCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 846a260c41f6d8f4583f1473632c1d62 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/VisualElementCaptureSupport.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/VisualElementCaptureSupport.cs new file mode 100644 index 00000000..f04c6f3d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/VisualElementCaptureSupport.cs @@ -0,0 +1,444 @@ +#if UNITY_6000_7_OR_NEWER +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using Newtonsoft.Json; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.UIElements; +using Object = UnityEngine.Object; + +namespace Unity.Pipeline.Runtime.Commands +{ + /// + /// Shared, runtime-safe helpers behind the visual-element capture commands + /// (capture_editor_element and capture_runtime_element). Lives in the runtime + /// assembly with no UnityEditor dependency so both the Editor command and the + /// player-side runtime command can use it. + /// + /// Capture uses (runtime + /// module) and encodes the PNG here, rather than the editor-only + /// VisualElementCaptureEditorExtensions.CaptureToPNG, so the same path works in a Player. + /// + /// Element selection maps a small USS-like selector string onto UQuery's public + /// (the engine's USS string parser is internal). Supported: + /// descendant (space) and child (>) combinators; each simple part is + /// Type#name.class1.class2 (any subset); optional pseudo-state suffixes + /// :checked :hover :focus :active :enabled :disabled and :not(<state>). + /// + public static class VisualElementCaptureSupport + { + /// + /// Returns a failure response when no GPU is available (batchmode/headless), where a capture + /// would read back a blank image; otherwise returns null (capture may proceed). + /// + public static CaptureElementResponse GpuUnavailableResponse() + { + if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null) + return CaptureElementResponse.Fail("No GPU available (batchmode/headless); cannot capture."); + return null; + } + + /// + /// Resolve the first under matching + /// , or null if the selector is empty/invalid or nothing matches. + /// + public static VisualElement ResolveElement(VisualElement root, string selector) + { + if (root == null || string.IsNullOrWhiteSpace(selector)) + return null; + + if (!TryBuildQuery(root, selector, out var query)) + return null; + + return query.First(); + } + + /// + /// Resolve the selector against each root in order, returning the first match (and the root + /// it was found under via ), or null if none match. + /// + public static VisualElement ResolveElement(IEnumerable roots, string selector, + out VisualElement matchedRoot) + { + matchedRoot = null; + if (roots == null) + return null; + + foreach (var root in roots) + { + var element = ResolveElement(root, selector); + if (element != null) + { + matchedRoot = root; + return element; + } + } + + return null; + } + + /// + /// Capture to PNG bytes at its own pixel size. Restores the active + /// RenderTexture and releases all temporaries so the capture leaves no global render state. + /// Throws for camera-drawn (world-space) or detached + /// panels (surfaced from ). + /// + public static byte[] CaptureElementToPng(VisualElement element, out int width, out int height) + { + if (element == null) + throw new ArgumentNullException(nameof(element)); + + var rt = element.CaptureToRenderTexture(); + var prevActive = RenderTexture.active; + Texture2D tex = null; + try + { + width = rt.width; + height = rt.height; + + RenderTexture.active = rt; + tex = new Texture2D(rt.width, rt.height, TextureFormat.RGBA32, false); + tex.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0, false); + tex.Apply(false, false); + return tex.EncodeToPNG(); + } + finally + { + RenderTexture.active = prevActive; + rt.Release(); + DestroyObject(rt); + if (tex != null) + DestroyObject(tex); + } + } + + /// + /// Resolve the output path. An explicit rooted path is used as-is; an explicit relative path + /// is resolved against ; an empty path produces a + /// timestamped <prefix>_yyyyMMdd_HHmmss_fff.png file under + /// . + /// + public static string ResolveOutputPath(string output, string relativeBaseDir, string defaultDir, string prefix) + { + if (!string.IsNullOrWhiteSpace(output)) + { + return Path.IsPathRooted(output) + ? output + : Path.GetFullPath(Path.Combine(relativeBaseDir, output)); + } + + var stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff", CultureInfo.InvariantCulture); + return Path.Combine(defaultDir, $"{prefix}_{stamp}.png"); + } + + /// Write PNG bytes to , creating the directory if needed. + public static void WritePng(string path, byte[] png) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + File.WriteAllBytes(path, png); + } + + /// + /// Capture the resolved , write the PNG to disk, and build a + /// populated success response (or a failure response if the panel cannot be captured). This + /// is the shared tail both commands run once they have an element + a panel root. + /// + public static CaptureElementResponse CaptureAndRespond(VisualElement element, string selector, + string source, string output, string relativeBaseDir, string defaultDir, string prefix) + { + var gpuGuard = GpuUnavailableResponse(); + if (gpuGuard != null) + return gpuGuard; + + try + { + var png = CaptureElementToPng(element, out var width, out var height); + var path = ResolveOutputPath(output, relativeBaseDir, defaultDir, prefix); + WritePng(path, png); + + return new CaptureElementResponse + { + Success = true, + Selector = selector, + Source = source, + Path = path, + Width = width, + Height = height, + Encoding = "png", + Base64 = Convert.ToBase64String(png), + Bytes = png.Length, + Message = $"Captured {source} to {path}" + }; + } + catch (InvalidOperationException ex) + { + // The capture API throws this for camera-drawn (world-space) or detached panels. + return CaptureElementResponse.Fail(ex.Message); + } + catch (Exception ex) + { + return CaptureElementResponse.Fail($"Failed to capture '{selector}' from {source}: {ex.Message}"); + } + } + + // ---- Selector parsing ------------------------------------------------------------------- + + // Build a UQuery from the selector, mapping tokens onto the public UQueryBuilder fluent API. + static bool TryBuildQuery(VisualElement root, string selector, out UQueryBuilder query) + { + query = root.Query(); + + var parts = Tokenize(selector); + if (parts.Count == 0) + return false; + + for (int i = 0; i < parts.Count; i++) + { + var (isChild, partStr) = parts[i]; + if (!TryParsePart(partStr, out var part)) + return false; + + if (i == 0) + query = query.Name(part.Name); + else + query = isChild + ? query.Children(part.Name) + : query.Descendents(part.Name); + + foreach (var cls in part.Classes) + query = query.Class(cls); + + if (part.Type != null) + { + var typeName = part.Type; + query = query.Where(e => TypeNameMatches(e, typeName)); + } + + foreach (var pseudo in part.Pseudos) + { + if (!TryApplyPseudo(ref query, pseudo.Name, pseudo.Negate)) + return false; + } + } + + return true; + } + + // Split a selector into simple parts, flagging each with whether it follows a '>' (child) + // combinator. Whitespace separates descendant parts; '>' is treated as its own token. + static List<(bool isChild, string part)> Tokenize(string selector) + { + var result = new List<(bool, string)>(); + var raw = selector.Replace(">", " > ") + .Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + + bool nextIsChild = false; + foreach (var token in raw) + { + if (token == ">") + { + nextIsChild = true; + continue; + } + + result.Add((nextIsChild, token)); + nextIsChild = false; + } + + return result; + } + + class SelectorPart + { + public string Type; + public string Name; + public readonly List Classes = new List(); + public readonly List<(bool Negate, string Name)> Pseudos = new List<(bool, string)>(); + } + + // Parse "Type#name.class1.class2" with optional ":pseudo" / ":not(pseudo)" suffixes. + static bool TryParsePart(string raw, out SelectorPart part) + { + part = new SelectorPart(); + + var simple = ExtractPseudos(raw, part.Pseudos); + if (simple == null) + return false; + + int i = 0; + var sb = new StringBuilder(); + + // Leading run (before any '#' or '.') is the type token. + while (i < simple.Length && simple[i] != '#' && simple[i] != '.') + sb.Append(simple[i++]); + if (sb.Length > 0) + { + var type = sb.ToString(); + if (type != "*") + part.Type = type; + } + + while (i < simple.Length) + { + char kind = simple[i++]; + sb.Clear(); + while (i < simple.Length && simple[i] != '#' && simple[i] != '.') + sb.Append(simple[i++]); + + var value = sb.ToString(); + if (value.Length == 0) + return false; // dangling '#' or '.' + + if (kind == '#') + { + if (part.Name != null) + return false; // more than one id + part.Name = value; + } + else + { + part.Classes.Add(value); + } + } + + return true; + } + + // Strip ":pseudo" and ":not(pseudo)" suffixes into 'pseudos', returning the simple-selector + // remainder, or null if a pseudo is malformed. + static string ExtractPseudos(string raw, List<(bool Negate, string Name)> pseudos) + { + int idx; + while ((idx = raw.IndexOf(':')) >= 0) + { + var head = raw.Substring(0, idx); + var rest = raw.Substring(idx + 1); + + if (rest.StartsWith("not(", StringComparison.Ordinal)) + { + int close = rest.IndexOf(')'); + if (close < 0) + return null; + var inner = rest.Substring(4, close - 4).Trim().TrimStart(':'); + if (inner.Length == 0) + return null; + pseudos.Add((true, inner.ToLowerInvariant())); + raw = head + rest.Substring(close + 1); + } + else + { + int next = rest.IndexOf(':'); + var name = next < 0 ? rest : rest.Substring(0, next); + if (name.Length == 0) + return null; + pseudos.Add((false, name.ToLowerInvariant())); + raw = head + (next < 0 ? string.Empty : rest.Substring(next)); + } + } + + return raw; + } + + // Apply one pseudo-state to the query, honoring negation. Returns false for an unknown state. + static bool TryApplyPseudo(ref UQueryBuilder query, string name, bool negate) + { + bool on = !negate; + switch (name) + { + case "checked": query = on ? query.Checked() : query.NotChecked(); return true; + case "hover": query = on ? query.Hovered() : query.NotHovered(); return true; + case "focus": query = on ? query.Focused() : query.NotFocused(); return true; + case "active": query = on ? query.Active() : query.NotActive(); return true; + case "enabled": query = on ? query.Enabled() : query.NotEnabled(); return true; + case "disabled": query = on ? query.NotEnabled() : query.Enabled(); return true; + default: return false; + } + } + + // Match a type token against the element's runtime type name or any of its base type names, + // so "Button" matches a Button and "VisualElement" matches everything. Avoids reflecting the + // generic OfType over a runtime-resolved Type. + static bool TypeNameMatches(VisualElement element, string typeName) + { + for (var t = element.GetType(); t != null && t != typeof(object); t = t.BaseType) + { + if (string.Equals(t.Name, typeName, StringComparison.Ordinal)) + return true; + } + return false; + } + + static void DestroyObject(Object obj) + { +#if UNITY_EDITOR + if (!Application.isPlaying) + { + Object.DestroyImmediate(obj); + return; + } +#endif + Object.Destroy(obj); + } + } + + /// + /// Result of a visual-element capture command: the PNG payload (base64), its dimensions, the + /// panel/source it was rendered from, and the path it was written to. JSON property names mirror + /// the GameView capture command's result shape (PR #13) for easy convergence later. + /// + [Serializable] + public class CaptureElementResponse + { + /// Whether the capture succeeded. + [JsonProperty("success")] + public bool Success { get; set; } + + /// Human-readable message (the error text on failure). + [JsonProperty("message")] + public string Message { get; set; } + + /// The selector used to locate the element. + [JsonProperty("selector")] + public string Selector { get; set; } + + /// What was captured, e.g. "window:Inspector" or "panel:MainUI". + [JsonProperty("source")] + public string Source { get; set; } + + /// Absolute filesystem path the PNG was written to. + [JsonProperty("path")] + public string Path { get; set; } + + /// Captured width in pixels. + [JsonProperty("width")] + public int Width { get; set; } + + /// Captured height in pixels. + [JsonProperty("height")] + public int Height { get; set; } + + /// Image encoding; always "png". + [JsonProperty("encoding")] + public string Encoding { get; set; } + + /// Base64-encoded PNG bytes. + [JsonProperty("base64")] + public string Base64 { get; set; } + + /// Length of the raw PNG byte array. + [JsonProperty("bytes")] + public int Bytes { get; set; } + + public static CaptureElementResponse Fail(string message) => new CaptureElementResponse + { + Success = false, + Message = message + }; + } +} +#endif diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/VisualElementCaptureSupport.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/VisualElementCaptureSupport.cs.meta new file mode 100644 index 00000000..4c2af275 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Commands/VisualElementCaptureSupport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3e2225ea88f5b5a4c9be3afc9c5d9f4e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common.meta new file mode 100644 index 00000000..614ed4af --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c9f4a11d82a4dd54d93200f6c3aa4678 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/BasePipelineServer.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/BasePipelineServer.cs new file mode 100644 index 00000000..f83a67ae --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/BasePipelineServer.cs @@ -0,0 +1,1177 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using Unity.Pipeline.Models; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Security; +using Unity.Pipeline.Threading; +using UnityEngine; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Unity.Pipeline +{ + /// + /// HTTP server that enables CLI tools to execute commands in Unity Editor. + /// Represents an Instance that can serve as a Pipeline Element for automation. + /// + public abstract class BasePipelineServer + { + /// + /// Maximum accepted request body size (1 MiB). Requests larger than this are rejected with + /// 413 to bound the memory a single remote request can force the server to buffer. + /// + private const long MaxRequestBodyBytes = 1 * 1024 * 1024; + + /// + /// Upper bound on how many bytes we read-and-discard from an over-limit request body before + /// sending the 413. HttpListener resets the TCP connection if we close the response while the + /// client is still uploading, and the client then sees a connection error instead of the 413. + /// Draining lets the client finish so it can read the response. Kept comfortably above + /// to cover clients only slightly over the cap, while still + /// bounding the work a single abusive request can force (a larger body simply resets). + /// + private const long MaxDrainBytes = 8 * 1024 * 1024; + + private bool m_IsRunning; + private int m_Port; + private HttpListener m_HttpListener; + private readonly Dispatcher m_Dispatcher = new Dispatcher(); + + private bool m_WatchdogEnabled; + private bool m_WatchdogArmed; + private DateTime m_LastWatchdogCheck; + + /// + /// This server's own main-thread dispatcher. Each server instance owns one (no global + /// singleton), so tests that start their own server never affect any other server's + /// dispatch. Pump it via ProcessWorkQueue from the main thread (auto-pumped on + /// EditorApplication.update in the editor; pumped by RuntimePipelineManager.Update in a + /// player; pumped explicitly by tests). + /// + public Dispatcher Dispatcher => m_Dispatcher; + + /// + /// Whether the server is running AND its HTTP listener is actually listening. More accurate + /// than the internal running flag alone: returns false if the listener was stopped/disposed + /// (e.g. by a domain reload) even when the flag is stale-true. Cheap and non-blocking. + /// + /// A self-HTTP probe was considered for "does it actually respond" but rejected: the server + /// processes requests strictly one-at-a-time (HandleRequests awaits ProcessRequest), so a + /// self-probe deadlocks if issued from within a handler and false-negatives whenever a + /// request is in flight — unsafe for a watchdog. IsListening reliably catches the realistic + /// failure (listener stopped by a domain reload), which is what we need. + /// + public bool IsRunning => m_IsRunning && m_HttpListener != null && m_HttpListener.IsListening; + + /// + /// When enabled, the server periodically checks its own HTTP listener and re-opens it in + /// place if it died without going through (e.g. an unexpected listener + /// fault). The server instance survives such failures — only the listener dies — so it can + /// self-heal without an external restart. Default OFF: transient/test servers must not + /// watchdog. The editor owner enables it for the live server. + /// + /// Setting this while the server is running arms/disarms the watchdog immediately; otherwise + /// it takes effect on the next . Domain reloads are NOT handled here (the + /// instance is torn down and recreated by [InitializeOnLoad]); the watchdog only revives a + /// listener that died while the instance is still alive. + /// + public bool WatchdogEnabled + { + get => m_WatchdogEnabled; + set + { + if (m_WatchdogEnabled == value) + return; + m_WatchdogEnabled = value; + if (!m_IsRunning) + return; + if (value) + ArmWatchdog(); + else + DisarmWatchdog(); + } + } + + /// + /// How often the watchdog checks the listener (seconds). Default 5. + /// + public double WatchdogIntervalSeconds { get; set; } = 5.0; + + /// + /// Port number the server is listening on. 0 if not running. + /// Range: 7800-7899 for Editor, 7900-7999 for Runtime (avoids unity-tools port range 37800-37899). + /// + public int Port => m_Port; + + public abstract DateTime StartedAt { get; } + + /// + /// Whether this server writes/deletes the shared instance descriptor (.unity-pipeline-port). + /// Test servers override this to false so they never clobber the live server's descriptor — + /// the test already knows its port, so no discovery file is needed. + /// + protected virtual bool WritesDescriptor => true; + + /// + /// Whether this server advertises commands marked [CliCommand(RuntimeOnly = true)] in + /// its /api/commands listing. Runtime servers list them; Editor servers hide them so a + /// client connected to an Editor only sees the Editor command surface. + /// + protected virtual bool IncludeRuntimeOnlyCommands => true; + + protected abstract void CreateInstanceDescriptor(); + protected abstract void DeleteInstanceDescriptor(); + protected abstract void UpdateHeartBeat(); + protected abstract object GetServerStatus(); + protected abstract string GetToken(); + + protected virtual void ServerStarted() + { + + } + + protected virtual void ServerStopped() + { + + } + + /// + /// This server's current auth token. Exposed to the test assembly (via InternalsVisibleTo) + /// so the test client can authenticate without re-deriving the token. + /// + internal string Token => GetToken(); + + /// + /// Start the HTTP server on the specified port or auto-assign from range. + /// + /// Port to bind to, or 0 for auto-assignment from server-specific range + public void Start(int port = 0) + { + if (m_IsRunning) + return; + + m_Port = port == 0 ? FindAvailablePort() : port; + + try + { + // Initialize this server's own dispatcher (captures the main thread; Start() is + // always called from the main thread). + m_Dispatcher.Initialize(); + + // Warm the token cache on the main thread before the listener accepts requests: + // per-request auth runs on background threads and the Editor token is SessionState- + // backed (main-thread-only). Re-runs after every domain reload via [InitializeOnLoad]. + SecurityTokenManager.GetOrCreateToken(); + + // Mark running before opening the listener so HandleRequests' loop guard stays true + // as soon as it starts on the threadpool. + m_IsRunning = true; + OpenListener(); + + System.Console.WriteLine($"Start HTTP server: port:{m_Port}"); + + if (WritesDescriptor) + CreateInstanceDescriptor(); + + ArmWatchdog(); + + // Keep the Editor and player loop (and thus Update -> Dispatcher.ProcessWorkQueue) running + // while the window is unfocused or minimized this is similar to auto_tick (especially for the Player). + Application.runInBackground = true; + ServerStarted(); + } + catch (Exception) + { + m_IsRunning = false; + m_HttpListener?.Stop(); + m_HttpListener = null; + throw; + } + } + + /// + /// Create the HTTP listener, bind it, and start the request-handling loop. Extracted from + /// so the watchdog can re-open a dead listener in place without + /// re-running the rest of startup (dispatcher init, descriptor). Caller sets m_IsRunning. + /// + private void OpenListener() + { + m_HttpListener = new HttpListener(); + AddLoopbackPrefixes(m_HttpListener, m_Port); + m_HttpListener.Start(); + + // Start request handling + _ = Task.Run(HandleRequests); + } + + /// + /// Bind a wildcard-host prefix so the listener accepts any Host header ("127.0.0.1", + /// "localhost", "::1") no matter how a client — or its DNS resolver — resolves the name. + /// This is required because Mono's HttpListener binds a hostname prefix to a single resolved + /// address family and then matches the request's Host literally per prefix: an + /// explicit "http://127.0.0.1/" prefix rejects "Host: localhost" (and vice-versa) with a + /// 400. A wildcard sidesteps that. The wildcard binds all interfaces at the socket level, so + /// access is confined to the local machine by the loopback check in + /// (plus bearer-token auth). + /// + private static void AddLoopbackPrefixes(HttpListener listener, int port) + { + listener.Prefixes.Add($"http://+:{port}/"); + } + + /// + /// Stop the HTTP server and clean up resources. + /// + public void Stop() + { + if (!m_IsRunning) + return; + + m_IsRunning = false; + + DisarmWatchdog(); + System.Console.WriteLine("Pipeline Server stopped"); + + try + { + if (WritesDescriptor) + DeleteInstanceDescriptor(); + + m_HttpListener?.Stop(); + m_HttpListener?.Close(); + + ServerStopped(); + } + catch + { + // Ignore cleanup errors + } + finally + { + m_HttpListener = null; + m_Dispatcher.Shutdown(); + } + // Note: Keep port value for diagnostic purposes + } + + /// + /// Arm the watchdog (no-op if disabled or already armed). In the editor the tick rides + /// EditorApplication.update; in a player it is driven by RuntimePipelineManager.Update via + /// . + /// + private void ArmWatchdog() + { + if (!m_WatchdogEnabled || m_WatchdogArmed) + return; + + m_WatchdogArmed = true; + m_LastWatchdogCheck = DateTime.UtcNow; +#if UNITY_EDITOR + UnityEditor.EditorApplication.update += WatchdogTick; +#endif + } + + private void DisarmWatchdog() + { + if (!m_WatchdogArmed) + return; + + m_WatchdogArmed = false; +#if UNITY_EDITOR + UnityEditor.EditorApplication.update -= WatchdogTick; +#endif + } + + /// + /// Watchdog heartbeat: throttled to , re-opens the HTTP + /// listener in place if it died while the server is still meant to be running. Safe to call + /// every frame. In the editor it is subscribed to EditorApplication.update by ArmWatchdog; + /// in a player RuntimePipelineManager.Update calls it. + /// + public void WatchdogTick() + { + if (!m_WatchdogArmed) + return; + +#if UNITY_EDITOR + // Don't fight domain reloads / asset updates — the listener is expected to be torn down + // then, and [InitializeOnLoad] recreates the server afterwards. + if (UnityEditor.EditorApplication.isCompiling || UnityEditor.EditorApplication.isUpdating) + return; +#endif + + var now = DateTime.UtcNow; + if ((now - m_LastWatchdogCheck).TotalSeconds < WatchdogIntervalSeconds) + return; + m_LastWatchdogCheck = now; + + if (m_HttpListener != null && m_HttpListener.IsListening) + return; // healthy + + try + { + // Dispose the dead listener so it releases the port binding before we re-bind a new + // one on the same port (a stopped-but-not-closed listener can keep the port held). + try { m_HttpListener?.Close(); } catch { } + m_HttpListener = null; + + // The instance is alive and the watchdog is armed → the server is meant to be + // listening. Re-open the listener in place. + m_IsRunning = true; + OpenListener(); + Debug.Log($"Pipeline watchdog re-opened HTTP listener on port {m_Port}"); + } + catch (Exception ex) + { + Debug.LogWarning($"Pipeline watchdog failed to re-open listener on port {m_Port}: {ex.Message}"); + } + } + + /// + /// Handle incoming HTTP requests with routing to appropriate endpoints. + /// + private async Task HandleRequests() + { + while (m_IsRunning && m_HttpListener != null && m_HttpListener.IsListening) + { + try + { + var context = await m_HttpListener.GetContextAsync(); + await ProcessRequest(context); + } + catch (ObjectDisposedException) + { + // Listener was stopped, exit gracefully + break; + } + catch (HttpListenerException) + { + // Listener was stopped, exit gracefully + break; + } + catch (Exception ex) + { + Debug.LogError(ex); + } + } + m_IsRunning = false; + } + + /// + /// Process individual HTTP request and route to appropriate handler. + /// + protected virtual async Task ProcessRequest(HttpListenerContext context) + { + var request = context.Request; + var response = context.Response; + + try + { + // Confine access to the local machine. The listener binds a wildcard host (see + // AddLoopbackPrefixes) so it accepts any Host header, which also means it accepts + // connections on non-loopback interfaces — reject anything that isn't loopback + // before doing any other work. Bearer-token auth is still enforced below. + var remoteAddress = request.RemoteEndPoint?.Address; + if (remoteAddress == null || !IPAddress.IsLoopback(remoteAddress)) + { + await SendStatusResponse(response, 403, "Forbidden", "Only loopback connections are allowed"); + return; + } + + // Reject browser-originated requests. Legitimate non-browser clients (CLI, CI) never + // send an Origin header; emitting no CORS headers and refusing any request that + // carries one prevents a website in the developer's browser from reaching this + // local server (and short-circuits CORS preflights). + if (!string.IsNullOrEmpty(request.Headers["Origin"])) + { + await SendStatusResponse(response, 403, "Forbidden", "Cross-origin requests are not allowed"); + return; + } + + // Authenticate every request with the bearer token before routing. + if (!IsAuthorized(request)) + { + await SendStatusResponse(response, 401, "Unauthorized", "Missing or invalid authentication token"); + return; + } + + // Route to appropriate endpoint + var path = request.Url.AbsolutePath.ToLowerInvariant(); + + switch (path) + { + case "/api/status": + await HandleStatusRequest(response); + break; + case "/api/editor_status": + await HandleEditorStatusRequest(response); + break; + case "/api/commands": + await HandleCommandsRequest(response); + break; + case "/api/exec": + if (request.HttpMethod == "POST") + await HandleExecRequest(request, response); + else + await HandleMethodNotAllowed(response); + break; + case "/api/test-status": + await HandleTestStatusRequest(response); + break; + default: + await HandleNotFound(response); + break; + } + } + catch (Exception) + { + // Ensure response is always closed + try + { + if (response.OutputStream.CanWrite) + { + response.StatusCode = 500; + response.OutputStream.Close(); + } + } + catch { } + } + } + + /// + /// Validate the request's bearer token against this server's token. + /// + private bool IsAuthorized(HttpListenerRequest request) + { + var header = request.Headers["Authorization"]; + if (string.IsNullOrEmpty(header)) + return false; + + const string prefix = "Bearer "; + if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return false; + + var token = header.Substring(prefix.Length).Trim(); + return SecurityTokenManager.ConstantTimeEquals(token, GetToken()); + } + + /// + /// Send a structured JSON error response with an explicit HTTP status code. + /// + private async Task SendStatusResponse(HttpListenerResponse response, int statusCode, string error, string details) + { + try + { + response.StatusCode = statusCode; + response.ContentType = "application/json"; + var json = JsonConvert.SerializeObject(BaseResponse.Failure(error, details), Formatting.Indented); + var buffer = Encoding.UTF8.GetBytes(json); + response.ContentLength64 = buffer.Length; + + await response.OutputStream.WriteAsync(buffer, 0, buffer.Length); + response.OutputStream.Close(); + } + catch + { + try { response.OutputStream.Close(); } catch { } + } + } + + /// + /// Handle /api/status endpoint - returns basic server health information. + /// No Editor API access required, always fast response. + /// + private async Task HandleStatusRequest(HttpListenerResponse response) + { + try + { + var basicStatus = GetServerStatus(); + var json = JsonConvert.SerializeObject(basicStatus, Formatting.Indented); + + response.StatusCode = 200; + response.ContentType = "application/json"; + + var buffer = Encoding.UTF8.GetBytes(json); + response.ContentLength64 = buffer.Length; + + await response.OutputStream.WriteAsync(buffer, 0, buffer.Length); + response.OutputStream.Close(); + } + catch (Exception ex) + { + Debug.LogError($"HandleStatusRequest failed: {ex.Message}"); + await SendErrorResponse(response, "Status Error", $"Failed to get status: {ex.Message}"); + } + } + + /// + /// Handle /api/editor_status endpoint - returns detailed Editor state via command execution. + /// Executes the "editor_status" command to get rich Editor information. + /// + private async Task HandleEditorStatusRequest(HttpListenerResponse response) + { + try + { + // TODO: should this be implemented as a command?? + + // Execute editor_status command to get rich Editor information + var result = await ExecuteCommandByName("editor_status", new JObject()); + + // The editor_status command returns a StatusResponse directly + var editorStatus = result as StatusResponse; + if (editorStatus != null) + { + // Update with server-specific information + UpdateStatusWithServerInfo(editorStatus); + + var json = JsonConvert.SerializeObject(editorStatus, Formatting.Indented); + + response.StatusCode = 200; + response.ContentType = "application/json"; + + var buffer = Encoding.UTF8.GetBytes(json); + response.ContentLength64 = buffer.Length; + + await response.OutputStream.WriteAsync(buffer, 0, buffer.Length); + response.OutputStream.Close(); + } + else + { + Debug.LogError($"HandleEditorStatusRequest: editor_status command returned wrong type: {result?.GetType().FullName ?? "null"}"); + await SendErrorResponse(response, "Editor Status Error", "editor_status command did not return valid StatusResponse"); + } + } + catch (Exception ex) + { + var errorMessage = !string.IsNullOrEmpty(ex.Message) ? ex.Message : "[EMPTY EXCEPTION MESSAGE]"; + Debug.LogError($"HandleEditorStatusRequest failed:"); + Debug.LogError($" Exception Type: {ex.GetType().FullName}"); + Debug.LogError($" Message: '{errorMessage}'"); + Debug.LogError($" Full Exception: {ex}"); + + await SendErrorResponse(response, "Editor Status Error", $"Failed to get editor status: {errorMessage}"); + } + } + + /// + /// Update StatusResponse with server-specific information. + /// Used by /api/editor_status to add server metadata to command result. + /// + private void UpdateStatusWithServerInfo(StatusResponse editorStatus) + { + UpdateHeartBeat(); + // Ensure heartbeat is current + editorStatus.LastHeartbeat = DateTime.UtcNow; + } + + /// + /// Handle /api/commands endpoint - returns available CLI commands with schemas. + /// + private async Task HandleCommandsRequest(HttpListenerResponse response) + { + try + { + var commands = CommandRegistry.DiscoverCommands() + .Where(c => IncludeRuntimeOnlyCommands || !c.RuntimeOnly) + .ToList(); + var commandsJson = commands.Select(BuildCommandResponse).ToList(); + + var responseData = new + { + commands = commandsJson, + count = commands.Count, + server = new + { + version = "0.0.1", // TODO: Get from package.json + port = m_Port, + startTime = StartedAt + } + }; + + var json = JsonConvert.SerializeObject(responseData, Formatting.Indented); + + response.StatusCode = 200; + response.ContentType = "application/json"; + + var buffer = Encoding.UTF8.GetBytes(json); + response.ContentLength64 = buffer.Length; + + await response.OutputStream.WriteAsync(buffer, 0, buffer.Length); + response.OutputStream.Close(); + } + catch (Exception ex) + { + Debug.LogError($"Failed to handle /api/commands request: {ex.Message}"); + + // Return error response + response.StatusCode = 500; + response.ContentType = "application/json"; + + var errorResponse = new { error = "Internal server error", message = ex.Message }; + var errorJson = JsonConvert.SerializeObject(errorResponse); + var errorBuffer = Encoding.UTF8.GetBytes(errorJson); + response.ContentLength64 = errorBuffer.Length; + + await response.OutputStream.WriteAsync(errorBuffer, 0, errorBuffer.Length); + response.OutputStream.Close(); + } + } + + /// + /// Build JSON response object for a single command. + /// + private object BuildCommandResponse(CommandInfo command) + { + return new + { + name = command.Name, + description = command.Description, + mainThreadRequired = command.MainThreadRequired, + runtimeOnly = command.RuntimeOnly, + parameters = command.Parameters.Select(p => new + { + name = p.Name, + description = p.Description, + type = p.ParameterType.Name, + required = p.Required, + defaultValue = p.DefaultValue + }).ToList(), + schema = JsonSchemaGenerator.GenerateCommandSchema(command) + }; + } + + /// + /// Handle 404 Not Found responses. + /// + private async Task HandleNotFound(HttpListenerResponse response) + { + response.StatusCode = 404; + response.ContentType = "text/plain"; + + var responseText = "Not Found"; + var buffer = Encoding.UTF8.GetBytes(responseText); + response.ContentLength64 = buffer.Length; + + await response.OutputStream.WriteAsync(buffer, 0, buffer.Length); + response.OutputStream.Close(); + } + + /// + /// Handle 405 Method Not Allowed responses. + /// + private async Task HandleMethodNotAllowed(HttpListenerResponse response) + { + await SendErrorResponse(response, "Method Not Allowed", "This endpoint only supports POST requests"); + } + + /// + /// Handle /api/test-status endpoint - returns status of async test execution. + /// + private async Task HandleTestStatusRequest(HttpListenerResponse response) + { + try + { + // Execute test_status command to get current test status + var result = await ExecuteCommandByName("test_status", new JObject()); + + string jsonResponse; + if (result is string statusString) + { + // test_status command returns JSON string directly + jsonResponse = statusString; + } + else + { + // Fallback - serialize whatever was returned + jsonResponse = JsonConvert.SerializeObject(result ?? new { status = "no_tests", message = "No test run in progress" }); + } + + response.StatusCode = 200; + response.ContentType = "application/json"; + + var buffer = Encoding.UTF8.GetBytes(jsonResponse); + response.ContentLength64 = buffer.Length; + + await response.OutputStream.WriteAsync(buffer, 0, buffer.Length); + response.OutputStream.Close(); + } + catch (Exception ex) + { + Debug.LogError($"HandleTestStatusRequest failed: {ex.Message}"); + await SendErrorResponse(response, "Test Status Error", $"Failed to get test status: {ex.Message}"); + } + } + + /// + /// Handle /api/exec endpoint - execute CLI commands with parameters. + /// + private async Task HandleExecRequest(HttpListenerRequest request, HttpListenerResponse response) + { + var cmd = ""; + string requestBody = null; + try + { + // Reject oversized bodies up front via Content-Length (cheap, before reading anything). + if (request.ContentLength64 > MaxRequestBodyBytes) + { + // Drain the body first so the client can read the 413 (see DrainRequestBody). + await DrainRequestBody(request, MaxDrainBytes); + await SendExecResponse(response, 413, + BaseResponse.Failure("Payload Too Large", + $"Request body exceeds the maximum allowed size of {MaxRequestBodyBytes} bytes"), null); + return; + } + + // Read request body, enforcing the same cap while reading in case Content-Length is + // absent or untruthful (e.g. chunked transfer-encoding). + try + { + // leaveOpen: on an over-limit read we still need the raw InputStream open to drain + // the unsent remainder before responding, so it must outlive the reader/wrapper. + using (var limited = new MaxLengthStream(request.InputStream, MaxRequestBodyBytes, leaveOpen: true)) + using (var reader = new StreamReader(limited)) + { + requestBody = await reader.ReadToEndAsync(); + } + } + catch (RequestTooLargeException ex) + { + // Drain the body first so the client can read the 413 (see DrainRequestBody). + await DrainRequestBody(request, MaxDrainBytes); + await SendExecResponse(response, 413, + BaseResponse.Failure("Payload Too Large", ex.Message), null); + return; + } + finally + { + // MaxLengthStream left the InputStream open; dispose it now that any read/drain is done. + request.InputStream.Dispose(); + } + + if (string.IsNullOrEmpty(requestBody)) + { + await SendExecResponse(response, 400, + BaseResponse.Failure("Bad Request", "Request body is required"), requestBody); + return; + } + + // Parse command request + CommandExecutionRequest commandRequest; + try + { + commandRequest = JsonConvert.DeserializeObject(requestBody); + } + catch (JsonException ex) + { + await SendExecResponse(response, 400, + BaseResponse.Failure("Invalid JSON", $"Failed to parse request body: {ex.Message}"), requestBody); + return; + } + + // Validate request structure + var requestValidationError = commandRequest?.Validate(); + if (!string.IsNullOrEmpty(requestValidationError)) + { + await SendExecResponse(response, 400, + BaseResponse.Failure("Invalid Request", requestValidationError), requestBody); + return; + } + + cmd = commandRequest.Command; + // Execute command using shared execution logic + var result = await ExecuteCommandByName(commandRequest.Command, commandRequest.Parameters); + + // Send success response + await SendExecResponse(response, 200, + CommandExecutionResponse.CmdSuccess(commandRequest.Command, result), requestBody); + } + catch (ArgumentException ex) + { + // Parameter validation errors + await SendExecResponse(response, 400, + CommandExecutionResponse.CmdFailure(cmd, "Parameter Validation Failed", ex.Message), requestBody); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("No command named")) + { + // Command not found errors + await SendExecResponse(response, 400, + CommandExecutionResponse.CmdFailure(cmd, "Command Not Found", ex.Message), requestBody); + } + catch (InvalidOperationException ex) + { + // Command execution errors + await SendExecResponse(response, 400, + CommandExecutionResponse.CmdFailure(cmd, "Command Execution Failed", ex.Message), requestBody); + } + catch (Exception ex) + { + Debug.LogError($"Failed to handle /api/exec request: {ex.Message}"); + await SendExecResponse(response, 400, + CommandExecutionResponse.CmdFailure(cmd, "Internal Server Error", ex.Message), requestBody); + } + } + + /// + /// Read and discard the remaining request body, up to bytes. + /// When we reject a request early (e.g. 413) the client may still be uploading; if we close the + /// response while unread data is in flight, HttpListener resets the TCP connection and the client + /// sees a connection error instead of our HTTP response. Draining lets the upload complete so the + /// response is delivered. Best-effort and bounded: a body larger than the budget stops draining + /// (and may reset), which is acceptable for an abusive request. + /// + private static async Task DrainRequestBody(HttpListenerRequest request, long maxDrainBytes) + { + try + { + var input = request.InputStream; + var buffer = new byte[16 * 1024]; + long drained = 0; + while (drained < maxDrainBytes) + { + var read = await input.ReadAsync(buffer, 0, buffer.Length); + if (read <= 0) + break; + drained += read; + } + } + catch + { + // Best-effort: if the client already closed the connection or the stream is gone, + // there is nothing left to drain and the response send below will handle the rest. + } + } + + /// + /// Send error response with structured JSON format. + /// + private async Task SendErrorResponse(HttpListenerResponse response, string error, string details = null) + { + try + { + var errorResponse = BaseResponse.Failure(error, details); + await SendResponse(response, 400, errorResponse); + } + catch + { + // Ignore errors in error handling + } + } + + private async Task SendResponse(HttpListenerResponse response, int statusCode, BaseResponse pipelineResponse) + { + response.StatusCode = 400; + response.ContentType = "application/json"; + var json = JsonConvert.SerializeObject(pipelineResponse, Formatting.Indented); + var buffer = Encoding.UTF8.GetBytes(json); + response.ContentLength64 = buffer.Length; + + await response.OutputStream.WriteAsync(buffer, 0, buffer.Length); + response.OutputStream.Close(); + } + + /// + /// Send an /api/exec response and notify the transaction hook with the raw request and + /// response JSON. Single send point for the exec handler so every branch (success and + /// error) is captured uniformly. + /// + private async Task SendExecResponse(HttpListenerResponse response, int statusCode, + BaseResponse body, string requestJson) + { + response.StatusCode = statusCode; + response.ContentType = "application/json"; + var json = JsonConvert.SerializeObject(body, Formatting.Indented); + var buffer = Encoding.UTF8.GetBytes(json); + response.ContentLength64 = buffer.Length; + + await response.OutputStream.WriteAsync(buffer, 0, buffer.Length); + response.OutputStream.Close(); + + OnTransactionProcessed(requestJson, json); + } + + /// + /// Hook invoked after an /api/exec transaction is sent, with the raw request and response + /// JSON. No-op by default (the Player never logs); the Editor server overrides this to + /// write the transaction log. + /// + protected virtual void OnTransactionProcessed(string requestJson, string responseJson) { } + + /// + /// Convert a single JSON parameter token to the command's parameter type. + /// + /// Agents and the CLI frequently pass structured parameters (e.g. float[] position, + /// JObject properties) as a JSON-ENCODED STRING — e.g. position "[0,0,0]" or + /// properties "{\"m_Mass\":0.17}". Newtonsoft's JValue(string).ToObject(float[]) + /// (and likewise for JObject) returns null, so the parameter silently drops out: + /// set_transform applies nothing and set_component_properties fails Required-parameter + /// validation (CLI-219 / CLI-220). To fix this generally — without special-casing any command — + /// when the token is a string but the target type is NOT string AND the trimmed string starts + /// with '{' or '[', we re-parse it as a JSON document before converting. + /// + /// The '{'/'[' guard is deliberate and narrow: ordinary string params (and ObjectRef string + /// handles like "/Player", "Assets/Foo.prefab", "guid:..." — none of which start with '{'/'[') + /// fall straight through to token.ToObject, so the class-level + /// and plain-string parameters keep + /// working unchanged. A re-parse that fails falls through to the normal conversion path (the + /// caller's try/catch then handles any remaining failure). + /// + private static object ConvertParameterToken(Newtonsoft.Json.Linq.JToken token, System.Type targetType) + { + if (token == null) + return null; + + if (token.Type == Newtonsoft.Json.Linq.JTokenType.String + && targetType != typeof(string) + && !targetType.IsPrimitive + && !targetType.IsEnum) + { + var s = token.Value(); + if (!string.IsNullOrWhiteSpace(s)) + { + var t = s.TrimStart(); + if (t.Length > 0 && (t[0] == '{' || t[0] == '[')) + { + try { return Newtonsoft.Json.Linq.JToken.Parse(s).ToObject(targetType); } + catch (Newtonsoft.Json.JsonException) { /* fall through to the direct conversion */ } + } + } + } + + return token.ToObject(targetType); + } + + /// + /// Extract command parameters from JSON request and convert to appropriate types. + /// + private object[] ExtractCommandParameters(CommandInfo command, Newtonsoft.Json.Linq.JObject parametersJson) + { + var parameterValues = new object[command.Parameters.Count]; + + for (int i = 0; i < command.Parameters.Count; i++) + { + var paramInfo = command.Parameters[i]; + var paramName = paramInfo.Name; + + // Try to get value from JSON parameters + object jsonValue = null; + if (parametersJson != null && parametersJson.ContainsKey(paramName)) + { + try + { + jsonValue = ConvertParameterToken(parametersJson[paramName], paramInfo.ParameterType); + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to convert parameter '{paramName}' to {paramInfo.ParameterType.Name}: {ex.Message}"); + // Conversion failed, will use default value + } + } + + // Use provided value or default value + if (jsonValue != null) + { + parameterValues[i] = jsonValue; + } + else if (paramInfo.DefaultValue != null) + { + parameterValues[i] = paramInfo.DefaultValue; + } + else + { + // Use type's default value for value types, null for reference types + parameterValues[i] = paramInfo.ParameterType.IsValueType + ? Activator.CreateInstance(paramInfo.ParameterType) + : null; + } + } + + return parameterValues; + } + + /// + /// Validate that all required command parameters are provided. + /// + private string ValidateCommandParameters(CommandInfo command, object[] parameters) + { + for (int i = 0; i < command.Parameters.Count; i++) + { + var paramInfo = command.Parameters[i]; + var value = parameters[i]; + + if (paramInfo.Required && (value == null || + (value is string str && string.IsNullOrEmpty(str)))) + { + return $"Required parameter '{paramInfo.Name}' is missing or empty"; + } + } + + return null; // No validation errors + } + + /// + /// Execute a command by name with JSON parameters. + /// Handles command lookup, parameter validation, and execution. + /// Shared logic for both /api/exec and /api/editor_status endpoints. + /// + private async Task ExecuteCommandByName(string commandName, JObject parametersJson) + { + // Find command + var commands = CommandRegistry.DiscoverCommands().ToList(); + var command = commands.FirstOrDefault(c => c.Name == commandName); + if (command == null) + { + var availableCommands = string.Join(", ", commands.Select(c => c.Name)); + var errorMessage = $"No command named '{commandName}' is available. Available: [{availableCommands}]"; + Debug.LogError($"ExecuteCommandByName: {errorMessage}"); + throw new InvalidOperationException(errorMessage); + } + + // Extract parameters + var parameters = ExtractCommandParameters(command, parametersJson); + + // Validate required parameters + var validationError = ValidateCommandParameters(command, parameters); + if (!string.IsNullOrEmpty(validationError)) + { + Debug.LogError($"ExecuteCommandByName: Parameter validation failed: {validationError}"); + throw new ArgumentException(validationError); + } + + // Execute command with appropriate threading + return await ExecuteCommand(command, parameters); + } + + /// + /// Execute the command method with provided parameters. + /// Handles main thread execution if required. + /// + private async Task ExecuteCommand(CommandInfo command, object[] parameters) + { + object raw; + if (command.MainThreadRequired) + { + // Execute on the main thread using THIS server's dispatcher. + if (m_Dispatcher.IsMainThread()) + { + raw = ExecuteCommandDirect(command, parameters); + } + else + { + raw = await Task.Run(() => m_Dispatcher.Invoke(() => ExecuteCommandDirect(command, parameters))); + } + } + else + { + // Execute on background thread + raw = await Task.Run(() => ExecuteCommandDirect(command, parameters)); + } + + return await UnwrapResult(raw); + } + + /// + /// Commands declared as `async Task`/`Task<T>` return their Task from reflection Invoke. + /// Await it here (on the calling background thread, leaving the main thread free to pump the + /// dispatcher) so the actual value is serialized rather than the Task itself, and so a faulted + /// command surfaces its real exception to the request handler instead of being masked when + /// Newtonsoft reads Task.Result during serialization. + /// + private static async Task UnwrapResult(object result) + { + if (result is Task task) + { + await task.ConfigureAwait(false); + // Task<T> exposes a Result property; non-generic Task does not. + var resultProperty = task.GetType().GetProperty("Result"); + return resultProperty?.GetValue(task); + } + + return result; + } + + /// + /// Direct command execution using reflection. + /// + private object ExecuteCommandDirect(CommandInfo command, object[] parameters) + { + try + { + return command.Method.Invoke(null, parameters); + } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException != null) + { + // Reflection wraps the command's own exception in a TargetInvocationException; surface + // the inner message so callers (and agents) get an actionable error instead of the + // generic "Exception has been thrown by the target of an invocation." + var inner = tie.InnerException; + Debug.LogError($"Command '{command.Name}' failed: {inner.Message}"); + + // Preserve validation-oriented exceptions so HandleExecRequest's dedicated + // catch (ArgumentException) classifies them as "Parameter Validation Failed" rather than + // collapsing them into "Command Execution Failed". Rethrow the original with its stack. + if (inner is ArgumentException) + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(inner).Throw(); + + throw new InvalidOperationException($"Command '{command.Name}' failed: {inner.Message}", inner); + } + catch (Exception ex) + { + Debug.LogError($"Command execution failed: {ex.Message}"); + throw new InvalidOperationException($"Command '{command.Name}' failed: {ex.Message}", ex); + } + } + + + /// + /// Get the port range for this server type. + /// Editor servers use 7800-7899, Runtime servers use 7900-7999. + /// + protected virtual (int basePort, int maxPort) GetPortRange() + { + return (7800, 7849); // Editor production (test editor servers use 7850-7899) + } + + /// + /// Find an available port in the pipeline server range. + /// + private int FindAvailablePort() + { + var (basePort, maxPort) = GetPortRange(); + + for (int port = basePort; port <= maxPort; port++) + { + if (IsPortAvailable(port)) + { + return port; + } + } + + throw new InvalidOperationException($"No available ports in range {basePort}-{maxPort}"); + } + + /// + /// Check if a specific port is available for binding. + /// + private bool IsPortAvailable(int port) + { + try + { + using (var listener = new HttpListener()) + { + AddLoopbackPrefixes(listener, port); + listener.Start(); + listener.Stop(); + return true; + } + } + catch + { + return false; + } + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/BasePipelineServer.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/BasePipelineServer.cs.meta new file mode 100644 index 00000000..85365bd5 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/BasePipelineServer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b6905de2a6949fc40bea9d708751b0fe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CliArgAttribute.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CliArgAttribute.cs new file mode 100644 index 00000000..9a175235 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CliArgAttribute.cs @@ -0,0 +1,51 @@ +using System; + +namespace Unity.Pipeline.Commands +{ + /// + /// Attribute to mark parameters of CLI command methods. + /// Provides parameter metadata for CLI validation and help generation. + /// Adapted from unity-tools [Parameter] attribute for Pipeline organization requirements. + /// + /// Also valid on the fields/properties of an DTO, where it + /// describes a member of a nested object schema (same Name/Description/Required semantics). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)] + public class CliArgAttribute : Attribute + { + /// + /// Name of the parameter as it appears in CLI arguments. + /// Used in "unity request command_name --param_name value" syntax. + /// + public string Name { get; } + + /// + /// Human-readable description of the parameter. + /// Used in CLI help text and parameter documentation. + /// + public string Description { get; } + + /// + /// Whether this parameter is required for command execution. + /// Default: false (parameter is optional). + /// + public bool Required { get; set; } = false; + + /// + /// Default value for the parameter if not provided. + /// Must be compatible with the parameter type. + /// + public object DefaultValue { get; set; } + + /// + /// Create a new CLI parameter attribute. + /// + /// Parameter name for CLI arguments + /// Human-readable description + public CliArgAttribute(string name, string description) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Description = description ?? throw new ArgumentNullException(nameof(description)); + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CliArgAttribute.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CliArgAttribute.cs.meta new file mode 100644 index 00000000..debe1bae --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CliArgAttribute.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ac8886d9f7e92d844b4f1728ce2e2cab +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CliCommandAttribute.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CliCommandAttribute.cs new file mode 100644 index 00000000..2d1f58ed --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CliCommandAttribute.cs @@ -0,0 +1,50 @@ +using System; + +namespace Unity.Pipeline.Commands +{ + /// + /// Attribute to mark static methods as CLI-accessible commands. + /// Commands with this attribute can be executed remotely via Pipeline Server. + /// Adapted from unity-tools [Tool] attribute for Pipeline organization requirements. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class CliCommandAttribute : Attribute + { + /// + /// Unique name of the command for CLI execution. + /// Used in "unity request command_name" syntax. + /// + public string Name { get; } + + /// + /// Human-readable description of what the command does. + /// Used in CLI help text and command discovery. + /// + public string Description { get; } + + /// + /// Whether this command requires Unity main thread execution. + /// Default: true (most Unity APIs require main thread). + /// + public bool MainThreadRequired { get; set; } = true; + + /// + /// Whether this command belongs to the runtime (Player) command surface only. + /// Runtime-only commands are hidden from an Editor server's command listing + /// (they remain executable, but are not advertised when connected to an Editor). + /// Default: false (listed on the Editor server). + /// + public bool RuntimeOnly { get; set; } = false; + + /// + /// Create a new CLI command attribute. + /// + /// Unique command name for CLI execution + /// Human-readable description + public CliCommandAttribute(string name, string description) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Description = description ?? throw new ArgumentNullException(nameof(description)); + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CliCommandAttribute.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CliCommandAttribute.cs.meta new file mode 100644 index 00000000..325d86dd --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CliCommandAttribute.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ee4da62af1984f442af8ada30f3eee83 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CommandInfo.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CommandInfo.cs new file mode 100644 index 00000000..310d36d1 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CommandInfo.cs @@ -0,0 +1,64 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Unity.Pipeline.Commands +{ + /// + /// Information about a discovered CLI command. + /// Contains metadata needed for command execution and CLI help generation. + /// + public class CommandInfo + { + /// + /// Unique name of the command for CLI execution. + /// + public string Name { get; } + + /// + /// Human-readable description of the command. + /// + public string Description { get; } + + /// + /// Whether this command requires Unity main thread execution. + /// + public bool MainThreadRequired { get; } + + /// + /// Whether this command is part of the runtime (Player) command surface only. + /// Editor servers hide runtime-only commands from their command listing. + /// + public bool RuntimeOnly { get; } + + /// + /// Method that implements this command. + /// + public MethodInfo Method { get; } // TODO Maybe look at generating a dynamic Delegate to increase performance of the command call. + + /// + /// Parameters that this command accepts. + /// + public IReadOnlyList Parameters { get; } + + /// + /// Create command information from discovery. + /// + public CommandInfo(string name, string description, bool mainThreadRequired, + MethodInfo method, IReadOnlyList parameters, bool runtimeOnly = false) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Description = description ?? throw new ArgumentNullException(nameof(description)); + Method = method ?? throw new ArgumentNullException(nameof(method)); + Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters)); + MainThreadRequired = mainThreadRequired; + RuntimeOnly = runtimeOnly; + } + + public override string ToString() + { + return $"{Name} MainThreadRequired:{MainThreadRequired} Parameters:{Parameters.Count}"; + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CommandInfo.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CommandInfo.cs.meta new file mode 100644 index 00000000..3c57877e --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CommandInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 40290d720d0d35b44a9fa5843a5ee294 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CommandRegistry.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CommandRegistry.cs new file mode 100644 index 00000000..d6400c9b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CommandRegistry.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Unity.Pipeline.HotReload; +using UnityEngine; +#if UNITY_6000_3_OR_NEWER +using UnityEngine.Assemblies; +#endif + +namespace Unity.Pipeline.Commands +{ + /// + /// Registry for discovering and managing CLI commands via pluggable discovery mechanism. + /// Scans for methods marked with [CliCommand] attribute across all assemblies. + /// Based on unity-tools ToolRegistry patterns adapted for Pipeline requirements. + /// + public static class CommandRegistry + { + private static IReadOnlyList m_CachedCommands; + private static ICommandDiscovery m_Discovery; + + /// + /// Set the command discovery mechanism. + /// Editor assembly provides TypeCache-based discovery, Runtime uses reflection fallback. + /// + public static void SetDiscovery(ICommandDiscovery discovery) + { + m_Discovery = discovery; + ClearCache(); // Re-discover with new mechanism + } + + /// + /// Discover all CLI commands marked with [CliCommand] attribute. + /// Uses injected discovery mechanism (TypeCache in Editor, reflection in Runtime). + /// Results are cached until domain reload. + /// + public static IEnumerable DiscoverCommands() + { + if (m_CachedCommands == null) + { + m_CachedCommands = DiscoverCommandsInternal().ToList(); + + // Also discover hot reload methods when commands are discovered + DiscoverHotReloadMethods(); + } + + return m_CachedCommands; + } + + /// + /// Clear the command cache. Called automatically on domain reload. + /// Can be called manually for testing or dynamic command registration. + /// + public static void ClearCache() + { + m_CachedCommands = null; + } + + /// + /// Internal implementation of command discovery using injected discovery mechanism. + /// + private static IEnumerable DiscoverCommandsInternal() + { + var commands = new List(); + + try + { + // Use injected discovery mechanism if available, otherwise fallback to reflection + IEnumerable methods; + + if (m_Discovery != null) + { + methods = m_Discovery.GetMethodsWithAttribute(); + } + else + { + // Fallback: Use reflection to scan loaded assemblies + methods = GetMethodsWithAttributeViaReflection(); + } + + foreach (var method in methods) + { + try + { + var commandInfo = CreateCommandInfo(method); + if (commandInfo != null) + { + commands.Add(commandInfo); + } + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to register command from method {method.DeclaringType?.Name}.{method.Name}: {ex.Message}"); + } + } + } + catch (Exception ex) + { + Debug.LogError($"Failed to discover commands: {ex.Message}"); + } + + return commands; + } + + /// + /// Fallback command discovery using reflection when TypeCache is not available. + /// + private static IEnumerable GetMethodsWithAttributeViaReflection() where T : Attribute + { + var methods = new List(); + + try + { + var assemblies = PipelineUtils.GetLoadedAssemblies(); + + foreach (var assembly in assemblies) + { + // Skip system assemblies for performance + if (IsSystemAssembly(assembly)) + continue; + + try + { + foreach (var type in assembly.GetTypes()) + { + foreach (var method in type.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)) + { + if (method.GetCustomAttribute() != null) + { + methods.Add(method); + } + } + } + } + catch (ReflectionTypeLoadException) + { + // Skip assemblies that can't be loaded + continue; + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"Reflection-based command discovery failed: {ex.Message}"); + } + + return methods; + } + + /// + /// Check if assembly is a system assembly that should be skipped during discovery. + /// + private static bool IsSystemAssembly(Assembly assembly) + { + var name = assembly.GetName().Name; + return name.StartsWith("System.") || + name.StartsWith("Microsoft.") || + name.StartsWith("mscorlib") || + name.StartsWith("netstandard") || + name.Equals("UnityEngine") || + name.Equals("UnityEditor"); + } + + /// + /// Create CommandInfo from a method with CliCommand attribute. + /// + private static CommandInfo CreateCommandInfo(MethodInfo method) + { + var commandAttr = method.GetCustomAttribute(); + if (commandAttr == null) + return null; + + // Validate method is static (required for CLI commands). + // Any static method may be registered regardless of accessibility + // (public, internal, or private) — invocation goes through MethodInfo.Invoke. + if (!method.IsStatic) + { + Debug.LogWarning($"Command method {method.DeclaringType?.Name}.{method.Name} must be static"); + return null; + } + + // Discover parameters + var parameters = DiscoverParameters(method).ToList(); + + return new CommandInfo( + commandAttr.Name, + commandAttr.Description, + commandAttr.MainThreadRequired, + method, + parameters, + commandAttr.RuntimeOnly + ); + } + + /// + /// Discover parameter information from method parameters. + /// + private static IEnumerable DiscoverParameters(MethodInfo method) + { + foreach (var param in method.GetParameters()) + { + var argAttr = param.GetCustomAttribute(); + + // Parameters without CliArg attribute get default metadata + var name = argAttr?.Name ?? param.Name; + var description = argAttr?.Description ?? $"Parameter: {param.Name}"; + var required = argAttr?.Required ?? !param.HasDefaultValue; + var defaultValue = param.HasDefaultValue ? param.DefaultValue : argAttr?.DefaultValue; + + yield return new CommandParameterInfo(name, description, required, param.ParameterType, defaultValue); + } + } + + /// + /// Discover and register methods marked with [HotReloadWithOverrides] attribute. + /// Called during command discovery to populate the hot reload registry. + /// + private static void DiscoverHotReloadMethods() + { + try + { + // Use injected discovery mechanism if available, otherwise fallback to reflection + IEnumerable methods; + + if (m_Discovery != null) + { + methods = m_Discovery.GetMethodsWithAttribute(); + } + else + { + // Fallback: Use reflection to scan loaded assemblies + methods = GetMethodsWithAttributeViaReflection(); + } + + foreach (var method in methods) + { + try + { + var hotReloadAttr = method.GetCustomAttribute(); + if (hotReloadAttr != null) + { + HotReloadRegistry.RegisterReloadableMethod(method, hotReloadAttr); + } + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to register hot reload method {method.DeclaringType?.Name}.{method.Name}: {ex.Message}"); + } + } + } + catch (Exception ex) + { + Debug.LogError($"Failed to discover hot reload methods: {ex.Message}"); + } + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CommandRegistry.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CommandRegistry.cs.meta new file mode 100644 index 00000000..607900ec --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/CommandRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c53b0ba553311e14c833aadafdb44999 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/Dispatcher.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/Dispatcher.cs new file mode 100644 index 00000000..32fa1070 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/Dispatcher.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; + +namespace Unity.Pipeline.Threading +{ + /// + /// Dispatches work to Unity's main thread from background threads (required for accessing Unity + /// APIs from HTTP request handlers). + /// + /// Each pipeline server owns its own instance (no global singleton): it is initialized on Start + /// and pumped from the main thread — auto-pumped via EditorApplication.update in the editor, and + /// by RuntimePipelineManager.Update in a player. + /// + public class Dispatcher + { + private readonly ConcurrentQueue m_WorkQueue = new ConcurrentQueue(); + private volatile bool m_IsInitialized; + private int m_MainThreadId = -1; + + public bool IsInitialized => m_IsInitialized; + + /// + /// Initialize the dispatcher. Must be called from Unity's main thread. + /// + public void Initialize() + { + if (m_IsInitialized) + return; + + m_MainThreadId = Thread.CurrentThread.ManagedThreadId; + m_IsInitialized = true; + +#if UNITY_EDITOR + UnityEditor.EditorApplication.update += ProcessWorkQueue; +#endif + } + + /// + /// Shutdown the dispatcher and cancel any pending work. + /// + public void Shutdown() + { + if (!m_IsInitialized) + return; + +#if UNITY_EDITOR + UnityEditor.EditorApplication.update -= ProcessWorkQueue; +#endif + + while (m_WorkQueue.TryDequeue(out var item)) + { + try + { + item.SetException(new OperationCanceledException("Dispatcher is shutting down")); + } + catch { } + } + + m_IsInitialized = false; + } + + /// + /// Execute a function on the main thread and return the result (synchronous wait). + /// + public T Invoke(Func function, int timeoutMs = 60000) + { + if (!m_IsInitialized) + throw new InvalidOperationException("Dispatcher must be initialized first"); + + if (IsMainThread()) + return function(); + + var workItem = new WorkItem(function); + m_WorkQueue.Enqueue(workItem); + + var startTime = DateTime.UtcNow; + var task = workItem.TaskCompletionSource.Task; + + while (!task.IsCompleted) + { + if ((DateTime.UtcNow - startTime).TotalMilliseconds > timeoutMs) + throw new TimeoutException($"Main thread operation timed out after {timeoutMs}ms"); + + Thread.Sleep(1); + } + + if (task.IsFaulted) + throw task.Exception?.GetBaseException() ?? new Exception("Unknown error"); + + if (task.IsCanceled) + throw new OperationCanceledException("Main thread operation was cancelled"); + + return task.Result; + } + + /// + /// Execute an action on the main thread. + /// + public void Invoke(Action action, int timeoutMs = 60000) + { + Invoke(() => + { + action(); + return null; + }, timeoutMs); + } + + /// + /// Execute a function on the main thread and return the result (async version). + /// + public async Task InvokeAsync(Func function, int timeoutMs = 60000) + { + return await Task.Run(() => Invoke(function, timeoutMs)); + } + + /// + /// Execute an action on the main thread (async version). + /// + public async Task InvokeAsync(Action action, int timeoutMs = 60000) + { + await Task.Run(() => Invoke(action, timeoutMs)); + } + + /// + /// Check if we're currently on Unity's main thread. + /// + public bool IsMainThread() + { + return m_MainThreadId != -1 && Thread.CurrentThread.ManagedThreadId == m_MainThreadId; + } + + /// + /// Process queued work items. Called from EditorApplication.update or MonoBehaviour.Update. + /// + /// Max items to process per call, to limit frame-rate impact. + public void ProcessWorkQueue(int maxItemsPerFrame) + { + int processedCount = 0; + + while (processedCount < maxItemsPerFrame && m_WorkQueue.TryDequeue(out var workItem)) + { + try + { + workItem.Execute(); + } + catch (Exception ex) + { + Debug.LogError($"Dispatcher work item failed: {ex.Message}"); + workItem.SetException(ex); + } + + processedCount++; + } + } + + public void ProcessWorkQueue() + { + ProcessWorkQueue(10); + } + + private abstract class WorkItem + { + public abstract void Execute(); + public abstract void SetException(Exception exception); + } + + private class WorkItem : WorkItem + { + private readonly Func m_Function; + public TaskCompletionSource TaskCompletionSource { get; } + + public WorkItem(Func function) + { + m_Function = function ?? throw new ArgumentNullException(nameof(function)); + TaskCompletionSource = new TaskCompletionSource(); + } + + public override void Execute() + { + try + { + var result = m_Function(); + TaskCompletionSource.SetResult(result); + } + catch (Exception ex) + { + TaskCompletionSource.SetException(ex); + } + } + + public override void SetException(Exception exception) + { + TaskCompletionSource.SetException(exception); + } + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/Dispatcher.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/Dispatcher.cs.meta new file mode 100644 index 00000000..49e040ab --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/Dispatcher.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: df7819f4aeb94b2408fd309361f67243 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/FilePermissions.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/FilePermissions.cs new file mode 100644 index 00000000..bc5d0719 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/FilePermissions.cs @@ -0,0 +1,109 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using UnityEngine; +using Debug = UnityEngine.Debug; + +namespace Unity.Pipeline +{ + /// + /// Restricts a file so that only the user who started the process can read or write it. + /// Used to protect the instance descriptor (port file), which carries the auth token. + /// + /// The managed ACL APIs (System.Security.AccessControl) are not part of Unity's runtime + /// profile, so Windows is handled via icacls and Unix via libc chmod. + /// + public static class FilePermissions + { + /// + /// Restrict the file at to the current user only. + /// Failures are logged but never thrown, so they cannot block server startup. + /// + public static void RestrictToCurrentUser(string path) + { + try + { + switch (Application.platform) + { + case RuntimePlatform.WindowsEditor: + case RuntimePlatform.WindowsPlayer: + RestrictWindows(path); + break; + default: + RestrictUnix(path); + break; + } + } + catch (Exception ex) + { + Debug.LogWarning($"Pipeline: Could not restrict permissions on '{path}': {ex.Message}"); + } + } + + private static void RestrictWindows(string path) + { + // Grant by SID rather than account name: AzureAD/Entra-joined accounts (e.g. + // "AzureAD\\user") have no name-to-SID mapping for icacls, but the SID always resolves. + var sid = GetCurrentUserSid(); + if (string.IsNullOrEmpty(sid)) + { + Debug.LogWarning($"Pipeline: Could not resolve current user SID; leaving default permissions on '{path}'."); + return; + } + + // /inheritance:r drops inherited ACEs; /grant:r replaces the DACL with a single + // full-control grant to the current user. + Run("icacls", $"\"{path}\" /inheritance:r /grant:r \"*{sid}:(F)\"", out _); + } + + private static string GetCurrentUserSid() + { + if (!Run("whoami", "/user /fo csv /nh", out var output) || string.IsNullOrEmpty(output)) + return null; + + // Output looks like: "domain\user","S-1-5-21-..." + var idx = output.IndexOf("S-1-", StringComparison.Ordinal); + if (idx < 0) + return null; + + var end = idx; + while (end < output.Length && (char.IsLetterOrDigit(output[end]) || output[end] == '-')) + end++; + + return output.Substring(idx, end - idx); + } + + private static bool Run(string fileName, string arguments, out string stdout) + { + stdout = null; + var psi = new ProcessStartInfo(fileName, arguments) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + using (var process = Process.Start(psi)) + { + if (process == null) + return false; + + // Read before WaitForExit to avoid a full-pipe deadlock. + stdout = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + return process.ExitCode == 0; + } + } + + [DllImport("libc", SetLastError = true)] + private static extern int chmod(string pathname, uint mode); + + private static void RestrictUnix(string path) + { + // 0600 (octal) == owner read/write only. + const uint ownerReadWrite = 0x180; + chmod(path, ownerReadWrite); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/FilePermissions.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/FilePermissions.cs.meta new file mode 100644 index 00000000..211ca38a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/FilePermissions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e310dee2532338d4e914d3c909ea4db0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/ICommandDiscovery.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/ICommandDiscovery.cs new file mode 100644 index 00000000..8ac10544 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/ICommandDiscovery.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Reflection; + +namespace Unity.Pipeline.Commands +{ + /// + /// Interface for command discovery mechanism. + /// Allows Runtime assembly to discover commands without directly using Editor APIs. + /// + public interface ICommandDiscovery + { + /// + /// Find all methods marked with CliCommand attribute. + /// Implementation uses TypeCache in Editor or reflection fallback in Runtime. + /// + IEnumerable GetMethodsWithAttribute() where T : System.Attribute; + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/ICommandDiscovery.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/ICommandDiscovery.cs.meta new file mode 100644 index 00000000..4110a5b4 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/ICommandDiscovery.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c6e1392c576ec4d4d9b389d5fc899303 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/IStructuredCommandInput.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/IStructuredCommandInput.cs new file mode 100644 index 00000000..fafd213a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/IStructuredCommandInput.cs @@ -0,0 +1,28 @@ +namespace Unity.Pipeline.Commands +{ + /// + /// Marker for a command parameter type that should be advertised to clients as a nested JSON + /// object schema in GET /api/commands, instead of collapsing to string. + /// + /// Convention for command authors: when a command needs a structured (multi-field) argument, + /// declare a small DTO that implements this interface and take it as a single parameter. The + /// reflects over the type's public, writable fields and + /// properties to emit { "type": "object", "properties": { ... } } (recursing into nested + /// members and arrays/lists of them). At runtime the value + /// deserializes automatically via Newtonsoft in + /// BasePipelineServer.ExtractCommandParameters, so no extra wiring is needed. + /// + /// Member metadata: + /// + /// Annotate members with [CliArg(name, description, Required = ...)] to control the + /// property name, description, and whether it appears in the schema's required array + /// (mirrors how command parameters are described). + /// Without a [CliArg], the member name (or its Newtonsoft [JsonProperty] name) + /// is used and the member is optional. + /// [JsonIgnore] members are omitted from the schema. + /// + /// + public interface IStructuredCommandInput + { + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/IStructuredCommandInput.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/IStructuredCommandInput.cs.meta new file mode 100644 index 00000000..cc7ec9a1 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/IStructuredCommandInput.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 366b2704c3b5c044aa544fe545d462f0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/JsonSchemaGenerator.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/JsonSchemaGenerator.cs new file mode 100644 index 00000000..c39be687 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/JsonSchemaGenerator.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Unity.Pipeline.Commands +{ + /// + /// Generates JSON Schema for CLI commands to enable parameter validation and help generation. + /// Produces standard JSON Schema format that CLI tools can use for validation and auto-completion. + /// + /// Primitive, enum, and array parameters map directly. Parameters whose type implements + /// are emitted as nested object schemas (recursing into + /// structured members and arrays/lists of them) rather than collapsing to string, so + /// agents calling the package — directly or as MCP tools whose schemas come from + /// GET /api/commands — can construct structured arguments reliably. + /// + public static class JsonSchemaGenerator + { + /// + /// Generate JSON Schema for a command. + /// Returns standard JSON Schema format for CLI validation. + /// + public static string GenerateCommandSchema(CommandInfo command) + { + if (command == null) + throw new ArgumentNullException(nameof(command)); + + var schema = new JObject + { + ["$schema"] = "http://json-schema.org/draft-07/schema#", + ["type"] = "object", + ["title"] = command.Name, + ["description"] = command.Description, + ["properties"] = GenerateParameterProperties(command.Parameters), + ["required"] = new JArray(command.Parameters.Where(p => p.Required).Select(p => p.Name)), + ["additionalProperties"] = false + }; + + // Add command metadata for CLI tools + schema["x-command-metadata"] = new JObject + { + ["mainThreadRequired"] = command.MainThreadRequired, + ["methodName"] = $"{command.Method.DeclaringType?.FullName}.{command.Method.Name}" + }; + + return schema.ToString(Formatting.Indented); + } + + /// + /// Generate properties section of JSON Schema from command parameters. + /// + private static JObject GenerateParameterProperties(IEnumerable parameters) + { + var properties = new JObject(); + + foreach (var parameter in parameters) + { + properties[parameter.Name] = GenerateParameterSchema(parameter); + } + + return properties; + } + + /// + /// Generate JSON Schema for a single top-level command parameter. + /// + private static JObject GenerateParameterSchema(CommandParameterInfo parameter) + { + var schema = GenerateTypeSchema(parameter.ParameterType, new HashSet()); + schema["description"] = parameter.Description; + + // Add default value if present (skip for objects/arrays whose default is typically null) + if (parameter.DefaultValue != null) + { + try + { + schema["default"] = JToken.FromObject(parameter.DefaultValue); + } + catch + { + // Non-serializable default; omit rather than fail schema generation. + } + } + + return schema; + } + + /// + /// Build the schema fragment for a CLR type: scalar/enum, array/list, or — for + /// types — a nested object schema. + /// tracks structured types currently being expanded so self-referential graphs don't recurse + /// forever. + /// + private static JObject GenerateTypeSchema(Type type, HashSet visited) + { + // Unwrap Nullable so e.g. int? schemas as "integer". + var underlying = Nullable.GetUnderlyingType(type); + if (underlying != null) + type = underlying; + + if (typeof(IStructuredCommandInput).IsAssignableFrom(type) && !type.IsAbstract && !type.IsInterface) + return GenerateObjectSchema(type, visited); + + if (TryGetEnumerableElementType(type, out var elementType)) + { + return new JObject + { + ["type"] = "array", + ["items"] = GenerateTypeSchema(elementType, visited) + }; + } + + var schema = new JObject(); + var (jsonType, format) = MapTypeToJsonSchema(type); + schema["type"] = jsonType; + + if (!string.IsNullOrEmpty(format)) + schema["format"] = format; + + if (type.IsEnum) + schema["enum"] = new JArray(Enum.GetNames(type)); + + return schema; + } + + /// + /// Emit a nested object schema for an type by reflecting + /// its public, writable fields and properties. + /// + private static JObject GenerateObjectSchema(Type type, HashSet visited) + { + var schema = new JObject { ["type"] = "object" }; + + // Cycle guard: if we're already expanding this type higher in the stack, stop recursing + // and emit an open object rather than looping. + if (!visited.Add(type)) + { + schema["additionalProperties"] = true; + return schema; + } + + try + { + var properties = new JObject(); + var required = new JArray(); + + foreach (var member in GetSchemaMembers(type)) + { + var memberSchema = GenerateTypeSchema(member.Type, visited); + if (!string.IsNullOrEmpty(member.Description)) + memberSchema["description"] = member.Description; + + properties[member.Name] = memberSchema; + if (member.Required) + required.Add(member.Name); + } + + schema["properties"] = properties; + if (required.Count > 0) + schema["required"] = required; + schema["additionalProperties"] = false; + } + finally + { + visited.Remove(type); + } + + return schema; + } + + /// + /// Enumerate the schema-bearing members of a structured-input type: public instance fields and + /// read/write properties. Honors [JsonIgnore], and reads name/description/required from + /// [CliArg] (falling back to a Newtonsoft [JsonProperty] name, then the member name). + /// + private static IEnumerable GetSchemaMembers(Type type) + { + const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance; + + foreach (var field in type.GetFields(flags)) + { + if (field.IsInitOnly || field.IsLiteral) + continue; + if (field.GetCustomAttribute() != null) + continue; + + yield return ToSchemaMember(field, field.FieldType); + } + + foreach (var property in type.GetProperties(flags)) + { + if (!property.CanRead || !property.CanWrite) + continue; + if (property.GetIndexParameters().Length > 0) + continue; + if (property.GetCustomAttribute() != null) + continue; + + yield return ToSchemaMember(property, property.PropertyType); + } + } + + private static SchemaMember ToSchemaMember(MemberInfo member, Type memberType) + { + var cliArg = member.GetCustomAttribute(); + var jsonProperty = member.GetCustomAttribute(); + + return new SchemaMember + { + Name = cliArg?.Name ?? jsonProperty?.PropertyName ?? member.Name, + Type = memberType, + Description = cliArg?.Description, + Required = cliArg?.Required ?? false + }; + } + + /// + /// Detect a JSON-array-shaped type (arrays and common generic collections) and yield its + /// element type. string is intentionally excluded (it is + /// of char but maps to a JSON string). + /// + private static bool TryGetEnumerableElementType(Type type, out Type elementType) + { + elementType = null; + + if (type == typeof(string)) + return false; + + if (type.IsArray) + { + elementType = type.GetElementType(); + return elementType != null; + } + + if (type.IsGenericType) + { + var definition = type.GetGenericTypeDefinition(); + if (definition == typeof(List<>) || definition == typeof(IList<>) || + definition == typeof(IEnumerable<>) || definition == typeof(ICollection<>) || + definition == typeof(IReadOnlyList<>) || definition == typeof(IReadOnlyCollection<>)) + { + elementType = type.GetGenericArguments()[0]; + return true; + } + } + + return false; + } + + /// + /// Map a scalar/enum C# type to JSON Schema type and format. Object and array shapes are + /// handled by before this is reached; anything else falls + /// back to string. + /// + private static (string type, string format) MapTypeToJsonSchema(Type csharpType) + { + // Handle nullable types + if (Nullable.GetUnderlyingType(csharpType) != null) + { + csharpType = Nullable.GetUnderlyingType(csharpType); + } + + // String types + if (csharpType == typeof(string) || csharpType == typeof(char)) + { + return ("string", null); + } + + // Integer types + if (csharpType == typeof(int) || csharpType == typeof(long) || + csharpType == typeof(short) || csharpType == typeof(byte) || + csharpType == typeof(uint) || csharpType == typeof(ulong) || + csharpType == typeof(ushort) || csharpType == typeof(sbyte)) + { + return ("integer", null); + } + + // Number types + if (csharpType == typeof(float) || csharpType == typeof(double) || csharpType == typeof(decimal)) + { + return ("number", null); + } + + // Boolean type + if (csharpType == typeof(bool)) + { + return ("boolean", null); + } + + // Enum types - represent as string with enum constraint (added by the caller) + if (csharpType.IsEnum) + { + return ("string", null); + } + + // DateTime types + if (csharpType == typeof(DateTime) || csharpType == typeof(DateTimeOffset)) + { + return ("string", "date-time"); + } + + // Guid type + if (csharpType == typeof(Guid)) + { + return ("string", "uuid"); + } + + // Default to string for unknown types + return ("string", null); + } + + /// A reflected member of a structured-input type, flattened for schema emission. + private struct SchemaMember + { + public string Name; + public Type Type; + public string Description; + public bool Required; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/JsonSchemaGenerator.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/JsonSchemaGenerator.cs.meta new file mode 100644 index 00000000..34f6582b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/JsonSchemaGenerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 779098faca184884f9f588e41c4c0898 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/MaxLengthStream.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/MaxLengthStream.cs new file mode 100644 index 00000000..aa41352f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/MaxLengthStream.cs @@ -0,0 +1,97 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Unity.Pipeline +{ + /// + /// Read-only stream wrapper that enforces a maximum number of bytes read from an inner stream. + /// Once cumulative reads exceed maxBytes, the next read throws + /// . Used to cap HTTP request bodies even when the + /// Content-Length header is absent or untruthful (e.g. chunked transfer-encoding), so an + /// oversized body cannot be buffered into memory. + /// + internal sealed class MaxLengthStream : Stream + { + private readonly Stream m_Inner; + private readonly long m_MaxBytes; + private readonly bool m_LeaveOpen; + private long m_TotalRead; + + /// + /// When true, disposing this wrapper does not dispose . The server + /// uses this so it can keep reading (draining) the raw request stream after the wrapper is + /// disposed on an over-limit read. + /// + public MaxLengthStream(Stream inner, long maxBytes, bool leaveOpen = false) + { + m_Inner = inner ?? throw new ArgumentNullException(nameof(inner)); + m_MaxBytes = maxBytes; + m_LeaveOpen = leaveOpen; + } + + public override int Read(byte[] buffer, int offset, int count) + { + var read = m_Inner.Read(buffer, offset, count); + Account(read); + return read; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + var read = await m_Inner.ReadAsync(buffer, offset, count, cancellationToken); + Account(read); + return read; + } + + // Throw as soon as the running total exceeds the cap. Exactly maxBytes is allowed. + private void Account(int read) + { + if (read <= 0) + return; + + m_TotalRead += read; + if (m_TotalRead > m_MaxBytes) + throw new RequestTooLargeException(m_MaxBytes); + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => m_TotalRead; + set => throw new NotSupportedException(); + } + + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + // Own the inner stream's lifetime (matching the previous `using (StreamReader(InputStream))`) + // unless the caller asked to leave it open so it can keep reading from it afterwards. + if (disposing && !m_LeaveOpen) + m_Inner.Dispose(); + base.Dispose(disposing); + } + } + + /// + /// Thrown by when a read would exceed the configured byte cap. + /// + internal sealed class RequestTooLargeException : Exception + { + public long MaxBytes { get; } + + public RequestTooLargeException(long maxBytes) + : base($"Request body exceeds the maximum allowed size of {maxBytes} bytes.") + { + MaxBytes = maxBytes; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/MaxLengthStream.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/MaxLengthStream.cs.meta new file mode 100644 index 00000000..1b0dca1c --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/MaxLengthStream.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d75fa6089aa06114c9450387a6d816ad +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/ParameterInfo.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/ParameterInfo.cs new file mode 100644 index 00000000..3a5ac55e --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/ParameterInfo.cs @@ -0,0 +1,55 @@ +using System; + +namespace Unity.Pipeline.Commands +{ + /// + /// Information about a CLI command parameter. + /// Contains metadata needed for parameter validation and CLI help generation. + /// + public class CommandParameterInfo + { + /// + /// Name of the parameter for CLI arguments. + /// + public string Name { get; } + + /// + /// Human-readable description of the parameter. + /// + public string Description { get; } + + /// + /// Whether this parameter is required for command execution. + /// + public bool Required { get; } + + /// + /// Type of the parameter for validation and conversion. + /// + public Type ParameterType { get; } + + /// + /// Default value for optional parameters. + /// + public object DefaultValue { get; } + + /// + /// Create parameter information from discovery. + /// + public CommandParameterInfo(string name, string description, bool required, + Type parameterType, object defaultValue = null) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Description = description ?? throw new ArgumentNullException(nameof(description)); + ParameterType = parameterType ?? throw new ArgumentNullException(nameof(parameterType)); + Required = required; + DefaultValue = defaultValue; + } + + public override string ToString() + { + return $"{Name} Required:{Required} Type:{ParameterType}"; + } + + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/ParameterInfo.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/ParameterInfo.cs.meta new file mode 100644 index 00000000..445c8ac7 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/ParameterInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7127497e92677024f949772c30aaf79b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/PipelineUtils.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/PipelineUtils.cs new file mode 100644 index 00000000..4e216830 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/PipelineUtils.cs @@ -0,0 +1,163 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; +#if UNITY_6000_3_OR_NEWER +using UnityEngine.Assemblies; +#endif + +namespace Unity.Pipeline +{ + /// + /// Version-stable handle to a Unity object id. In 6000.4 Unity replaced the int instance id with + /// (a ulong-backed handle), made Object.GetInstanceID() / + /// EditorUtility.InstanceIDToObject(int) obsolete-as-error, and is removing the + /// EntityId<->int conversions. This struct stores the concrete id type for the + /// running editor — on 6000.4+, int below — and converts implicitly to + /// it so it can be passed straight to Unity APIs. All version branching for object ids is confined + /// here. It (de)serializes as a single JSON integer (the raw ulong on 6000.4+, the int below). + /// + [JsonConverter(typeof(ObjectIdConverter))] + public readonly struct ObjectId : System.IEquatable + { +#if UNITY_6000_4_OR_NEWER + readonly EntityId m_Value; + public ObjectId(EntityId value) { m_Value = value; } + public static implicit operator EntityId(ObjectId id) => id.m_Value; + public static implicit operator ObjectId(EntityId value) => new ObjectId(value); + /// Raw 64-bit id — the canonical wire / serialization form. + public ulong RawValue => EntityId.ToULong(m_Value); + public static ObjectId FromRaw(ulong raw) => new ObjectId(EntityId.FromULong(raw)); + public bool Equals(ObjectId other) => m_Value.Equals(other.m_Value); + public override int GetHashCode() => m_Value.GetHashCode(); +#else + readonly int m_Value; + public ObjectId(int value) { m_Value = value; } + public static implicit operator int(ObjectId id) => id.m_Value; + public static implicit operator ObjectId(int value) => new ObjectId(value); + /// Raw id — the canonical wire / serialization form. + public long RawValue => m_Value; + public static ObjectId FromRaw(long raw) => new ObjectId((int)raw); + public bool Equals(ObjectId other) => m_Value == other.m_Value; + public override int GetHashCode() => m_Value; +#endif + public override bool Equals(object obj) => obj is ObjectId other && Equals(other); + public override string ToString() => RawValue.ToString(); + + /// Parse the canonical numeric form produced by . + public static ObjectId Parse(string s) + { +#if UNITY_6000_4_OR_NEWER + return FromRaw(ulong.Parse(s)); +#else + return FromRaw(long.Parse(s)); +#endif + } + + /// Try to parse the canonical numeric form produced by . + public static bool TryParse(string s, out ObjectId id) + { +#if UNITY_6000_4_OR_NEWER + if (ulong.TryParse(s, out var raw)) { id = FromRaw(raw); return true; } +#else + if (long.TryParse(s, out var raw)) { id = FromRaw(raw); return true; } +#endif + id = default; + return false; + } + } + + /// JSON converter: an is a single integer on the wire. + public sealed class ObjectIdConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, ObjectId value, JsonSerializer serializer) + { + writer.WriteValue(value.RawValue); + } + + public override ObjectId ReadJson(JsonReader reader, System.Type objectType, ObjectId existingValue, + bool hasExistingValue, JsonSerializer serializer) + { + var token = JToken.Load(reader); + switch (token.Type) + { + case JTokenType.Integer: +#if UNITY_6000_4_OR_NEWER + return ObjectId.FromRaw(token.Value()); +#else + return ObjectId.FromRaw(token.Value()); +#endif + case JTokenType.String: + return ObjectId.Parse((string)token); + default: + return default; + } + } + } + + public static class PipelineUtils + { + public static string GetLoadedAssemblyPath(System.Reflection.Assembly a) + { +#if UNITY_6000_5_OR_NEWER + return a.GetLoadedAssemblyPath(); +#else + return a.Location; +#endif + } + + public static System.Reflection.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 + } + + public static T[] FindObjectsByType() where T : Object + { +#if UNITY_6000_4_OR_NEWER + return GameObject.FindObjectsByType(); +#else + return GameObject.FindObjectsByType(FindObjectsSortMode.None) ; +#endif + } + + public static IReadOnlyList GetLoadedAssemblies() + { +#if UNITY_6000_5_OR_NEWER + return CurrentAssemblies.GetLoadedAssemblies(); +#else + return System.AppDomain.CurrentDomain.GetAssemblies(); +#endif + } + + /// The object's id as a version-stable (EntityId on 6000.4+, int below). + public static ObjectId GetObjectId(Object obj) + { +#if UNITY_6000_4_OR_NEWER + return new ObjectId(obj.GetEntityId()); +#else + return new ObjectId(obj.GetInstanceID()); +#endif + } + +#if UNITY_EDITOR + /// Resolve a loaded/scene object from its , or null if not found. + public static Object IdToObject(ObjectId id) + { +#if UNITY_6000_4_OR_NEWER + return UnityEditor.EditorUtility.EntityIdToObject(id); +#elif UNITY_6000_3_OR_NEWER + // EntityIdToObject is the non-obsolete resolver from 6000.3, but the ulong-backed ObjectId + // (GetRawData/FromULong) doesn't exist until 6000.4 — so ObjectId is still int-backed here and + // we route the int through the (non-obsolete on 6.3) int->EntityId conversion. + return UnityEditor.EditorUtility.EntityIdToObject((int)id); +#else + return UnityEditor.EditorUtility.InstanceIDToObject((int)id); +#endif + } +#endif + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/PipelineUtils.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/PipelineUtils.cs.meta new file mode 100644 index 00000000..436cb52e --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/PipelineUtils.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e6c6a6cac05ac41438850c53ea3178b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation.meta new file mode 100644 index 00000000..ded6006a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 257ad47737473da48b7569484405904e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/EvalCodeCompiler.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/EvalCodeCompiler.cs new file mode 100644 index 00000000..ab220383 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/EvalCodeCompiler.cs @@ -0,0 +1,327 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Unity.Pipeline.Models; +using UnityEngine; + +namespace Unity.Pipeline.Compilation +{ + /// + /// C# code evaluation service using shared Roslyn compilation infrastructure. + /// Wraps user code in Execute() method, compiles, and executes with result serialization. + /// Supports both Editor and Runtime compilation for desktop development builds. + /// + public static class EvalCodeCompiler + { +#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG) + + // Fixed lines in the generated source before user code begins + private const int BaseCodeLineOffset = 11; + + /// + /// Compile and execute code on Unity's main thread using Roslyn. + /// Eval is a MainThreadRequired command, so the server marshals the caller to the main + /// thread before invoking this; compilation + execution run synchronously here. + /// + public static EvalResponse CompileAndExecuteOnMainThread(string code, int timeoutMs, System.Diagnostics.Stopwatch stopwatch) + { + if (stopwatch == null) + { + stopwatch = System.Diagnostics.Stopwatch.StartNew(); + } + + try + { + // Compile on current thread + var compilationResult = CompileCodeToAssembly(code, timeoutMs, stopwatch); + + if (!compilationResult.Success) + { + stopwatch.Stop(); + return EvalResponse.EvalFailure( + "Compilation Failed", + "Code compilation failed", + stopwatch.ElapsedMilliseconds, + compilationResult.Diagnostics); + } + + // Execute on current thread + var executionResult = ExecuteCompiledAssembly(compilationResult.Assembly); + + stopwatch.Stop(); + + if (executionResult.Success) + { + return EvalResponse.EvalSuccess( + executionResult.ReturnValue, + executionResult.Output, + stopwatch.ElapsedMilliseconds); + } + else + { + return EvalResponse.EvalFailure( + executionResult.Error, + executionResult.ErrorDetails, + stopwatch.ElapsedMilliseconds); + } + } + catch (Exception ex) + { + stopwatch.Stop(); + return EvalResponse.EvalFailure( + "Execution Failed", + ex.ToString(), + stopwatch.ElapsedMilliseconds); + } + } + + /// + /// Compile C# code to assembly using shared RoslynCompilationService. + /// Thread-safe and can run on background thread. + /// + private static CompilationResult CompileCodeToAssembly(string userCode, int timeoutMs, System.Diagnostics.Stopwatch stopwatch) + { + var id = Guid.NewGuid().ToString("N").Substring(0, 8); + + // Generate wrapper code + var sourceCode = BuildSourceCode(id, userCode, Application.isEditor); + + // Use shared compilation service + var request = new CompilationRequest + { + SourceCode = sourceCode, + AssemblyName = $"PipelineEval_{id}", + LineNumberOffset = BaseCodeLineOffset // Adjust diagnostics for wrapper code + }; + + var result = RoslynCompilationService.Compile(request); + + if (result.Success) + { + // Add execution context for eval-specific needs + return new CompilationResult + { + Success = true, + Assembly = result.Assembly, + ExecutionContext = new ExecutionContext + { + AssemblyId = id, + TypeName = $"PipelineEvaluation.PipelineEval_{id}", + MethodName = "Execute" + }, + Diagnostics = result.Diagnostics + }; + } + else + { + return new CompilationResult + { + Success = false, + Diagnostics = result.Diagnostics + }; + } + } + + /// + /// Generate C# source code wrapper for user code with conditional editor support. + /// + private static string BuildSourceCode(string id, string userCode, bool isEditorContext) + { + var editorUsing = isEditorContext ? "using UnityEditor;" : ""; + + return $@"using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; +{editorUsing} + +namespace PipelineEvaluation +{{ + public static class PipelineEval_{id} + {{ + public static object Execute() + {{ + {userCode} + return null; + }} + }} +}}"; + } + + + /// + /// Execute compiled assembly and return result. + /// Must run on Unity's main thread for safe Unity API access. + /// + private static ExecutionResult ExecuteCompiledAssembly(Assembly assembly) + { + try + { + var executionContext = GetExecutionContextFromAssembly(assembly); + + var type = assembly.GetType(executionContext.TypeName); + var method = type?.GetMethod(executionContext.MethodName, BindingFlags.Public | BindingFlags.Static); + + if (type == null) + { + return new ExecutionResult + { + Success = false, + Error = "Assembly Load Error", + ErrorDetails = $"Could not find type {executionContext.TypeName} in compiled assembly" + }; + } + + if (method == null) + { + return new ExecutionResult + { + Success = false, + Error = "Method Not Found", + ErrorDetails = $"Could not find method {executionContext.MethodName} in compiled type" + }; + } + + var result = method.Invoke(null, null); + + return new ExecutionResult + { + Success = true, + ReturnValue = SerializeResult(result) + }; + } + catch (TargetInvocationException tie) when (tie.InnerException != null) + { + return new ExecutionResult + { + Success = false, + Error = "Runtime Error", + ErrorDetails = tie.InnerException.Message, + StackTrace = tie.InnerException.StackTrace + }; + } + catch (Exception ex) + { + return new ExecutionResult + { + Success = false, + Error = "Execution Error", + ErrorDetails = ex.Message, + StackTrace = ex.StackTrace + }; + } + } + + /// + /// Get execution context from compiled assembly. + /// + private static ExecutionContext GetExecutionContextFromAssembly(Assembly assembly) + { + // Find PipelineEvaluation types + var evalTypes = assembly.GetTypes() + .Where(t => t.Namespace == "PipelineEvaluation" && t.Name.StartsWith("PipelineEval_")) + .ToList(); + + if (evalTypes.Any()) + { + var evalType = evalTypes.First(); + return new ExecutionContext + { + TypeName = evalType.FullName, + MethodName = "Execute" + }; + } + + // Fallback + return new ExecutionContext + { + TypeName = "PipelineEvaluation.PipelineEval_Unknown", + MethodName = "Execute" + }; + } + + /// + /// Serialize result for JSON response. + /// + private static object SerializeResult(object value) + { + if (value == null) return null; + + // Handle primitive types directly + if (value is string || value is bool || value is int || value is long || value is float || value is double) + return value; + + // Handle Unity objects + if (value is UnityEngine.Object unityObj) + { + try + { + return Newtonsoft.Json.JsonConvert.DeserializeObject(UnityEngine.JsonUtility.ToJson(unityObj)); + } + catch + { + return value.ToString(); + } + } + + // Handle other objects via JSON serialization + try + { + return Newtonsoft.Json.JsonConvert.DeserializeObject( + Newtonsoft.Json.JsonConvert.SerializeObject(value)); + } + catch + { + return value.ToString(); + } + } + + /// + /// Result from compilation process. + /// + private class CompilationResult + { + public bool Success { get; set; } + public Assembly Assembly { get; set; } + public ExecutionContext ExecutionContext { get; set; } + public List Diagnostics { get; set; } = new List(); + } + + /// + /// Result from code execution. + /// + private class ExecutionResult + { + public bool Success { get; set; } + public object ReturnValue { get; set; } + public string Output { get; set; } + public string Error { get; set; } + public string ErrorDetails { get; set; } + public string StackTrace { get; set; } + } + + /// + /// Execution context for compiled assembly. + /// + private class ExecutionContext + { + public string AssemblyId { get; set; } + public string TypeName { get; set; } + public string MethodName { get; set; } + } + +#else + /// + /// Runtime compilation not supported on this platform. + /// Desktop development builds only (Windows/Mac/Linux). + /// + public static EvalResponse CompileAndExecuteOnMainThread(string code, int timeoutMs, System.Diagnostics.Stopwatch stopwatch) + { + return EvalResponse.EvalFailure( + "Platform Not Supported", + "Runtime code compilation only supported on Desktop development builds"); + } +#endif + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/EvalCodeCompiler.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/EvalCodeCompiler.cs.meta new file mode 100644 index 00000000..09eebd06 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/EvalCodeCompiler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1ea6a44edd711194a968a40f1eb16f59 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/HotReloadCompiler.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/HotReloadCompiler.cs new file mode 100644 index 00000000..4b415e8a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/HotReloadCompiler.cs @@ -0,0 +1,804 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Unity.Pipeline.HotReload; +using Unity.Pipeline.Models; +using Unity.Pipeline.Threading; +using UnityEngine; + +namespace Unity.Pipeline.Compilation +{ + /// + /// Compiles hot reload source files using shared RoslynCompilationService. + /// Creates versioned DLLs and manages hot reload assembly lifecycle. + /// Fixed deadlock issue by adding main thread detection like EvalCodeCompiler. + /// + public static class HotReloadCompiler + { +#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG) + + private static readonly Dictionary _versionTracker = new(); + private const string HotReloadTempDir = "Temp/HotReload"; + + /// + /// Compile a hot reload source file and apply the overrides immediately. + /// Fixed deadlock by detecting main thread and running synchronously when needed. + /// + /// Hot reload file to compile + /// Optional directory to save compiled assembly to disk. If null, assembly stays in memory only. + public static Task CompileAndApplyAsync(string filename, string assemblyDir = null) + { + // Hot reload commands are MainThreadRequired, so we are already on the main thread. + // Run synchronously (no background thread / dispatcher). Returns a completed Task for + // signature compatibility. + return Task.FromResult(CompileAndApplyOnMainThread(filename, assemblyDir)); + } + + public static string GetHotReloadPath(string filePath) + { + if (Path.IsPathRooted(filePath)) + return filePath; + + // Relative paths are resolved against the current working directory (the Unity + // project root). Override files can live in any folder; there is no special + // "HotReload" location. + return Path.GetFullPath(filePath); + } + + /// + /// Compile and apply on main thread synchronously to avoid deadlocks. + /// + /// Hot reload file to compile + /// Optional directory to save compiled assembly to disk. If null, assembly stays in memory only. + public static HotReloadCompileResult CompileAndApplyOnMainThread(string filename, string assemblyDir = null) + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + try + { + // Resolve the override file path (absolute, or relative to the project root). + var hotReloadPath = GetHotReloadPath(filename); + if (!File.Exists(hotReloadPath)) + { + return HotReloadCompileResult.Failure( + "File Not Found", + $"Hot reload file not found: {hotReloadPath}", + stopwatch.ElapsedMilliseconds); + } + + Debug.Log($"HotReload: Starting synchronous compilation of {filename}"); + + // Read source code + var sourceCode = File.ReadAllText(hotReloadPath); + if (string.IsNullOrWhiteSpace(sourceCode)) + { + return HotReloadCompileResult.Failure( + "Empty File", + $"Hot reload file is empty: {hotReloadPath}", + stopwatch.ElapsedMilliseconds); + } + + // Generate versioned assembly name + var baseFilename = Path.GetFileNameWithoutExtension(filename); + var nextVersion = GetNextVersion(baseFilename); + var assemblyName = $"{baseFilename}_{nextVersion:D3}"; + + // Ensure temp directory exists + Directory.CreateDirectory(HotReloadTempDir); + + // Compile using shared RoslynCompilationService + var compilationResult = CompileHotReloadAssembly(sourceCode, assemblyName); + + if (!compilationResult.Success) + { + return HotReloadCompileResult.Failure( + "Compilation Failed", + "Hot reload file compilation failed", + stopwatch.ElapsedMilliseconds, + compilationResult.Diagnostics.Select(d => d.Message).ToList()); + } + + // Register hot reload methods from compiled assembly + var registeredMethods = RegisterHotReloadMethods(compilationResult.Assembly, assemblyName, out var skippedOverrides); + + // Save assembly to disk if assemblyDir is specified + string actualAssemblyPath = null; + if (!string.IsNullOrWhiteSpace(assemblyDir)) + { + Directory.CreateDirectory(assemblyDir); + actualAssemblyPath = Path.Combine(assemblyDir, $"{assemblyName}.dll"); + File.WriteAllBytes(actualAssemblyPath, compilationResult.AssemblyBytes); + Debug.Log($"HotReload: Assembly saved to disk: {actualAssemblyPath}"); + } + else + { + actualAssemblyPath = Path.Combine(HotReloadTempDir, $"{assemblyName}.dll"); // For compatibility (in-memory) + } + + stopwatch.Stop(); + + Debug.Log($"HotReload: Successfully compiled {filename} -> {assemblyName}.dll with {registeredMethods.Count} methods in {stopwatch.ElapsedMilliseconds}ms"); + + var compileResult = HotReloadCompileResult.Success( + assemblyName, + actualAssemblyPath, + registeredMethods, + stopwatch.ElapsedMilliseconds); + compileResult.Diagnostics = skippedOverrides; + return compileResult; + } + catch (Exception ex) + { + stopwatch.Stop(); + Debug.LogError($"HotReload: Synchronous compilation failed for {filename}: {ex.Message}"); + return HotReloadCompileResult.Failure( + "Exception", + ex.ToString(), + stopwatch.ElapsedMilliseconds); + } + } + + /// + /// Internal async compilation implementation for background threads. + /// + /// Hot reload file to compile + /// Optional directory to save compiled assembly to disk. If null, assembly stays in memory only. + private static async Task CompileAndApplyInternalAsync(string filename, string assemblyDir = null) + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + try + { + // Resolve the override file path (absolute, or relative to the project root). + var hotReloadPath = GetHotReloadPath(filename); + if (!File.Exists(hotReloadPath)) + { + return HotReloadCompileResult.Failure( + "File Not Found", + $"Hot reload file not found: {hotReloadPath}", + stopwatch.ElapsedMilliseconds); + } + + Debug.Log($"HotReload: Starting async compilation of {filename}"); + + // Read source code + var sourceCode = File.ReadAllText(hotReloadPath); + if (string.IsNullOrWhiteSpace(sourceCode)) + { + return HotReloadCompileResult.Failure( + "Empty File", + $"Hot reload file is empty: {hotReloadPath}", + stopwatch.ElapsedMilliseconds); + } + + // Generate versioned assembly name + var baseFilename = Path.GetFileNameWithoutExtension(filename); + var nextVersion = GetNextVersion(baseFilename); + var assemblyName = $"{baseFilename}_{nextVersion:D3}"; + + // Ensure temp directory exists + Directory.CreateDirectory(HotReloadTempDir); + + // Compile using shared RoslynCompilationService async + var compilationResult = await CompileHotReloadAssemblyAsync(sourceCode, assemblyName); + + if (!compilationResult.Success) + { + return HotReloadCompileResult.Failure( + "Compilation Failed", + "Hot reload file compilation failed", + stopwatch.ElapsedMilliseconds, + compilationResult.Diagnostics.Select(d => d.Message).ToList()); + } + + // Register hot reload methods from compiled assembly + var registeredMethods = RegisterHotReloadMethods(compilationResult.Assembly, assemblyName, out var skippedOverrides); + + // Save assembly to disk if assemblyDir is specified + string actualAssemblyPath = null; + if (!string.IsNullOrWhiteSpace(assemblyDir)) + { + Directory.CreateDirectory(assemblyDir); + actualAssemblyPath = Path.Combine(assemblyDir, $"{assemblyName}.dll"); + File.WriteAllBytes(actualAssemblyPath, compilationResult.AssemblyBytes); + Debug.Log($"HotReload: Assembly saved to disk: {actualAssemblyPath}"); + } + else + { + actualAssemblyPath = Path.Combine(HotReloadTempDir, $"{assemblyName}.dll"); // For compatibility (in-memory) + } + + stopwatch.Stop(); + + Debug.Log($"HotReload: Successfully compiled {filename} -> {assemblyName}.dll with {registeredMethods.Count} methods in {stopwatch.ElapsedMilliseconds}ms"); + + var compileResult = HotReloadCompileResult.Success( + assemblyName, + actualAssemblyPath, + registeredMethods, + stopwatch.ElapsedMilliseconds); + compileResult.Diagnostics = skippedOverrides; + return compileResult; + } + catch (Exception ex) + { + stopwatch.Stop(); + Debug.LogError($"HotReload: Async compilation failed for {filename}: {ex.Message}"); + return HotReloadCompileResult.Failure( + "Exception", + ex.ToString(), + stopwatch.ElapsedMilliseconds); + } + } + + /// + /// Compile hot reload source code directly (for in-place editing workflow). + /// + /// Hot reload source code to compile + /// Base filename for versioned assembly naming + /// Optional directory to save compiled assembly to disk + public static Task CompileSourceCodeAsync(string sourceCode, string baseFileName, string assemblyDir = null, bool emitPdb = false, string documentPath = null) + { + // Runs synchronously on the calling (main) thread; returns a completed Task. + return Task.FromResult(CompileSourceCodeOnMainThread(sourceCode, baseFileName, assemblyDir, emitPdb, documentPath)); + } + + /// + /// Compile source code on main thread synchronously to avoid deadlocks. + /// + /// Emit a portable PDB and load symbols so breakpoints can bind. + /// Source document path recorded in the PDB (the original .cs file). + public static HotReloadCompilationResult CompileSourceCodeOnMainThread(string sourceCode, string baseFileName, string assemblyDir = null, bool emitPdb = false, string documentPath = null) + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + try + { + Debug.Log($"HotReload: Starting synchronous source code compilation for {baseFileName}"); + + if (string.IsNullOrWhiteSpace(sourceCode)) + { + return HotReloadCompilationResult.Failure( + "Empty Source Code", + "Source code cannot be empty", + stopwatch.ElapsedMilliseconds); + } + + // Generate versioned assembly name + var nextVersion = GetNextVersion(baseFileName); + var assemblyName = $"{baseFileName}_{nextVersion:D3}"; + + // Ensure temp directory exists + Directory.CreateDirectory(HotReloadTempDir); + + // Compile using shared RoslynCompilationService + var compilationResult = CompileHotReloadAssembly(sourceCode, assemblyName, emitPdb, documentPath); + + if (!compilationResult.Success) + { + return HotReloadCompilationResult.Failure( + "Compilation Failed", + "Hot reload source code compilation failed", + stopwatch.ElapsedMilliseconds, + compilationResult.Diagnostics.Select(d => d.Message).ToList()); + } + + // Register hot reload methods from compiled assembly + var registeredMethods = RegisterHotReloadMethods(compilationResult.Assembly, assemblyName, out var skippedOverrides); + + // Save assembly to disk if assemblyDir is specified + string actualAssemblyPath = null; + if (!string.IsNullOrWhiteSpace(assemblyDir)) + { + Directory.CreateDirectory(assemblyDir); + actualAssemblyPath = Path.Combine(assemblyDir, $"{assemblyName}.dll"); + File.WriteAllBytes(actualAssemblyPath, compilationResult.AssemblyBytes); + Debug.Log($"HotReload: Assembly saved to disk: {actualAssemblyPath}"); + + // Symbols are loaded in-memory; also write the .pdb next to the .dll for convenience. + if (compilationResult.PdbBytes != null) + { + var pdbPath = Path.Combine(assemblyDir, $"{assemblyName}.pdb"); + File.WriteAllBytes(pdbPath, compilationResult.PdbBytes); + Debug.Log($"HotReload: Symbols saved to disk: {pdbPath}"); + } + } + else + { + actualAssemblyPath = Path.Combine(HotReloadTempDir, $"{assemblyName}.dll"); // For compatibility (in-memory) + } + + stopwatch.Stop(); + + Debug.Log($"HotReload: Successfully compiled source code -> {assemblyName}.dll with {registeredMethods.Count} methods in {stopwatch.ElapsedMilliseconds}ms"); + + var sourceCompileResult = HotReloadCompilationResult.Success( + assemblyName, + actualAssemblyPath, + registeredMethods, + stopwatch.ElapsedMilliseconds); + sourceCompileResult.Diagnostics = skippedOverrides; + return sourceCompileResult; + } + catch (Exception ex) + { + stopwatch.Stop(); + Debug.LogError($"HotReload: Synchronous source code compilation failed for {baseFileName}: {ex.Message}"); + return HotReloadCompilationResult.Failure( + "Exception", + ex.ToString(), + stopwatch.ElapsedMilliseconds); + } + } + + /// + /// Internal async source code compilation implementation for background threads. + /// + private static async Task CompileSourceCodeInternalAsync(string sourceCode, string baseFileName, string assemblyDir = null) + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + try + { + Debug.Log($"HotReload: Starting async source code compilation for {baseFileName}"); + + if (string.IsNullOrWhiteSpace(sourceCode)) + { + return HotReloadCompilationResult.Failure( + "Empty Source Code", + "Source code cannot be empty", + stopwatch.ElapsedMilliseconds); + } + + // Generate versioned assembly name + var nextVersion = GetNextVersion(baseFileName); + var assemblyName = $"{baseFileName}_{nextVersion:D3}"; + + // Ensure temp directory exists + Directory.CreateDirectory(HotReloadTempDir); + + // Compile using shared RoslynCompilationService async + var compilationResult = await CompileHotReloadAssemblyAsync(sourceCode, assemblyName); + + if (!compilationResult.Success) + { + return HotReloadCompilationResult.Failure( + "Compilation Failed", + "Hot reload source code compilation failed", + stopwatch.ElapsedMilliseconds, + compilationResult.Diagnostics.Select(d => d.Message).ToList()); + } + + // Register hot reload methods from compiled assembly + var registeredMethods = RegisterHotReloadMethods(compilationResult.Assembly, assemblyName, out var skippedOverrides); + + // Save assembly to disk if assemblyDir is specified + string actualAssemblyPath = null; + if (!string.IsNullOrWhiteSpace(assemblyDir)) + { + Directory.CreateDirectory(assemblyDir); + actualAssemblyPath = Path.Combine(assemblyDir, $"{assemblyName}.dll"); + File.WriteAllBytes(actualAssemblyPath, compilationResult.AssemblyBytes); + Debug.Log($"HotReload: Assembly saved to disk: {actualAssemblyPath}"); + } + else + { + actualAssemblyPath = Path.Combine(HotReloadTempDir, $"{assemblyName}.dll"); // For compatibility (in-memory) + } + + stopwatch.Stop(); + + Debug.Log($"HotReload: Successfully compiled source code -> {assemblyName}.dll with {registeredMethods.Count} methods in {stopwatch.ElapsedMilliseconds}ms"); + + var sourceCompileResult = HotReloadCompilationResult.Success( + assemblyName, + actualAssemblyPath, + registeredMethods, + stopwatch.ElapsedMilliseconds); + sourceCompileResult.Diagnostics = skippedOverrides; + return sourceCompileResult; + } + catch (Exception ex) + { + stopwatch.Stop(); + Debug.LogError($"HotReload: Async source code compilation failed for {baseFileName}: {ex.Message}"); + return HotReloadCompilationResult.Failure( + "Exception", + ex.ToString(), + stopwatch.ElapsedMilliseconds); + } + } + + /// + /// Clean up old hot reload DLL versions, keeping only the latest for each base filename. + /// + public static HotReloadCleanupResult CleanupHotReloadDlls(string assemblyDir = null) + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var deletedFiles = new List(); + + try + { + // Use specified assemblyDir or default to HotReloadTempDir + var targetDir = !string.IsNullOrWhiteSpace(assemblyDir) ? assemblyDir : HotReloadTempDir; + + if (!Directory.Exists(targetDir)) + { + return new HotReloadCleanupResult + { + Success = true, + DeletedFiles = deletedFiles, + Message = $"No hot reload directory found: {targetDir}", + ExecutionTimeMs = stopwatch.ElapsedMilliseconds + }; + } + + var dllFiles = Directory.GetFiles(targetDir, "*.dll"); + var groupedFiles = dllFiles + .Select(f => new + { + FilePath = f, + FileName = Path.GetFileNameWithoutExtension(f), + BaseFilename = ExtractBaseFilename(Path.GetFileNameWithoutExtension(f)), + Version = ExtractVersion(Path.GetFileNameWithoutExtension(f)) + }) + .Where(f => f.Version > 0) // Only versioned hot reload DLLs + .GroupBy(f => f.BaseFilename) + .ToList(); + + foreach (var group in groupedFiles) + { + var sortedFiles = group.OrderByDescending(f => f.Version).ToList(); + + // Keep the latest version, delete older ones + for (int i = 1; i < sortedFiles.Count; i++) + { + var fileToDelete = sortedFiles[i]; + File.Delete(fileToDelete.FilePath); + deletedFiles.Add(fileToDelete.FileName + ".dll"); + Debug.Log($"HotReload: Deleted old version {fileToDelete.FileName}.dll"); + } + } + + // Clear registry and reset version tracker + HotReloadRegistry.ClearAllOverrides(); + _versionTracker.Clear(); + + stopwatch.Stop(); + + Debug.Log($"HotReload: Cleanup completed - deleted {deletedFiles.Count} files in {stopwatch.ElapsedMilliseconds}ms"); + + return new HotReloadCleanupResult + { + Success = true, + DeletedFiles = deletedFiles, + Message = $"Deleted {deletedFiles.Count} old DLL versions", + ExecutionTimeMs = stopwatch.ElapsedMilliseconds + }; + } + catch (Exception ex) + { + stopwatch.Stop(); + Debug.LogError($"HotReload: Cleanup failed: {ex.Message}"); + + return new HotReloadCleanupResult + { + Success = false, + DeletedFiles = deletedFiles, + Message = $"Cleanup failed: {ex.Message}", + ExecutionTimeMs = stopwatch.ElapsedMilliseconds + }; + } + } + + /// + /// Get the next version number for a base filename. + /// + private static int GetNextVersion(string baseFilename) + { + if (!_versionTracker.ContainsKey(baseFilename)) + { + _versionTracker[baseFilename] = 0; + } + + return ++_versionTracker[baseFilename]; + } + + /// + /// Wrap hot reload user code with proper using statements and namespace. + /// + + /// + /// Compile hot reload source code using shared RoslynCompilationService. + /// Synchronous compilation for main thread use. + /// + private static CompilationResult CompileHotReloadAssembly(string sourceCode, string assemblyName, bool emitPdb = false, string documentPath = null) + { + var request = new CompilationRequest + { + SourceCode = sourceCode, + AssemblyName = assemblyName, + // Ensure Unity.Pipeline assemblies are included (contains HotReloadOverrideMethodAttribute) + // Also include test assemblies for testing scenarios + AdditionalAssemblyPrefixes = new[] { "Unity.Pipeline", "Unity.Pipeline.Tests" }, + EmitDebugInformation = emitPdb, + DocumentPath = documentPath + }; + + return RoslynCompilationService.Compile(request); + } + + /// + /// Compile hot reload source code using shared RoslynCompilationService. + /// Async compilation for background thread use. + /// + private static async Task CompileHotReloadAssemblyAsync(string sourceCode, string assemblyName) + { + var request = new CompilationRequest + { + SourceCode = sourceCode, + AssemblyName = assemblyName, + // Ensure Unity.Pipeline assemblies are included (contains HotReloadOverrideMethodAttribute) + // Also include test assemblies for testing scenarios + AdditionalAssemblyPrefixes = new[] { "Unity.Pipeline", "Unity.Pipeline.Tests" } + }; + + return await RoslynCompilationService.CompileAsync(request); + } + + /// + /// Register hot reload methods from compiled assembly with the registry. + /// Also registers any [HotReloadWithOverrides] target methods found in the assembly. + /// Only overrides that actually bind are returned; overrides that were skipped (e.g. the + /// target is not currently [HotReloadWithOverrides], or a signature mismatch) are reported via + /// with a user-facing reason. + /// + private static List RegisterHotReloadMethods(Assembly assembly, string assemblyId, out List skipped) + { + var registeredMethods = new List(); + skipped = new List(); + + try + { + Debug.Log($"HotReload: Registering methods from assembly {assemblyId} with {assembly.GetTypes().Length} types"); + + // Register assembly types for discovery + foreach (var type in assembly.GetTypes()) + { + Debug.Log($"HotReload: Processing type {type.Name} in {type.Namespace}"); + HotReloadRegistry.RegisterHotReloadType(type, assemblyId); + + // First, scan for [HotReloadWithOverrides] methods to register as targets + var allMethods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static); + foreach (var method in allMethods) + { + var reloadableAttr = method.GetCustomAttribute(); + if (reloadableAttr != null) + { + Debug.Log($"HotReload: Found reloadable method {method.Name} with ID {reloadableAttr.Id}"); + HotReloadRegistry.RegisterReloadableMethod(method, reloadableAttr); + } + } + + // Then, find methods with [HotReloadOverrideMethod] attribute for overrides + var staticMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Static); + Debug.Log($"HotReload: Found {staticMethods.Length} static methods in {type.Name}"); + + foreach (var method in staticMethods) + { + var customAttributes = method.GetCustomAttributes(true); + Debug.Log($"HotReload: Method {method.Name} has {customAttributes.Length} custom attributes: {string.Join(", ", customAttributes.Select(a => a.GetType().Name))}"); + + // Try multiple ways to find the attribute + var hotReloadAttr = method.GetCustomAttribute(); + if (hotReloadAttr == null) + { + // Try by name in case of type loading issues + var attrByName = customAttributes.FirstOrDefault(a => a.GetType().Name == "HotReloadOverrideMethodAttribute"); + if (attrByName != null) + { + Debug.Log($"HotReload: Found HotReloadOverrideMethodAttribute by name on {method.Name}"); + // Cast to the attribute type + hotReloadAttr = attrByName as HotReloadOverrideMethodAttribute; + } + } + + if (hotReloadAttr != null) + { + Debug.Log($"HotReload: Registering method override {method.Name} -> {hotReloadAttr.TargetMethodId}"); + if (HotReloadRegistry.RegisterMethodOverride(method, hotReloadAttr, type, out var skipReason)) + { + registeredMethods.Add(hotReloadAttr.TargetMethodId); + } + else + { + skipped.Add($"{method.Name} -> {hotReloadAttr.TargetMethodId}: {skipReason}"); + } + } + } + } + + Debug.Log($"HotReload: Registered {registeredMethods.Count} override methods: {string.Join(", ", registeredMethods)}"); + } + catch (Exception ex) + { + Debug.LogError($"HotReload: Error registering methods from assembly {assemblyId}: {ex.Message}"); + Debug.LogError($"HotReload: Stack trace: {ex.StackTrace}"); + } + + return registeredMethods; + } + + /// + /// Extract base filename from versioned filename (e.g., "PlayerMovement_001" -> "PlayerMovement"). + /// + private static string ExtractBaseFilename(string versionedFilename) + { + var lastUnderscoreIndex = versionedFilename.LastIndexOf('_'); + if (lastUnderscoreIndex > 0) + { + var potentialVersion = versionedFilename.Substring(lastUnderscoreIndex + 1); + if (int.TryParse(potentialVersion, out _)) + { + return versionedFilename.Substring(0, lastUnderscoreIndex); + } + } + return versionedFilename; + } + + /// + /// Extract version number from versioned filename (e.g., "PlayerMovement_001" -> 1). + /// + private static int ExtractVersion(string versionedFilename) + { + var lastUnderscoreIndex = versionedFilename.LastIndexOf('_'); + if (lastUnderscoreIndex > 0) + { + var potentialVersion = versionedFilename.Substring(lastUnderscoreIndex + 1); + if (int.TryParse(potentialVersion, out var version)) + { + return version; + } + } + return 0; + } + +#else + // Hot reload requires Roslyn compilation, which is only available in the Editor and in + // Desktop development builds. In all other builds the methods below are compiled instead, + // matching the public surface used by callers (HotReloadCommands, InPlaceReloadProcessor) + // so the project still builds, and returning a clear "not supported" failure at runtime. + const string NotSupportedMessage = + "Hot reload compilation is only supported on Desktop development builds (Windows/Mac/Linux)."; + + /// + /// Hot reload compilation not supported on this build. + /// + public static Task CompileAndApplyAsync(string filename, string assemblyDir = null) + { + return Task.FromResult(HotReloadCompileResult.Failure("Platform Not Supported", NotSupportedMessage)); + } + + /// + /// Hot reload source compilation (in-place editing) not supported on this build. + /// + public static HotReloadCompilationResult CompileSourceCodeOnMainThread(string sourceCode, string baseFileName, string assemblyDir = null, bool emitPdb = false, string documentPath = null) + { + return HotReloadCompilationResult.Failure("Platform Not Supported", NotSupportedMessage); + } + + /// + /// Hot reload source compilation (in-place editing) not supported on this build. + /// + public static Task CompileSourceCodeAsync(string sourceCode, string baseFileName, string assemblyDir = null, bool emitPdb = false, string documentPath = null) + { + return Task.FromResult(HotReloadCompilationResult.Failure("Platform Not Supported", NotSupportedMessage)); + } + + /// + /// Hot reload cleanup not supported on this build. + /// + public static HotReloadCleanupResult CleanupHotReloadDlls(string assemblyDir = null) + { + return new HotReloadCleanupResult + { + Success = false, + Message = NotSupportedMessage, + ExecutionTimeMs = 0 + }; + } +#endif + } + + /// + /// Result from hot reload compilation operation. + /// + public class HotReloadCompileResult + { + public bool IsSuccess { get; set; } + public string AssemblyName { get; set; } + public string OutputPath { get; set; } + public List RegisteredMethods { get; set; } = new List(); + public long ExecutionTimeMs { get; set; } + public string Error { get; set; } + public string ErrorDetails { get; set; } + public List Diagnostics { get; set; } = new List(); + + public static HotReloadCompileResult Success(string assemblyName, string outputPath, List registeredMethods, long executionTimeMs) + { + return new HotReloadCompileResult + { + IsSuccess = true, + AssemblyName = assemblyName, + OutputPath = outputPath, + RegisteredMethods = registeredMethods, + ExecutionTimeMs = executionTimeMs + }; + } + + public static HotReloadCompileResult Failure(string error, string errorDetails, long executionTimeMs = 0, List diagnostics = null) + { + return new HotReloadCompileResult + { + IsSuccess = false, + Error = error, + ErrorDetails = errorDetails, + ExecutionTimeMs = executionTimeMs, + Diagnostics = diagnostics ?? new List() + }; + } + } + + /// + /// Result from hot reload cleanup operation. + /// + public class HotReloadCleanupResult + { + public bool Success { get; set; } + public List DeletedFiles { get; set; } = new List(); + public string Message { get; set; } + public long ExecutionTimeMs { get; set; } + } + + /// + /// Result from hot reload source code compilation operation (for in-place editing). + /// + public class HotReloadCompilationResult + { + public bool IsSuccess { get; set; } + public string AssemblyName { get; set; } + public string OutputPath { get; set; } + public List RegisteredMethods { get; set; } = new List(); + public long ExecutionTimeMs { get; set; } + public string Error { get; set; } + public string ErrorDetails { get; set; } + public List Diagnostics { get; set; } = new List(); + + public static HotReloadCompilationResult Success(string assemblyName, string outputPath, List registeredMethods, long executionTimeMs) + { + return new HotReloadCompilationResult + { + IsSuccess = true, + AssemblyName = assemblyName, + OutputPath = outputPath, + RegisteredMethods = registeredMethods, + ExecutionTimeMs = executionTimeMs + }; + } + + public static HotReloadCompilationResult Failure(string error, string errorDetails, long executionTimeMs = 0, List diagnostics = null) + { + return new HotReloadCompilationResult + { + IsSuccess = false, + Error = error, + ErrorDetails = errorDetails, + ExecutionTimeMs = executionTimeMs, + Diagnostics = diagnostics ?? new List() + }; + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/HotReloadCompiler.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/HotReloadCompiler.cs.meta new file mode 100644 index 00000000..5c30a103 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/HotReloadCompiler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 29ede4b57fcc6c64b84e5f6ee75df373 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/RoslynCompilationService.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/RoslynCompilationService.cs new file mode 100644 index 00000000..4aec5187 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/RoslynCompilationService.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Microsoft.CodeAnalysis.Text; +using Unity.Pipeline.Models; +using UnityEngine; + +namespace Unity.Pipeline.Compilation +{ + /// + /// Shared Roslyn compilation service for both code evaluation and hot reload systems. + /// Eliminates code duplication by providing common compilation infrastructure. + /// Thread-safe and can be called from any thread. + /// + public static class RoslynCompilationService + { +#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG) + + /// + /// Compile source code to assembly with customizable options. + /// Thread-safe compilation that can run on any thread. + /// + public static CompilationResult Compile(CompilationRequest request) + { + try + { + // Parse syntax tree. When emitting debug info, the tree needs a document path and + // UTF-8 encoding so the portable PDB can reference a source document. + var syntaxTree = request.EmitDebugInformation + ? CSharpSyntaxTree.ParseText( + SourceText.From(request.SourceCode, Encoding.UTF8), + path: request.DocumentPath ?? request.AssemblyName + ".cs") + : CSharpSyntaxTree.ParseText(request.SourceCode); + + // Get metadata references with optional additional prefixes + var references = GetMetadataReferences(request.AdditionalAssemblyPrefixes); + + // Create compilation. Disable optimizations when emitting debug info so the JIT + // keeps full sequence points (otherwise breakpoints can't bind). + var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary); + if (request.EmitDebugInformation) + options = options.WithOptimizationLevel(OptimizationLevel.Debug); + + var compilation = CSharpCompilation.Create( + request.AssemblyName, + new[] { syntaxTree }, + references, + options); + + // Get diagnostics + var diagnostics = compilation.GetDiagnostics(); + var diagnosticInfos = ConvertDiagnostics(diagnostics, request.LineNumberOffset); + + // Check for compilation errors + var errors = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + if (errors.Any()) + { + return new CompilationResult + { + Success = false, + Diagnostics = diagnosticInfos + }; + } + + // Compile to memory + using (var peStream = new MemoryStream()) + { + byte[] pdbBytes = null; + EmitResult emitResult; + + if (request.EmitDebugInformation) + { + using (var pdbStream = new MemoryStream()) + { + emitResult = compilation.Emit( + peStream, + pdbStream, + options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb)); + + if (emitResult.Success) + pdbBytes = pdbStream.ToArray(); + } + } + else + { + emitResult = compilation.Emit(peStream); + } + + if (!emitResult.Success) + { + var emitDiagnostics = ConvertDiagnostics(emitResult.Diagnostics, request.LineNumberOffset); + return new CompilationResult + { + Success = false, + Diagnostics = diagnosticInfos.Concat(emitDiagnostics).ToList() + }; + } + + // Load assembly from memory. When symbols are present, load them alongside the + // assembly so an attached debugger can bind breakpoints into the emitted code. + var assemblyBytes = peStream.ToArray(); + var assembly = pdbBytes != null + ? PipelineUtils.LoadFromBytes(assemblyBytes, pdbBytes) + : PipelineUtils.LoadFromBytes(assemblyBytes); + + return new CompilationResult + { + Success = true, + Assembly = assembly, + AssemblyBytes = assemblyBytes, + PdbBytes = pdbBytes, + Diagnostics = diagnosticInfos + }; + } + } + catch (Exception ex) + { + return new CompilationResult + { + Success = false, + Diagnostics = new List + { + new DiagnosticInfo + { + Severity = "error", + Message = $"Compilation exception: {ex.Message}", + Line = 0, + Column = 0, + Id = "ROSLYN001" + } + } + }; + } + } + + /// + /// Async wrapper for compilation that runs on background thread. + /// Use when you need to avoid blocking the calling thread. + /// + public static Task CompileAsync(CompilationRequest request) + { + return Task.Run(() => Compile(request)); + } + + /// + /// Get metadata references for compilation with optional additional assembly prefixes. + /// Handles both Editor and Runtime scenarios with appropriate filtering. + /// + public static List GetMetadataReferences(string[] additionalPrefixes = null) + { + var references = new List(); + var assemblies = PipelineUtils.GetLoadedAssemblies() + .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(PipelineUtils.GetLoadedAssemblyPath(a))); + + if (Application.isEditor) + { + // Editor: Include all loaded assemblies for maximum compatibility + foreach (var assembly in assemblies) + { + try + { + references.Add(MetadataReference.CreateFromFile(PipelineUtils.GetLoadedAssemblyPath(assembly))); + } + catch + { + // Skip problematic assemblies + } + } + } + else + { + // Runtime: Use curated filtering for Unity + user assemblies + var allowedPrefixes = new List + { + "UnityEngine", + "Assembly-CSharp", + "netstandard", + "mscorlib", + "System.", + "Unity.Pipeline" + }; + + // Add any additional prefixes (e.g., for hot reload: "Microsoft.CodeAnalysis") + if (additionalPrefixes != null) + { + allowedPrefixes.AddRange(additionalPrefixes); + } + + foreach (var assembly in assemblies) + { + try + { + var assemblyName = assembly.GetName().Name; + if (allowedPrefixes.Any(prefix => assemblyName.StartsWith(prefix))) + { + references.Add(MetadataReference.CreateFromFile(PipelineUtils.GetLoadedAssemblyPath(assembly))); + } + } + catch + { + // Skip problematic assemblies + } + } + } + + return references; + } + + /// + /// Convert Roslyn diagnostics to DiagnosticInfo list with optional line number adjustment. + /// Handles line number offset for wrapped code scenarios. + /// + private static List ConvertDiagnostics(IEnumerable diagnostics, int lineOffset = 0) + { + var result = new List(); + + foreach (var diagnostic in diagnostics.Where(d => d.Severity >= DiagnosticSeverity.Warning)) + { + var location = diagnostic.Location.GetLineSpan(); + var line = Math.Max(0, location.StartLinePosition.Line - lineOffset); + var column = location.StartLinePosition.Character; + + result.Add(new DiagnosticInfo + { + Severity = diagnostic.Severity.ToString().ToLower(), + Message = diagnostic.GetMessage(), + Line = line, + Column = column, + Id = diagnostic.Id + }); + } + + return result; + } + +#else + /// + /// Runtime compilation not supported on this platform. + /// Desktop development builds only (Windows/Mac/Linux). + /// + public static CompilationResult Compile(CompilationRequest request) + { + return new CompilationResult + { + Success = false, + Diagnostics = new List + { + new DiagnosticInfo + { + Severity = "error", + Message = "Runtime code compilation only supported on Desktop development builds", + Line = 0, + Column = 0, + Id = "PLATFORM001" + } + } + }; + } + + /// + /// Runtime compilation not supported on this platform. + /// + public static Task CompileAsync(CompilationRequest request) + { + return Task.FromResult(Compile(request)); + } + + /// + /// Runtime compilation not supported on this platform. + /// + public static List GetMetadataReferences(string[] additionalPrefixes = null) + { + return new List(); + } +#endif + } + + /// + /// Request for Roslyn compilation with customizable options. + /// + public class CompilationRequest + { + /// + /// Source code to compile. + /// + public string SourceCode { get; set; } + + /// + /// Name for the generated assembly (should be unique). + /// + public string AssemblyName { get; set; } + + /// + /// Additional assembly prefixes to include in metadata references. + /// Useful for hot reload scenarios that need "Microsoft.CodeAnalysis" etc. + /// + public string[] AdditionalAssemblyPrefixes { get; set; } + + /// + /// Line number offset to subtract from diagnostic line numbers. + /// Used when source code has wrapper code that should be hidden from diagnostics. + /// + public int LineNumberOffset { get; set; } + + /// + /// When true, emit a portable PDB and load the assembly with its symbols so an attached + /// managed debugger can bind breakpoints. Compiles unoptimized. Default false (no symbols). + /// + public bool EmitDebugInformation { get; set; } + + /// + /// Document path recorded in the syntax tree when is set. + /// Source that is not covered by explicit #line directives maps to this path. + /// + public string DocumentPath { get; set; } + } + + /// + /// Result from Roslyn compilation. + /// + public class CompilationResult + { + /// + /// Whether compilation was successful. + /// + public bool Success { get; set; } + + /// + /// Compiled assembly (only available if Success = true). + /// + public Assembly Assembly { get; set; } + + /// + /// Raw assembly bytes (for potential disk saving or caching). + /// + public byte[] AssemblyBytes { get; set; } + + /// + /// Portable PDB bytes when was set; + /// null otherwise. + /// + public byte[] PdbBytes { get; set; } + + /// + /// Compilation diagnostics (errors, warnings, info). + /// + public List Diagnostics { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/RoslynCompilationService.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/RoslynCompilationService.cs.meta new file mode 100644 index 00000000..b2ec11a5 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Compilation/RoslynCompilationService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 957feb25e68236e45b15cd5e9f095c31 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Console.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Console.meta new file mode 100644 index 00000000..77428918 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Console.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7c1f4a9e2b6d4c8f9a0e3d5b7c1a2f48 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Console/ConsoleLogBuffer.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Console/ConsoleLogBuffer.cs new file mode 100644 index 00000000..745d1137 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Console/ConsoleLogBuffer.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; +using Unity.Pipeline.Models; +using UnityEngine; + +namespace Unity.Pipeline.Console +{ + /// + /// Bounded, thread-safe ring buffer of captured Unity console entries that backs the + /// console command. + /// + /// Why thread-safe: entries arrive via , + /// which can fire from background threads, while the command reads the buffer from an HTTP + /// request thread. All access is guarded by a single lock. + /// + /// Each entry gets a monotonic that never repeats and only + /// increases — including across domain reloads, because the buffer (and the seq counter) is + /// persisted via /. The seq is the cursor a "--follow" + /// client uses to fetch only newer entries. + /// + public class ConsoleLogBuffer + { + /// Maximum number of entries retained. Older entries are evicted first. + public const int Capacity = 2000; + + // Severity levels, ordered so a "minimum severity" filter is a simple >= comparison. + public const int SeverityLog = 0; + public const int SeverityWarn = 1; + public const int SeverityError = 2; + + public const string LevelLog = "log"; + public const string LevelWarn = "warn"; + public const string LevelError = "error"; + + readonly object m_Lock = new object(); + readonly Queue m_Entries = new Queue(); + long m_LastSeq; + + struct StoredEntry + { + public int Severity; + public ConsoleLogEntry Entry; + } + + /// + /// Capture a console entry. Assigns the next sequence number and evicts the oldest entry if + /// the buffer is full. Safe to call from any thread. + /// + public void Add(LogType type, string message, string stackTrace, DateTime timestampUtc) + { + var severity = SeverityFromLogType(type); + lock (m_Lock) + { + m_LastSeq++; + if (m_Entries.Count >= Capacity) + m_Entries.Dequeue(); + + m_Entries.Enqueue(new StoredEntry + { + Severity = severity, + Entry = new ConsoleLogEntry + { + Seq = m_LastSeq, + TimestampUtc = timestampUtc, + Level = LevelName(severity), + Message = message ?? string.Empty, + StackTrace = stackTrace ?? string.Empty + } + }); + } + } + + /// + /// Query the buffer for the console command. + /// + /// + /// Cursor: return only entries with Seq > since. Pass a negative value (e.g. -1) + /// for a snapshot of the most recent entries with no cursor filtering. + /// + /// + /// Maximum number of entries to return (the most recent matches). Values <= 0 mean "no + /// limit" (up to ). + /// + /// Minimum severity to include (see the Severity* constants). + public ConsoleLogResponse Query(long since, int tail, int minSeverity) + { + lock (m_Lock) + { + var matches = new List(); + foreach (var stored in m_Entries) + { + if (stored.Severity < minSeverity) + continue; + if (since >= 0 && stored.Entry.Seq <= since) + continue; + matches.Add(stored.Entry); + } + + // Keep only the most recent `tail` matches. + if (tail > 0 && matches.Count > tail) + matches.RemoveRange(0, matches.Count - tail); + + return new ConsoleLogResponse + { + Entries = matches.ToArray(), + Cursor = m_LastSeq, + Returned = matches.Count, + Dropped = ComputeDropped(since) + }; + } + } + + /// + /// True when refers to entries that have already been evicted, so + /// the caller missed some output. Caller must hold . + /// + bool ComputeDropped(long since) + { + if (since < 0) + return false; + if (since >= m_LastSeq) + return false; + + // Everything after `since` was evicted (or never retained): buffer empty but seq moved on. + if (m_Entries.Count == 0) + return m_LastSeq > since; + + // The next entry the caller expects is since+1; if the oldest entry we still hold is + // newer than that, the gap in between was evicted. + var oldestSeq = m_Entries.Peek().Entry.Seq; + return oldestSeq > since + 1; + } + + /// Remove all entries. Does NOT reset the sequence counter (cursors stay monotonic). + public void Clear() + { + lock (m_Lock) + { + m_Entries.Clear(); + } + } + + /// Current number of retained entries. For diagnostics/tests. + public int Count + { + get { lock (m_Lock) { return m_Entries.Count; } } + } + + /// The highest sequence number assigned so far (the live cursor). + public long LastSeq + { + get { lock (m_Lock) { return m_LastSeq; } } + } + + #region Persistence + + [Serializable] + class Snapshot + { + [JsonProperty("lastSeq")] public long LastSeq; + [JsonProperty("entries")] public ConsoleLogEntry[] Entries; + } + + /// + /// Persist the buffer (entries + sequence counter) to so it survives + /// a domain reload. Failures are swallowed (logging is best-effort, never fatal). + /// + public void Save(string path) + { + try + { + Snapshot snapshot; + lock (m_Lock) + { + var entries = new ConsoleLogEntry[m_Entries.Count]; + var i = 0; + foreach (var stored in m_Entries) + entries[i++] = stored.Entry; + snapshot = new Snapshot { LastSeq = m_LastSeq, Entries = entries }; + } + + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + File.WriteAllText(path, JsonConvert.SerializeObject(snapshot)); + } + catch (Exception ex) + { + Debug.LogWarning($"[Pipeline] Failed to persist console log buffer: {ex.Message}"); + } + } + + /// + /// Restore a buffer previously written by . Missing or unreadable files + /// leave the buffer empty. Returns true if a snapshot was loaded. + /// + public bool Load(string path) + { + try + { + if (!File.Exists(path)) + return false; + + var snapshot = JsonConvert.DeserializeObject(File.ReadAllText(path)); + if (snapshot == null) + return false; + + lock (m_Lock) + { + m_Entries.Clear(); + long lastRestoredSeq = 0; + if (snapshot.Entries != null) + { + foreach (var entry in snapshot.Entries) + { + if (entry == null) + continue; + if (m_Entries.Count >= Capacity) + m_Entries.Dequeue(); + + m_Entries.Enqueue(new StoredEntry + { + Severity = SeverityFromLevelName(entry.Level), + Entry = entry + }); + lastRestoredSeq = entry.Seq; + } + } + + // Keep the counter ahead of any restored entry so seq stays monotonic. + m_LastSeq = snapshot.LastSeq; + if (m_Entries.Count > 0) + m_LastSeq = Math.Max(m_LastSeq, lastRestoredSeq); + } + + return true; + } + catch (Exception ex) + { + Debug.LogWarning($"[Pipeline] Failed to restore console log buffer: {ex.Message}"); + return false; + } + } + + #endregion + + #region Severity mapping + + /// Map a Unity to an ordered severity. + public static int SeverityFromLogType(LogType type) + { + switch (type) + { + case LogType.Warning: + return SeverityWarn; + case LogType.Error: + case LogType.Exception: + case LogType.Assert: + return SeverityError; + default: + return SeverityLog; + } + } + + /// + /// Parse a "--level" value ("log"/"warn"/"error", plus common aliases) into a minimum + /// severity. Unknown values fall back to (everything), matching the + /// lenient behavior of the existing log command. + /// + public static int SeverityFromLevelName(string level) + { + switch ((level ?? string.Empty).Trim().ToLowerInvariant()) + { + case "error": + case "err": + case "exception": + return SeverityError; + case "warn": + case "warning": + return SeverityWarn; + default: + return SeverityLog; + } + } + + /// The canonical level name for a severity. + public static string LevelName(int severity) + { + switch (severity) + { + case SeverityError: + return LevelError; + case SeverityWarn: + return LevelWarn; + default: + return LevelLog; + } + } + + #endregion + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Console/ConsoleLogBuffer.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Console/ConsoleLogBuffer.cs.meta new file mode 100644 index 00000000..1a81c697 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Console/ConsoleLogBuffer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1e014974635df5fe6a6787c4ba983f80 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Console/ConsoleLogCapture.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Console/ConsoleLogCapture.cs new file mode 100644 index 00000000..3ffa740a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Console/ConsoleLogCapture.cs @@ -0,0 +1,67 @@ +using System; +using UnityEngine; + +namespace Unity.Pipeline.Console +{ + /// + /// Captures Unity console output into a process-wide so the + /// console command can serve it to CLI clients. This type lives in the runtime + /// assembly, so it ships in player builds as well as the Editor. + /// + /// Lifecycle: + /// - In a Player, the [RuntimeInitializeOnLoadMethod] hook starts capture as the app boots. + /// - In the Editor, the editor-only EditorConsoleCaptureBootstrap starts capture on every + /// domain reload and layers on persistence so entries survive reloads (e.g. after a recompile). + /// Both paths funnel through , which is idempotent — entering Play + /// mode in the Editor fires the runtime hook too, and the guard makes that a no-op. + /// - is used (not the non-threaded variant) + /// so logs emitted from background threads are captured too. The buffer is thread-safe. + /// + /// Capture starts when this type first initializes. Console entries produced before that — or + /// before the package's first import — are not retroactively captured; this reads from the public + /// log callback, not Unity's internal console store. + /// + public static class ConsoleLogCapture + { + static readonly ConsoleLogBuffer s_Buffer = new ConsoleLogBuffer(); + static readonly object s_SubscriptionLock = new object(); + static bool s_Subscribed; + + /// The shared buffer holding captured console entries. + public static ConsoleLogBuffer Buffer => s_Buffer; + + /// + /// Player entry point. Runs as the application boots so console output is captured from the + /// start. In the Editor this also fires when entering Play mode, but + /// is idempotent so it does not double-subscribe. + /// + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + static void RuntimeBootstrap() + { + EnsureCapturing(); + } + + /// + /// Subscribe to Unity's log callback if not already subscribed. Safe to call repeatedly and + /// from either the runtime bootstrap or the Editor bootstrap. + /// + public static void EnsureCapturing() + { + lock (s_SubscriptionLock) + { + if (s_Subscribed) + return; + + // Defensive: remove before adding in case a prior subscription leaked across a reload. + Application.logMessageReceivedThreaded -= OnLogMessage; + Application.logMessageReceivedThreaded += OnLogMessage; + s_Subscribed = true; + } + } + + static void OnLogMessage(string condition, string stackTrace, LogType type) + { + s_Buffer.Add(type, condition, stackTrace, DateTime.UtcNow); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Console/ConsoleLogCapture.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Console/ConsoleLogCapture.cs.meta new file mode 100644 index 00000000..88136c44 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Console/ConsoleLogCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3d9b6e1a4f2c40a78c5e0b1d2a6f9c84 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload.meta new file mode 100644 index 00000000..5fa83283 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c29607a31a43d7149b8296de99b81d8e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/AccessibilityValidator.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/AccessibilityValidator.cs new file mode 100644 index 00000000..6d458b8a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/AccessibilityValidator.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Unity.Pipeline.Compilation; +using UnityEngine; + +namespace Unity.Pipeline.HotReload +{ + /// + /// Validates that [HotReload] method bodies only access PUBLIC instance members of the + /// declaring type. In-place overrides are compiled into a separate assembly, so they can only + /// reach public members of the original type; private/internal/protected access would fail to + /// compile. This check runs up front (via a Roslyn semantic model) to produce a clear message + /// instead of a raw compiler error. + /// + public static class AccessibilityValidator + { + public static AccessibilityValidationResult ValidatePublicAccess( + string sourceCode, + Dictionary methodBodies, + string originalTypeName) + { + var result = new AccessibilityValidationResult + { + IsValid = true, + Violations = new List() + }; + + try + { + var tree = CSharpSyntaxTree.ParseText(sourceCode); + var root = tree.GetRoot(); + + var compilation = CSharpCompilation.Create( + "HotReloadInPlaceValidation", + new[] { tree }, + RoslynCompilationService.GetMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var model = compilation.GetSemanticModel(tree); + + var classDecl = root.DescendantNodes() + .OfType() + .FirstOrDefault(c => c.Identifier.ValueText == originalTypeName); + if (classDecl == null) + { + result.IsValid = false; + result.ValidationError = $"Could not find class '{originalTypeName}' in source."; + return result; + } + + var classSymbol = model.GetDeclaredSymbol(classDecl); + + foreach (var method in classDecl.Members.OfType()) + { + var methodName = method.Identifier.ValueText; + if (!methodBodies.ContainsKey(methodName) || method.Body == null) + continue; + + var seen = new HashSet(); + foreach (var name in method.Body.DescendantNodes().OfType()) + { + if (IsMemberName(name)) + continue; + + var symbol = model.GetSymbolInfo(name).Symbol; + if (!IsInstanceMemberOf(symbol, classSymbol)) + continue; + + if (symbol.DeclaredAccessibility != Accessibility.Public && seen.Add(symbol.Name)) + { + result.Violations.Add(new AccessibilityViolation + { + MemberName = symbol.Name, + MethodName = methodName, + AccessLevel = symbol.DeclaredAccessibility, + ViolationType = AccessibilityViolationType.PrivateAccess, + ErrorMessage = $"Cannot access non-public member '{symbol.Name}' " + + $"({symbol.DeclaredAccessibility}) in [HotReload] method '{methodName}'", + Suggestion = $"Make '{symbol.Name}' public in {originalTypeName}, or use a " + + "public property/method. In-place overrides compile in a separate assembly " + + "and can only access public members." + }); + } + } + } + + result.IsValid = result.Violations.Count == 0; + return result; + } + catch (Exception ex) + { + Debug.LogError($"HotReload: Accessibility validation error: {ex.Message}"); + return new AccessibilityValidationResult + { + IsValid = false, + ValidationError = ex.Message, + Violations = new List() + }; + } + } + + /// True if the name is the right-hand member name of an access (foo.Bar -> Bar). + private static bool IsMemberName(SimpleNameSyntax node) + { + if (node.Parent is MemberAccessExpressionSyntax ma && ma.Name == node) + return true; + if (node.Parent is QualifiedNameSyntax) + return true; + if (node.Parent is MemberBindingExpressionSyntax) + return true; + return false; + } + + /// True if the symbol is an instance field/property/method/event of the type or a base. + private static bool IsInstanceMemberOf(ISymbol symbol, INamedTypeSymbol type) + { + if (symbol == null || symbol.IsStatic) + return false; + + switch (symbol.Kind) + { + case SymbolKind.Field: + case SymbolKind.Property: + case SymbolKind.Method: + case SymbolKind.Event: + break; + default: + return false; + } + + for (var t = type; t != null; t = t.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(t, symbol.ContainingType)) + return true; + } + return false; + } + } + + /// + /// Result of accessibility validation for hot reload methods. + /// + public class AccessibilityValidationResult + { + public bool IsValid { get; set; } + public List Violations { get; set; } = new List(); + public string ValidationError { get; set; } + + public string GetFormattedErrorMessage() + { + if (!string.IsNullOrEmpty(ValidationError)) + return $"HotReload Validation Error: {ValidationError}"; + + if (Violations.Count == 0) + return "All member access is valid for hot reload."; + + var errorMessage = $"HotReload Validation Failed: {Violations.Count} accessibility violation(s) found\n\n"; + for (int i = 0; i < Violations.Count; i++) + { + var violation = Violations[i]; + errorMessage += $"{i + 1}. Method '{violation.MethodName}': {violation.ErrorMessage}\n"; + errorMessage += $" → Suggestion: {violation.Suggestion}\n"; + if (i < Violations.Count - 1) + errorMessage += "\n"; + } + + errorMessage += "\nFix these accessibility issues and run reload_file again."; + return errorMessage; + } + } + + /// + /// Information about a specific accessibility violation in hot reload code. + /// + public class AccessibilityViolation + { + public string MemberName { get; set; } + public string MethodName { get; set; } + public Accessibility AccessLevel { get; set; } + public AccessibilityViolationType ViolationType { get; set; } + public string ErrorMessage { get; set; } + public string Suggestion { get; set; } + } + + /// + /// Types of accessibility violations that can occur in hot reload methods. + /// + public enum AccessibilityViolationType + { + PrivateAccess, + InternalAccess, + ProtectedAccess, + ParseError + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/AccessibilityValidator.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/AccessibilityValidator.cs.meta new file mode 100644 index 00000000..3958537d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/AccessibilityValidator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1bdaafa6e8d172648a57310104db179a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadAttributes.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadAttributes.cs new file mode 100644 index 00000000..518c7c8a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadAttributes.cs @@ -0,0 +1,166 @@ +using System; + +namespace Unity.Pipeline.HotReload +{ + /// + /// Marks a method as hot reloadable. The method can be overridden at runtime + /// through the hot reload system without triggering Unity domain reload. + /// + /// Belongs to the helper (separate override file) workflow, together with + /// . For the in-place workflow use + /// instead. + /// + /// Pattern A: Attribute-Based Method Override + /// - Individual methods can be surgically replaced + /// - Hot reload methods must have same signature + instance parameter + /// - Access limited to public fields/properties for simplicity + /// - Best for: Quick gameplay tweaks, formula adjustments + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class HotReloadWithOverridesAttribute : Attribute + { + /// + /// Optional identifier for this hot reloadable method. + /// If not specified, uses TypeName.MethodName format. + /// + public string Id { get; set; } + + /// + /// Whether this method requires main thread execution. + /// Defaults to true for Unity API safety. + /// + public bool RequireMainThread { get; set; } = true; + } + + /// + /// Marks a method for the in-place hot reload workflow: the method body is edited directly + /// in the original source file and applied via the reload_file command. + /// + /// This attribute is exclusive to the in-place workflow and is independent of + /// / , which + /// belong to the helper (separate override file) workflow. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class HotReloadAttribute : Attribute + { + /// + /// Optional identifier for this in-place hot reloadable method. + /// If not specified, uses TypeName.MethodName format. + /// + public string Id { get; set; } + + /// + /// Whether this method requires main thread execution. + /// Defaults to true for Unity API safety. + /// + public bool RequireMainThread { get; set; } = true; + } + + /// + /// Marks a static method as a hot reload override for a specific method. + /// Used in hot reload files to define replacement logic for [HotReloadWithOverrides] methods. + /// + /// Method signature must match original method plus instance parameter as first argument. + /// Example: void Update() becomes static void Update(TargetType instance) + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class HotReloadOverrideMethodAttribute : Attribute + { + /// + /// Target method identifier in format "TypeName.MethodName" + /// Must match a method marked with [HotReloadWithOverrides]. + /// + public string TargetMethodId { get; } + + /// + /// Optional description of what this hot reload does. + /// Useful for debugging and hot reload management. + /// + public string Description { get; set; } + + public HotReloadOverrideMethodAttribute(string targetMethodId) + { + TargetMethodId = targetMethodId ?? throw new ArgumentNullException(nameof(targetMethodId)); + } + } + + /// + /// Marks a component class as hot reloadable via complete behavior replacement. + /// The component becomes a proxy that delegates to hot reload implementations. + /// + /// Pattern C: Component Override + /// - Complete component behavior replacement via interfaces + /// - Maximum flexibility for major behavioral experiments + /// - Pattern C wins when multiple patterns apply to same component + /// - Best for: Major AI changes, experimental features + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class HotReloadableComponentAttribute : Attribute + { + /// + /// Optional identifier for this hot reloadable component. + /// If not specified, uses the class name. + /// + public string Id { get; set; } + + /// + /// Interface type that hot reload implementations must implement. + /// If not specified, generates interface automatically. + /// + public Type InterfaceType { get; set; } + } + + /// + /// Marks a class as a hot reload implementation for a specific component. + /// Used in hot reload files to provide complete component behavior replacement. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class HotReloadComponentAttribute : Attribute + { + /// + /// Target component type to override. + /// Must be a type marked with [HotReloadableComponent]. + /// + public Type TargetType { get; } + + /// + /// Optional description of what this hot reload implementation does. + /// + public string Description { get; set; } + + public HotReloadComponentAttribute(Type targetType) + { + TargetType = targetType ?? throw new ArgumentNullException(nameof(targetType)); + } + } + + /// + /// Marks a static method as a hot reload target for Pattern B explicit wrapper system. + /// Used with HotReloadMethod<T> wrapper for type-safe delegate hot reload. + /// + /// Pattern B: Explicit Wrapper + /// - HotReloadMethod<T> wrapper with type-safe delegate calls + /// - Built-in fallback logic, zero method signature changes + /// - Explicit opt-in with performance-optimized direct calls + /// - Best for: Performance-critical paths, type-safe hot reload + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class HotReloadTargetAttribute : Attribute + { + /// + /// Unique identifier that matches HotReloadMethod<T> wrapper registration. + /// This ID connects the wrapper to the hot reload implementation. + /// + public string TargetId { get; } + + /// + /// Optional description of what this hot reload target does. + /// + public string Description { get; set; } + + public HotReloadTargetAttribute(string targetId) + { + TargetId = targetId ?? throw new ArgumentNullException(nameof(targetId)); + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadAttributes.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadAttributes.cs.meta new file mode 100644 index 00000000..2d20e06c --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadAttributes.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0bd43f6237214774ab4a178b811eb453 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadHelper.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadHelper.cs new file mode 100644 index 00000000..163432bc --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadHelper.cs @@ -0,0 +1,140 @@ +using System; +using System.Reflection; +using UnityEngine; + +namespace Unity.Pipeline.HotReload +{ + /// + /// Helper utilities for integrating hot reload calls into methods marked with [HotReloadWithOverrides]. + /// Provides convenience methods for checking and invoking hot reload overrides. + /// + public static class HotReloadHelper + { + /// + /// Execute method with hot reload override check. + /// If hot reload override exists, invokes it; otherwise invokes original method. + /// + /// Type of the instance + /// Instance to invoke method on + /// Name of the method to invoke + /// Original method implementation + /// Method parameters + /// Result of method invocation + public static object ExecuteWithHotReload(T instance, string methodName, Func originalMethod, params object[] parameters) + { + var methodId = GetMethodId(methodName); + + // Try hot reload first + if (HotReloadRegistry.TryInvokeHotReload(methodId, instance, parameters)) + { + return null; // Hot reload methods currently don't return values (can be enhanced later) + } + + // Fall back to original method + return originalMethod?.Invoke(); + } + + /// + /// Execute void method with hot reload override check. + /// Convenience overload for void methods. + /// + /// Type of the instance + /// Instance to invoke method on + /// Name of the method to invoke + /// Original method implementation + /// Method parameters + public static void ExecuteWithHotReload(T instance, string methodName, Action originalMethod, params object[] parameters) + { + var methodId = GetMethodId(methodName); + + // Try hot reload first + if (HotReloadRegistry.TryInvokeHotReload(methodId, instance, parameters)) + { + return; // Hot reload override was invoked + } + + // Fall back to original method + originalMethod?.Invoke(); + } + + /// + /// Execute method with custom method ID and hot reload override check. + /// Use this when the method has a custom ID specified in [HotReloadWithOverrides(Id = "...")]. + /// + /// Type of the instance + /// Instance to invoke method on + /// Custom method ID from HotReloadWithOverrides attribute + /// Original method implementation + /// Method parameters + /// Result of method invocation + public static object ExecuteWithHotReloadCustomId(T instance, string customMethodId, Func originalMethod, params object[] parameters) + { + // Try hot reload first + if (HotReloadRegistry.TryInvokeHotReload(customMethodId, instance, parameters)) + { + return null; // Hot reload methods currently don't return values + } + + // Fall back to original method + return originalMethod?.Invoke(); + } + + /// + /// Execute void method with custom method ID and hot reload override check. + /// Use this when the method has a custom ID specified in [HotReloadWithOverrides(Id = "...")]. + /// + /// Type of the instance + /// Instance to invoke method on + /// Custom method ID from HotReloadWithOverrides attribute + /// Original method implementation + /// Method parameters + public static void ExecuteWithHotReloadCustomId(T instance, string customMethodId, Action originalMethod, params object[] parameters) + { + // Try hot reload first + if (HotReloadRegistry.TryInvokeHotReload(customMethodId, instance, parameters)) + { + return; // Hot reload override was invoked + } + + // Fall back to original method + originalMethod?.Invoke(); + } + + /// + /// Check if a hot reload override is active for a specific method. + /// Useful for debugging or conditional logic based on hot reload state. + /// + /// Type of the instance + /// Name of the method to check + /// True if hot reload override is active + public static bool IsHotReloadActive(string methodName) + { + var methodId = GetMethodId(methodName); + var stats = HotReloadRegistry.GetStats(); + return stats.ActiveOverrideIds.Contains(methodId); + } + + /// + /// Check if a hot reload override is active for a specific custom method ID. + /// + /// Custom method ID to check + /// True if hot reload override is active + public static bool IsHotReloadActive(string customMethodId) + { + var stats = HotReloadRegistry.GetStats(); + return stats.ActiveOverrideIds.Contains(customMethodId); + } + + /// + /// Generate standard method ID from type and method name. + /// Format: TypeName.MethodName + /// + /// Type containing the method + /// Name of the method + /// Standard method ID + private static string GetMethodId(string methodName) + { + return $"{typeof(T).Name}.{methodName}"; + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadHelper.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadHelper.cs.meta new file mode 100644 index 00000000..1ca56c9f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5a2835bfdec4fc24cb67a208766efcff +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadRegistry.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadRegistry.cs new file mode 100644 index 00000000..901b81ac --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadRegistry.cs @@ -0,0 +1,365 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Unity.Pipeline.Threading; +using UnityEngine; + +namespace Unity.Pipeline.HotReload +{ + /// + /// Central registry for managing hot reload method and component overrides. + /// Handles runtime method resolution, override registration, and fallback logic. + /// Thread-safe for runtime hot reload operations. + /// + public static class HotReloadRegistry + { + /// + /// Dispatcher used to marshal main-thread-required hot-reload overrides to the main thread. + /// Injected by RuntimePipelineManager (= its server's dispatcher). Null = run on the + /// current thread (no marshaling). + /// + public static Dispatcher Dispatcher { get; set; } + + /// + /// Absolute roots a running Player is allowed to hot reload source files from (Assets folder + /// + loaded package locations). Baked into RuntimePipelineManager at build time (a running + /// Player cannot resolve the project layout) and published here when the runtime server + /// starts, so reload commands can validate incoming file paths. Null until published. + /// + public static IReadOnlyList AllowedReloadRoots { get; set; } + + // Thread-safe collections for runtime hot reload switching + private static readonly ConcurrentDictionary m_MethodOverrides = new(); + private static readonly ConcurrentDictionary> m_ReloadableMethods = new(); + private static readonly ConcurrentDictionary m_LoadedHotReloadTypes = new(); + + /// + /// Register a method as hot reloadable. Called during discovery phase. + /// + public static void RegisterReloadableMethod(MethodInfo method, HotReloadWithOverridesAttribute attribute) + { + var methodId = GetMethodId(method, attribute.Id); + + if (!m_ReloadableMethods.ContainsKey(methodId)) + { + m_ReloadableMethods[methodId] = new List(); + } + + m_ReloadableMethods[methodId].Add(method); + } + + /// + /// Register a hot reload method override from compiled hot reload assembly. + /// Returns true if the override was registered, false if it was skipped. + /// + public static bool RegisterMethodOverride(MethodInfo overrideMethod, HotReloadOverrideMethodAttribute attribute, Type sourceType) + { + return RegisterMethodOverride(overrideMethod, attribute, sourceType, out _); + } + + /// + /// Register a hot reload method override from compiled hot reload assembly. + /// Returns true if the override was registered, false if it was skipped (target not + /// reloadable or signature mismatch). The out parameter carries a user-facing reason + /// when registration is skipped. + /// + public static bool RegisterMethodOverride(MethodInfo overrideMethod, HotReloadOverrideMethodAttribute attribute, Type sourceType, out string skipReason) + { + skipReason = null; + var targetMethodId = attribute.TargetMethodId; + + // Validate that target method exists and is reloadable + if (!m_ReloadableMethods.ContainsKey(targetMethodId)) + { + Debug.LogWarning($"HotReload: Target method '{targetMethodId}' not found or not marked [HotReloadWithOverrides]"); + skipReason = $"target '{targetMethodId}' is not registered as [HotReloadWithOverrides]. " + + "Ensure the component is in the scene and in play mode, and that it calls " + + "HotReloadRegistry.RegisterReloadableType(...) in Awake."; + return false; + } + + var originalMethods = m_ReloadableMethods[targetMethodId]; + if (!originalMethods.Any()) + { + Debug.LogWarning($"HotReload: No original methods registered for '{targetMethodId}'"); + skipReason = $"no original methods registered for '{targetMethodId}'."; + return false; + } + + // Validate signature compatibility (basic check - instance parameter + matching return type) + var originalMethod = originalMethods.First(); + if (!ValidateSignatureCompatibility(originalMethod, overrideMethod)) + { + Debug.LogError($"HotReload: Signature mismatch for '{targetMethodId}'. Override method must have instance parameter as first argument."); + skipReason = $"signature mismatch for '{targetMethodId}'. The override must be " + + $"'public static {originalMethod.ReturnType.Name} {overrideMethod.Name}" + + $"({originalMethod.DeclaringType?.Name} instance, ...)'. " + + "A common cause is the override file redeclaring the target type."; + return false; + } + + var methodOverride = new MethodOverride + { + TargetMethodId = targetMethodId, + OverrideMethod = overrideMethod, + SourceType = sourceType, + RequireMainThread = GetMainThreadRequirement(originalMethod), + Description = attribute.Description + }; + + m_MethodOverrides[targetMethodId] = methodOverride; + return true; + } + + /// + /// Attempt to invoke hot reload override if available, otherwise invoke original method. + /// Returns true if hot reload override was invoked, false if original method should be called. + /// + public static bool TryInvokeHotReload(string methodId, T instance, object[] parameters = null) + { + return TryInvokeHotReload(methodId, (object)instance, parameters); + } + + /// + /// Non-generic dispatch entry point. This is the method woven into [HotReload] + /// methods at compile time (a non-generic signature keeps the injected IL simple). + /// Returns true if a hot reload override was invoked, false if the original body should run. + /// + public static bool TryInvokeHotReload(string methodId, object instance, object[] parameters = null) + { + if (!m_MethodOverrides.TryGetValue(methodId, out var methodOverride)) + { + return false; // No hot reload override available + } + + try + { + // Prepare parameters with instance as first parameter + var invokeParams = new object[parameters?.Length + 1 ?? 1]; + invokeParams[0] = instance; + if (parameters != null && parameters.Length > 0) + { + Array.Copy(parameters, 0, invokeParams, 1, parameters.Length); + } + + // Invoke on appropriate thread. The dispatcher is injected by the runtime manager + // (HotReloadRegistry is reached from hot-reloaded runtime code, not a command). + // If no dispatcher was injected, run on the current thread. + if (methodOverride.RequireMainThread && Dispatcher != null && !Dispatcher.IsMainThread()) + { + Dispatcher.Invoke(() => + { + methodOverride.OverrideMethod.Invoke(null, invokeParams); + }); + } + else + { + methodOverride.OverrideMethod.Invoke(null, invokeParams); + } + + return true; + } + catch (Exception ex) + { + Debug.LogError($"HotReload: Error invoking override for '{methodId}': {ex.Message}"); + Debug.LogError($"HotReload: Stack trace: {ex.StackTrace}"); + return false; // Fall back to original method + } + } + + /// + /// Register all methods in a type that have the HotReloadWithOverrides attribute. + /// Uses reflection to discover and register methods marked with [HotReloadWithOverrides]. + /// + public static void RegisterReloadableType(System.Type type) + { + if (type == null) + { + Debug.LogWarning("HotReload: Cannot register null type"); + return; + } + + int registeredCount = 0; + + // Get all instance methods (public and non-public) + var instanceMethods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + foreach (var method in instanceMethods) + { + var hotReloadAttr = method.GetCustomAttribute(); + if (hotReloadAttr != null) + { + RegisterReloadableMethod(method, hotReloadAttr); + registeredCount++; + } + } + + // Get all static methods (public and non-public) + var staticMethods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + foreach (var method in staticMethods) + { + var hotReloadAttr = method.GetCustomAttribute(); + if (hotReloadAttr != null) + { + RegisterReloadableMethod(method, hotReloadAttr); + registeredCount++; + } + } + + if (registeredCount > 0) + { + Debug.Log($"HotReload: Registered {registeredCount} reloadable methods from type {type.Name}"); + } + else + { + Debug.Log($"HotReload: No methods with [HotReloadWithOverrides] attribute found in type {type.Name}"); + } + } + + /// + /// Register a type from loaded hot reload assembly for discovery scanning. + /// + public static void RegisterHotReloadType(Type type, string assemblyId) + { + var typeKey = $"{assemblyId}:{type.FullName}"; + m_LoadedHotReloadTypes[typeKey] = type; + } + + /// + /// Clear all hot reload overrides. Used by cleanup_hotreload command. + /// + public static void ClearAllOverrides() + { + var overrideCount = m_MethodOverrides.Count; + var typeCount = m_LoadedHotReloadTypes.Count; + + m_MethodOverrides.Clear(); + m_LoadedHotReloadTypes.Clear(); + } + + /// + /// Clear all registry state including reloadable methods. + /// FOR TESTING ONLY - this should not be called in production code. + /// + public static void ClearAllForTesting() + { + var overrideCount = m_MethodOverrides.Count; + var typeCount = m_LoadedHotReloadTypes.Count; + var reloadableCount = m_ReloadableMethods.Sum(kvp => kvp.Value.Count); + + m_MethodOverrides.Clear(); + m_LoadedHotReloadTypes.Clear(); + m_ReloadableMethods.Clear(); + } + + /// + /// Get statistics about current hot reload state. + /// + public static HotReloadStats GetStats() + { + return new HotReloadStats + { + ReloadableMethodCount = m_ReloadableMethods.Sum(kvp => kvp.Value.Count), + ActiveOverrideCount = m_MethodOverrides.Count, + LoadedTypeCount = m_LoadedHotReloadTypes.Count, + ReloadableMethodIds = m_ReloadableMethods.Keys.ToList(), + ActiveOverrideIds = m_MethodOverrides.Keys.ToList() + }; + } + + /// + /// Generate method ID from MethodInfo and optional custom ID. + /// Format: TypeName.MethodName or custom ID if provided. + /// + private static string GetMethodId(MethodInfo method, string customId = null) + { + if (!string.IsNullOrEmpty(customId)) + { + return customId; + } + + return $"{method.DeclaringType?.Name}.{method.Name}"; + } + + /// + /// Validate that hot reload method signature is compatible with original method. + /// Hot reload method must have instance parameter as first argument. + /// + private static bool ValidateSignatureCompatibility(MethodInfo originalMethod, MethodInfo overrideMethod) + { + var originalParams = originalMethod.GetParameters(); + var overrideParams = overrideMethod.GetParameters(); + + // Hot reload method must have at least one parameter (the instance) + if (overrideParams.Length == 0) + { + return false; + } + + // First parameter must be compatible with declaring type of original method + var firstParam = overrideParams[0]; + if (!originalMethod.DeclaringType.IsAssignableFrom(firstParam.ParameterType)) + { + return false; + } + + // Remaining parameters must match original method parameters + if (overrideParams.Length - 1 != originalParams.Length) + { + return false; + } + + for (int i = 0; i < originalParams.Length; i++) + { + if (overrideParams[i + 1].ParameterType != originalParams[i].ParameterType) + { + return false; + } + } + + // Return types must match + return originalMethod.ReturnType == overrideMethod.ReturnType; + } + + /// + /// Determine if original method requires main thread execution. + /// + private static bool GetMainThreadRequirement(MethodInfo originalMethod) + { + // Check for HotReloadWithOverridesAttribute main thread requirement + var hotReloadAttr = originalMethod.GetCustomAttribute(); + if (hotReloadAttr != null) + { + return hotReloadAttr.RequireMainThread; + } + + // Default to main thread requirement for safety + return true; + } + + /// + /// Information about a registered method override. + /// + private class MethodOverride + { + public string TargetMethodId { get; set; } + public MethodInfo OverrideMethod { get; set; } + public Type SourceType { get; set; } + public bool RequireMainThread { get; set; } + public string Description { get; set; } + } + } + + /// + /// Statistics about current hot reload registry state. + /// + public class HotReloadStats + { + public int ReloadableMethodCount { get; set; } + public int ActiveOverrideCount { get; set; } + public int LoadedTypeCount { get; set; } + public List ReloadableMethodIds { get; set; } = new(); + public List ActiveOverrideIds { get; set; } = new(); + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadRegistry.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadRegistry.cs.meta new file mode 100644 index 00000000..bcabbeea --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/HotReloadRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 23cd8d0c782f1974e8a3b4095a178092 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/InPlaceReloadProcessor.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/InPlaceReloadProcessor.cs new file mode 100644 index 00000000..665a19d3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/InPlaceReloadProcessor.cs @@ -0,0 +1,560 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Unity.Pipeline.Compilation; +using UnityEngine; + +namespace Unity.Pipeline.HotReload +{ + /// + /// Main orchestrator for in-place hot reload processing. + /// Handles the complete pipeline: source parsing -> validation -> transformation -> compilation. + /// + public static class InPlaceReloadProcessor + { + /// + /// Process a source file for in-place hot reload. + /// Extracts [HotReloadWithOverrides] methods, validates accessibility, transforms to static overrides, and compiles. + /// DEADLOCK FIX: Detects main thread and uses synchronous processing when needed. + /// + /// Path to source file containing [HotReloadWithOverrides] methods + /// Optional directory to save compiled assembly + /// Emit debug symbols mapped to the original source so breakpoints bind. + /// Compilation result with success/failure and diagnostic information + public static Task ProcessSourceFileAsync(string sourceFilePath, string assemblyDir = null, bool pdb = false) + { + // Runs synchronously on the calling (main) thread; returns a completed Task. + return Task.FromResult(ProcessSourceFileOnMainThread(sourceFilePath, assemblyDir, pdb)); + } + + /// + /// Process source file on main thread synchronously to avoid deadlocks. + /// + public static InPlaceReloadResult ProcessSourceFileOnMainThread(string sourceFilePath, string assemblyDir = null, bool pdb = false) + { + var result = new InPlaceReloadResult + { + SourceFilePath = sourceFilePath, + Success = false + }; + + try + { + Debug.Log($"HotReload: Processing source file synchronously on main thread: {sourceFilePath}"); + + // 1. Read and parse the source file (synchronous) + var sourceCode = ReadSourceFile(sourceFilePath); + if (string.IsNullOrEmpty(sourceCode)) + { + result.ErrorMessage = $"Could not read source file: {sourceFilePath}"; + return result; + } + + // 2. Extract [HotReloadWithOverrides] methods + var extractionResult = ExtractHotReloadableMethods(sourceCode); + if (!extractionResult.HasMethods) + { + result.ErrorMessage = $"No [HotReload] methods found in {sourceFilePath}"; + return result; + } + + result.OriginalTypeName = extractionResult.TypeName; + result.ExtractedMethods = extractionResult.Methods.Keys.ToList(); + + Debug.Log($"HotReload: Extracted {extractionResult.Methods.Count} [HotReloadWithOverrides] methods from {extractionResult.TypeName}"); + + // 3. Validate accessibility (public members only) + var validationResult = AccessibilityValidator.ValidatePublicAccess( + sourceCode, + extractionResult.Methods, + extractionResult.TypeName); + + if (!validationResult.IsValid) + { + result.ErrorMessage = validationResult.GetFormattedErrorMessage(); + result.ValidationViolations = validationResult.Violations; + Debug.LogWarning($"HotReload: Accessibility validation failed: {result.ErrorMessage}"); + return result; + } + + Debug.Log($"HotReload: Accessibility validation passed for {extractionResult.TypeName}"); + + // 4. Transform method bodies to static overrides + var originalSource = File.ReadAllText(sourceFilePath); + var transformedCode = SourceCodeTransformer.TransformMethodBodies( + extractionResult.Methods, + extractionResult.TypeName, + extractionResult.MethodSignatures, + originalSource, + emitLineDirectives: pdb, + originalFilePath: sourceFilePath); + + result.TransformedCode = transformedCode; + + Debug.Log($"HotReload: Code transformation completed for {extractionResult.TypeName}"); + + // 5. Compile the transformed code (synchronous) + var compilationResult = CompileTransformedCode( + transformedCode, + extractionResult.TypeName, + assemblyDir, + pdb, + sourceFilePath); + + result.Success = compilationResult.IsSuccess; + result.AssemblyName = compilationResult.AssemblyName; + result.RegisteredMethods = compilationResult.RegisteredMethods; + result.CompilationDiagnostics = compilationResult.Diagnostics; + + if (result.Success) + { + Debug.Log($"HotReload: In-place reload successful for {sourceFilePath} - {result.RegisteredMethods.Count} methods registered"); + } + else + { + result.ErrorMessage = compilationResult.ErrorDetails ?? "Compilation failed"; + Debug.LogError($"HotReload: In-place reload compilation failed: {result.ErrorMessage}"); + } + + return result; + } + catch (Exception ex) + { + Debug.LogError($"HotReload: Error processing source file {sourceFilePath}: {ex.Message}"); + Debug.LogError($"HotReload: Stack trace: {ex.StackTrace}"); + + result.ErrorMessage = $"Processing error: {ex.Message}"; + return result; + } + } + + /// + /// Internal async processing implementation for background threads. + /// + private static async Task ProcessSourceFileInternalAsync(string sourceFilePath, string assemblyDir = null) + { + var result = new InPlaceReloadResult + { + SourceFilePath = sourceFilePath, + Success = false + }; + + try + { + Debug.Log($"HotReload: Processing source file asynchronously: {sourceFilePath}"); + + // 1. Read and parse the source file (async) + var sourceCode = await ReadSourceFileAsync(sourceFilePath); + if (string.IsNullOrEmpty(sourceCode)) + { + result.ErrorMessage = $"Could not read source file: {sourceFilePath}"; + return result; + } + + // 2-4. Same processing as main thread version + var extractionResult = ExtractHotReloadableMethods(sourceCode); + if (!extractionResult.HasMethods) + { + result.ErrorMessage = $"No [HotReload] methods found in {sourceFilePath}"; + return result; + } + + result.OriginalTypeName = extractionResult.TypeName; + result.ExtractedMethods = extractionResult.Methods.Keys.ToList(); + + var validationResult = AccessibilityValidator.ValidatePublicAccess( + sourceCode, + extractionResult.Methods, + extractionResult.TypeName); + + if (!validationResult.IsValid) + { + result.ErrorMessage = validationResult.GetFormattedErrorMessage(); + result.ValidationViolations = validationResult.Violations; + return result; + } + + var originalSource = File.ReadAllText(sourceFilePath); + var transformedCode = SourceCodeTransformer.TransformMethodBodies( + extractionResult.Methods, + extractionResult.TypeName, + extractionResult.MethodSignatures, + originalSource); + + result.TransformedCode = transformedCode; + + // 5. Compile the transformed code (async) + var compilationResult = await CompileTransformedCodeAsync( + transformedCode, + extractionResult.TypeName, + assemblyDir); + + result.Success = compilationResult.IsSuccess; + result.AssemblyName = compilationResult.AssemblyName; + result.RegisteredMethods = compilationResult.RegisteredMethods; + result.CompilationDiagnostics = compilationResult.Diagnostics; + + if (!result.Success) + { + result.ErrorMessage = compilationResult.ErrorDetails ?? "Compilation failed"; + } + + return result; + } + catch (Exception ex) + { + result.ErrorMessage = $"Processing error: {ex.Message}"; + return result; + } + } + + /// + /// Check if a source file contains [HotReload] methods. + /// Simple synchronous check to avoid async deadlocks in tests. + /// + /// Path to source file to check + /// True if file contains [HotReload] methods + public static Task ContainsHotReloadableMethodsAsync(string sourceFilePath) + { + try + { + if (!File.Exists(sourceFilePath)) + { + return Task.FromResult(false); + } + + // Use synchronous read for simple attribute check to avoid deadlocks + var sourceCode = File.ReadAllText(sourceFilePath); + if (string.IsNullOrEmpty(sourceCode)) + { + return Task.FromResult(false); + } + + // Quick check for [HotReload] attribute + var result = sourceCode.Contains("[HotReload]"); + return Task.FromResult(result); + } + catch (Exception ex) + { + Debug.LogError($"HotReload: Error checking for [HotReload] methods in {sourceFilePath}: {ex.Message}"); + return Task.FromResult(false); + } + } + + /// + /// Read source file content synchronously (for main thread). + /// + private static string ReadSourceFile(string filePath) + { + try + { + if (!File.Exists(filePath)) + { + Debug.LogError($"HotReload: Source file not found: {filePath}"); + return null; + } + + return File.ReadAllText(filePath); + } + catch (Exception ex) + { + Debug.LogError($"HotReload: Error reading source file {filePath}: {ex.Message}"); + return null; + } + } + + /// + /// Read source file content asynchronously (for background threads). + /// + private static Task ReadSourceFileAsync(string filePath) + { + try + { + if (!File.Exists(filePath)) + { + Debug.LogError($"HotReload: Source file not found: {filePath}"); + return Task.FromResult(null); + } + + // Use synchronous read wrapped in Task.FromResult to avoid deadlock issues + var content = File.ReadAllText(filePath); + return Task.FromResult(content); + } + catch (Exception ex) + { + Debug.LogError($"HotReload: Error reading source file {filePath}: {ex.Message}"); + return Task.FromResult(null); + } + } + + /// + /// Extract [HotReloadWithOverrides] methods from source code. + /// + private static HotReloadableExtractionResult ExtractHotReloadableMethods(string sourceCode) + { + var result = new HotReloadableExtractionResult(); + + try + { + var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode); + var root = syntaxTree.GetRoot(); + + // Find the class containing [HotReload] methods + var classDeclaration = root.DescendantNodes() + .OfType() + .FirstOrDefault(c => c.DescendantNodes() + .OfType() + .Any(m => HasHotReloadAttribute(m))); + + if (classDeclaration == null) + { + return result; + } + + result.TypeName = classDeclaration.Identifier.ValueText; + + // Extract all [HotReload] methods + var hotReloadableMethods = classDeclaration.DescendantNodes() + .OfType() + .Where(m => HasHotReloadAttribute(m)); + + foreach (var method in hotReloadableMethods) + { + var methodName = method.Identifier.ValueText; + var methodBody = ExtractMethodBody(method); + var signature = ExtractMethodSignature(method); + + if (!string.IsNullOrEmpty(methodBody)) + { + result.Methods[methodName] = methodBody; + result.MethodSignatures[methodName] = signature; + } + } + + Debug.Log($"HotReload: Extracted {result.Methods.Count} [HotReload] methods from class {result.TypeName}"); + return result; + } + catch (Exception ex) + { + Debug.LogError($"HotReload: Error extracting [HotReload] methods: {ex.Message}"); + return result; + } + } + + /// + /// Check if method has [HotReload] attribute. + /// + private static bool HasHotReloadAttribute(MethodDeclarationSyntax method) + { + return method.AttributeLists + .SelectMany(al => al.Attributes) + .Any(a => a.Name.ToString().EndsWith("HotReload") || a.Name.ToString().EndsWith("HotReloadAttribute")); + } + + /// + /// Extract method body content (excluding braces). + /// + private static string ExtractMethodBody(MethodDeclarationSyntax method) + { + if (method.Body != null) + { + var bodyText = method.Body.ToString().Trim(); + // Remove outer braces + if (bodyText.StartsWith("{") && bodyText.EndsWith("}")) + { + bodyText = bodyText.Substring(1, bodyText.Length - 2).Trim(); + } + return bodyText; + } + + return ""; + } + + /// + /// Extract method signature information. + /// + private static MethodSignatureInfo ExtractMethodSignature(MethodDeclarationSyntax method) + { + var signature = new MethodSignatureInfo + { + ReturnType = method.ReturnType.ToString() + }; + + foreach (var parameter in method.ParameterList.Parameters) + { + var paramInfo = new ParameterInfo + { + Type = parameter.Type?.ToString() ?? "object", + Name = parameter.Identifier.ValueText, + HasDefaultValue = parameter.Default != null, + DefaultValue = parameter.Default?.Value?.ToString() + }; + + signature.Parameters.Add(paramInfo); + } + + return signature; + } + + /// + /// Compile transformed code synchronously (for main thread). + /// + private static HotReloadCompilationResult CompileTransformedCode( + string transformedCode, + string originalTypeName, + string assemblyDir, + bool emitPdb = false, + string documentPath = null) + { + try + { + // Generate a temporary file name for the transformed code + var tempFileName = $"InPlace_{originalTypeName}_{DateTime.Now:yyyyMMdd_HHmmss}"; + + // Use HotReloadCompiler synchronous method + var compileResult = HotReloadCompiler.CompileSourceCodeOnMainThread( + transformedCode, + tempFileName, + assemblyDir, + emitPdb, + documentPath); + + return compileResult; + } + catch (Exception ex) + { + Debug.LogError($"HotReload: Error compiling transformed code for {originalTypeName}: {ex.Message}"); + + return HotReloadCompilationResult.Failure( + "Compilation Error", + ex.Message, + 0, + new List { ex.ToString() }); + } + } + + /// + /// Compile transformed code asynchronously (for background threads). + /// + private static async Task CompileTransformedCodeAsync( + string transformedCode, + string originalTypeName, + string assemblyDir) + { + try + { + // Generate a temporary file name for the transformed code + var tempFileName = $"InPlace_{originalTypeName}_{DateTime.Now:yyyyMMdd_HHmmss}"; + + // Use HotReloadCompiler to compile the transformed source code + var compileResult = await HotReloadCompiler.CompileSourceCodeAsync( + transformedCode, + tempFileName, + assemblyDir); + + return compileResult; + } + catch (Exception ex) + { + Debug.LogError($"HotReload: Error compiling transformed code for {originalTypeName}: {ex.Message}"); + + return HotReloadCompilationResult.Failure( + "Compilation Error", + ex.Message, + 0, + new List { ex.ToString() }); + } + } + + /// + /// Result of extracting [HotReloadWithOverrides] methods from source code. + /// + private class HotReloadableExtractionResult + { + /// + /// Name of the class containing [HotReloadWithOverrides] methods. + /// + public string TypeName { get; set; } + + /// + /// Dictionary of method names to their extracted body code. + /// + public Dictionary Methods { get; set; } = new Dictionary(); + + /// + /// Dictionary of method names to their signature information. + /// + public Dictionary MethodSignatures { get; set; } = new Dictionary(); + + /// + /// Whether any [HotReloadWithOverrides] methods were found. + /// + public bool HasMethods => Methods.Count > 0; + } + } + + /// + /// Result of in-place hot reload processing. + /// + public class InPlaceReloadResult + { + /// + /// Path to the source file that was processed. + /// + public string SourceFilePath { get; set; } + + /// + /// Whether the processing was successful. + /// + public bool Success { get; set; } + + /// + /// Name of the original type containing [HotReloadWithOverrides] methods. + /// + public string OriginalTypeName { get; set; } + + /// + /// List of method names that were extracted and processed. + /// + public List ExtractedMethods { get; set; } = new List(); + + /// + /// Generated transformed code for hot reload assembly. + /// + public string TransformedCode { get; set; } + + /// + /// Name of the compiled assembly (if successful). + /// + public string AssemblyName { get; set; } + + /// + /// List of registered method IDs (if successful). + /// + public List RegisteredMethods { get; set; } = new List(); + + /// + /// Error message if processing failed. + /// + public string ErrorMessage { get; set; } + + /// + /// Accessibility validation violations (if any). + /// + public List ValidationViolations { get; set; } = new List(); + + /// + /// Compilation diagnostics (warnings, errors). + /// + public List CompilationDiagnostics { get; set; } = new List(); + + /// + /// Execution time in milliseconds. + /// + public long ExecutionTimeMs { get; set; } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/InPlaceReloadProcessor.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/InPlaceReloadProcessor.cs.meta new file mode 100644 index 00000000..a8da6aac --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/InPlaceReloadProcessor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 411fb44c3329710449bcbfc2af627524 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/OverrideFileValidator.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/OverrideFileValidator.cs new file mode 100644 index 00000000..b4a2b7f2 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/OverrideFileValidator.cs @@ -0,0 +1,136 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Unity.Pipeline.HotReload +{ + /// + /// Up-front, parse-only validation for the helper (separate override file) workflow used by + /// the reload_file_override command. Catches the common setup mistakes before compilation so + /// the user gets an actionable message instead of a confusing compiler error. + /// + /// Rules: + /// 1. The file must contain at least one method annotated [HotReloadOverrideMethod(...)]. + /// 2. The file must not redeclare a type that an override targets (the override's first + /// parameter type). Redeclaring the target type binds the override to a duplicate type + /// and the registration silently mismatches. + /// 3. Each [HotReloadOverrideMethod] method must be 'public static' and take the instance as its + /// first parameter. + /// + public static class OverrideFileValidator + { + public static OverrideFileValidationResult Validate(string sourceCode, string displayName) + { + var result = new OverrideFileValidationResult { DisplayName = displayName }; + + var root = CSharpSyntaxTree.ParseText(sourceCode ?? string.Empty).GetRoot(); + + // All type names declared in this file (class/struct), by simple name. + var declaredTypeNames = new HashSet(root.DescendantNodes() + .OfType() + .Select(t => t.Identifier.ValueText)); + + // All methods annotated [HotReloadOverrideMethod(...)]. + var overrideMethods = root.DescendantNodes() + .OfType() + .Where(HasHotReloadOverrideMethodAttribute) + .ToList(); + + // Rule 1: must contain at least one override method. + if (overrideMethods.Count == 0) + { + result.Errors.Add( + $"No [HotReloadOverrideMethod] overrides found in '{displayName}'. An override file must contain " + + "public static methods annotated [HotReloadOverrideMethod(\"Type.Method\")]. " + + "If you meant to edit a method body directly in its own source file, that is the in-place " + + "workflow (reload_file), not reload_file_override."); + return result; + } + + foreach (var method in overrideMethods) + { + var name = method.Identifier.ValueText; + var modifiers = method.Modifiers.Select(m => m.ValueText).ToList(); + + // Rule 3: must be public static. + if (!modifiers.Contains("static") || !modifiers.Contains("public")) + { + result.Errors.Add( + $"Override method '{name}' must be declared 'public static'."); + } + + // Rule 3: must take the instance as its first parameter. + var parameters = method.ParameterList.Parameters; + if (parameters.Count == 0) + { + result.Errors.Add( + $"Override method '{name}' must take the target instance as its first parameter, " + + $"e.g. 'public static void {name}(MyComponent instance, ...)'."); + continue; + } + + // Rule 2: the file must not redeclare the target type (the first parameter type). + var targetTypeName = GetSimpleTypeName(parameters[0].Type); + if (targetTypeName != null && declaredTypeNames.Contains(targetTypeName)) + { + result.Errors.Add( + $"'{displayName}' declares '{targetTypeName}', which is the type that override " + + $"'{name}' targets. Put the override in a separate file (any folder) that does not " + + $"redeclare your gameplay type '{targetTypeName}'."); + } + } + + return result; + } + + private static bool HasHotReloadOverrideMethodAttribute(MethodDeclarationSyntax method) + { + return method.AttributeLists + .SelectMany(al => al.Attributes) + .Any(a => a.Name.ToString().Contains("HotReloadOverrideMethod")); + } + + /// + /// Extract the simple (unqualified) name of a parameter type, e.g. "BossController" from + /// "Namespace.BossController". Returns null for types we cannot reduce to a single name. + /// + private static string GetSimpleTypeName(TypeSyntax type) + { + switch (type) + { + case IdentifierNameSyntax id: + return id.Identifier.ValueText; + case QualifiedNameSyntax qualified: + return qualified.Right.Identifier.ValueText; + default: + return null; + } + } + } + + /// + /// Result of validating a helper-workflow override file. + /// + public class OverrideFileValidationResult + { + public string DisplayName { get; set; } + public List Errors { get; } = new List(); + public bool IsValid => Errors.Count == 0; + + public string GetFormattedErrorMessage() + { + if (IsValid) + { + return "Override file is valid."; + } + + var message = $"reload_file_override validation failed for '{DisplayName}': {Errors.Count} issue(s)\n"; + for (int i = 0; i < Errors.Count; i++) + { + message += $"\n{i + 1}. {Errors[i]}"; + } + return message; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/OverrideFileValidator.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/OverrideFileValidator.cs.meta new file mode 100644 index 00000000..87ae4c45 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/OverrideFileValidator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f111479a805a488b81989f5451e4b203 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/SourceCodeTransformer.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/SourceCodeTransformer.cs new file mode 100644 index 00000000..9854c5ab --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/SourceCodeTransformer.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Unity.Pipeline.Compilation; +using UnityEngine; + +namespace Unity.Pipeline.HotReload +{ + /// + /// Transforms [HotReload] method bodies (edited in place on a MonoBehaviour) into static + /// hot reload override methods that the registry can dispatch to. + /// + /// The transform is driven by a Roslyn : every identifier that binds + /// to an instance member of the declaring type (or a base, e.g. transform on Component) is + /// rewritten to instance.<member>; locals, parameters, static members and Unity + /// built-ins are left untouched. The output mirrors the helper workflow's override shape: + /// + /// [HotReloadOverrideMethod("Type.Method")] + /// public static <ret> Method(Type instance, <params>) { ...rewritten body... } + /// + public static class SourceCodeTransformer + { + /// + /// Transform the [HotReload] methods named in into a + /// static override class. (the full source of the + /// file) is required so the semantic model can resolve member bindings in context. + /// + /// + /// When true, each rewritten body is bracketed by #line directives that map it back to + /// the original source file so an attached debugger binds breakpoints in the file the user + /// edited. Requires . The body is emitted with its original + /// whitespace (no NormalizeWhitespace) so line positions survive the transform. + /// + /// Absolute path of the source file, recorded in the #line directives. + public static string TransformMethodBodies( + Dictionary methodBodies, + string originalTypeName, + Dictionary originalMethodSignatures, + string originalTypeDefinition = null, + bool emitLineDirectives = false, + string originalFilePath = null) + { + if (string.IsNullOrEmpty(originalTypeDefinition)) + { + throw new InvalidOperationException( + "In-place transformation requires the full original source to build a semantic model."); + } + + try + { + var tree = CSharpSyntaxTree.ParseText(originalTypeDefinition); + var root = tree.GetRoot(); + + var compilation = CSharpCompilation.Create( + "HotReloadInPlaceTransform", + new[] { tree }, + RoslynCompilationService.GetMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var model = compilation.GetSemanticModel(tree); + + var classDecl = root.DescendantNodes() + .OfType() + .FirstOrDefault(c => c.Identifier.ValueText == originalTypeName); + if (classDecl == null) + { + throw new InvalidOperationException($"Could not find class '{originalTypeName}' in source."); + } + + var classSymbol = model.GetDeclaredSymbol(classDecl); + var rewriter = new InstanceMemberQualifier(model, classSymbol); + + var sb = new StringBuilder(); + foreach (var u in root.DescendantNodes().OfType()) + sb.AppendLine(u.ToString()); + sb.AppendLine("using Unity.Pipeline.HotReload;"); + + var namespaceName = GetNamespace(classDecl); + if (!string.IsNullOrEmpty(namespaceName)) + sb.AppendLine($"using {namespaceName};"); + sb.AppendLine(); + + sb.AppendLine($"public static class {originalTypeName}HotReloadOverrides"); + sb.AppendLine("{"); + + foreach (var method in classDecl.Members.OfType()) + { + var methodName = method.Identifier.ValueText; + if (!methodBodies.ContainsKey(methodName) || method.Body == null) + continue; + + var rewrittenBody = (BlockSyntax)rewriter.Visit(method.Body); + var returnType = method.ReturnType.ToString(); + + var parameters = new List { $"{originalTypeName} instance" }; + parameters.AddRange(method.ParameterList.Parameters.Select( + p => $"{p.Type} {p.Identifier.ValueText}")); + + sb.AppendLine($" [HotReloadOverrideMethod(\"{originalTypeName}.{methodName}\")]"); + sb.AppendLine($" public static {returnType} {methodName}({string.Join(", ", parameters)})"); + + if (emitLineDirectives && !string.IsNullOrEmpty(originalFilePath)) + { + // Map the body back to the original file. The rewriter only makes intra-line + // edits, so a single #line at the body's opening brace maps every line of the + // block; emit the body with original trivia (not NormalizeWhitespace) to keep + // line counts intact. #line hidden masks the generated scaffolding that follows. + var bodyLine = method.Body.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + var escapedPath = originalFilePath.Replace("\\", "\\\\"); + sb.AppendLine($"#line {bodyLine} \"{escapedPath}\""); + sb.AppendLine(rewrittenBody.ToString()); + sb.AppendLine("#line hidden"); + } + else + { + sb.AppendLine(" " + rewrittenBody.NormalizeWhitespace().ToString()); + } + sb.AppendLine(); + } + + sb.AppendLine("}"); + + var result = sb.ToString(); + Debug.Log($"HotReload: Transformed {methodBodies.Count} in-place method(s) for {originalTypeName}"); + return result; + } + catch (Exception ex) + { + Debug.LogError($"HotReload: Error transforming in-place methods for {originalTypeName}: {ex.Message}"); + throw new InvalidOperationException( + $"Failed to transform in-place methods for {originalTypeName}: {ex.Message}", ex); + } + } + + private static string GetNamespace(SyntaxNode node) + { + for (var current = node.Parent; current != null; current = current.Parent) + { + if (current is NamespaceDeclarationSyntax ns) + return ns.Name.ToString(); + } + return null; + } + + /// + /// Rewrites implicit instance-member access (this.x or bare x) to instance.x using the + /// semantic model. Locals, parameters, static members and built-ins are left untouched. + /// + private class InstanceMemberQualifier : CSharpSyntaxRewriter + { + private readonly SemanticModel m_Model; + private readonly INamedTypeSymbol m_Type; + + public InstanceMemberQualifier(SemanticModel model, INamedTypeSymbol type) + { + m_Model = model; + m_Type = type; + } + + public override SyntaxNode VisitThisExpression(ThisExpressionSyntax node) + { + return SyntaxFactory.IdentifierName("instance").WithTriviaFrom(node); + } + + public override SyntaxNode VisitIdentifierName(IdentifierNameSyntax node) + { + if (ShouldSkip(node)) + return base.VisitIdentifierName(node); + + if (IsImplicitInstanceMember(node)) + return Qualify(node); + + return base.VisitIdentifierName(node); + } + + public override SyntaxNode VisitGenericName(GenericNameSyntax node) + { + // e.g. GetComponent() — visit type arguments first. + var visited = (GenericNameSyntax)base.VisitGenericName(node); + + if (ShouldSkip(node)) + return visited; + + if (IsImplicitInstanceMember(node)) + return Qualify(visited); + + return visited; + } + + private static bool ShouldSkip(SimpleNameSyntax node) + { + // Right-hand side of a member access (foo.Bar -> Bar), or part of a qualified name. + if (node.Parent is MemberAccessExpressionSyntax ma && ma.Name == node) + return true; + if (node.Parent is QualifiedNameSyntax) + return true; + if (node.Parent is MemberBindingExpressionSyntax) + return true; + return false; + } + + private bool IsImplicitInstanceMember(SimpleNameSyntax node) + { + var symbol = m_Model.GetSymbolInfo(node).Symbol; + if (symbol == null || symbol.IsStatic) + return false; + + switch (symbol.Kind) + { + case SymbolKind.Field: + case SymbolKind.Property: + case SymbolKind.Method: + case SymbolKind.Event: + return InTypeHierarchy(symbol.ContainingType); + default: + return false; + } + } + + private bool InTypeHierarchy(INamedTypeSymbol containingType) + { + if (containingType == null) + return false; + for (var t = m_Type; t != null; t = t.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(t, containingType)) + return true; + } + return false; + } + + private static SyntaxNode Qualify(SimpleNameSyntax node) + { + return SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("instance"), + node.WithoutTrivia()) + .WithTriviaFrom(node); + } + } + } + + /// + /// Information about a method's signature for transformation purposes. + /// + public class MethodSignatureInfo + { + public string ReturnType { get; set; } + public List Parameters { get; set; } = new List(); + public bool ReturnsValue => !string.IsNullOrEmpty(ReturnType) && ReturnType != "void"; + } + + /// + /// Information about a method parameter. + /// + public class ParameterInfo + { + public string Type { get; set; } + public string Name { get; set; } + public bool HasDefaultValue { get; set; } + public string DefaultValue { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/SourceCodeTransformer.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/SourceCodeTransformer.cs.meta new file mode 100644 index 00000000..75ef1fbf --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/HotReload/SourceCodeTransformer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 425cdc175cccce547a6a3bf6f73c1f4f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models.meta new file mode 100644 index 00000000..425d69aa --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 68a343bc078aa5e459201276f7813b32 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/AuthoringResult.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/AuthoringResult.cs new file mode 100644 index 00000000..ea232826 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/AuthoringResult.cs @@ -0,0 +1,42 @@ +using System; +using Newtonsoft.Json; + +namespace Unity.Pipeline.Models +{ + /// + /// Canonical identity of an object an authoring command created or acted on, returned as the + /// command result so an agent can reference it in a follow-up call (via ). + /// Asset objects carry assetPath/guid(/fileId); loaded/scene objects carry instanceId/hierarchyPath. + /// + [Serializable] + public class AuthoringResult + { + /// Canonical GlobalObjectId string for the object. + [JsonProperty("globalId")] + public string GlobalId { get; set; } + + /// Project-relative asset path, if the object is an asset. + [JsonProperty("assetPath")] + public string AssetPath { get; set; } + + /// Asset GUID, if the object is an asset. + [JsonProperty("guid")] + public string Guid { get; set; } + + /// Local file id within the asset, for sub-assets. + [JsonProperty("fileId")] + public long? FileId { get; set; } + + /// Instance id, if the object is loaded/in a scene. + [JsonProperty("instanceId")] + public ObjectId? InstanceId { get; set; } + + /// Scene hierarchy path, for scene objects. + [JsonProperty("hierarchyPath")] + public string HierarchyPath { get; set; } + + /// Object type name (e.g. "GameObject", "Material", "DefaultAsset"). + [JsonProperty("type")] + public string Type { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/AuthoringResult.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/AuthoringResult.cs.meta new file mode 100644 index 00000000..6875c3a3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/AuthoringResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6b92c58d435dcee4e8312f67b90af25c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/BaseResponse.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/BaseResponse.cs new file mode 100644 index 00000000..1000ed4b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/BaseResponse.cs @@ -0,0 +1,49 @@ +using System; +using Newtonsoft.Json; + +namespace Unity.Pipeline.Models +{ + /// + /// Base class for all responses. Serve also as the base class for all errors. + /// + [Serializable] + public class BaseResponse + { + /// + /// Response message if no error + /// + [JsonProperty("message")] + public string Message { get; set; } + + /// + /// Error message if the command failed. + /// + [JsonProperty("error")] + public string Error { get; set; } + + /// + /// Additional error details for debugging. + /// + [JsonProperty("errorDetails")] + public string ErrorDetails { get; set; } + + /// + /// When was the response created. + /// + [JsonProperty("executedAt")] + public DateTime ExecutedAt { get; set; } + + /// + /// Create a failed execution response. + /// + public static BaseResponse Failure(string error, string errorDetails) + { + return new BaseResponse + { + ExecutedAt = DateTime.UtcNow, + Error = error, + ErrorDetails = errorDetails + }; + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/BaseResponse.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/BaseResponse.cs.meta new file mode 100644 index 00000000..a5c363bf --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/BaseResponse.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 00ec05951f34dac4494abb6eb6c90026 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/CommandExecutionRequest.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/CommandExecutionRequest.cs new file mode 100644 index 00000000..269e9311 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/CommandExecutionRequest.cs @@ -0,0 +1,93 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Unity.Pipeline.Models +{ + /// + /// Request model for /api/exec endpoint. + /// Contains command name and parameters for remote command execution. + /// + [Serializable] + public class CommandExecutionRequest + { + /// + /// Name of the command to execute. + /// Must match a registered [CliCommand] name. + /// + [JsonProperty("command", Required = Required.Always)] + public string Command { get; set; } + + /// + /// Parameters for the command execution. + /// Contains key-value pairs matching command parameter names. + /// + [JsonProperty("parameters")] + public JObject Parameters { get; set; } + + /// + /// Optional timeout for command execution in milliseconds. + /// Default: 60000 (60 seconds) + /// + [JsonProperty("timeout")] + public int? Timeout { get; set; } + + /// + /// Create a new command execution request. + /// + public CommandExecutionRequest() + { + Parameters = new JObject(); + } + + /// + /// Create a command execution request with specified command and parameters. + /// + public CommandExecutionRequest(string command, JObject parameters = null) + { + Command = command ?? throw new ArgumentNullException(nameof(command)); + Parameters = parameters ?? new JObject(); + } + + /// + /// Get a parameter value as the specified type. + /// Returns the default value if the parameter is not found or cannot be converted. + /// + public T GetParameter(string name, T defaultValue = default(T)) + { + if (Parameters == null || !Parameters.ContainsKey(name)) + return defaultValue; + + try + { + return Parameters[name].ToObject(); + } + catch + { + return defaultValue; + } + } + + /// + /// Check if a parameter exists in the request. + /// + public bool HasParameter(string name) + { + return Parameters != null && Parameters.ContainsKey(name); + } + + /// + /// Validate the request structure. + /// + public string Validate() + { + if (string.IsNullOrWhiteSpace(Command)) + return "Command name is required"; + + if (Timeout.HasValue && Timeout.Value <= 0) + return "Timeout must be a positive number"; + + return null; // No validation errors + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/CommandExecutionRequest.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/CommandExecutionRequest.cs.meta new file mode 100644 index 00000000..94629e64 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/CommandExecutionRequest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cb08d2e5232819b43bbec0549c02a689 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/CommandExecutionResponse.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/CommandExecutionResponse.cs new file mode 100644 index 00000000..85c9b8fc --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/CommandExecutionResponse.cs @@ -0,0 +1,68 @@ +using System; +using Newtonsoft.Json; + +namespace Unity.Pipeline.Models +{ + /// + /// Response model for /api/exec endpoint. + /// Contains execution result and metadata for remote command execution. + /// + [Serializable] + public class CommandExecutionResponse : BaseResponse + { + /// + /// Whether the command executed successfully. + /// + [JsonProperty("success")] + public bool Success { get; set; } + + /// + /// Name of the command that was executed. + /// + [JsonProperty("command")] + public string Command { get; set; } + + + /// + /// Result returned by the command (if any). + /// + [JsonProperty("result")] + public object Result { get; set; } + + /// + /// Execution duration in milliseconds. + /// + [JsonProperty("executionTimeMs")] + public long? ExecutionTimeMs { get; set; } + + /// + /// Create a successful execution response. + /// + public static CommandExecutionResponse CmdSuccess(string command, object result = null, long? executionTimeMs = null) + { + return new CommandExecutionResponse + { + Success = true, + Command = command, + ExecutedAt = DateTime.UtcNow, + Result = result, + ExecutionTimeMs = executionTimeMs + }; + } + + /// + /// Create a failed execution response. + /// + public static CommandExecutionResponse CmdFailure(string cmd, string error, string errorDetails) + { + return new CommandExecutionResponse + { + Command = cmd, + Success = false, + ExecutedAt = DateTime.UtcNow, + Error = error, + ErrorDetails = errorDetails + }; + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/CommandExecutionResponse.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/CommandExecutionResponse.cs.meta new file mode 100644 index 00000000..1303e898 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/CommandExecutionResponse.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1911e492692621e4998635d7ddce0489 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ConsoleLogEntry.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ConsoleLogEntry.cs new file mode 100644 index 00000000..1acf3831 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ConsoleLogEntry.cs @@ -0,0 +1,46 @@ +using System; +using Newtonsoft.Json; + +namespace Unity.Pipeline.Models +{ + /// + /// A single captured Unity console entry, as returned by the console command. + /// + [Serializable] + public class ConsoleLogEntry + { + /// + /// Monotonic sequence number assigned when the entry was captured. Acts as the cursor for + /// incremental ("--follow") retrieval: pass the highest seq seen back as the since + /// parameter to get only newer entries. Never reused, and increasing for the life of the + /// buffer (including across domain reloads, since the buffer is persisted). + /// + [JsonProperty("seq")] + public long Seq { get; set; } + + /// + /// When the entry was captured (UTC). + /// + [JsonProperty("timestampUtc")] + public DateTime TimestampUtc { get; set; } + + /// + /// Normalized severity: "log", "warn", or "error". Unity's Error/Exception/Assert log types + /// all map to "error"; Warning maps to "warn"; Log maps to "log". + /// + [JsonProperty("level")] + public string Level { get; set; } + + /// + /// The log message text. + /// + [JsonProperty("message")] + public string Message { get; set; } + + /// + /// The stack trace associated with the entry, if any. Empty for most plain logs. + /// + [JsonProperty("stackTrace")] + public string StackTrace { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ConsoleLogEntry.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ConsoleLogEntry.cs.meta new file mode 100644 index 00000000..b3752370 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ConsoleLogEntry.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bd1bd665815e523ee0da6504d78923df diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ConsoleLogResponse.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ConsoleLogResponse.cs new file mode 100644 index 00000000..b8444b84 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ConsoleLogResponse.cs @@ -0,0 +1,42 @@ +using System; +using Newtonsoft.Json; + +namespace Unity.Pipeline.Models +{ + /// + /// Response model for the console command: a page of captured console entries plus + /// the cursor needed to fetch the next page. + /// + [Serializable] + public class ConsoleLogResponse + { + /// + /// The matching entries, oldest first, after applying the level filter, since cursor, + /// and tail limit. + /// + [JsonProperty("entries")] + public ConsoleLogEntry[] Entries { get; set; } + + /// + /// The highest currently held in the buffer (regardless of + /// the level filter). A "--follow" client passes this back as since on the next poll + /// so it resumes exactly where it left off, even if the only new entries were filtered out. + /// + [JsonProperty("cursor")] + public long Cursor { get; set; } + + /// + /// Number of entries returned in . + /// + [JsonProperty("returned")] + public int Returned { get; set; } + + /// + /// True when the requested since cursor pointed at entries that have already been + /// evicted from the bounded buffer, meaning the consumer missed some entries. Lets a + /// "--follow" client warn about a gap instead of silently skipping output. + /// + [JsonProperty("dropped")] + public bool Dropped { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ConsoleLogResponse.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ConsoleLogResponse.cs.meta new file mode 100644 index 00000000..3ac7c4c1 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ConsoleLogResponse.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3382b91cda66d80db6ab090f9fbeac16 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/EvalResponse.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/EvalResponse.cs new file mode 100644 index 00000000..3b839a3f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/EvalResponse.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Unity.Pipeline.Models +{ + /// + /// Response from code evaluation commands containing results, output, and diagnostics. + /// + [Serializable] + public class EvalResponse :CommandExecutionResponse + { + /// + /// Console output captured during execution. + /// + [JsonProperty("output")] + public string Output { get; set; } + + /// + /// Compilation diagnostics (errors, warnings) from Roslyn. + /// + [JsonProperty("diagnostics")] + public List Diagnostics { get; set; } = new List(); + + /// + /// Create a successful evaluation response. + /// + public static EvalResponse EvalSuccess(object result, string output = null, long executionTimeMs = 0, List diagnostics = null) + { + return new EvalResponse + { + Success = true, + Result = result, + Output = output, + ExecutionTimeMs = executionTimeMs, + Diagnostics = diagnostics ?? new List() + }; + } + + /// + /// Create a failed evaluation response. + /// + public static EvalResponse EvalFailure(string error, string errorDetails = null, long executionTimeMs = 0, List diagnostics = null) + { + return new EvalResponse + { + Success = false, + Error = error, + ErrorDetails = errorDetails, + ExecutionTimeMs = executionTimeMs, + Diagnostics = diagnostics ?? new List() + }; + } + } + + /// + /// Compilation diagnostic information from Roslyn. + /// + [Serializable] + public class DiagnosticInfo + { + /// + /// Severity level: "error", "warning", "info". + /// + [JsonProperty("severity")] + public string Severity { get; set; } + + /// + /// Diagnostic message text. + /// + [JsonProperty("message")] + public string Message { get; set; } + + /// + /// Line number (0-based). + /// + [JsonProperty("line")] + public int Line { get; set; } + + /// + /// Column number (0-based). + /// + [JsonProperty("column")] + public int Column { get; set; } + + /// + /// Diagnostic identifier (e.g., "CS1002"). + /// + [JsonProperty("id")] + public string Id { get; set; } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/EvalResponse.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/EvalResponse.cs.meta new file mode 100644 index 00000000..3ee6d5b1 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/EvalResponse.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7f93e07b346878148b61c36fd3ece752 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/InstanceDescriptor.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/InstanceDescriptor.cs new file mode 100644 index 00000000..a2352437 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/InstanceDescriptor.cs @@ -0,0 +1,203 @@ +using System; +using System.IO; +using System.Diagnostics; +using Newtonsoft.Json; +using UnityEngine; +using Unity.Pipeline.Security; + +namespace Unity.Pipeline.Models +{ + /// + /// Manages instance descriptor files for CLI discovery. + /// Written to Library/Pipeline/.unity-pipeline-port (under the project's git-ignored Library + /// folder) for CLI tools to find running Editor instances. + /// + [Serializable] + public class InstanceDescriptor + { + private const string DescriptorFileName = ".unity-pipeline-port"; + + /// + /// Process ID of the Unity Editor + /// + [JsonProperty("pid")] + public int Pid { get; set; } + + /// + /// HTTP port the pipeline server is listening on + /// + [JsonProperty("port")] + public int Port { get; set; } + + /// + /// Full path to the Unity project + /// + [JsonProperty("projectPath")] + public string ProjectPath { get; set; } + + /// + /// Name of the Unity project + /// + [JsonProperty("projectName")] + public string ProjectName { get; set; } + + /// + /// Unity Editor version string + /// + [JsonProperty("unityVersion")] + public string UnityVersion { get; set; } + + /// + /// Editor mode: "editor", "batchmode" + /// + [JsonProperty("mode")] + public string Mode { get; set; } + + /// + /// When the Editor instance was started + /// + [JsonProperty("startedAt")] + public DateTime StartedAt { get; set; } + + /// + /// Last heartbeat timestamp + /// + [JsonProperty("lastHeartbeat")] + public DateTime LastHeartbeat { get; set; } + + /// + /// Security token for code evaluation commands + /// + [JsonProperty("evalToken")] + public string EvalToken { get; set; } + + /// + /// Create instance descriptor for current Editor session + /// + public static InstanceDescriptor CreateCurrent(int port) + { + try + { + var projectPath = Path.GetDirectoryName(Application.dataPath); + var projectName = Path.GetFileName(projectPath); + var pid = Process.GetCurrentProcess().Id; + var unityVersion = Application.unityVersion; + var isBatchMode = Application.isBatchMode; + + // Generate eval token at server startup for CLI auto-discovery + var evalToken = SecurityTokenManager.GetOrCreateToken(); + + return new InstanceDescriptor + { + Pid = pid, + Port = port, + ProjectPath = projectPath, + ProjectName = projectName, + UnityVersion = unityVersion, + Mode = isBatchMode ? "batchmode" : "editor", + StartedAt = DateTime.UtcNow, + LastHeartbeat = DateTime.UtcNow, + EvalToken = evalToken + }; + } + catch (Exception ex) + { + UnityEngine.Debug.LogError($"CreateCurrent failed: {ex.Message}"); + UnityEngine.Debug.LogError($"Stack trace: {ex.StackTrace}"); + throw; + } + } + + /// + /// Write instance descriptor to project root + /// + public static void WriteToProjectRoot(InstanceDescriptor descriptor) + { + try + { + var filePath = GetDescriptorFilePath(descriptor.ProjectPath); + var isNewFile = !File.Exists(filePath); + // The descriptor lives under Library/Pipeline, which may not exist yet. + Directory.CreateDirectory(Path.GetDirectoryName(filePath)); + var json = JsonConvert.SerializeObject(descriptor, Formatting.Indented); + File.WriteAllText(filePath, json); + + // The descriptor carries the auth token; keep it readable only by the current user. + // Applied once on creation (heartbeat rewrites preserve the existing permissions). + if (isNewFile) + FilePermissions.RestrictToCurrentUser(filePath); + } + catch (Exception ex) + { + UnityEngine.Debug.LogError($"WriteToProjectRoot failed: {ex.Message}"); + UnityEngine.Debug.LogError($"Descriptor: {descriptor?.ProjectPath}, PID: {descriptor?.Pid}"); + throw; + } + } + + /// + /// Read instance descriptor from project root + /// + public static InstanceDescriptor ReadFromProjectRoot(string projectPath) + { + var filePath = GetDescriptorFilePath(projectPath); + + if (!File.Exists(filePath)) + return null; + + try + { + var json = File.ReadAllText(filePath); + return JsonConvert.DeserializeObject(json); + } + catch + { + // Invalid or corrupted file + return null; + } + } + + /// + /// Remove instance descriptor from project root + /// + public static void RemoveFromProjectRoot(string projectPath) + { + var filePath = GetDescriptorFilePath(projectPath); + + if (File.Exists(filePath)) + { + try + { + File.Delete(filePath); + } + catch + { + // Ignore cleanup errors + } + } + } + + /// + /// Update heartbeat timestamp in existing descriptor file + /// + public static void UpdateHeartbeat(string projectPath) + { + var descriptor = ReadFromProjectRoot(projectPath); + if (descriptor != null) + { + descriptor.LastHeartbeat = DateTime.UtcNow; + WriteToProjectRoot(descriptor); + } + } + + /// + /// Absolute path to the editor descriptor file for a project: the git-ignored + /// Library/Pipeline folder under the project root. Public so discovery code and tests + /// derive the location from one place. + /// + public static string GetDescriptorFilePath(string projectPath) + { + return Path.Combine(projectPath, "Library", "Pipeline", DescriptorFileName); + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/InstanceDescriptor.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/InstanceDescriptor.cs.meta new file mode 100644 index 00000000..c86575bd --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/InstanceDescriptor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c19a2cde67d600341b17d8056b8393e2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ObjectRef.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ObjectRef.cs new file mode 100644 index 00000000..3b50e829 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ObjectRef.cs @@ -0,0 +1,57 @@ +using System; +using Newtonsoft.Json; + +namespace Unity.Pipeline.Models +{ + /// + /// Agent-supplied handle that references an existing Unity object — an asset or a + /// loaded/scene object. Supply ONE form; + /// tries them in order: globalId, path, guid (+ optional fileId), instanceId, hierarchyPath. + /// + [Serializable] + [JsonConverter(typeof(ObjectRefConverter))] + public class ObjectRef + { + /// Canonical GlobalObjectId string (addresses assets and scene objects uniformly). + [JsonProperty("globalId")] + public string GlobalId { get; set; } + + /// Project-relative asset path, e.g. "Assets/Foo/Bar.prefab". + [JsonProperty("path")] + public string Path { get; set; } + + /// Asset GUID. + [JsonProperty("guid")] + public string Guid { get; set; } + + /// Local file id, used with to address a sub-asset. + [JsonProperty("fileId")] + public long? FileId { get; set; } + + /// Instance id of a loaded/scene object. + [JsonProperty("instanceId")] + public ObjectId? InstanceId { get; set; } + + /// Scene hierarchy path, e.g. "/Root/Child/Leaf". + [JsonProperty("hierarchyPath")] + public string HierarchyPath { get; set; } + + [JsonIgnore] + public bool IsEmpty => + string.IsNullOrEmpty(GlobalId) && + string.IsNullOrEmpty(Path) && + string.IsNullOrEmpty(Guid) && + InstanceId == null && + string.IsNullOrEmpty(HierarchyPath); + + public override string ToString() + { + if (!string.IsNullOrEmpty(GlobalId)) return GlobalId; + if (!string.IsNullOrEmpty(Path)) return Path; + if (!string.IsNullOrEmpty(Guid)) return FileId.HasValue ? $"guid:{Guid}:{FileId}" : $"guid:{Guid}"; + if (InstanceId.HasValue) return $"instanceId:{InstanceId}"; + if (!string.IsNullOrEmpty(HierarchyPath)) return HierarchyPath; + return ""; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ObjectRef.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ObjectRef.cs.meta new file mode 100644 index 00000000..e436c15d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ObjectRef.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e970b2bf3a890d743bea6785f8af3765 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ObjectRefConverter.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ObjectRefConverter.cs new file mode 100644 index 00000000..138f6745 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ObjectRefConverter.cs @@ -0,0 +1,188 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Unity.Pipeline.Models +{ + /// + /// JSON converter for that accepts BOTH a structured object + /// (all six fields via their JSON property names) AND a single canonical string handle. + /// + /// The MCP tool schemas intentionally expose object-reference parameters as plain strings, so an + /// agent typically passes e.g. "target": "/Player" or "target": "GlobalObjectId_V1-...". + /// Without this converter, Newtonsoft's ToObject<ObjectRef>() (used by + /// BasePipelineServer.ExtractCommandParameters) gets a JValue(string), returns null, and the + /// command fails Required-parameter validation. This converter coerces the string into the matching + /// field, mirroring the priority order of ObjectResolver.TryResolve and ObjectRef.ToString(). + /// + /// Registered on the class itself via [JsonConverter(typeof(ObjectRefConverter))] so it applies + /// everywhere an is (de)serialized — no server or command changes required. + /// + public class ObjectRefConverter : JsonConverter + { + private static readonly Regex s_GuidWithFileId = new Regex(@"^guid:([0-9a-fA-F]+):([0-9]+)$", RegexOptions.Compiled); + private static readonly Regex s_GuidOnly = new Regex(@"^guid:([0-9a-fA-F]+)$", RegexOptions.Compiled); + private static readonly Regex s_InstanceId = new Regex(@"^instanceId:(-?[0-9]+)$", RegexOptions.Compiled); + private static readonly Regex s_PlainInt = new Regex(@"^-?[0-9]+$", RegexOptions.Compiled); + private static readonly Regex s_Hex32 = new Regex(@"^[0-9a-fA-F]{32}$", RegexOptions.Compiled); + + public override ObjectRef ReadJson(JsonReader reader, Type objectType, ObjectRef existingValue, + bool hasExistingValue, JsonSerializer serializer) + { + var token = JToken.Load(reader); + switch (token.Type) + { + case JTokenType.Null: + return null; + case JTokenType.String: + return FromString((string)token); + case JTokenType.Integer: + // A bare JSON number is treated as an instance id (e.g. "target": 48184). + return new ObjectRef { InstanceId = token.ToObject() }; + case JTokenType.Object: + return FromObject((JObject)token); + default: + return null; + } + } + + public override void WriteJson(JsonWriter writer, ObjectRef value, JsonSerializer serializer) + { + if (value == null) + { + writer.WriteNull(); + return; + } + + // Write a standard JSON object (matching the [JsonProperty] names on ObjectRef). Done + // manually rather than via serializer.Serialize to avoid re-entering this converter. + writer.WriteStartObject(); + writer.WritePropertyName("globalId"); + writer.WriteValue(value.GlobalId); + writer.WritePropertyName("path"); + writer.WriteValue(value.Path); + writer.WritePropertyName("guid"); + writer.WriteValue(value.Guid); + writer.WritePropertyName("fileId"); + if (value.FileId.HasValue) writer.WriteValue(value.FileId.Value); else writer.WriteNull(); + writer.WritePropertyName("instanceId"); + // Route the id through ObjectIdConverter so it serializes as its raw numeric form + // (the 64-bit EntityId on 6000.4+, the int below). Not this converter — no recursion. + if (value.InstanceId.HasValue) serializer.Serialize(writer, value.InstanceId.Value); else writer.WriteNull(); + writer.WritePropertyName("hierarchyPath"); + writer.WriteValue(value.HierarchyPath); + writer.WriteEndObject(); + } + + /// + /// Map a structured JSON object to an ObjectRef by reading each field directly (rather than + /// ToObject<ObjectRef>(), which would recurse back into this converter). + /// + /// Keys are matched case-INSENSITIVELY: agents commonly vary the casing (e.g. "instanceID" for + /// instanceId, "fileID" for fileId), and because this custom converter bypasses Newtonsoft's + /// default case-insensitive property binding, an exact-case lookup would otherwise drop the + /// field and yield an empty (silently unresolved) handle. + /// + private static ObjectRef FromObject(JObject jo) + { + return new ObjectRef + { + GlobalId = Str(jo, "globalId"), + Path = Str(jo, "path"), + Guid = Str(jo, "guid"), + FileId = Long(jo, "fileId"), + InstanceId = ObjId(jo, "instanceId"), + HierarchyPath = Str(jo, "hierarchyPath"), + }; + } + + /// Find a property value by name, ignoring case (Newtonsoft's JObject lookup is case-sensitive). + private static JToken Get(JObject jo, string key) + { + foreach (var p in jo.Properties()) + { + if (string.Equals(p.Name, key, StringComparison.OrdinalIgnoreCase)) + return p.Value; + } + + return null; + } + + private static string Str(JObject jo, string key) + { + var t = Get(jo, key); + return t == null || t.Type == JTokenType.Null ? null : t.Value(); + } + + private static ObjectId? ObjId(JObject jo, string key) + { + var t = Get(jo, key); + return t == null || t.Type == JTokenType.Null ? (ObjectId?)null : t.ToObject(); + } + + private static long? Long(JObject jo, string key) + { + var t = Get(jo, key); + return t == null || t.Type == JTokenType.Null ? (long?)null : t.Value(); + } + + /// + /// Parse a single canonical string handle into the matching field. Priority order matches + /// ObjectResolver.TryResolve / ObjectRef.ToString(); a bare name falls back to + /// the hierarchy path (the most common agent input). + /// + internal static ObjectRef FromString(string s) + { + if (string.IsNullOrWhiteSpace(s)) + return null; + + s = s.Trim(); + + if (s.StartsWith("GlobalObjectId_V1", StringComparison.Ordinal)) + return new ObjectRef { GlobalId = s }; + + // "Assets/..." and "Packages/..." (and the bare roots) are asset paths that + // AssetDatabase.LoadMainAssetAtPath can resolve; everything else falls through to the + // hierarchy-path fallback at the end. + if (s.StartsWith("Assets/", StringComparison.Ordinal) + || s.StartsWith("Packages/", StringComparison.Ordinal) + || s == "Assets" || s == "Packages") + return new ObjectRef { Path = s }; + + var guidFile = s_GuidWithFileId.Match(s); + if (guidFile.Success && long.TryParse(guidFile.Groups[2].Value, out var fileId)) + return new ObjectRef { Guid = guidFile.Groups[1].Value, FileId = fileId }; + + var guidOnly = s_GuidOnly.Match(s); + if (guidOnly.Success) + return new ObjectRef { Guid = guidOnly.Groups[1].Value }; + + var inst = s_InstanceId.Match(s); + if (inst.Success && ObjectId.TryParse(inst.Groups[1].Value, out var prefixedId)) + return new ObjectRef { InstanceId = prefixedId }; + + // A plain integer is an instance id. On 6000.0-6000.3 negative ids are valid (unsaved scene + // objects); on 6000.4+ the id is the unsigned 64-bit EntityId, so a leading '-' won't parse + // and falls through to the hierarchy-path handling below. + if (s_PlainInt.IsMatch(s) && ObjectId.TryParse(s, out var plainId)) + return new ObjectRef { InstanceId = plainId }; + + // Exactly 32 hex chars with no other prefix is a bare asset GUID. + if (s_Hex32.IsMatch(s)) + return new ObjectRef { Guid = s }; + + // A relative string with a file extension (e.g. "Materials/Floor.mat", "Enemy.prefab") is + // most likely an authoring-root-relative asset path — route it to Path so the resolver can + // normalize it under the authoring root. A leading "/" always means a scene hierarchy path, + // and an extension-less string ("Player", "Root/Child") stays one. Dotted GameObject names + // ("Cube.001") land here too, but the resolver falls back to a hierarchy lookup for those. + if (!s.StartsWith("/", StringComparison.Ordinal) && Path.HasExtension(s)) + return new ObjectRef { Path = s }; + + // Leading-"/" canonical paths and bare names resolve as a scene hierarchy path. + return new ObjectRef { HierarchyPath = s }; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ObjectRefConverter.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ObjectRefConverter.cs.meta new file mode 100644 index 00000000..0b538def --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/ObjectRefConverter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e31382a242c2a084e97ba76716798aa3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/RuntimeInstanceDescriptor.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/RuntimeInstanceDescriptor.cs new file mode 100644 index 00000000..7775a1f9 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/RuntimeInstanceDescriptor.cs @@ -0,0 +1,203 @@ +using System; +using Newtonsoft.Json; +using UnityEngine; +using Unity.Pipeline.Config; +using System.IO; +using Unity.Pipeline.Security; + +namespace Unity.Pipeline.Models +{ + /// + /// Instance descriptor for runtime Unity Player builds with Pipeline server. + /// Different from Editor InstanceDescriptor to reflect runtime context and security. + /// + [Serializable] + public class RuntimeInstanceDescriptor + { + private const string RuntimeDescriptorFileName = ".unity-pipeline-runtime-port"; + + /// + /// Process ID of the Unity Player application + /// + [JsonProperty("pid")] + public int Pid { get; set; } + + /// + /// HTTP port the pipeline server is listening on (7900-7999 range) + /// + [JsonProperty("port")] + public int Port { get; set; } + + /// + /// Unity runtime platform (Windows, macOS, Linux, etc.) + /// + [JsonProperty("platform")] + public string Platform { get; set; } + + /// + /// Unity Player version string + /// + [JsonProperty("unityVersion")] + public string UnityVersion { get; set; } + + /// + /// Unique build identifier + /// + [JsonProperty("buildGuid")] + public string BuildGuid { get; set; } + + /// + /// When the runtime instance was started + /// + [JsonProperty("startedAt")] + public DateTime StartedAt { get; set; } + + /// + /// Last heartbeat timestamp + /// + [JsonProperty("lastHeartbeat")] + public DateTime LastHeartbeat { get; set; } + + /// + /// Working directory where the application is running + /// + [JsonProperty("workingDirectory")] + public string WorkingDirectory { get; set; } + + /// + /// Security token used to authorize requests to this runtime server. + /// + [JsonProperty("evalToken")] + public string EvalToken { get; set; } + + /// + /// Create runtime instance descriptor for current Player application. + /// + public static RuntimeInstanceDescriptor CreateCurrent(int port, RuntimePipelineConfig config) + { + try + { + var pid = System.Diagnostics.Process.GetCurrentProcess().Id; + var unityVersion = Application.unityVersion; + var platform = Application.platform.ToString(); + var buildGuid = Application.buildGUID; + var workingDir = Directory.GetCurrentDirectory(); + + var token = SecurityTokenManager.GetOrCreateToken(); + + return new RuntimeInstanceDescriptor + { + Pid = pid, + Port = port, + Platform = platform, + UnityVersion = unityVersion, + BuildGuid = buildGuid, + StartedAt = DateTime.UtcNow, + LastHeartbeat = DateTime.UtcNow, + WorkingDirectory = workingDir, + EvalToken = token + }; + } + catch (Exception ex) + { + UnityEngine.Debug.LogError($"Pipeline: CreateCurrent failed: {ex.Message}"); + throw; + } + } + + /// + /// Write runtime instance descriptor to working directory. + /// + public static void WriteToWorkingDirectory(RuntimeInstanceDescriptor descriptor) + { + try + { + var filePath = GetDescriptorFilePath(); + var isNewFile = !File.Exists(filePath); + var json = JsonConvert.SerializeObject(descriptor, Formatting.Indented); + + File.WriteAllText(filePath, json); + + // The descriptor carries the auth token; keep it readable only by the current user. + // Applied once on creation (heartbeat rewrites preserve the existing permissions). + if (isNewFile) + FilePermissions.RestrictToCurrentUser(filePath); + + System.Console.WriteLine($"Pipeline: Runtime descriptor written to {filePath}"); + } + catch (Exception ex) + { + UnityEngine.Debug.LogError($"Pipeline: Failed to write runtime descriptor: {ex.Message}"); + throw; + } + } + + /// + /// Read runtime instance descriptor from working directory. + /// + public static RuntimeInstanceDescriptor ReadFromWorkingDirectory() + { + var filePath = GetDescriptorFilePath(); + + if (!File.Exists(filePath)) + return null; + + try + { + var json = File.ReadAllText(filePath); + return JsonConvert.DeserializeObject(json); + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"Pipeline: Failed to read runtime descriptor: {ex.Message}"); + return null; + } + } + + /// + /// Remove runtime instance descriptor from working directory. + /// + public static void RemoveFromWorkingDirectory() + { + var filePath = GetDescriptorFilePath(); + + if (File.Exists(filePath)) + { + try + { + File.Delete(filePath); + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"Pipeline: Failed to remove runtime descriptor: {ex.Message}"); + } + } + } + + /// + /// Update heartbeat timestamp in existing descriptor file. + /// + public static void UpdateHeartbeat() + { + try + { + var descriptor = ReadFromWorkingDirectory(); + if (descriptor != null) + { + descriptor.LastHeartbeat = DateTime.UtcNow; + WriteToWorkingDirectory(descriptor); + } + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"Pipeline: Failed to update runtime heartbeat: {ex.Message}"); + } + } + + private static string GetDescriptorFilePath() + { + var workingDir = new FileInfo($"{Application.dataPath}/..").FullName; + return Path.Combine(workingDir, RuntimeDescriptorFileName); + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/RuntimeInstanceDescriptor.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/RuntimeInstanceDescriptor.cs.meta new file mode 100644 index 00000000..6c7a30e6 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/RuntimeInstanceDescriptor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a7787403e02096545b7798cd3d5d076d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/StatusResponse.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/StatusResponse.cs new file mode 100644 index 00000000..6df8a129 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/StatusResponse.cs @@ -0,0 +1,55 @@ +using System; +using Newtonsoft.Json; + +namespace Unity.Pipeline.Models +{ + /// + /// Response model for /api/status endpoint. + /// Provides Editor state information for CLI tools and automation scripts. + /// + [Serializable] + public class StatusResponse + { + /// + /// Current Editor status: "ready", "compiling", "busy" + /// + [JsonProperty("status")] + public string Status { get; set; } + + /// + /// Whether Unity is currently compiling scripts + /// + [JsonProperty("compiling")] + public bool Compiling { get; set; } + + /// + /// Whether a domain reload is in progress + /// + [JsonProperty("domainReloadInProgress")] + public bool DomainReloadInProgress { get; set; } + + /// + /// Current play mode state: "stopped", "playing", "paused" + /// + [JsonProperty("playMode")] + public string PlayMode { get; set; } + + /// + /// Timestamp of last heartbeat update + /// + [JsonProperty("lastHeartbeat")] + public DateTime LastHeartbeat { get; set; } + + /// + /// Full path to the Unity project + /// + [JsonProperty("projectPath")] + public string ProjectPath { get; set; } + + /// + /// Unity Editor version string + /// + [JsonProperty("unityVersion")] + public string UnityVersion { get; set; } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/StatusResponse.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/StatusResponse.cs.meta new file mode 100644 index 00000000..12a903d0 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/StatusResponse.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 86f7bba2bb7101544a486545cb8e2f70 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestExecutionResponse.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestExecutionResponse.cs new file mode 100644 index 00000000..4ae4c9eb --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestExecutionResponse.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using Unity.Pipeline.Models; + +namespace Unity.Pipeline +{ + /// + /// Response for test execution commands, containing summary and detailed results + /// + [Serializable] + public class TestExecutionResponse : CommandExecutionResponse + { + public TestSummary Summary { get; set; } + public List Results { get; set; } + public double Duration { get; set; } + public string StatusPath { get; set; } // For async mode + public string Mode { get; set; } // EditMode, PlayMode, or All + public string FilterApplied { get; set; } // What filter was used, if any + + public TestExecutionResponse() + { + Results = new List(); + Summary = new TestSummary(); + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestExecutionResponse.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestExecutionResponse.cs.meta new file mode 100644 index 00000000..29f2d39c --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestExecutionResponse.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 27c96238399817b4c8ffb66925bcbc6f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestResult.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestResult.cs new file mode 100644 index 00000000..33b3ab91 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestResult.cs @@ -0,0 +1,17 @@ +using System; + +namespace Unity.Pipeline +{ + /// + /// Individual test result information + /// + [Serializable] + public class TestResult + { + public string FullName { get; set; } + public string Status { get; set; } // Passed, Failed, Skipped, Inconclusive + public double Duration { get; set; } + public string Message { get; set; } + public string StackTrace { get; set; } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestResult.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestResult.cs.meta new file mode 100644 index 00000000..80f8419d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7ccc266e8f58db84dab523cd4dabc489 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestSummary.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestSummary.cs new file mode 100644 index 00000000..22fce421 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestSummary.cs @@ -0,0 +1,26 @@ +using System; + +namespace Unity.Pipeline +{ + /// + /// Summary statistics for test execution + /// + [Serializable] + public class TestSummary + { + public int Total { get; set; } + public int Passed { get; set; } + public int Failed { get; set; } + public int Skipped { get; set; } + public int Inconclusive { get; set; } + + public TestSummary() + { + Total = 0; + Passed = 0; + Failed = 0; + Skipped = 0; + Inconclusive = 0; + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestSummary.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestSummary.cs.meta new file mode 100644 index 00000000..7bbaa286 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Models/TestSummary.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: df11040e7d969ff4fa8aaa417c44bd44 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport.meta new file mode 100644 index 00000000..21bb235d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4a339d7263cb1ac4f879d79c8e7fe3ca +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/FrameStatsSampler.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/FrameStatsSampler.cs new file mode 100644 index 00000000..860c19d5 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/FrameStatsSampler.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using Unity.Profiling; +using UnityEngine; + +namespace Unity.Pipeline.Runtime.Telemetry +{ + /// + /// Samples per-frame timing on the main thread so the runtime telemetry command can report fps and + /// frame-time statistics over a rolling window. + /// + /// A Player build has no Editor profiler window to read from and no EditorApplication.update + /// to drive sampling, so owns one shared instance and feeds it + /// every frame from its Update. The sampler is deliberately allocation-free on the hot path: + /// each frame time is written into a fixed ring buffer and only reduced to a + /// on demand when a telemetry request arrives (far rarer than the frame rate). + /// + /// Sampling uses Time.unscaledDeltaTime so reported fps reflects real wall-clock frame pacing + /// independent of Time.timeScale — a paused or slow-motion game still reports its true fps. + /// + public sealed class FrameStatsSampler : IDisposable + { + /// + /// The process-wide sampler fed by the active . Null when no + /// manager is present (e.g. EditMode tests, or a scene without the component). Telemetry consumers + /// must treat a null sampler as "frame stats unavailable" rather than failing — memory and counter + /// data that do not depend on per-frame sampling can still be reported without it. + /// + public static FrameStatsSampler Shared { get; set; } + + private readonly float[] m_FrameTimesMs; + private int m_Head; + private int m_Count; + private float m_LastFrameTimeMs; + + // ProfilerRecorders pull counters straight from the profiler stream. They are started lazily on + // the first snapshot (creation must happen on the main thread) and disposed with the sampler. + // Recorders that do not resolve on this platform/Unity version report Valid == false and are + // simply skipped when building a snapshot. + private readonly List m_Counters = new List(); + private bool m_CountersStarted; + + private struct CounterRecorder + { + public string Name; + public ProfilerRecorder Recorder; + } + + /// Number of frames the rolling window can hold. + public int Capacity => m_FrameTimesMs.Length; + + /// Number of frames currently recorded (ramps up to ). + public int SampleCount => m_Count; + + public FrameStatsSampler(int windowFrames = 120) + { + if (windowFrames < 1) + windowFrames = 1; + m_FrameTimesMs = new float[windowFrames]; + } + + /// + /// Record one completed frame. Call once per frame from the main thread. + /// is the unscaled delta time of the frame just completed + /// (typically Time.unscaledDeltaTime). + /// + public void Sample(float deltaSeconds) + { + var ms = deltaSeconds * 1000f; + m_LastFrameTimeMs = ms; + m_FrameTimesMs[m_Head] = ms; + m_Head = (m_Head + 1) % m_FrameTimesMs.Length; + if (m_Count < m_FrameTimesMs.Length) + m_Count++; + + // FrameTimingManager only yields data for frames in which timings were captured, so the + // capture call has to happen on the per-frame path, not when a snapshot is requested. + FrameTimingManager.CaptureFrameTimings(); + } + + /// + /// Reduce the current window to an immutable snapshot. Must be called on the main thread (it reads + /// profiler recorders and the frame timing manager). + /// + public FrameStatsSnapshot GetSnapshot() + { + var snap = new FrameStatsSnapshot(); + + if (m_Count > 0) + { + float sum = 0f, min = float.MaxValue, max = 0f; + for (int i = 0; i < m_Count; i++) + { + var v = m_FrameTimesMs[i]; + sum += v; + if (v < min) min = v; + if (v > max) max = v; + } + + var avg = sum / m_Count; + snap.Available = true; + snap.SampleWindow = m_Count; + snap.AverageFrameTimeMs = avg; + snap.MinFrameTimeMs = min; + snap.MaxFrameTimeMs = max; + snap.LastFrameTimeMs = m_LastFrameTimeMs; + snap.Fps = avg > 0f ? 1000f / avg : 0f; + } + + ReadFrameTimingManager(snap); + ReadCounters(snap); + return snap; + } + + // CPU/GPU frame time from FrameTimingManager. Only populated when the platform reports timings + // (requires "Frame Timing Stats" in Player settings / -enable-frame-timing-stats); otherwise the + // snapshot's GpuTimingAvailable stays false rather than reporting misleading zeros. + private static void ReadFrameTimingManager(FrameStatsSnapshot snap) + { + var timings = new FrameTiming[1]; + var captured = FrameTimingManager.GetLatestTimings(1, timings); + if (captured > 0) + { + snap.GpuTimingAvailable = true; + snap.CpuFrameTimeMs = timings[0].cpuFrameTime; + snap.GpuFrameTimeMs = timings[0].gpuFrameTime; + } + } + + private void ReadCounters(FrameStatsSnapshot snap) + { + EnsureCountersStarted(); + var counters = new Dictionary(); + foreach (var c in m_Counters) + { + if (c.Recorder.Valid) + counters[c.Name] = c.Recorder.LastValue; + } + snap.Counters = counters; + } + + private void EnsureCountersStarted() + { + if (m_CountersStarted) + return; + m_CountersStarted = true; + + // Common render/memory counters. Stat names that do not exist on the running platform resolve + // to invalid recorders and are filtered out at snapshot time. + TryAddCounter("DrawCalls", ProfilerCategory.Render, "Draw Calls Count"); + TryAddCounter("SetPassCalls", ProfilerCategory.Render, "SetPass Calls Count"); + TryAddCounter("Triangles", ProfilerCategory.Render, "Triangles Count"); + TryAddCounter("Vertices", ProfilerCategory.Render, "Vertices Count"); + TryAddCounter("GcAllocInFrame", ProfilerCategory.Memory, "GC Allocated In Frame"); + } + + private void TryAddCounter(string reportedName, ProfilerCategory category, string statName) + { + m_Counters.Add(new CounterRecorder + { + Name = reportedName, + Recorder = ProfilerRecorder.StartNew(category, statName) + }); + } + + public void Dispose() + { + foreach (var c in m_Counters) + { + if (c.Recorder.Valid) + c.Recorder.Dispose(); + } + m_Counters.Clear(); + m_CountersStarted = false; + } + } + + /// + /// Immutable point-in-time reduction of 's rolling window plus the + /// counters/frame-timing read at snapshot time. Serialized as the frame-stats portion of the runtime + /// telemetry response. + /// + [Serializable] + public class FrameStatsSnapshot + { + /// True when at least one frame has been sampled (i.e. a manager is feeding the sampler). + public bool Available { get; set; } + + /// Number of frames included in the averages. + public int SampleWindow { get; set; } + + /// Frames per second derived from the average frame time over the window. + public float Fps { get; set; } + + public float AverageFrameTimeMs { get; set; } + public float MinFrameTimeMs { get; set; } + public float MaxFrameTimeMs { get; set; } + public float LastFrameTimeMs { get; set; } + + /// True when FrameTimingManager returned CPU/GPU timings for a recent frame. + public bool GpuTimingAvailable { get; set; } + public double CpuFrameTimeMs { get; set; } + public double GpuFrameTimeMs { get; set; } + + /// Valid profiler counters by reported name (e.g. DrawCalls, Triangles). + public Dictionary Counters { get; set; } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/FrameStatsSampler.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/FrameStatsSampler.cs.meta new file mode 100644 index 00000000..4fd085d3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/FrameStatsSampler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0e2b7696d6f845cba4f7c776006707d1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineConfig.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineConfig.cs new file mode 100644 index 00000000..b36678d6 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineConfig.cs @@ -0,0 +1,68 @@ +using System; +using UnityEngine; + +namespace Unity.Pipeline.Config +{ + /// + /// Configuration for Pipeline server functionality in Unity Player builds. + /// Create this asset and place in Resources folder to enable runtime Pipeline support. + /// + [CreateAssetMenu(fileName = "RuntimePipelineConfig", menuName = "Pipeline/Runtime Configuration", order = 1)] + public class RuntimePipelineConfig : ScriptableObject + { + [Header("Server Configuration")] + [Tooltip("Enable Pipeline HTTP server in Player builds. SECURITY WARNING: Only enable in development/QA builds, never production without proper security measures.")] + public bool enableInBuilds = false; + + [Tooltip("HTTP port for Pipeline server. Use 0 for auto-assignment from range 7900-7999.")] + [Range(0, 65535)] + public int port = 0; + + [Header("Advanced")] + [Tooltip("Request timeout in milliseconds. Higher values allow longer-running commands.")] + [Range(1000, 60000)] + public int requestTimeoutMs = 30000; + + [Tooltip("Enable detailed logging of all remote requests for security auditing.")] + public bool enableAuditLogging = true; + + /// + /// Validate the configuration for correctness. + /// Called by build processor to ensure safe deployment. + /// + public ValidationResult Validate() + { + if (!enableInBuilds) + return ValidationResult.Success("Runtime Pipeline disabled"); + + // Port validation + if (port != 0 && (port < 7900 || port > 7999)) + { + return ValidationResult.Warning( + $"Port {port} is outside recommended runtime range 7900-7999. May conflict with Editor instances."); + } + + return ValidationResult.Success("Configuration is valid"); + } + } + + /// + /// Result from configuration validation. + /// + [Serializable] + public class ValidationResult + { + public bool IsValid { get; set; } + public string Level { get; set; } // "success", "warning", "error" + public string Message { get; set; } + + public static ValidationResult Success(string message = "Valid") => + new ValidationResult { IsValid = true, Level = "success", Message = message }; + + public static ValidationResult Warning(string message) => + new ValidationResult { IsValid = true, Level = "warning", Message = message }; + + public static ValidationResult Error(string message) => + new ValidationResult { IsValid = false, Level = "error", Message = message }; + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineConfig.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineConfig.cs.meta new file mode 100644 index 00000000..7f579c16 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0fd19cf31d54e3848a1075f001aed64f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineManager.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineManager.cs new file mode 100644 index 00000000..1e0e9771 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineManager.cs @@ -0,0 +1,368 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Unity.Pipeline.Config; +using Unity.Pipeline.Commands; +using Unity.Pipeline.HotReload; +using Unity.Pipeline.Runtime.Telemetry; +using UnityEngine; + +namespace Unity.Pipeline +{ + /// + /// Unified Pipeline runtime manager that handles both server creation and event processing. + /// Replaces the need for separate RuntimePipelineConfig and RuntimePipelineProcessor. + /// + /// Simply add this component to a GameObject in your scene to enable Pipeline runtime support. + /// Only one instance should exist in your project. + /// + [AddComponentMenu("Pipeline/Runtime Pipeline Manager")] + [DisallowMultipleComponent] + public class RuntimePipelineManager : MonoBehaviour + { + [Header("Server Configuration")] + [Tooltip("Enable Pipeline HTTP server in Player builds. SECURITY WARNING: Only enable in development/QA builds, never production without proper security measures.")] + public bool enableInBuilds = false; + + [Tooltip("HTTP port for Pipeline server. Use 0 for auto-assignment from range 7900-7999.")] + [Range(0, 65535)] + public int port = 0; + + [Header("Advanced")] + [Tooltip("Request timeout in milliseconds. Higher values allow longer-running commands.")] + [Range(1000, 60000)] + public int requestTimeoutMs = 30000; + + [Tooltip("Enable detailed logging of all remote requests for security auditing.")] + public bool enableAuditLogging = true; + + [Header("Runtime")] + [Tooltip("Enable automatic server startup when the component starts (for runtime builds only).")] + public bool autoStart = true; + + [Tooltip("Maximum work items to process per frame to maintain performance.")] + [Range(1, 50)] + public int maxWorkItemsPerFrame = 10; + + // Absolute roots this build is allowed to hot reload source files from (Assets + loaded + // package locations). Baked at build time by the build processor (a running Player cannot + // resolve the project layout) and published to HotReloadFileScope when the server starts. + // Hidden from the inspector: it is build-injected, not user-edited. + [SerializeField, HideInInspector] private List m_AllowedReloadRoots = new List(); + + // Internal state + private RuntimePipelineServer m_Server; + private RuntimePipelineConfig m_RuntimeConfig; + + // True only for the instance that installed FrameStatsSampler.Shared, so a rejected duplicate's + // OnDestroy never tears down the real sampler. + private bool m_OwnsSampler; + + /// + /// Get the runtime server instance if it's running. + /// + public RuntimePipelineServer Server => m_Server; + + /// + /// Whether the server is currently running. + /// + public bool IsServerRunning => m_Server != null && m_Server.IsRunning; + + /// + /// Get the actual port the server is running on. + /// + public int ActualPort => m_Server?.Port ?? 0; + + public RuntimePipelineConfig Config => m_RuntimeConfig; + + /// + /// Absolute roots this build may hot reload source files from. Baked at build time. + /// + public IReadOnlyList AllowedReloadRoots => m_AllowedReloadRoots; + + /// + /// Set the allowed hot reload roots. Called by the build processor to bake the project's + /// Assets and package locations into the build. + /// + public void SetAllowedReloadRoots(IEnumerable roots) + { + m_AllowedReloadRoots = roots != null ? new List(roots) : new List(); + } + + void Awake() + { + // Ensure only one instance exists + var existing = PipelineUtils.FindObjectsByType(); + if (existing.Length > 1) + { + Debug.LogWarning($"Pipeline: Multiple RuntimePipelineManager instances found. Destroying duplicate on '{name}'."); + Destroy(this); + return; + } + + // Don't destroy on load for persistent operation across scenes + DontDestroyOnLoad(gameObject); + + // Set up command discovery (the server owns its own dispatcher, initialized on Start). + CommandRegistry.SetDiscovery(null); // Triggers reflection-based discovery + + // Auto-discover and register hot-reloadable methods. Hot reload requires this manager to + // be present (it owns the communication workflow), so discovery lives here rather than in + // each gameplay script's Awake. + RegisterDiscoveredHotReloadMethods(); + + // Own the process-wide frame-stats sampler. A Player has no profiler window or + // EditorApplication.update to drive sampling, so the manager feeds it from Update (below) and + // the runtime_status telemetry command reads FrameStatsSampler.Shared. Created here (rather than + // on server start) so fps history is already warm by the time an agent connects. + FrameStatsSampler.Shared = new FrameStatsSampler(); + m_OwnsSampler = true; + } + + void Start() + { + if (autoStart && enableInBuilds) + { + StartServer(); + } + } + + /// + /// Scan loaded user assemblies for methods tagged [HotReload] (in-place workflow) and + /// [HotReloadWithOverrides] (helper workflow) and register them as reload targets. This replaces any + /// per-script RegisterReloadableType call in Awake. + /// + private static void RegisterDiscoveredHotReloadMethods() + { + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic + | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + int registered = 0; + + foreach (var assembly in PipelineUtils.GetLoadedAssemblies()) + { + if (!ShouldScanForHotReload(assembly)) + continue; + + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + types = ex.Types; // best effort: keep the types that did load + } + + foreach (var type in types) + { + if (type == null) + continue; + + foreach (var method in type.GetMethods(flags)) + { + var inPlace = method.GetCustomAttribute(); + if (inPlace != null) + { + // The weaver keys dispatch on TypeName.MethodName, so register with the + // default id (no custom Id) to match. + HotReloadRegistry.RegisterReloadableMethod( + method, new HotReloadWithOverridesAttribute { RequireMainThread = inPlace.RequireMainThread }); + registered++; + continue; + } + + var reloadable = method.GetCustomAttribute(); + if (reloadable != null) + { + HotReloadRegistry.RegisterReloadableMethod(method, reloadable); + registered++; + } + } + } + } + + if (registered > 0) + Debug.Log($"Pipeline: Auto-discovered and registered {registered} hot-reloadable method(s)."); + } + + /// + /// Skip engine/framework assemblies that cannot contain user hot-reload methods. + /// + private static bool ShouldScanForHotReload(Assembly assembly) + { + if (assembly.IsDynamic) + return false; + + var name = assembly.GetName().Name; + if (string.IsNullOrEmpty(name)) + return false; + + foreach (var prefix in s_HotReloadSkipPrefixes) + { + if (name.StartsWith(prefix, StringComparison.Ordinal)) + return false; + } + + return true; + } + + private static readonly string[] s_HotReloadSkipPrefixes = + { + "System", "mscorlib", "netstandard", "Microsoft", "Mono.", "nunit", + "UnityEngine", "UnityEditor", "Unity.Collections", "Unity.Burst", + "Unity.Mathematics", "Newtonsoft", "log4net", "ICSharpCode", + }; + + void Update() + { + // Feed the frame-stats sampler once per frame on the main thread. Uses unscaled delta so + // reported fps reflects real frame pacing regardless of Time.timeScale. + FrameStatsSampler.Shared?.Sample(Time.unscaledDeltaTime); + + // Pump this server's own dispatcher for main-thread operations (player builds have no + // EditorApplication.update to auto-pump it). + m_Server?.Dispatcher.ProcessWorkQueue(maxWorkItemsPerFrame); + + // Drive the watchdog (player builds have no EditorApplication.update). No-op unless the + // server's watchdog is enabled and armed. + m_Server?.WatchdogTick(); + } + + void OnApplicationQuit() + { + StopServer(); + } + + void OnDestroy() + { + // Dispose the sampler's profiler recorders and drop the shared reference, but only if this + // manager is the one that installed it (a rejected duplicate must not tear down the real one). + if (m_OwnsSampler) + { + FrameStatsSampler.Shared?.Dispose(); + FrameStatsSampler.Shared = null; + m_OwnsSampler = false; + } + } + + /// + /// Manually start the Pipeline server. + /// + public void StartServer() + { + if (m_Server != null && m_Server.IsRunning) + { + Debug.LogWarning("Pipeline: Server already started or initialization attempted."); + return; + } + + try + { + System.Console.WriteLine("Pipeline: Starting runtime server from RuntimePipelineManager..."); + + if (!enableInBuilds) + { + Debug.LogWarning("Pipeline: Runtime server disabled in configuration (enableInBuilds = false)"); + return; + } + + // Create runtime config from component settings + m_RuntimeConfig = CreateRuntimeConfig(); + + // Validate configuration + var validation = m_RuntimeConfig.Validate(); + if (!validation.IsValid) + { + Debug.LogError($"Pipeline: Invalid runtime configuration: {validation.Message}"); + return; + } + + if (validation.Level == "warning") + { + Debug.LogWarning($"Pipeline: Runtime configuration warning: {validation.Message}"); + } + + // Create runtime server + m_Server = new RuntimePipelineServer(m_RuntimeConfig); + + // Start server on configured port + var serverPort = port == 0 ? 0 : port; + m_Server.Start(serverPort); + + if (m_Server.IsRunning) + { + // The hot-reload registry marshals main-thread overrides through a dispatcher + // when hot-reloaded code is invoked from a background thread at runtime. Inject + // this server's dispatcher here (a player has exactly one manager), so editor and + // per-test servers never clobber the global registry's dispatcher. + Unity.Pipeline.HotReload.HotReloadRegistry.Dispatcher = m_Server.Dispatcher; + + // Publish the build-baked allowed roots so reload commands can validate that + // incoming files are inside the project before compiling and injecting them. + Unity.Pipeline.HotReload.HotReloadRegistry.AllowedReloadRoots = m_AllowedReloadRoots; + + System.Console.WriteLine($"Pipeline: Runtime server started successfully on port {m_Server.Port}"); + } + else + { + Debug.LogError("Pipeline: Failed to start runtime server"); + } + } + catch (Exception ex) + { + Debug.LogError($"Pipeline: Runtime initialization failed: {ex.Message}"); + Debug.LogError($"Pipeline: Stack trace: {ex.StackTrace}"); + } + } + + /// + /// Manually stop the Pipeline server. + /// + public void StopServer() + { + try + { + if (m_Server != null && m_Server.IsRunning) + { + System.Console.WriteLine("Pipeline: Stopping runtime server..."); + // Drop the registry's reference to this server's (about to be shut down) dispatcher. + if (Unity.Pipeline.HotReload.HotReloadRegistry.Dispatcher == m_Server.Dispatcher) + Unity.Pipeline.HotReload.HotReloadRegistry.Dispatcher = null; + m_Server.Stop(); // Stop() shuts down the server's own dispatcher. + } + } + catch (Exception ex) + { + Debug.LogError($"Pipeline: Runtime shutdown error: {ex.Message}"); + } + } + + /// + /// Create a RuntimePipelineConfig from the component settings. + /// + private RuntimePipelineConfig CreateRuntimeConfig() + { + var config = ScriptableObject.CreateInstance(); + + // Copy settings from component to config + config.enableInBuilds = this.enableInBuilds; + config.port = this.port; + config.requestTimeoutMs = this.requestTimeoutMs; + config.enableAuditLogging = this.enableAuditLogging; + + return config; + } + + /// + /// Validate the current configuration. + /// + public ValidationResult ValidateConfiguration() + { + if (m_RuntimeConfig == null) + m_RuntimeConfig = CreateRuntimeConfig(); + + return m_RuntimeConfig.Validate(); + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineManager.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineManager.cs.meta new file mode 100644 index 00000000..8d1e3112 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dd4ab1c9091e0194196b5f5d51cb932f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineServer.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineServer.cs new file mode 100644 index 00000000..7f627e7f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineServer.cs @@ -0,0 +1,74 @@ +using System; +using Unity.Pipeline.Config; +using Unity.Pipeline.Models; +using UnityEngine; + +namespace Unity.Pipeline +{ + public class RuntimePipelineServer : BasePipelineServer + { + private RuntimePipelineConfig m_Config; + private RuntimeInstanceDescriptor m_InstanceDescriptor; + + public override DateTime StartedAt => m_InstanceDescriptor == null ? new DateTime() : m_InstanceDescriptor.StartedAt; + + public RuntimePipelineServer(RuntimePipelineConfig config) + { + m_Config = config; + } + + protected override (int basePort, int maxPort) GetPortRange() + { + return (7900, 7949); // Runtime production (test runtime servers use 7950-7999) + } + + protected override void CreateInstanceDescriptor() + { + // Create and write instance descriptor for CLI discovery + m_InstanceDescriptor = RuntimeInstanceDescriptor.CreateCurrent(Port, m_Config); + RuntimeInstanceDescriptor.WriteToWorkingDirectory(m_InstanceDescriptor); + } + + protected override void DeleteInstanceDescriptor() + { + // Clean up instance descriptor file + if (m_InstanceDescriptor != null) + { + RuntimeInstanceDescriptor.RemoveFromWorkingDirectory(); + } + m_InstanceDescriptor = null; + } + + protected override void UpdateHeartBeat() + { + // Update heartbeat in instance descriptor + if (m_InstanceDescriptor != null) + { + m_InstanceDescriptor.LastHeartbeat = DateTime.UtcNow; + try + { + RuntimeInstanceDescriptor.WriteToWorkingDirectory(m_InstanceDescriptor); + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to update instance descriptor: {ex.Message}"); + } + } + } + + protected override object GetServerStatus() + { + UpdateHeartBeat(); + return new + { + status = m_InstanceDescriptor == null ? "error" : "ready", + lastHeartbeat = m_InstanceDescriptor?.LastHeartbeat + }; + } + + protected override string GetToken() + { + return m_InstanceDescriptor.EvalToken; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineServer.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineServer.cs.meta new file mode 100644 index 00000000..5afc6dc1 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/PlayerSupport/RuntimePipelineServer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4599450d651dea74cb4b90289360aa3e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins.meta new file mode 100644 index 00000000..40fe832b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a25774b4dfb214b4f8c39d802c589d7d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis.meta new file mode 100644 index 00000000..95f2154d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 014d6925adce7c84bb3742e7557d8581 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/CHECKSUMS b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/CHECKSUMS new file mode 100644 index 00000000..007eb480 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/CHECKSUMS @@ -0,0 +1,13 @@ +# SHA-256 integrity manifest for the bundled Roslyn (Microsoft.CodeAnalysis) DLLs. +# Verified at build time by PipelineRuntimeBuildProcessor.VerifyBundledChecksums(); +# a mismatch (tampered/swapped DLL) fails the build. +# +# Format per line: # +# Regenerate hashes with: sha256sum *.dll (run in this folder) +# Version fields marked TODO should be set to the exact NuGet package version the DLL came from. + +159f595e313f8cc09bac14588e2466c312f512a4778d9fa2352fcccf2a53aa3d Microsoft.CodeAnalysis.dll # Microsoft.CodeAnalysis.Common TODO +e822156145e1add437b1d893c0315257c12e3d5b1b89d83494db6d88d37fe20a Microsoft.CodeAnalysis.CSharp.dll # Microsoft.CodeAnalysis.CSharp TODO +5b1b1c83ba3d135c2fdfe425842fbe9c7432878b7e468623acb554c69b4c130f System.Collections.Immutable.dll # System.Collections.Immutable TODO +55ef768ffa305de58e38e15420669258b16f420618eb1f702bddb30e5c797961 System.Reflection.Metadata.dll # System.Reflection.Metadata TODO +bac58578ec05ecb8c234bf3402a0aa1491a5e437b03008b431e78d663ab52b78 System.Runtime.CompilerServices.Unsafe.dll # System.Runtime.CompilerServices.Unsafe TODO diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/CHECKSUMS.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/CHECKSUMS.meta new file mode 100644 index 00000000..9ac33302 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/CHECKSUMS.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b61d5dbe422f84644aa94e10778b9e94 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/Microsoft.CodeAnalysis.CSharp.dll b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/Microsoft.CodeAnalysis.CSharp.dll new file mode 100644 index 00000000..f98e395d Binary files /dev/null and b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/Microsoft.CodeAnalysis.CSharp.dll differ diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/Microsoft.CodeAnalysis.CSharp.dll.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/Microsoft.CodeAnalysis.CSharp.dll.meta new file mode 100644 index 00000000..39e931e4 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/Microsoft.CodeAnalysis.CSharp.dll.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: b5a1f47d969a4054bbbf0bc7cc231ee5 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/Microsoft.CodeAnalysis.dll b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/Microsoft.CodeAnalysis.dll new file mode 100644 index 00000000..c69fa370 Binary files /dev/null and b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/Microsoft.CodeAnalysis.dll differ diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/Microsoft.CodeAnalysis.dll.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/Microsoft.CodeAnalysis.dll.meta new file mode 100644 index 00000000..b243e915 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/Microsoft.CodeAnalysis.dll.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: ff3508870465d8e4b83e5a2c71492756 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Collections.Immutable.dll b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Collections.Immutable.dll new file mode 100644 index 00000000..30822887 Binary files /dev/null and b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Collections.Immutable.dll differ diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Collections.Immutable.dll.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Collections.Immutable.dll.meta new file mode 100644 index 00000000..0e88c7ca --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Collections.Immutable.dll.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: bedf8ea7477e57d46afe86233f846897 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Reflection.Metadata.dll b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Reflection.Metadata.dll new file mode 100644 index 00000000..4e0d053a Binary files /dev/null and b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Reflection.Metadata.dll differ diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Reflection.Metadata.dll.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Reflection.Metadata.dll.meta new file mode 100644 index 00000000..8d5bee78 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Reflection.Metadata.dll.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: 7e46a81867cf8f24a859c42b98fe7138 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Runtime.CompilerServices.Unsafe.dll b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Runtime.CompilerServices.Unsafe.dll new file mode 100644 index 00000000..23a1be25 Binary files /dev/null and b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Runtime.CompilerServices.Unsafe.dll differ diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Runtime.CompilerServices.Unsafe.dll.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Runtime.CompilerServices.Unsafe.dll.meta new file mode 100644 index 00000000..979dc1fb --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Plugins/CodeAnalysis/System.Runtime.CompilerServices.Unsafe.dll.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: 99bd0ed185b59ef4fabfd6fe42ac2d19 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/SecurityTokenManager.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/SecurityTokenManager.cs new file mode 100644 index 00000000..38807a5f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/SecurityTokenManager.cs @@ -0,0 +1,139 @@ +using System; +using System.Security.Cryptography; +using System.Threading; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace Unity.Pipeline.Security +{ + /// + /// Manages the security token used to authorize Pipeline server requests. In the Editor the token + /// is persisted in SessionState so it survives domain reloads (but dies with the editor process), + /// keeping long-lived clients (MCP/IDE) authenticated instead of hitting 401 after every reload. + /// Player builds use a per-process in-memory token. Published to the instance descriptor (port + /// file) for CLI discovery; never written to a separate file. + /// + public static class SecurityTokenManager + { + // In-editor persistence key. SessionState is session-scoped editor state (wiped on editor exit). + private const string SessionStateKey = "Unity.Pipeline.SecurityToken"; + + // Fast in-memory cache, warmed on the main thread at server start (see BasePipelineServer.Start) + // so background auth reads a populated static instead of touching SessionState off-thread. + private static string s_CachedToken; + + // Guards the check-and-set so two threads can't race to generate a token on first access. + private static readonly object s_Lock = new object(); + + // Thread that first touches this class (the main thread, since the server warms on it at start). + // SessionState is main-thread-only, so it is only touched when running on this thread. + private static readonly int s_MainThreadId = Thread.CurrentThread.ManagedThreadId; + + /// + /// Get or create the session's security token. In the Editor it is persisted in SessionState so + /// it survives domain reloads (regenerated only on editor restart or via + /// / ); player builds generate it once per process. + /// + public static string GetOrCreateToken() + { + // Fast path: plain read of the warmed cache — no lock, no SessionState. + var cached = s_CachedToken; + if (!string.IsNullOrEmpty(cached)) + return cached; + + lock (s_Lock) + { + // Re-check: another thread may have generated the token while we waited on the lock; + // regenerating here would overwrite (and invalidate) the token it just handed out. + if (!string.IsNullOrEmpty(s_CachedToken)) + return s_CachedToken; + +#if UNITY_EDITOR + // Main thread only: safe to touch SessionState (the warm path). + if (Thread.CurrentThread.ManagedThreadId == s_MainThreadId) + { + // Rehydrate a token from earlier this session (survives domain reloads). + var persisted = SessionState.GetString(SessionStateKey, string.Empty); + var mainToken = string.IsNullOrEmpty(persisted) ? GenerateSecureToken() : persisted; + if (string.IsNullOrEmpty(persisted)) + SessionState.SetString(SessionStateKey, mainToken); + // Publish last so a fast-path reader sees either null (and locks) or the persisted token. + s_CachedToken = mainToken; + return s_CachedToken; + } +#endif + + // Player builds; or Editor off the main thread with a cold cache, where we generate + // in-memory rather than touch SessionState off-thread (which would throw/500). + s_CachedToken = GenerateSecureToken(); + return s_CachedToken; + } + } + + /// + /// Compare two tokens in length-independent constant time to avoid leaking the expected + /// token through comparison timing. + /// + public static bool ConstantTimeEquals(string a, string b) + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) + return false; + + var diff = a.Length ^ b.Length; + for (int i = 0; i < a.Length && i < b.Length; i++) + diff |= a[i] ^ b[i]; + + return diff == 0; + } + + /// + /// Generate a cryptographically secure token. + /// + private static string GenerateSecureToken() + { + using (var rng = new RNGCryptoServiceProvider()) + { + var bytes = new byte[32]; // 256 bits + rng.GetBytes(bytes); + return Convert.ToBase64String(bytes); + } + } + + /// + /// Clear the cached token (and the persisted SessionState copy) so the next call generates a + /// fresh one. Used by tests and by . Call on the main thread (touches + /// SessionState). + /// + public static void ClearCache() + { + lock (s_Lock) + { + s_CachedToken = null; +#if UNITY_EDITOR + SessionState.EraseString(SessionStateKey); +#endif + } + } + + /// + /// Force a new token and return it. Old-token revocation is immediate for request auth + /// (GetToken validates live); the port file refreshes on the next heartbeat. Call on the main + /// thread; a command exposing this must be MainThreadRequired and republish the descriptor. + /// + public static string RotateToken() + { + ClearCache(); + return GetOrCreateToken(); + } + + /// + /// Test-only: drop the in-memory cache but keep SessionState, to simulate a domain reload + /// (statics cleared, SessionState survives). + /// + internal static void ResetInMemoryCacheForTests() + { + s_CachedToken = null; + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/SecurityTokenManager.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/SecurityTokenManager.cs.meta new file mode 100644 index 00000000..cbd37aa3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/SecurityTokenManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9d47c8a5848e4b543936670e8482ff69 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Unity.Pipeline.asmdef b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Unity.Pipeline.asmdef new file mode 100644 index 00000000..c5b1a79c --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Unity.Pipeline.asmdef @@ -0,0 +1,29 @@ +{ + "name": "Unity.Pipeline", + "rootNamespace": "Unity.Pipeline", + "references": [ + "Unity.InputSystem" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "Microsoft.CodeAnalysis.dll", + "Microsoft.CodeAnalysis.CSharp.dll", + "System.Collections.Immutable.dll", + "System.Runtime.CompilerServices.Unsafe.dll", + "System.Reflection.Metadata.dll", + "Newtonsoft.Json.dll" + ], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [ + { + "name": "com.unity.modules.unitywebrequest", + "expression": "1.0.0", + "define": "UNITY_2022_3_OR_NEWER" + } + ], + "noEngineReferences": false +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Unity.Pipeline.asmdef.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Unity.Pipeline.asmdef.meta new file mode 100644 index 00000000..023d6c21 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Unity.Pipeline.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 71e9815631ba2574b92564e93b8efc9c +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload.meta new file mode 100644 index 00000000..370c8e56 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7a0ab883fda51549426c60701bd05576 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_InPlace.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_InPlace.meta new file mode 100644 index 00000000..0846c6d6 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_InPlace.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4f40406811f5efe4eb95440aebf17388 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_InPlace/HotReload_InPlace.unity b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_InPlace/HotReload_InPlace.unity new file mode 100644 index 00000000..fa96981d --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_InPlace/HotReload_InPlace.unity @@ -0,0 +1,502 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {fileID: 0} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 10 + m_Fog: 0 + m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1} + m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1} + m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1} + m_AmbientIntensity: 1 + m_AmbientMode: 0 + m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1} + m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {fileID: 0} + m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {fileID: 0} + m_Sun: {fileID: 0} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 13 + m_BakeOnSceneLoad: 0 + m_GISettings: + serializedVersion: 2 + m_BounceScale: 1 + m_IndirectOutputScale: 1 + m_AlbedoBoost: 1 + m_EnvironmentLightingMode: 0 + m_EnableBakedLightmaps: 1 + m_EnableRealtimeLightmaps: 0 + m_LightmapEditorSettings: + serializedVersion: 12 + m_Resolution: 2 + m_BakeResolution: 40 + m_AtlasSize: 1024 + m_AO: 0 + m_AOMaxDistance: 1 + m_CompAOExponent: 1 + m_CompAOExponentDirect: 0 + m_ExtractAmbientOcclusion: 0 + m_Padding: 2 + m_LightmapParameters: {fileID: 0} + m_LightmapsBakeMode: 1 + m_TextureCompression: 1 + m_ReflectionCompression: 2 + m_MixedBakeMode: 2 + m_BakeBackend: 2 + m_PVRSampling: 1 + m_PVRDirectSampleCount: 32 + m_PVRSampleCount: 512 + m_PVRBounces: 2 + m_PVREnvironmentSampleCount: 256 + m_PVREnvironmentReferencePointCount: 2048 + m_PVRFilteringMode: 1 + m_PVRDenoiserTypeDirect: 1 + m_PVRDenoiserTypeIndirect: 1 + m_PVRDenoiserTypeAO: 1 + m_PVRFilterTypeDirect: 0 + m_PVRFilterTypeIndirect: 0 + m_PVRFilterTypeAO: 0 + m_PVREnvironmentMIS: 1 + m_PVRCulling: 1 + m_PVRFilteringGaussRadiusDirect: 1 + m_PVRFilteringGaussRadiusIndirect: 1 + m_PVRFilteringGaussRadiusAO: 1 + m_PVRFilteringAtrousPositionSigmaDirect: 0.5 + m_PVRFilteringAtrousPositionSigmaIndirect: 2 + m_PVRFilteringAtrousPositionSigmaAO: 1 + m_ExportTrainingData: 0 + m_TrainingDataDestination: TrainingData + m_LightProbeSampleCountMultiplier: 4 + m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0} + m_LightingSettings: {fileID: 0} +--- !u!196 &4 +NavMeshSettings: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_BuildSettings: + serializedVersion: 3 + agentTypeID: 0 + agentRadius: 0.5 + agentHeight: 2 + agentSlope: 45 + agentClimb: 0.4 + ledgeDropHeight: 0 + maxJumpAcrossDistance: 0 + minRegionArea: 2 + manualCellSize: 0 + cellSize: 0.16666667 + manualTileSize: 0 + tileSize: 256 + buildHeightMesh: 0 + maxJobWorkers: 0 + preserveTilesOutsideBounds: 0 + debug: + m_Flags: 0 + m_NavMeshData: {fileID: 0} +--- !u!1 &701989028 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 701989030} + - component: {fileID: 701989029} + m_Layer: 0 + m_Name: Directional Light + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!108 &701989029 +Light: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 701989028} + m_Enabled: 1 + serializedVersion: 13 + m_Type: 1 + m_Color: {r: 1, g: 0.95686275, b: 0.8392157, a: 1} + m_Intensity: 1 + m_Range: 10 + m_SpotAngle: 30 + m_InnerSpotAngle: 21.80208 + m_CookieSize2D: {x: 0.5, y: 0.5} + m_Shadows: + m_Type: 2 + m_Resolution: -1 + m_CustomResolution: -1 + m_Strength: 1 + m_Bias: 0.05 + m_NormalBias: 0.4 + m_NearPlane: 0.2 + m_CullingMatrixOverride: + e00: 1 + e01: 0 + e02: 0 + e03: 0 + e10: 0 + e11: 1 + e12: 0 + e13: 0 + e20: 0 + e21: 0 + e22: 1 + e23: 0 + e30: 0 + e31: 0 + e32: 0 + e33: 1 + m_UseCullingMatrixOverride: 0 + m_Cookie: {fileID: 0} + m_DrawHalo: 0 + m_Flare: {fileID: 0} + m_RenderMode: 0 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingLayerMask: 1 + m_Lightmapping: 4 + m_LightShadowCasterMode: 0 + m_AreaSize: {x: 1, y: 1} + m_BounceIntensity: 1 + m_ColorTemperature: 6570 + m_UseColorTemperature: 0 + m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0} + m_UseBoundingSphereOverride: 0 + m_UseViewFrustumForShadowCasterCull: 1 + m_ForceVisible: 0 + m_ShapeRadius: 0.025 + m_ShadowAngle: 0 + m_LightUnit: 1 + m_LuxAtDistance: 1 + m_EnableSpotReflector: 1 +--- !u!4 &701989030 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 701989028} + serializedVersion: 2 + m_LocalRotation: {x: 0.40821788, y: -0.23456968, z: 0.10938163, w: 0.8754261} + m_LocalPosition: {x: 0, y: 3, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 50, y: -30, z: 0} +--- !u!1 &890368703 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 890368707} + - component: {fileID: 890368706} + - component: {fileID: 890368705} + - component: {fileID: 890368704} + - component: {fileID: 890368708} + m_Layer: 0 + m_Name: Player + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!65 &890368704 +BoxCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 890368703} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 3 + m_Size: {x: 1, y: 1, z: 1} + m_Center: {x: 0, y: 0, z: 0} +--- !u!23 &890368705 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 890368703} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10303, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!33 &890368706 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 890368703} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!4 &890368707 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 890368703} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0.5, y: 0.35, z: 0.5} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &890368708 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 890368703} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f2e2dbbd2e928074d83ba49a57cb9cfa, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.Pipeline.Samples::Unity.Pipeline.Samples.HotReload.InPlaceHotReloadExample + rotationSpeed: 90 + enableRotation: 1 + rotationAxis: {x: 0, y: 1, z: 0} + moveSpeed: 2 + enableMovement: 1 +--- !u!1 &1185268903 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1185268906} + - component: {fileID: 1185268905} + - component: {fileID: 1185268904} + m_Layer: 0 + m_Name: Main Camera + m_TagString: MainCamera + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!81 &1185268904 +AudioListener: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1185268903} + m_Enabled: 1 +--- !u!20 &1185268905 +Camera: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1185268903} + m_Enabled: 1 + serializedVersion: 2 + m_ClearFlags: 1 + m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0} + m_projectionMatrixMode: 1 + m_GateFitMode: 2 + m_FOVAxisMode: 0 + m_Iso: 200 + m_ShutterSpeed: 0.005 + m_Aperture: 16 + m_FocusDistance: 10 + m_FocalLength: 50 + m_BladeCount: 5 + m_Curvature: {x: 2, y: 11} + m_BarrelClipping: 0.25 + m_Anamorphism: 0 + m_SensorSize: {x: 36, y: 24} + m_LensShift: {x: 0, y: 0} + m_NormalizedViewPortRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 1 + height: 1 + near clip plane: 0.3 + far clip plane: 1000 + field of view: 60 + orthographic: 0 + orthographic size: 5 + m_Depth: -1 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingPath: -1 + m_TargetTexture: {fileID: 0} + m_TargetDisplay: 0 + m_TargetEye: 3 + m_HDR: 1 + m_AllowMSAA: 1 + m_AllowDynamicResolution: 0 + m_ForceIntoRT: 0 + m_OcclusionCulling: 1 + m_StereoConvergence: 10 + m_StereoSeparation: 0.022 +--- !u!4 &1185268906 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1185268903} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 1, z: -10} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1623457542 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1623457543} + - component: {fileID: 1623457544} + m_Layer: 0 + m_Name: PipelineServer + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1623457543 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1623457542} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: -0, y: 0, z: -0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &1623457544 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1623457542} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: dd4ab1c9091e0194196b5f5d51cb932f, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.Pipeline::Unity.Pipeline.RuntimePipelineManager + enableInBuilds: 1 + port: 0 + securityLevel: 0 + customToken: + ipAllowlist: + - 127.0.0.1 + requestTimeoutMs: 30000 + enableAuditLogging: 1 + autoStart: 1 + maxWorkItemsPerFrame: 10 +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 1185268906} + - {fileID: 701989030} + - {fileID: 1623457543} + - {fileID: 890368707} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_InPlace/HotReload_InPlace.unity.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_InPlace/HotReload_InPlace.unity.meta new file mode 100644 index 00000000..04461877 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_InPlace/HotReload_InPlace.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1a4fb3f52f24e174d9e515c8b77ceb71 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_InPlace/InPlaceHotReloadExample.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_InPlace/InPlaceHotReloadExample.cs new file mode 100644 index 00000000..12547118 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_InPlace/InPlaceHotReloadExample.cs @@ -0,0 +1,83 @@ +using UnityEngine; +using Unity.Pipeline.HotReload; + +namespace Unity.Pipeline.Samples.HotReload +{ + /// + /// Example demonstrating in-place hot reload editing workflow. + /// + /// Usage: + /// 1. Add this component to a GameObject in the scene + /// 2. Play the scene - you'll see the object rotating + /// 3. Modify the Update() method below (try changing Vector3.up to Vector3.forward) + /// 4. Run: reload_file Assets/Samples/HotReload_InPlace/InPlaceHotReloadExample.cs + /// 5. See the changes take effect immediately without stopping play mode! + /// + /// Requirements: + /// - Only use PUBLIC fields, properties, and methods + /// - Private/internal members will cause validation errors + /// + public class InPlaceHotReloadExample : MonoBehaviour + { + [Header("Hot Reload Configuration")] + public float rotationSpeed = 90f; + public bool enableRotation = true; + public Vector3 rotationAxis = Vector3.up; + + [Header("Movement Configuration")] + public float moveSpeed = 2f; + public bool enableMovement = false; + + [HotReload] + void Update() + { + // Edit this body live, then run: reload_file + // Try changing rotationAxis (e.g. Vector3.up -> Vector3.forward) or the speed. + if (enableRotation) + { + transform.Rotate(rotationAxis, rotationSpeed * Time.deltaTime); + } + + if (enableMovement) + { + var movement = Mathf.Sin(Time.time) * moveSpeed; + transform.position = new Vector3(movement, transform.position.y, transform.position.z); + } + } + + [HotReload] + public void ResetTransform() + { + // Another method you can hot reload + transform.position = Vector3.zero; + transform.rotation = Quaternion.identity; + Debug.Log("Transform reset via hot reload!"); + } + + // Note: value-returning methods are not yet supported by in-place reload (void methods only), + // so this one is a normal method. + public float CalculateDistance(Vector3 targetPosition) + { + return Vector3.Distance(transform.position, targetPosition); + } + + void Awake() + { + // Run the sample in a 640x480 window instead of fullscreen. + Screen.SetResolution(640, 480, FullScreenMode.Windowed); + } + + // Regular methods (not hot reloadable) - these work normally + void Start() + { + Debug.Log($"InPlaceHotReloadExample started. Try hot reloading the Update method!"); + Debug.Log($"Command: reload_file {GetType().Name}.cs"); + } + + public void TriggerResetFromUI() + { + // This calls the hot reloadable method + ResetTransform(); + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_InPlace/InPlaceHotReloadExample.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_InPlace/InPlaceHotReloadExample.cs.meta new file mode 100644 index 00000000..8db378d3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_InPlace/InPlaceHotReloadExample.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f2e2dbbd2e928074d83ba49a57cb9cfa \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers.meta new file mode 100644 index 00000000..e40b5fb8 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 683c08de2e0fa9c4e8042353b200ce2b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/BossController.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/BossController.cs new file mode 100644 index 00000000..246406b3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/BossController.cs @@ -0,0 +1,25 @@ +using Unity.Pipeline.HotReload; +using UnityEngine; + +public class BossController : MonoBehaviour +{ + void Awake() + { + // Run the sample in a 640x480 window instead of fullscreen. + Screen.SetResolution(640, 480, FullScreenMode.Windowed); + } + + // No manual registration needed: RuntimePipelineManager auto-discovers [HotReloadWithOverrides] methods. + + [HotReloadWithOverrides] + void Update() + { + HotReloadHelper.ExecuteWithHotReload(this, "Update", OriginalUpdate); + } + + public void OriginalUpdate() + { + transform.Rotate(45 * Time.deltaTime, 0, 0); + } +} + diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/BossController.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/BossController.cs.meta new file mode 100644 index 00000000..9360e874 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/BossController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: eedf5d75fab7b1e408a621dd7c0ab911 \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/BossOverrides.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/BossOverrides.cs new file mode 100644 index 00000000..f24a12b0 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/BossOverrides.cs @@ -0,0 +1,23 @@ +using Unity.Pipeline.HotReload; +using UnityEngine; + +/// +/// Hot reload overrides for (helper workflow). +/// +/// The override lives in its OWN file and does NOT redeclare BossController, so it binds to the +/// running type. Edit the tweaked logic here, then apply it without leaving play mode: +/// unity command reload_file_override "(absolute path)/Samples/HotReload_WithHelpers/BossOverrides.cs" +/// +public static class BossOverrides +{ + [HotReloadOverrideMethod("BossController.Update")] + public static void TweakedUpdate(BossController instance) + { + // Tweaked behaviour: add a color pulse for visual feedback. + var renderer = instance.GetComponent(); + if (renderer != null) + { + renderer.material.color = Color.Lerp(Color.red, Color.yellow, Mathf.Sin(Time.time * 2f) * 0.5f + 0.5f); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/BossOverrides.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/BossOverrides.cs.meta new file mode 100644 index 00000000..1bc35f76 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/BossOverrides.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 778e3a9bb56e4b5c8576ce8fa9460763 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/HotReloadTestManager.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/HotReloadTestManager.cs new file mode 100644 index 00000000..83b2f71a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/HotReloadTestManager.cs @@ -0,0 +1,206 @@ +using System.Reflection; +using Unity.Pipeline.HotReload; +using Unity.Pipeline.Samples.HotReload; +using UnityEngine; + +namespace Unity.Pipeline.Samples.HotReload +{ + /// + /// Test manager for manually testing hot reload functionality in Unity Editor. + /// Add this to a GameObject and use the inspector buttons or call methods from console. + /// + public class HotReloadTestManager : MonoBehaviour + { + [Header("Test References")] + public TestPlayerController testPlayer; + + [Header("Test Controls")] + [SerializeField] private bool autoFindPlayer = true; + [SerializeField] private bool showDebugLogs = true; + + void Start() + { + if (autoFindPlayer && testPlayer == null) + { + testPlayer = FindAnyObjectByType(); + } + + if (showDebugLogs) + { + Debug.Log("=== HOT RELOAD TEST MANAGER ==="); + Debug.Log("Use the buttons in the inspector or call these methods from console:"); + Debug.Log("- RegisterTestMethods() - Register player methods for hot reload"); + Debug.Log("- SimulateHotReloadOverride() - Apply hot reload overrides manually"); + Debug.Log("- ClearHotReload() - Remove hot reload overrides"); + Debug.Log("- ShowRegistryStats() - Display current hot reload state"); + Debug.Log("- TestPlayerActions() - Test player methods"); + } + } + + [ContextMenu("Register Test Methods")] + public void RegisterTestMethods() + { + if (testPlayer == null) + { + Debug.LogError("No TestPlayerController assigned!"); + return; + } + + // TestPlayerController.RegisterOverrides(); + Debug.Log("✅ Registered TestPlayerController methods for hot reload"); + ShowRegistryStats(); + } + + [ContextMenu("Simulate Hot Reload Override")] + public void SimulateHotReloadOverride() + { + // Manually register the override methods from TestPlayerOverrides + var overrideType = typeof(TestPlayerOverrides); + + var updateOverride = overrideType.GetMethod("Update"); + var moveOverride = overrideType.GetMethod("Move"); + var damageOverride = overrideType.GetMethod("CalculateDamage"); + var takeDamageOverride = overrideType.GetMethod("TakeDamage"); + + if (updateOverride != null) + { + HotReloadRegistry.RegisterMethodOverride(updateOverride, + new HotReloadOverrideMethodAttribute("TestPlayerController.Update"), overrideType); + } + + if (moveOverride != null) + { + HotReloadRegistry.RegisterMethodOverride(moveOverride, + new HotReloadOverrideMethodAttribute("TestPlayerController.Move"), overrideType); + } + + if (damageOverride != null) + { + HotReloadRegistry.RegisterMethodOverride(damageOverride, + new HotReloadOverrideMethodAttribute("PlayerCombat"), overrideType); + } + + if (takeDamageOverride != null) + { + HotReloadRegistry.RegisterMethodOverride(takeDamageOverride, + new HotReloadOverrideMethodAttribute("TestPlayerController.TakeDamage"), overrideType); + } + + Debug.Log("🔥 Applied hot reload overrides! Player behavior should now be different."); + ShowRegistryStats(); + + if (testPlayer != null) + { + testPlayer.LogHotReloadStatus(); + } + } + + [ContextMenu("Clear Hot Reload")] + public void ClearHotReload() + { + HotReloadRegistry.ClearAllOverrides(); + Debug.Log("🧹 Cleared hot reload overrides. Player should return to original behavior."); + ShowRegistryStats(); + } + + [ContextMenu("Show Registry Stats")] + public void ShowRegistryStats() + { + var stats = HotReloadRegistry.GetStats(); + Debug.Log($"📊 Hot Reload Stats:"); + Debug.Log($" Reloadable Methods: {stats.ReloadableMethodCount}"); + Debug.Log($" Active Overrides: {stats.ActiveOverrideCount}"); + Debug.Log($" Loaded Types: {stats.LoadedTypeCount}"); + + if (stats.ReloadableMethodIds.Count > 0) + { + Debug.Log($" Reloadable Method IDs: {string.Join(", ", stats.ReloadableMethodIds)}"); + } + + if (stats.ActiveOverrideIds.Count > 0) + { + Debug.Log($" Active Override IDs: {string.Join(", ", stats.ActiveOverrideIds)}"); + } + } + + [ContextMenu("Test Player Actions")] + public void TestPlayerActions() + { + if (testPlayer == null) + { + Debug.LogError("No TestPlayerController assigned!"); + return; + } + + Debug.Log("🎮 Testing player actions..."); + + testPlayer.TestMovement(); + testPlayer.TestDamage(); + testPlayer.LogHotReloadStatus(); + + Debug.Log($" Last Action: {testPlayer.lastActionLog}"); + Debug.Log($" Health: {testPlayer.health}"); + Debug.Log($" Last Movement: {testPlayer.lastMovement}"); + } + + [ContextMenu("Reset Test Environment")] + public void ResetTestEnvironment() + { + HotReloadRegistry.ClearAllForTesting(); + + if (testPlayer != null) + { + testPlayer.health = 100; + testPlayer.lastActionLog = "Environment reset"; + testPlayer.lastMovement = Vector3.zero; + testPlayer.lastDamageCalculated = 0; + } + + Debug.Log("🔄 Reset test environment to clean state"); + } + + // Keyboard shortcuts for quick testing + void Update() + { + if (Input.GetKeyDown(KeyCode.F1)) + { + RegisterTestMethods(); + } + else if (Input.GetKeyDown(KeyCode.F2)) + { + SimulateHotReloadOverride(); + } + else if (Input.GetKeyDown(KeyCode.F3)) + { + ClearHotReload(); + } + else if (Input.GetKeyDown(KeyCode.F4)) + { + TestPlayerActions(); + } + else if (Input.GetKeyDown(KeyCode.F5)) + { + ShowRegistryStats(); + } + } + + void OnGUI() + { + GUILayout.BeginArea(new Rect(10, 10, 300, 200)); + GUILayout.Label("Hot Reload Testing (F1-F5):"); + GUILayout.Label("F1: Register Methods"); + GUILayout.Label("F2: Apply Hot Reload"); + GUILayout.Label("F3: Clear Hot Reload"); + GUILayout.Label("F4: Test Actions"); + GUILayout.Label("F5: Show Stats"); + + if (testPlayer != null) + { + GUILayout.Space(10); + GUILayout.Label($"Status: {testPlayer.lastActionLog}"); + GUILayout.Label($"Health: {testPlayer.health}"); + } + GUILayout.EndArea(); + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/HotReloadTestManager.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/HotReloadTestManager.cs.meta new file mode 100644 index 00000000..e9be180a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/HotReloadTestManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7ab9d64d0dbf76644abf31ebe245bedb \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/HotReload_WithHelpers.unity b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/HotReload_WithHelpers.unity new file mode 100644 index 00000000..64b47610 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/HotReload_WithHelpers.unity @@ -0,0 +1,631 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {fileID: 0} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 10 + m_Fog: 0 + m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1} + m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1} + m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1} + m_AmbientIntensity: 1 + m_AmbientMode: 0 + m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1} + m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {fileID: 0} + m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {fileID: 0} + m_Sun: {fileID: 0} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 13 + m_BakeOnSceneLoad: 0 + m_GISettings: + serializedVersion: 2 + m_BounceScale: 1 + m_IndirectOutputScale: 1 + m_AlbedoBoost: 1 + m_EnvironmentLightingMode: 0 + m_EnableBakedLightmaps: 1 + m_EnableRealtimeLightmaps: 0 + m_LightmapEditorSettings: + serializedVersion: 12 + m_Resolution: 2 + m_BakeResolution: 40 + m_AtlasSize: 1024 + m_AO: 0 + m_AOMaxDistance: 1 + m_CompAOExponent: 1 + m_CompAOExponentDirect: 0 + m_ExtractAmbientOcclusion: 0 + m_Padding: 2 + m_LightmapParameters: {fileID: 0} + m_LightmapsBakeMode: 1 + m_TextureCompression: 1 + m_ReflectionCompression: 2 + m_MixedBakeMode: 2 + m_BakeBackend: 2 + m_PVRSampling: 1 + m_PVRDirectSampleCount: 32 + m_PVRSampleCount: 512 + m_PVRBounces: 2 + m_PVREnvironmentSampleCount: 256 + m_PVREnvironmentReferencePointCount: 2048 + m_PVRFilteringMode: 1 + m_PVRDenoiserTypeDirect: 1 + m_PVRDenoiserTypeIndirect: 1 + m_PVRDenoiserTypeAO: 1 + m_PVRFilterTypeDirect: 0 + m_PVRFilterTypeIndirect: 0 + m_PVRFilterTypeAO: 0 + m_PVREnvironmentMIS: 1 + m_PVRCulling: 1 + m_PVRFilteringGaussRadiusDirect: 1 + m_PVRFilteringGaussRadiusIndirect: 1 + m_PVRFilteringGaussRadiusAO: 1 + m_PVRFilteringAtrousPositionSigmaDirect: 0.5 + m_PVRFilteringAtrousPositionSigmaIndirect: 2 + m_PVRFilteringAtrousPositionSigmaAO: 1 + m_ExportTrainingData: 0 + m_TrainingDataDestination: TrainingData + m_LightProbeSampleCountMultiplier: 4 + m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0} + m_LightingSettings: {fileID: 0} +--- !u!196 &4 +NavMeshSettings: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_BuildSettings: + serializedVersion: 3 + agentTypeID: 0 + agentRadius: 0.5 + agentHeight: 2 + agentSlope: 45 + agentClimb: 0.4 + ledgeDropHeight: 0 + maxJumpAcrossDistance: 0 + minRegionArea: 2 + manualCellSize: 0 + cellSize: 0.16666667 + manualTileSize: 0 + tileSize: 256 + buildHeightMesh: 0 + maxJobWorkers: 0 + preserveTilesOutsideBounds: 0 + debug: + m_Flags: 0 + m_NavMeshData: {fileID: 0} +--- !u!1 &434715460 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 434715462} + - component: {fileID: 434715461} + m_Layer: 0 + m_Name: Directional Light + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!108 &434715461 +Light: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 434715460} + m_Enabled: 1 + serializedVersion: 12 + m_Type: 1 + m_Color: {r: 1, g: 0.95686275, b: 0.8392157, a: 1} + m_Intensity: 1 + m_Range: 10 + m_SpotAngle: 30 + m_InnerSpotAngle: 21.80208 + m_CookieSize2D: {x: 0.5, y: 0.5} + m_Shadows: + m_Type: 2 + m_Resolution: -1 + m_CustomResolution: -1 + m_Strength: 1 + m_Bias: 0.05 + m_NormalBias: 0.4 + m_NearPlane: 0.2 + m_CullingMatrixOverride: + e00: 1 + e01: 0 + e02: 0 + e03: 0 + e10: 0 + e11: 1 + e12: 0 + e13: 0 + e20: 0 + e21: 0 + e22: 1 + e23: 0 + e30: 0 + e31: 0 + e32: 0 + e33: 1 + m_UseCullingMatrixOverride: 0 + m_Cookie: {fileID: 0} + m_DrawHalo: 0 + m_Flare: {fileID: 0} + m_RenderMode: 0 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingLayerMask: 1 + m_Lightmapping: 4 + m_LightShadowCasterMode: 0 + m_AreaSize: {x: 1, y: 1} + m_BounceIntensity: 1 + m_ColorTemperature: 6570 + m_UseColorTemperature: 0 + m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0} + m_UseBoundingSphereOverride: 0 + m_UseViewFrustumForShadowCasterCull: 1 + m_ForceVisible: 0 + m_ShadowRadius: 0 + m_ShadowAngle: 0 + m_LightUnit: 1 + m_LuxAtDistance: 1 + m_EnableSpotReflector: 1 +--- !u!4 &434715462 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 434715460} + serializedVersion: 2 + m_LocalRotation: {x: 0.40821788, y: -0.23456968, z: 0.10938163, w: 0.8754261} + m_LocalPosition: {x: 0, y: 3, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 50, y: -30, z: 0} +--- !u!1 &494037149 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 494037151} + - component: {fileID: 494037150} + m_Layer: 0 + m_Name: PipelineServer + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &494037150 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 494037149} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: dd4ab1c9091e0194196b5f5d51cb932f, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.Pipeline::Unity.Pipeline.RuntimePipelineManager + enableInBuilds: 1 + port: 0 + securityLevel: 0 + customToken: + ipAllowlist: + - 127.0.0.1 + requestTimeoutMs: 30000 + enableAuditLogging: 1 + autoStart: 1 + maxWorkItemsPerFrame: 10 +--- !u!4 &494037151 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 494037149} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 1, y: 0.7, z: 1} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1321733044 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1321733049} + - component: {fileID: 1321733048} + - component: {fileID: 1321733047} + - component: {fileID: 1321733046} + - component: {fileID: 1321733045} + m_Layer: 0 + m_Name: Player + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1321733045 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1321733044} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 3dc859ce66713a54597d865487663ea2, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.Pipeline.Samples::Unity.Pipeline.Samples.HotReload.TestPlayerController + moveSpeed: 5 + debugColor: {r: 1, g: 1, b: 1, a: 1} + health: 100 + lastMovement: {x: 0, y: 0, z: 0} + lastActionLog: No action yet + lastDamageCalculated: 0 +--- !u!65 &1321733046 +BoxCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1321733044} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 3 + m_Size: {x: 1, y: 1, z: 1} + m_Center: {x: 0, y: 0, z: 0} +--- !u!23 &1321733047 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1321733044} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10303, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!33 &1321733048 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1321733044} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!4 &1321733049 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1321733044} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0.5, y: 0.35, z: 0.5} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1366089692 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1366089695} + - component: {fileID: 1366089694} + - component: {fileID: 1366089693} + m_Layer: 0 + m_Name: Main Camera + m_TagString: MainCamera + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!81 &1366089693 +AudioListener: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1366089692} + m_Enabled: 1 +--- !u!20 &1366089694 +Camera: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1366089692} + m_Enabled: 1 + serializedVersion: 2 + m_ClearFlags: 1 + m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0} + m_projectionMatrixMode: 1 + m_GateFitMode: 2 + m_FOVAxisMode: 0 + m_Iso: 200 + m_ShutterSpeed: 0.005 + m_Aperture: 16 + m_FocusDistance: 10 + m_FocalLength: 50 + m_BladeCount: 5 + m_Curvature: {x: 2, y: 11} + m_BarrelClipping: 0.25 + m_Anamorphism: 0 + m_SensorSize: {x: 36, y: 24} + m_LensShift: {x: 0, y: 0} + m_NormalizedViewPortRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 1 + height: 1 + near clip plane: 0.3 + far clip plane: 1000 + field of view: 60 + orthographic: 0 + orthographic size: 5 + m_Depth: -1 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingPath: -1 + m_TargetTexture: {fileID: 0} + m_TargetDisplay: 0 + m_TargetEye: 3 + m_HDR: 1 + m_AllowMSAA: 1 + m_AllowDynamicResolution: 0 + m_ForceIntoRT: 0 + m_OcclusionCulling: 1 + m_StereoConvergence: 10 + m_StereoSeparation: 0.022 +--- !u!4 &1366089695 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1366089692} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 1, z: -10} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1487321921 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1487321925} + - component: {fileID: 1487321924} + - component: {fileID: 1487321923} + - component: {fileID: 1487321922} + - component: {fileID: 1487321926} + m_Layer: 0 + m_Name: Boss + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!136 &1487321922 +CapsuleCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1487321921} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 2 + m_Radius: 0.5000001 + m_Height: 2 + m_Direction: 1 + m_Center: {x: 0.000000059604645, y: 0, z: -0.00000008940697} +--- !u!23 &1487321923 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1487321921} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10303, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!33 &1487321924 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1487321921} + m_Mesh: {fileID: 10206, guid: 0000000000000000e000000000000000, type: 0} +--- !u!4 &1487321925 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1487321921} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 5, y: 0.35, z: 0.5} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &1487321926 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1487321921} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: eedf5d75fab7b1e408a621dd7c0ab911, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.Pipeline.Samples::Unity.Pipeline.Samples.BossController +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 1366089695} + - {fileID: 434715462} + - {fileID: 1321733049} + - {fileID: 494037151} + - {fileID: 1487321925} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/HotReload_WithHelpers.unity.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/HotReload_WithHelpers.unity.meta new file mode 100644 index 00000000..21c92434 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/HotReload_WithHelpers.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6db5cc709033bf34db56aa9c5c081cc7 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/PlayerController.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/PlayerController.cs new file mode 100644 index 00000000..4c38ca0a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/PlayerController.cs @@ -0,0 +1,122 @@ +using System.Reflection; +using Unity.Pipeline.HotReload; +using UnityEngine; + +namespace Unity.Pipeline.Samples.HotReload +{ + /// + /// Test component to demonstrate hot reload functionality in Unity Editor. + /// Add this to a GameObject to test the hot reload workflow. + /// + public class TestPlayerController : MonoBehaviour + { + [Header("Hot Reload Test Values")] + public float moveSpeed = 5f; + public Color debugColor = Color.white; + public int health = 100; + + [Header("Runtime Values - Watch These Change")] + public Vector3 lastMovement; + public string lastActionLog = "No action yet"; + public float lastDamageCalculated; + + // No manual registration needed: RuntimePipelineManager auto-discovers [HotReloadWithOverrides] methods. + + void Start() + { + Debug.Log("TestPlayerController: Starting up - ready for hot reload testing"); + } + + [HotReloadWithOverrides] + void Update() + { + HotReloadHelper.ExecuteWithHotReload(this, "Update", OriginalUpdate); + } + + [HotReloadWithOverrides] + public void Move(Vector3 direction) + { + HotReloadHelper.ExecuteWithHotReload(this, "Move", + () => OriginalMove(direction), direction); + } + + [HotReloadWithOverrides(Id = "PlayerCombat")] + public float CalculateDamage(float baseDamage, float multiplier) + { + var result = HotReloadHelper.ExecuteWithHotReloadCustomId(this, "PlayerCombat", + () => OriginalCalculateDamage(baseDamage, multiplier), baseDamage, multiplier); + + return result != null ? (float)result : OriginalCalculateDamage(baseDamage, multiplier); + } + + [HotReloadWithOverrides] + public void TakeDamage(float damage) + { + HotReloadHelper.ExecuteWithHotReload(this, "TakeDamage", + () => OriginalTakeDamage(damage), damage); + } + + // Original implementations + private void OriginalUpdate() + { + // Simple rotation for visual feedback + transform.Rotate(0, 45 * Time.deltaTime, 0); + lastActionLog = $"Original Update: Rotating at {Time.time:F1}s"; + + // Auto-move for testing + if (Input.GetKey(KeyCode.Space)) + { + Move(Vector3.forward); + } + } + + private void OriginalMove(Vector3 direction) + { + Vector3 movement = direction * moveSpeed * Time.deltaTime; + transform.position += movement; + lastMovement = movement; + lastActionLog = $"Original Move: {movement} at speed {moveSpeed}"; + } + + private float OriginalCalculateDamage(float baseDamage, float multiplier) + { + float damage = baseDamage * multiplier; + lastDamageCalculated = damage; + lastActionLog = $"Original Damage: {baseDamage} * {multiplier} = {damage}"; + return damage; + } + + private void OriginalTakeDamage(float damage) + { + health = Mathf.Max(0, health - Mathf.RoundToInt(damage)); + lastActionLog = $"Original TakeDamage: -{damage}, health now {health}"; + } + + // Test methods you can call from other scripts or console + public void TestMovement() + { + Move(Vector3.right); + Debug.Log($"Tested movement: {lastActionLog}"); + } + + public void TestDamage() + { + float damage = CalculateDamage(10f, 1.5f); + TakeDamage(damage); + Debug.Log($"Tested damage: calculated {damage}, {lastActionLog}"); + } + + public void LogHotReloadStatus() + { + bool updateActive = HotReloadHelper.IsHotReloadActive("Update"); + bool moveActive = HotReloadHelper.IsHotReloadActive("Move"); + bool combatActive = HotReloadHelper.IsHotReloadActive("PlayerCombat"); + bool damageActive = HotReloadHelper.IsHotReloadActive("TakeDamage"); + + Debug.Log($"Hot Reload Status - Update: {updateActive}, Move: {moveActive}, Combat: {combatActive}, TakeDamage: {damageActive}"); + + var stats = HotReloadRegistry.GetStats(); + Debug.Log($"Registry Stats - Reloadable: {stats.ReloadableMethodCount}, Active: {stats.ActiveOverrideCount}, Types: {stats.LoadedTypeCount}"); + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/PlayerController.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/PlayerController.cs.meta new file mode 100644 index 00000000..6c08eda9 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/PlayerController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3dc859ce66713a54597d865487663ea2 \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/PlayerOverrides.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/PlayerOverrides.cs new file mode 100644 index 00000000..1fc4f351 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/PlayerOverrides.cs @@ -0,0 +1,87 @@ +using Unity.Pipeline.HotReload; +using UnityEngine; + +namespace Unity.Pipeline.Samples.HotReload +{ + /// + /// Hot reload overrides for TestPlayerController. + /// This file demonstrates different override patterns and can be compiled manually for testing. + /// + /// You can tweak gameplay code here. When done run: + /// ucli request --runtime UnityPipelineTests.exe reload_file_override "(Absolute Path To)\com.unity.pipeline\Samples\HotReload_WithHelpers\TestPlayerOverrides.cs" + /// + public static class TestPlayerOverrides + { + [HotReloadOverrideMethod("TestPlayerController.Update")] + public static void Update(TestPlayerController instance) + { + // HOT RELOAD VERSION: Different behavior + instance.transform.Rotate(0, -90 * Time.deltaTime, 0); // Rotate opposite direction + instance.lastActionLog = $"HOT RELOAD Update: Reverse rotation at {Time.time:F1}s"; + + // Add some color change for visual feedback + var renderer = instance.GetComponent(); + if (renderer != null) + { + renderer.material.color = Color.Lerp(Color.red, Color.yellow, Mathf.Sin(Time.time * 2f) * 0.5f + 0.5f); + } + + // Auto-move with different key + if (Input.GetKey(KeyCode.LeftShift)) + { + instance.Move(Vector3.up * 0.5f); // Move up instead of forward + } + } + + [HotReloadOverrideMethod("TestPlayerController.Move")] + public static void Move(TestPlayerController instance, Vector3 direction) + { + // HOT RELOAD VERSION: Faster, bouncy movement + Vector3 movement = direction * instance.moveSpeed * 2.5f * Time.deltaTime; // 2.5x faster + movement.y += Mathf.Sin(Time.time * 5f) * 0.02f; // Add bounce + + instance.transform.position += movement; + instance.lastMovement = movement; + instance.lastActionLog = $"HOT RELOAD Move: Bouncy {movement} at 2.5x speed"; + + // Add trail effect + Debug.DrawRay(instance.transform.position, movement * 10f, Color.cyan, 1f); + } + + [HotReloadOverrideMethod("PlayerCombat")] + public static float CalculateDamage(TestPlayerController instance, float baseDamage, float multiplier) + { + // HOT RELOAD VERSION: Different damage formula with randomness + float damage = (baseDamage + Random.Range(0f, 5f)) * multiplier * 1.2f; // +randomness, +20% bonus + damage = Mathf.Round(damage * 10f) / 10f; // Round to 1 decimal + + instance.lastDamageCalculated = damage; + instance.lastActionLog = $"HOT RELOAD Damage: ({baseDamage} + rnd) * {multiplier} * 1.2 = {damage}"; + + return damage; + } + + [HotReloadOverrideMethod("TestPlayerController.TakeDamage")] + public static void TakeDamage(TestPlayerController instance, float damage) + { + // HOT RELOAD VERSION: Damage reduction and visual feedback + float reducedDamage = damage * 0.7f; // 30% damage reduction + instance.health = Mathf.Max(0, instance.health - Mathf.RoundToInt(reducedDamage)); + instance.lastActionLog = $"HOT RELOAD TakeDamage: -{damage} reduced to -{reducedDamage}, health now {instance.health}"; + + // Visual feedback + var renderer = instance.GetComponent(); + if (renderer != null) + { + // Flash red briefly + renderer.material.color = Color.red; + } + + // Screen shake effect (simple) + if (Camera.main != null) + { + Debug.Log("*SCREEN SHAKE*"); + } + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/PlayerOverrides.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/PlayerOverrides.cs.meta new file mode 100644 index 00000000..e8d24705 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/HotReload_WithHelpers/PlayerOverrides.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 73783215b9f67c0438362983015a686f \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/Unity.Pipeline.Samples.asmdef b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/Unity.Pipeline.Samples.asmdef new file mode 100644 index 00000000..845b0fb7 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/Unity.Pipeline.Samples.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Unity.Pipeline.Samples", + "rootNamespace": "Unity.Pipeline.Samples.HotReload", + "references": [ + "Unity.Pipeline" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/Unity.Pipeline.Samples.asmdef.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/Unity.Pipeline.Samples.asmdef.meta new file mode 100644 index 00000000..abc3ce16 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Samples~/HotReload/Unity.Pipeline.Samples.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: fadfe42d23f96db47a2c41e046892acb +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests.meta new file mode 100644 index 00000000..4f2196a3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 005114723dbbd384bb5fe02db11a3b99 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor.meta new file mode 100644 index 00000000..6b4fea2a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3ffa6a16c12a1a248884139af2530848 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation.meta new file mode 100644 index 00000000..1365e8e3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: de3774b0893944d8ac6871cbcaa631ab +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/AnimationClipCommandsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/AnimationClipCommandsTests.cs new file mode 100644 index 00000000..584b4c23 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/AnimationClipCommandsTests.cs @@ -0,0 +1,211 @@ +using System.Linq; +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Editor.Commands.Animation; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Tests.Editor.Animation +{ + /// + /// Tests for CLI-214 Group A (animation clips): create clip with frame rate + loop, set/get/remove + /// float curves, the overwrite-not-duplicate behaviour, the destructive remove guard, dry_run, and + /// the sandbox guard. Assets are generated in-test under a throwaway root and cleaned up. + /// + public class AnimationClipCommandsTests + { + private const string Root = "Assets/__CLI214AnimTest"; + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(); + } + } + + private static ObjectRef PathRef(string path) => new ObjectRef { Path = path }; + + private static JArray TwoKeys() => new JArray + { + new JObject { ["time"] = 0f, ["value"] = 0f }, + new JObject { ["time"] = 1f, ["value"] = 5f } + }; + + [Test] + public void CreateAnimationClip_SetsFrameRateAndLoop() + { + var path = Root + "/Clips/Walk.anim"; + var result = AnimationClipCommands.CreateAnimationClip(path, frameRate: 30f, loop: true); + + Assert.AreEqual(path, result.AssetPath); + + var loaded = AssetDatabase.LoadAssetAtPath(path); + Assert.IsNotNull(loaded, "Clip should exist on disk"); + Assert.AreEqual(30f, loaded.frameRate, 0.001f); + + var info = AnimationClipCommands.GetAnimationClip(PathRef(path)); + Assert.AreEqual(30f, info.FrameRate, 0.001f); + Assert.IsTrue(info.Loop, "loop should be reflected by get_animation_clip"); + } + + [Test] + public void CreateAnimationClip_OverwriteWithoutConfirm_Throws() + { + var path = Root + "/Clips/Dup.anim"; + AnimationClipCommands.CreateAnimationClip(path); + + Assert.Throws(() => AnimationClipCommands.CreateAnimationClip(path)); + Assert.DoesNotThrow(() => AnimationClipCommands.CreateAnimationClip(path, confirm: true)); + } + + [Test] + public void CreateAnimationClip_OutOfProject_Throws() + { + Assert.Throws(() => AnimationClipCommands.CreateAnimationClip("../Outside.anim")); + Assert.Throws(() => AnimationClipCommands.CreateAnimationClip("Assets/../Outside.anim")); + } + + [Test] + public void CreateAnimationClip_DryRun_WritesNothing() + { + var path = Root + "/Clips/DryRun.anim"; + var result = AnimationClipCommands.CreateAnimationClip(path, dryRun: true); + + Assert.AreEqual(path, result.AssetPath); + Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "dry_run must not write a clip"); + } + + [Test] + public void SetAnimationCurve_AddsCurveWithKeys() + { + var path = Root + "/Clips/Curve.anim"; + AnimationClipCommands.CreateAnimationClip(path); + + var result = AnimationClipCommands.SetAnimationCurve( + PathRef(path), path: "", type: "Transform", property: "m_LocalPosition.x", keys: TwoKeys()); + + Assert.AreEqual(2, result.KeyCount); + Assert.AreEqual("m_LocalPosition.x", result.Binding.Property); + + var info = AnimationClipCommands.GetAnimationClip(PathRef(path), includeKeys: true); + Assert.AreEqual(1, info.Bindings.Count); + var binding = info.Bindings[0]; + Assert.AreEqual("Transform", binding.Type); + Assert.AreEqual("m_LocalPosition.x", binding.Property); + Assert.AreEqual(2, binding.KeyCount); + Assert.IsNotNull(binding.Keys); + Assert.AreEqual(2, binding.Keys.Count); + Assert.AreEqual(0f, binding.Keys[0].Value, 0.001f); + Assert.AreEqual(5f, binding.Keys[1].Value, 0.001f); + } + + [Test] + public void SetAnimationCurve_ReplaceExisting_Overwrites() + { + var path = Root + "/Clips/Replace.anim"; + AnimationClipCommands.CreateAnimationClip(path); + + AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", TwoKeys()); + + var threeKeys = new JArray + { + new JObject { ["time"] = 0f, ["value"] = 0f }, + new JObject { ["time"] = 0.5f, ["value"] = 2f }, + new JObject { ["time"] = 1f, ["value"] = 4f } + }; + var result = AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", threeKeys); + Assert.AreEqual(3, result.KeyCount); + + var info = AnimationClipCommands.GetAnimationClip(PathRef(path)); + // Same binding => not duplicated; the single binding now has 3 keys. + Assert.AreEqual(1, info.Bindings.Count, "Replacing a binding must not duplicate it"); + Assert.AreEqual(3, info.Bindings[0].KeyCount); + } + + [Test] + public void SetAnimationCurve_DryRun_WritesNothing() + { + var path = Root + "/Clips/DryCurve.anim"; + AnimationClipCommands.CreateAnimationClip(path); + + var result = AnimationClipCommands.SetAnimationCurve( + PathRef(path), "", "Transform", "m_LocalPosition.x", TwoKeys(), dryRun: true); + Assert.AreEqual(2, result.KeyCount); + + var info = AnimationClipCommands.GetAnimationClip(PathRef(path)); + Assert.AreEqual(0, info.Bindings.Count, "dry_run must not write a curve"); + } + + [Test] + public void RemoveAnimationCurve_WithoutConfirm_Throws() + { + var path = Root + "/Clips/Remove.anim"; + AnimationClipCommands.CreateAnimationClip(path); + AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", TwoKeys()); + + Assert.Throws( + () => AnimationClipCommands.RemoveAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x"), + "remove without confirm should be rejected"); + + var info = AnimationClipCommands.GetAnimationClip(PathRef(path)); + Assert.AreEqual(1, info.Bindings.Count, "binding should survive a refused remove"); + } + + [Test] + public void RemoveAnimationCurve_WithConfirm_Removes() + { + var path = Root + "/Clips/Remove2.anim"; + AnimationClipCommands.CreateAnimationClip(path); + AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", TwoKeys()); + + AnimationClipCommands.RemoveAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", confirm: true); + + var info = AnimationClipCommands.GetAnimationClip(PathRef(path)); + Assert.AreEqual(0, info.Bindings.Count, "binding should be gone after a confirmed remove"); + } + + [Test] + public void SetAnimationCurve_UnknownType_Throws() + { + var path = Root + "/Clips/BadType.anim"; + AnimationClipCommands.CreateAnimationClip(path); + + Assert.Throws( + () => AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "NotARealComponent__CLI214", "m_X", TwoKeys())); + } + + [Test] + public void SetAnimationCurve_ViaClient_Succeeds() + { + var path = Root + "/ViaClient/Curve.anim"; + AnimationClipCommands.CreateAnimationClip(path); + + using (var server = new PipelineTestServer()) + { + var response = server.Execute("set_animation_curve", new + { + clip = new { path }, + type = "Transform", + property = "m_LocalPosition.x", + keys = new object[] + { + new { time = 0f, value = 0f }, + new { time = 1f, value = 5f } + } + }); + + Assert.IsTrue(response.IsSuccess, $"set_animation_curve should succeed: {response.Error}"); + } + + var info = AnimationClipCommands.GetAnimationClip(PathRef(path)); + Assert.AreEqual(1, info.Bindings.Count); + Assert.AreEqual(2, info.Bindings[0].KeyCount); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/AnimationClipCommandsTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/AnimationClipCommandsTests.cs.meta new file mode 100644 index 00000000..3af6f9b2 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/AnimationClipCommandsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 613a4bb343db4a4cb96ae6fef8195425 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/AnimatorControllerCommandsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/AnimatorControllerCommandsTests.cs new file mode 100644 index 00000000..b52e7d43 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/AnimatorControllerCommandsTests.cs @@ -0,0 +1,261 @@ +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Editor.Commands.Animation; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Tests.Editor.Animation +{ + /// + /// Tests for CLI-214 Group B (animator controllers): create controller (Base Layer, no states), + /// add parameter (and duplicate => duplicate_parameter), add layer, add state (with motion + as + /// default), add transition (with a matching condition), and the validation failures (missing + /// parameter, mode/type mismatch). Assets are generated in-test and cleaned up. + /// + public class AnimatorControllerCommandsTests + { + private const string Root = "Assets/__CLI214CtrlTest"; + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(); + } + } + + private static ObjectRef PathRef(string path) => new ObjectRef { Path = path }; + + private string CreateController(string name) + { + var path = $"{Root}/{name}.controller"; + AnimatorControllerCommands.CreateAnimatorController(path); + return path; + } + + [Test] + public void CreateController_HasOneBaseLayerNoStates() + { + var path = CreateController("Empty"); + + var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path)); + Assert.AreEqual(1, info.Layers.Count, "A new controller should have exactly one (Base) layer"); + Assert.AreEqual(0, info.Layers[0].States.Count, "Base Layer should start with no states"); + Assert.AreEqual(0, info.Parameters.Count); + } + + [Test] + public void AddParameter_Float_AndDuplicate() + { + var path = CreateController("Params"); + + var added = AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float", new JValue(0f)); + Assert.IsInstanceOf(added); + + var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path)); + Assert.AreEqual(1, info.Parameters.Count); + Assert.AreEqual("Speed", info.Parameters[0].Name); + Assert.AreEqual("Float", info.Parameters[0].ParamType); + + // Duplicate name => structured error, not exception. + var dup = AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float"); + Assert.IsInstanceOf(dup); + Assert.AreEqual("duplicate_parameter", ((ErrorResult)dup).Code); + } + + [Test] + public void AddLayer_AppendsLayer() + { + var path = CreateController("Layers"); + + var added = AnimatorControllerCommands.AddAnimatorLayer(PathRef(path), "Upper", weight: 0.5f, blendingMode: "Additive"); + Assert.IsInstanceOf(added); + Assert.AreEqual(1, ((AddLayerResult)added).Layer.Index); + + var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path)); + Assert.AreEqual(2, info.Layers.Count); + Assert.AreEqual("Upper", info.Layers[1].Name); + } + + [Test] + public void AddState_WithMotionAndDefault() + { + var path = CreateController("States"); + + // A clip to use as the state motion. + var clipPath = Root + "/Idle.anim"; + AnimationClipCommands.CreateAnimationClip(clipPath); + + var added = AnimatorControllerCommands.AddAnimatorState( + PathRef(path), layer: null, name: "Idle", motion: PathRef(clipPath), isDefault: true); + Assert.IsInstanceOf(added); + + var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path)); + Assert.AreEqual(1, info.Layers[0].States.Count); + var state = info.Layers[0].States[0]; + Assert.AreEqual("Idle", state.Name); + Assert.IsTrue(state.IsDefault, "Idle should be the layer default"); + Assert.IsNotNull(state.Motion, "Idle should have a non-null motion"); + Assert.AreEqual("Idle", info.Layers[0].DefaultState); + } + + [Test] + public void AddState_UnknownLayerName_ReturnsLayerNotFound() + { + var path = CreateController("BadLayer"); + + var result = AnimatorControllerCommands.AddAnimatorState( + PathRef(path), layer: new JValue("NoSuchLayer"), name: "S"); + Assert.IsInstanceOf(result); + Assert.AreEqual("layer_not_found", ((ErrorResult)result).Code); + } + + [Test] + public void AddTransition_WithCondition() + { + var path = CreateController("Transitions"); + AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float", new JValue(0f)); + AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle"); + AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run"); + + var conditions = new JArray + { + new JObject { ["parameter"] = "Speed", ["mode"] = "Greater", ["threshold"] = 0.1f } + }; + var added = AnimatorControllerCommands.AddAnimatorTransition( + PathRef(path), null, "Idle", "Run", conditions); + Assert.IsInstanceOf(added); + Assert.AreEqual(1, ((AddTransitionResult)added).Transition.ConditionCount); + + var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path)); + var transitions = info.Layers[0].Transitions; + Assert.AreEqual(1, transitions.Count); + Assert.AreEqual("Idle", transitions[0].From); + Assert.AreEqual("Run", transitions[0].To); + Assert.AreEqual(1, transitions[0].Conditions.Count); + Assert.AreEqual("Speed", transitions[0].Conditions[0].Parameter); + Assert.AreEqual("Greater", transitions[0].Conditions[0].Mode); + } + + [Test] + public void AddTransition_UnknownParameter_ThrowsAndWritesNothing() + { + var path = CreateController("BadParam"); + AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle"); + AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run"); + + var conditions = new JArray + { + new JObject { ["parameter"] = "Ghost", ["mode"] = "Greater", ["threshold"] = 0.1f } + }; + + var ex = Assert.Throws( + () => AnimatorControllerCommands.AddAnimatorTransition(PathRef(path), null, "Idle", "Run", conditions)); + StringAssert.Contains("Ghost", ex.Message, "error should name the offending parameter"); + + var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path)); + Assert.AreEqual(0, info.Layers[0].Transitions.Count, "nothing should be written on a bad condition"); + } + + [Test] + public void AddTransition_ModeTypeMismatch_Throws() + { + var path = CreateController("Mismatch"); + AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float", new JValue(0f)); + AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle"); + AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run"); + + // "If" is only valid for Bool/Trigger, not a Float parameter. + var conditions = new JArray + { + new JObject { ["parameter"] = "Speed", ["mode"] = "If" } + }; + + Assert.Throws( + () => AnimatorControllerCommands.AddAnimatorTransition(PathRef(path), null, "Idle", "Run", conditions)); + } + + [Test] + public void AddState_FractionalLayerIndex_ReturnsLayerNotFound() + { + var path = CreateController("FractionalLayer"); + + // 0.9 must NOT be silently rounded to layer 1; a non-integer index is rejected. + var result = AnimatorControllerCommands.AddAnimatorState( + PathRef(path), layer: new JValue(0.9d), name: "S"); + Assert.IsInstanceOf(result); + Assert.AreEqual("layer_not_found", ((ErrorResult)result).Code); + } + + [Test] + public void AddState_IntegerValuedFloatLayer_Resolves() + { + var path = CreateController("IntFloatLayer"); + + // 0.0 is an integer-valued float and resolves to the Base Layer (index 0). + var added = AnimatorControllerCommands.AddAnimatorState( + PathRef(path), layer: new JValue(0d), name: "Idle"); + Assert.IsInstanceOf(added); + Assert.AreEqual(0, ((AddStateResult)added).State.Layer); + } + + [Test] + public void AddTransition_ExitTimeOutOfRange_Throws() + { + var path = CreateController("BadExitTime"); + AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle"); + AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run"); + + Assert.Throws( + () => AnimatorControllerCommands.AddAnimatorTransition( + PathRef(path), null, "Idle", "Run", conditions: null, hasExitTime: true, exitTime: 1.5f)); + + var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path)); + Assert.AreEqual(0, info.Layers[0].Transitions.Count, "an out-of-range exitTime must write nothing"); + } + + [Test] + public void AddTransition_NegativeDuration_Throws() + { + var path = CreateController("BadDuration"); + AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle"); + AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run"); + + Assert.Throws( + () => AnimatorControllerCommands.AddAnimatorTransition( + PathRef(path), null, "Idle", "Run", conditions: null, duration: -1f)); + + var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path)); + Assert.AreEqual(0, info.Layers[0].Transitions.Count, "a negative duration must write nothing"); + } + + [Test] + public void AddParameter_DryRun_WritesNothing() + { + var path = CreateController("DryParam"); + + AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float", new JValue(0f), dryRun: true); + + var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path)); + Assert.AreEqual(0, info.Parameters.Count, "dry_run must not write a parameter"); + } + + [Test] + public void CreateController_ViaClient_Succeeds() + { + using (var server = new PipelineTestServer()) + { + var path = Root + "/ViaClient/Made.controller"; + var response = server.Execute("create_animator_controller", new { path }); + + Assert.IsTrue(response.IsSuccess, $"create_animator_controller should succeed: {response.Error}"); + Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(path), "Controller should exist on disk"); + } + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/AnimatorControllerCommandsTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/AnimatorControllerCommandsTests.cs.meta new file mode 100644 index 00000000..6655cd61 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/AnimatorControllerCommandsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c1d78b8f19fc48cea840bc62c3204b3e diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/TimelineCommandsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/TimelineCommandsTests.cs new file mode 100644 index 00000000..1c38953c --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/TimelineCommandsTests.cs @@ -0,0 +1,81 @@ +using NUnit.Framework; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Editor.Commands.Animation; +using Unity.Pipeline.Models; +using UnityEditor; + +namespace Unity.Pipeline.Tests.Editor.Animation +{ + /// + /// Tests for CLI-214 Group C (Timeline). Timeline ships in the OPTIONAL com.unity.timeline package, + /// so this fixture must COMPILE and PASS whether or not the package is installed: + /// - runs only when the package is ABSENT + /// and asserts the structured package_not_found error (no exception). + /// - the create -> add track -> add clip -> get flow runs only when the package is PRESENT and + /// calls Assert.Pass (not Assert.Ignore) when absent, so Yamato strict-mode + /// agents see "Passed" rather than "Not Run" (Inconclusive) for the conditional skip. + /// + /// All commands are reached through the public static methods; the Timeline types themselves are + /// only touched via reflection inside the commands, never referenced by this test assembly. + /// + public class TimelineCommandsTests + { + private const string Root = "Assets/__CLI214TimelineTest"; + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(); + } + } + + private static ObjectRef PathRef(string path) => new ObjectRef { Path = path }; + + [Test] + public void Timeline_NotInstalled_ReturnsPackageNotFound() + { + if (TimelineGuard.IsInstalled()) + Assert.Pass("com.unity.timeline is installed; the not-installed path is covered only when it is absent."); + + var result = TimelineCommands.CreateTimeline(Root + "/T.playable"); + Assert.IsInstanceOf(result, "Timeline commands must not throw when the package is absent"); + Assert.AreEqual("package_not_found", ((ErrorResult)result).Code); + } + + [Test] + public void Timeline_CreateTrackClipGet_Flow() + { + if (!TimelineGuard.IsInstalled()) + Assert.Pass("com.unity.timeline is not installed; the Timeline authoring flow is validated only when the package is present."); + + // create_timeline + var timelinePath = Root + "/Cutscene.playable"; + var created = TimelineCommands.CreateTimeline(timelinePath, frameRate: 30f); + Assert.IsInstanceOf(created, "create_timeline should return an AuthoringResult when installed"); + + // add an Animation track + var trackResult = TimelineCommands.AddTimelineTrack(PathRef(timelinePath), "Animation", name: "Anim"); + Assert.IsInstanceOf(trackResult); + + // a clip asset for the Animation track + var clipPath = Root + "/Move.anim"; + AnimationClipCommands.CreateAnimationClip(clipPath); + + // add_timeline_clip (start 0, duration 2) + var clipResult = TimelineCommands.AddTimelineClip( + PathRef(timelinePath), track: "Anim", start: 0f, duration: 2f, asset: PathRef(clipPath)); + Assert.IsInstanceOf(clipResult); + + // get_timeline => one Animation track with one 2s clip + var info = (TimelineInfo)TimelineCommands.GetTimeline(PathRef(timelinePath)); + Assert.AreEqual(1, info.Tracks.Count, "expected exactly one track"); + Assert.AreEqual("Animation", info.Tracks[0].TrackType); + Assert.AreEqual(1, info.Tracks[0].Clips.Count, "expected exactly one clip"); + Assert.AreEqual(2d, info.Tracks[0].Clips[0].Duration, 0.001d); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/TimelineCommandsTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/TimelineCommandsTests.cs.meta new file mode 100644 index 00000000..fd8848bd --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Animation/TimelineCommandsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 72d8c657533f4b8b96feb3af780d619a diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/ApiEndpointsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/ApiEndpointsTests.cs new file mode 100644 index 00000000..b0319359 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/ApiEndpointsTests.cs @@ -0,0 +1,259 @@ +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor; +using Unity.Pipeline.Models; +using Newtonsoft.Json.Linq; +using UnityEngine.TestTools; +using System.Text.RegularExpressions; + +namespace Unity.Pipeline.Tests.Editor +{ + /// + /// Tests for HTTP API endpoints that CLI tools will consume. + /// These test the complete server API surface for remote command execution. + /// + public class ApiEndpointsTests + { + private EditorPipelineServer m_Server; + private Unity.Pipeline.Tests.Runtime.PipelineClient m_PipelineClient; + + [SetUp] + public void SetUp() + { + // Setup command discovery for tests + CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery()); + + // Start an ISOLATED test server (ports 7850-7899, writes no descriptor) for endpoint + // testing, so we never bind the live server's port (7800) or clobber its descriptor. + m_Server = new TestEditorPipelineServer(); + m_Server.Start(); + + m_PipelineClient = new Unity.Pipeline.Tests.Runtime.PipelineClient(m_Server); + } + + [TearDown] + public void TearDown() + { + m_PipelineClient?.Dispose(); + m_Server?.Stop(); + } + + [Test] + public async Task ApiCommands_GetEndpoint_ReturnsCommandList() + { + // Act - Call /api/commands endpoint using unified Pipeline client + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + + // Assert - Response structure + Assert.IsTrue(httpResponse.IsSuccessStatusCode, + $"Commands endpoint should return success, got: {httpResponse.StatusCode}"); + Assert.AreEqual("application/json", httpResponse.Content.Headers.ContentType.MediaType, + "Commands endpoint should return JSON content type"); + + // Assert - JSON parsing + var responseJson = JObject.Parse(jsonContent); + Assert.IsNotNull(responseJson, "Should be able to parse commands JSON"); + + // Verify response structure + Assert.IsNotNull(responseJson["commands"], "Response should have commands array"); + Assert.IsNotNull(responseJson["count"], "Response should have count field"); + Assert.IsNotNull(responseJson["server"], "Response should have server info"); + + // Verify commands array contains discovered commands + var commands = responseJson["commands"] as JArray; + Assert.Greater(commands.Count, 0, "Should have at least one discovered command"); + + // Verify a specific test command is included + var testCommand = commands.Cast() + .FirstOrDefault(cmd => cmd["name"]?.ToString() == "log_editor"); + Assert.IsNotNull(testCommand, "Should include log_editor test command"); + Assert.AreEqual("Log a message to Unity Editor console", testCommand["description"]?.ToString()); + } + + [Test] + public async Task ApiCommands_OnEditorServer_ExcludesRuntimeOnlyCommands() + { + // Act - List commands from the Editor server + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + var responseJson = JObject.Parse(jsonContent); + var commandNames = (responseJson["commands"] as JArray) + .Cast() + .Select(cmd => cmd["name"]?.ToString()) + .ToList(); + + // Assert - editor commands are listed, runtime-only commands are hidden + Assert.Contains("editor_status", commandNames, "Editor command should be listed"); + CollectionAssert.DoesNotContain(commandNames, "runtime_status", "Runtime-only eval should be hidden on the Editor server"); + CollectionAssert.DoesNotContain(commandNames, "set_target_framerate", "Runtime-only reload_file_override should be hidden on the Editor server"); + } + + [Test] + public async Task ApiCommands_CommandStructure_ContainsRequiredFields() + { + // Act - Call /api/commands endpoint using unified Pipeline client + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + var responseJson = JObject.Parse(jsonContent); + + // Assert - Command structure validation + var commands = responseJson["commands"] as JArray; + var firstCommand = commands[0] as JObject; + + // Required command fields + Assert.IsNotNull(firstCommand["name"], "Command should have name field"); + Assert.IsNotNull(firstCommand["description"], "Command should have description field"); + Assert.IsNotNull(firstCommand["parameters"], "Command should have parameters array"); + Assert.IsNotNull(firstCommand["schema"], "Command should have JSON schema"); + Assert.IsNotNull(firstCommand["mainThreadRequired"], "Command should have mainThreadRequired field"); + + // Verify schema is valid JSON + var schema = firstCommand["schema"]?.ToString(); + var schemaJson = JObject.Parse(schema); + Assert.AreEqual(firstCommand["name"]?.ToString(), schemaJson["title"]?.ToString(), + "Schema title should match command name"); + } + + [Test] + public async Task ApiStatus_GetBasicStatus_ReturnsServerInfo() + { + // Act - Call basic /api/status endpoint using unified Pipeline client + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/status"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + + // Assert - Response structure + Assert.IsTrue(httpResponse.IsSuccessStatusCode, + $"Basic status endpoint should return success, got: {httpResponse.StatusCode}"); + Assert.AreEqual("application/json", httpResponse.Content.Headers.ContentType.MediaType, + "Basic status endpoint should return JSON content type"); + + // Assert - JSON structure (basic server info only, no Editor APIs) + var statusJson = JObject.Parse(jsonContent); + Assert.IsNotNull(statusJson["status"], "Should have status field"); + + // Verify basic values + Assert.AreEqual("ready", statusJson["status"]?.ToString()); + } + + [Test] + public async Task ApiEditorStatus_GetDetailedStatus_ReturnsEditorInfo() + { + // Act - Call rich /api/editor_status endpoint using unified Pipeline client + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/editor_status"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + + // Assert - Response structure + Assert.IsTrue(httpResponse.IsSuccessStatusCode, + $"Editor status endpoint should return success, got: {httpResponse.StatusCode}. Response: {jsonContent}"); + Assert.AreEqual("application/json", httpResponse.Content.Headers.ContentType.MediaType, + "Editor status endpoint should return JSON content type"); + + // Assert - Rich Editor status structure + var statusJson = JObject.Parse(jsonContent); + Assert.IsNotNull(statusJson["status"], "Should have overall status"); + Assert.IsNotNull(statusJson["compiling"], "Should have compiling state"); + Assert.IsNotNull(statusJson["domainReloadInProgress"], "Should have domain reload state"); + Assert.IsNotNull(statusJson["playMode"], "Should have play mode state"); + Assert.IsNotNull(statusJson["unityVersion"], "Should have Unity version"); + + // Verify Editor-specific data is present + Assert.Contains(statusJson["status"]?.ToString(), new[] { "ready", "compiling", "playing", "reloading" }); + Assert.Contains(statusJson["playMode"]?.ToString(), new[] { "stopped", "playing", "paused" }); + Assert.IsInstanceOf(statusJson["compiling"]?.ToObject()); + } + + [Test] + public async Task ApiExec_PostCommand_ExecutesSuccessfully() + { + // Arrange + var commandRequest = new CommandExecutionRequest("log_editor"); + commandRequest.Parameters["message"] = "Test message from CLI"; + + // Act - Execute command via /api/exec endpoint using unified Pipeline client + var response = await m_PipelineClient.PostJsonAsync("/api/exec", commandRequest); + var responseContent = response.RawResponse; + + // Assert - Response structure + Assert.IsTrue(response.IsSuccess, + $"Exec endpoint should return success, got: {response.StatusCode}. Response: {responseContent}"); + + // Assert - JSON parsing and structure + Assert.IsTrue(response.HasValidJson, "Response should have valid JSON"); + var responseJson = response.JsonResponse; + Assert.IsNotNull(responseJson["success"], "Response should have success field"); + Assert.IsNotNull(responseJson["command"], "Response should have command field"); + Assert.IsNotNull(responseJson["executedAt"], "Response should have executedAt timestamp"); + + // Assert - Successful execution + Assert.IsTrue(responseJson["success"].ToObject(), "Command should execute successfully"); + Assert.AreEqual("log_editor", responseJson["command"]?.ToString()); + } + + [Test] + public async Task ApiExec_InvalidCommand_ReturnsError() + { + // Arrange + var invalidRequest = new CommandExecutionRequest("nonexistent_command"); + + // Act - Execute invalid command via /api/exec endpoint using unified Pipeline client + var response = await m_PipelineClient.PostJsonAsync("/api/exec", invalidRequest); + var responseContent = response.RawResponse; + + LogAssert.Expect(new Regex("^ExecuteCommandByName: No command named")); + + // Assert - Should return error + Assert.IsFalse(response.IsSuccess, "Should return error for invalid command"); + + Assert.IsTrue(response.HasValidJson, "Error response should have valid JSON"); + var responseJson = response.JsonResponse; + Assert.IsNotNull(responseJson["error"], "Error response should have error field"); + Assert.IsNotNull(responseJson["message"], "Error response should have message field"); + } + + [Test] + public async Task ApiExec_MissingRequiredParameter_ReturnsValidationError() + { + // Arrange - Try to execute log_editor without required message parameter + var invalidRequest = new CommandExecutionRequest("log_editor"); + // Intentionally not setting the 'message' parameter to test validation + + // Act - Execute command with missing parameter via /api/exec endpoint using unified Pipeline client + var response = await m_PipelineClient.PostJsonAsync("/api/exec", invalidRequest); + var responseContent = response.RawResponse; + + LogAssert.Expect("ExecuteCommandByName: Parameter validation failed: Required parameter 'message' is missing or empty"); + + // Assert - Should return validation error + Assert.IsFalse(response.IsSuccess, "Should return error for missing required parameter"); + + Assert.IsTrue(response.HasValidJson, "Error response should have valid JSON"); + var responseJson = response.JsonResponse; + Assert.IsNotNull(responseJson["error"], "Should have error field"); + Assert.That(responseJson["errorDetails"]?.ToString(), + Contains.Substring("message").IgnoreCase, + "Error should mention missing message parameter"); + } + + [Test] + public async Task ApiExec_OversizedBody_ReturnsPayloadTooLarge() + { + // Arrange - a request whose body exceeds the 1 MiB cap (Content-Length will advertise it). + var oversized = new CommandExecutionRequest("log_editor"); + oversized.Parameters["message"] = new string('a', (1 * 1024 * 1024) + 1024); + + // Act + var response = await m_PipelineClient.PostJsonAsync("/api/exec", oversized); + + // Assert - rejected with 413 before the command ever runs. + Assert.AreEqual(413, response.StatusCode, + $"Oversized body should be rejected with 413. Response: {response.RawResponse}"); + Assert.IsTrue(response.HasValidJson, "413 response should have valid JSON"); + Assert.That(response.JsonResponse["error"]?.ToString(), + Contains.Substring("Payload Too Large"), + "413 response should identify the payload-too-large error"); + } + } +} \ No newline at end of file diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/ApiEndpointsTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/ApiEndpointsTests.cs.meta new file mode 100644 index 00000000..1df84789 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/ApiEndpointsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fddeee399ff1c104d82a62adbac36d43 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets.meta new file mode 100644 index 00000000..5b478aa5 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7571005014bc4727a0ef06b2056351a2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/AssetCommandsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/AssetCommandsTests.cs new file mode 100644 index 00000000..2469e6e2 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/AssetCommandsTests.cs @@ -0,0 +1,246 @@ +using System.Text.RegularExpressions; +using NUnit.Framework; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Editor.Commands.Assets; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Pipeline.Tests.Editor.Assets +{ + /// + /// A trivial ScriptableObject used by the asset-command tests to exercise create / find / move / + /// delete with a real, project-resolvable type. + /// + public class CLI191SampleAsset : ScriptableObject + { + public int Value; + } + + /// + /// Tests for the CLI-191 asset lifecycle commands, exercised both directly (static method) and via + /// the isolated . Covers the acceptance flow (create folder tree → + /// create ScriptableObject → find by type → move → delete), the sandbox guard (out-of-project + /// writes rejected), and the destructive guard (delete without confirm rejected). + /// + public class AssetCommandsTests + { + private const string Root = "Assets/__CLI191Test"; + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(); + } + } + + private static ObjectRef PathRef(string path) => new ObjectRef { Path = path }; + + #region Direct + + [Test] + public void CreateAsset_ScriptableObject_CreatesAssetAndReturnsIdentity() + { + var path = Root + "/Data/Sample.asset"; + var result = AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName); + + Assert.AreEqual(path, result.AssetPath); + Assert.IsNotEmpty(result.Guid, "Created asset should have a GUID"); + Assert.IsNotNull(AssetDatabase.LoadAssetAtPath(path), "Asset should exist on disk"); + } + + [Test] + public void CreateAsset_OverwriteWithoutConfirm_Throws() + { + var path = Root + "/Data/Dup.asset"; + AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName); + + Assert.Throws( + () => AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName), + "Overwriting an existing asset without confirm should be rejected"); + + // With confirm it succeeds. + var result = AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName, confirm: true); + Assert.AreEqual(path, result.AssetPath); + } + + [Test] + public void CreateAsset_OutOfProject_Throws() + { + Assert.Throws( + () => AssetCommands.CreateAsset("../Outside.asset", typeof(CLI191SampleAsset).FullName)); + Assert.Throws( + () => AssetCommands.CreateAsset("Assets/../Outside.asset", typeof(CLI191SampleAsset).FullName)); + } + + [Test] + public void FindAssets_ByType_FindsCreatedAsset() + { + var path = Root + "/Find/Target.asset"; + AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName); + + var found = AssetCommands.FindAssets(type: nameof(CLI191SampleAsset), searchIn: Root); + + Assert.GreaterOrEqual(found.Count, 1, "Should find the created asset by type"); + CollectionAssert.Contains( + System.Linq.Enumerable.Select(found.Assets, a => a.AssetPath), + path); + } + + [Test] + public void FindAssets_NoFilter_Throws() + { + Assert.Throws(() => AssetCommands.FindAssets()); + } + + [Test] + public void MoveAsset_RelocatesAndPreservesGuid() + { + var src = Root + "/Move/Src.asset"; + AssetCommands.CreateAsset(src, typeof(CLI191SampleAsset).FullName); + var srcGuid = AssetDatabase.AssetPathToGUID(src); + + var dest = Root + "/Move/Dest/Moved.asset"; + var result = AssetCommands.MoveAsset(PathRef(src), dest); + + Assert.AreEqual(dest, result.AssetPath); + Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(src), "Source should no longer exist"); + Assert.AreEqual(srcGuid, AssetDatabase.AssetPathToGUID(dest), "GUID should be preserved across a move"); + } + + [Test] + public void CopyAsset_CreatesDistinctAsset() + { + var src = Root + "/Copy/Src.asset"; + AssetCommands.CreateAsset(src, typeof(CLI191SampleAsset).FullName); + + var dest = Root + "/Copy/Copy.asset"; + var result = AssetCommands.CopyAsset(PathRef(src), dest); + + Assert.AreEqual(dest, result.AssetPath); + Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(src), "Source should still exist after copy"); + Assert.AreNotEqual(AssetDatabase.AssetPathToGUID(src), AssetDatabase.AssetPathToGUID(dest), + "A copy should get a fresh GUID"); + } + + [Test] + public void RenameAsset_RenamesInPlace() + { + var src = Root + "/Rename/Old.asset"; + AssetCommands.CreateAsset(src, typeof(CLI191SampleAsset).FullName); + + var result = AssetCommands.RenameAsset(PathRef(src), "New"); + + Assert.AreEqual(Root + "/Rename/New.asset", result.AssetPath); + Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(src)); + } + + [Test] + public void DeleteAsset_WithoutConfirm_Throws() + { + var path = Root + "/Delete/Doomed.asset"; + AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName); + + Assert.Throws(() => AssetCommands.DeleteAsset(PathRef(path)), + "delete_asset without confirm should be rejected"); + Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(path), "Asset should survive a refused delete"); + } + + [Test] + public void DeleteAsset_WithConfirm_Deletes() + { + var path = Root + "/Delete/Doomed2.asset"; + AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName); + + var result = AssetCommands.DeleteAsset(PathRef(path), confirm: true); + + Assert.AreEqual(path, result.AssetPath); + Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "Asset should be gone after a confirmed delete"); + } + + [Test] + public void AcceptanceFlow_CreateFindMoveDelete() + { + // create folder tree + FolderCommands.CreateFolder(Root + "/Flow/Configs"); + Assert.IsTrue(AssetDatabase.IsValidFolder(Root + "/Flow/Configs")); + + // create ScriptableObject asset + var path = Root + "/Flow/Configs/Cfg.asset"; + AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName); + + // find by type + var found = AssetCommands.FindAssets(type: nameof(CLI191SampleAsset), searchIn: Root + "/Flow"); + Assert.GreaterOrEqual(found.Count, 1); + + // move it + var moved = AssetCommands.MoveAsset(PathRef(path), Root + "/Flow/Moved.asset"); + Assert.AreEqual(Root + "/Flow/Moved.asset", moved.AssetPath); + + // delete it (with confirm) + AssetCommands.DeleteAsset(PathRef(moved.AssetPath), confirm: true); + Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(moved.AssetPath)); + } + + #endregion + + #region ViaClient + + [Test] + public void CreateAsset_ViaClient_Succeeds() + { + using (var server = new PipelineTestServer()) + { + var path = Root + "/ViaClient/Made.asset"; + var response = server.Execute("create_asset", new { path, type = typeof(CLI191SampleAsset).FullName }); + + Assert.IsTrue(response.IsSuccess, $"create_asset should succeed: {response.Error}"); + Assert.IsTrue(response.HasValidJson, "Response should have valid JSON"); + Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field"); + Assert.IsNotNull(AssetDatabase.LoadAssetAtPath(path), "Asset should exist"); + } + } + + [Test] + public void DeleteAsset_ViaClient_WithoutConfirm_Fails() + { + var path = Root + "/ViaClient/ToDelete.asset"; + AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName); + + using (var server = new PipelineTestServer()) + { + // The server logs the refusal as a Unity [Error]; expect it so the test does not fail on + // the unexpected log. Matches the message thrown by DeleteAsset ("Refusing to delete ..."). + LogAssert.Expect(LogType.Error, new Regex("Refusing to delete")); + + var response = server.Execute("delete_asset", new { asset = new { path } }); + + Assert.IsFalse(response.IsSuccess, "delete_asset without confirm should fail over the wire"); + Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(path), "Asset should survive a refused delete"); + } + } + + [Test] + public void FindAssets_ViaClient_ReturnsResults() + { + var path = Root + "/ViaClient/Findable.asset"; + AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName); + + using (var server = new PipelineTestServer()) + { + var response = server.Execute("find_assets", new { type = nameof(CLI191SampleAsset), search_in = Root }); + + Assert.IsTrue(response.IsSuccess, $"find_assets should succeed: {response.Error}"); + Assert.IsTrue(response.HasValidJson, "Response should have valid JSON"); + Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field"); + } + } + + #endregion + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/AssetCommandsTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/AssetCommandsTests.cs.meta new file mode 100644 index 00000000..b5259953 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/AssetCommandsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d4736a7747a847edb2355c95559ce0b0 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/AssetImportCommandsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/AssetImportCommandsTests.cs new file mode 100644 index 00000000..56f3c33b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/AssetImportCommandsTests.cs @@ -0,0 +1,103 @@ +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Editor.Commands.Assets; +using Unity.Pipeline.Models; +using UnityEditor; + +namespace Unity.Pipeline.Tests.Editor.Assets +{ + /// + /// Tests for set_import_settings (CLI-191). Uses the importer's userData property — present + /// on every AssetImporter — so the test is independent of any specific importer kind. Verifies the + /// applied/unknown bookkeeping and that the setting actually persists on the importer. + /// + public class AssetImportCommandsTests + { + private const string Root = "Assets/__CLI191ImportTest"; + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(); + } + } + + private static ObjectRef PathRef(string path) => new ObjectRef { Path = path }; + + private static string CreateSampleAsset() + { + var path = Root + "/Imported.asset"; + AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName); + return path; + } + + #region Direct + + [Test] + public void SetImportSettings_AppliesKnownMember() + { + var path = CreateSampleAsset(); + var settings = new JObject { ["userData"] = "cli191" }; + + var result = AssetImportCommands.SetImportSettings(PathRef(path), settings); + + CollectionAssert.Contains(result.Applied, "userData"); + CollectionAssert.IsEmpty(result.Unknown); + Assert.AreEqual("cli191", AssetImporter.GetAtPath(path).userData, "Setting should persist on the importer"); + } + + [Test] + public void SetImportSettings_UnknownOnly_Throws() + { + var path = CreateSampleAsset(); + var settings = new JObject { ["definitelyNotAMember"] = 42 }; + + Assert.Throws( + () => AssetImportCommands.SetImportSettings(PathRef(path), settings), + "When no supplied setting exists on the importer, the command should fail"); + } + + [Test] + public void SetImportSettings_DryRun_DoesNotPersist() + { + var path = CreateSampleAsset(); + var settings = new JObject { ["userData"] = "dryrun" }; + + var result = AssetImportCommands.SetImportSettings(PathRef(path), settings, dryRun: true); + + // dry_run reports the member as applicable but never mutates the importer. + CollectionAssert.Contains(result.Applied, "userData"); + Assert.AreNotEqual("dryrun", AssetImporter.GetAtPath(path).userData, "dry_run should not persist the change"); + } + + #endregion + + #region ViaClient + + [Test] + public void SetImportSettings_ViaClient_Succeeds() + { + var path = CreateSampleAsset(); + + using (var server = new PipelineTestServer()) + { + var response = server.Execute("set_import_settings", new + { + asset = new { path }, + settings = new { userData = "viaclient" } + }); + + Assert.IsTrue(response.IsSuccess, $"set_import_settings should succeed: {response.Error}"); + Assert.IsTrue(response.HasValidJson, "Response should have valid JSON"); + Assert.AreEqual("viaclient", AssetImporter.GetAtPath(path).userData); + } + } + + #endregion + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/AssetImportCommandsTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/AssetImportCommandsTests.cs.meta new file mode 100644 index 00000000..c008460f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/AssetImportCommandsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 121fa6671cf449ce863c389aa9b7e18c diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/ImportSettingsCommandTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/ImportSettingsCommandTests.cs new file mode 100644 index 00000000..f4278c00 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/ImportSettingsCommandTests.cs @@ -0,0 +1,432 @@ +using System.IO; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Editor.Commands.Assets; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Tests.Editor.Assets +{ + /// + /// Tests for the CLI-212 per-type import settings + platform overrides: + /// get_import_settings and the platform-aware extension of set_import_settings. + /// + /// Real importers are exercised by writing tiny generated assets, so the actual Unity read/write + /// code paths (including per-platform overrides) are covered end to end: a 4x4 PNG (TextureImporter), + /// a minimal OBJ (ModelImporter), and a tiny 8-bit PCM WAV (AudioImporter). The audio fixture drives + /// a full per-platform override round-trip (ApplyAudioPlatform + + /// ReadAudioPlatformOverride): set override, read it back, then clear it. + /// + public class ImportSettingsCommandTests + { + private const string Root = "Assets/__CLI212ImportTest"; + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(); + } + } + + private static ObjectRef PathRef(string path) => new ObjectRef { Path = path }; + + // ------------------------------------------------------------------ asset fixtures + + /// Write a tiny 4x4 PNG so Unity imports it with a TextureImporter. + private static string CreateTexture(string name = "Tex.png") + { + EnsureRoot(); + var tex = new Texture2D(4, 4, TextureFormat.RGBA32, false); + var pixels = new Color32[16]; + for (var i = 0; i < pixels.Length; i++) + pixels[i] = new Color32(255, 128, 64, 255); + tex.SetPixels32(pixels); + tex.Apply(); + var bytes = tex.EncodeToPNG(); + Object.DestroyImmediate(tex); + + var projectPath = Root + "/" + name; + File.WriteAllBytes(ToAbsolute(projectPath), bytes); + AssetDatabase.ImportAsset(projectPath, ImportAssetOptions.ForceSynchronousImport); + return projectPath; + } + + /// Write a minimal single-triangle OBJ so Unity imports it with a ModelImporter. + private static string CreateModel(string name = "Tri.obj") + { + EnsureRoot(); + const string obj = + "o Tri\n" + + "v 0 0 0\n" + + "v 1 0 0\n" + + "v 0 1 0\n" + + "vn 0 0 1\n" + + "f 1//1 2//1 3//1\n"; + var projectPath = Root + "/" + name; + File.WriteAllText(ToAbsolute(projectPath), obj); + AssetDatabase.ImportAsset(projectPath, ImportAssetOptions.ForceSynchronousImport); + return projectPath; + } + + /// + /// Write a tiny mono 8-bit PCM WAV (a handful of samples) so Unity imports it with an + /// AudioImporter — enough to exercise the real per-platform override read/write paths. + /// + private static string CreateAudio(string name = "Beep.wav") + { + EnsureRoot(); + + const int sampleRate = 8000; + const int sampleCount = 64; + var pcm = new byte[sampleCount]; + for (var i = 0; i < pcm.Length; i++) + pcm[i] = (byte)(128 + (i % 2 == 0 ? 32 : -32)); // crude square wave around the 8-bit midpoint + + using (var stream = new MemoryStream()) + using (var w = new BinaryWriter(stream)) + { + const short channels = 1; + const short bitsPerSample = 8; + var byteRate = sampleRate * channels * bitsPerSample / 8; + var blockAlign = (short)(channels * bitsPerSample / 8); + + // RIFF header + w.Write(new[] { 'R', 'I', 'F', 'F' }); + w.Write(36 + pcm.Length); + w.Write(new[] { 'W', 'A', 'V', 'E' }); + // fmt chunk + w.Write(new[] { 'f', 'm', 't', ' ' }); + w.Write(16); // PCM fmt chunk size + w.Write((short)1); // audio format = PCM + w.Write(channels); + w.Write(sampleRate); + w.Write(byteRate); + w.Write(blockAlign); + w.Write(bitsPerSample); + // data chunk + w.Write(new[] { 'd', 'a', 't', 'a' }); + w.Write(pcm.Length); + w.Write(pcm); + w.Flush(); + + var projectPath = Root + "/" + name; + File.WriteAllBytes(ToAbsolute(projectPath), stream.ToArray()); + AssetDatabase.ImportAsset(projectPath, ImportAssetOptions.ForceSynchronousImport); + return projectPath; + } + } + + private static void EnsureRoot() + { + if (!AssetDatabase.IsValidFolder(Root)) + AssetDatabase.CreateFolder("Assets", "__CLI212ImportTest"); + } + + private static string ToAbsolute(string projectRelative) => + $"{ProjectPaths.ProjectRoot.Replace('\\', '/')}/{projectRelative}"; + + // ------------------------------------------------------------------ texture: read + + [Test] + public void GetImportSettings_Texture_ReturnsStructuredDefaultsAndOverrideBlock() + { + var path = CreateTexture(); + + var result = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android"); + + Assert.AreEqual("TextureImporter", result.ImporterType); + Assert.AreEqual("Android", result.Platform); + Assert.IsNotNull(result.Settings); + Assert.IsTrue(result.Settings.ContainsKey("textureType")); + Assert.IsTrue(result.Settings.ContainsKey("isReadable")); + Assert.IsTrue(result.Settings.ContainsKey("mipmapEnabled")); + Assert.IsTrue(result.Settings.ContainsKey("wrapMode")); + Assert.IsNotNull(result.PlatformOverride, "Texture should expose a platform override block"); + Assert.IsTrue(result.PlatformOverride.ContainsKey("overridden")); + } + + // ------------------------------------------------------------------ texture: default round-trip + + [Test] + public void SetImportSettings_TextureDefault_AppliesAndReadBackReflects() + { + var path = CreateTexture(); + + var settings = new JObject + { + ["isReadable"] = true, + ["textureType"] = "NormalMap" + }; + var set = AssetImportCommands.SetImportSettings(PathRef(path), settings); + + CollectionAssert.Contains(set.Applied, "isReadable"); + CollectionAssert.Contains(set.Applied, "textureType"); + CollectionAssert.IsEmpty(set.Unknown); + + var read = AssetImportCommands.GetImportSettings(PathRef(path)); + Assert.AreEqual(true, read.Settings["isReadable"]); + Assert.AreEqual("NormalMap", read.Settings["textureType"]); + } + + // ------------------------------------------------------------------ texture: platform override set + clear + + [Test] + public void SetImportSettings_TextureAndroidOverride_SetsThenClears() + { + var path = CreateTexture(); + + // Default platform maxTextureSize before the override (should be unchanged afterwards). + var defaultBefore = AssetImportCommands.GetImportSettings(PathRef(path)); + var defaultMaxBefore = defaultBefore.Settings["maxTextureSize"]; + + var set = AssetImportCommands.SetImportSettings( + PathRef(path), + new JObject { ["maxTextureSize"] = 1024, ["format"] = "ASTC_6x6", ["overridden"] = true }, + platform: "Android"); + CollectionAssert.Contains(set.Applied, "maxTextureSize"); + CollectionAssert.Contains(set.Applied, "format"); + + var afterSet = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android"); + Assert.AreEqual(true, afterSet.PlatformOverride["overridden"]); + Assert.AreEqual(1024, afterSet.PlatformOverride["maxTextureSize"]); + Assert.AreEqual("ASTC_6x6", afterSet.PlatformOverride["format"]); + + // Default platform unchanged by the Android override. + var defaultAfter = AssetImportCommands.GetImportSettings(PathRef(path)); + Assert.AreEqual(defaultMaxBefore, defaultAfter.Settings["maxTextureSize"], + "Default platform settings must be unchanged by a platform override"); + + // Clear the override. + AssetImportCommands.SetImportSettings( + PathRef(path), + new JObject { ["overridden"] = false }, + platform: "Android"); + + var afterClear = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android"); + Assert.AreEqual(false, afterClear.PlatformOverride["overridden"], "Override should be cleared"); + } + + // ------------------------------------------------------------------ audio: read + + [Test] + public void GetImportSettings_Audio_ReturnsStructuredDefaultsAndOverrideBlock() + { + var path = CreateAudio(); + + var result = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android"); + + Assert.AreEqual("AudioImporter", result.ImporterType); + Assert.AreEqual("Android", result.Platform); + Assert.IsNotNull(result.Settings); + Assert.IsTrue(result.Settings.ContainsKey("loadType")); + Assert.IsTrue(result.Settings.ContainsKey("compressionFormat")); + Assert.IsTrue(result.Settings.ContainsKey("quality")); + Assert.IsNotNull(result.PlatformOverride, "Audio should expose a platform override block"); + Assert.IsTrue(result.PlatformOverride.ContainsKey("overridden")); + // No override has been written yet. + Assert.AreEqual(false, result.PlatformOverride["overridden"]); + } + + // ------------------------------------------------------------------ audio: platform override set + clear + + [Test] + public void SetImportSettings_AudioAndroidOverride_SetsThenClears() + { + var path = CreateAudio(); + + // Default-platform compressionFormat before the override (must be unchanged afterwards). + var defaultBefore = AssetImportCommands.GetImportSettings(PathRef(path)); + var defaultCompressionBefore = defaultBefore.Settings["compressionFormat"]; + + var set = AssetImportCommands.SetImportSettings( + PathRef(path), + new JObject { ["compressionFormat"] = "Vorbis", ["loadType"] = "CompressedInMemory", ["overridden"] = true }, + platform: "Android"); + CollectionAssert.Contains(set.Applied, "compressionFormat"); + CollectionAssert.Contains(set.Applied, "loadType"); + CollectionAssert.Contains(set.Applied, "overridden"); + CollectionAssert.IsEmpty(set.Unknown); + + var afterSet = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android"); + Assert.AreEqual(true, afterSet.PlatformOverride["overridden"], "Override should be present after a platform write"); + Assert.AreEqual("Vorbis", afterSet.PlatformOverride["compressionFormat"]); + Assert.AreEqual("CompressedInMemory", afterSet.PlatformOverride["loadType"]); + + // Default platform unchanged by the Android override. + var defaultAfter = AssetImportCommands.GetImportSettings(PathRef(path)); + Assert.AreEqual(defaultCompressionBefore, defaultAfter.Settings["compressionFormat"], + "Default platform settings must be unchanged by a platform override"); + + // Clear the override. + AssetImportCommands.SetImportSettings( + PathRef(path), + new JObject { ["overridden"] = false }, + platform: "Android"); + + var afterClear = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android"); + Assert.AreEqual(false, afterClear.PlatformOverride["overridden"], "Override should be cleared"); + } + + // ------------------------------------------------------------------ model: read + default write + + [Test] + public void GetImportSettings_Model_ReturnsNullPlatformOverride() + { + var path = CreateModel(); + + var result = AssetImportCommands.GetImportSettings(PathRef(path)); + + Assert.AreEqual("ModelImporter", result.ImporterType); + Assert.IsTrue(result.Settings.ContainsKey("meshCompression")); + Assert.IsTrue(result.Settings.ContainsKey("animationType")); + Assert.IsTrue(result.Settings.ContainsKey("materialImportMode")); + Assert.IsNull(result.PlatformOverride, "ModelImporter has no per-platform overrides"); + } + + [Test] + public void SetImportSettings_ModelDefault_AppliesAndReadBack() + { + var path = CreateModel(); + + var set = AssetImportCommands.SetImportSettings( + PathRef(path), + new JObject { ["meshCompression"] = "High", ["importNormals"] = "Calculate" }); + + CollectionAssert.Contains(set.Applied, "meshCompression"); + CollectionAssert.Contains(set.Applied, "importNormals"); + + var read = AssetImportCommands.GetImportSettings(PathRef(path)); + Assert.AreEqual("High", read.Settings["meshCompression"]); + Assert.AreEqual("Calculate", read.Settings["importNormals"]); + } + + [Test] + public void SetImportSettings_ModelWithPlatform_ReturnsNoPlatformOverrides_AndWritesNothing() + { + var path = CreateModel(); + var before = AssetImportCommands.GetImportSettings(PathRef(path)); + var meshBefore = before.Settings["meshCompression"]; + + var ex = Assert.Throws(() => + AssetImportCommands.SetImportSettings( + PathRef(path), + new JObject { ["meshCompression"] = "High" }, + platform: "iOS")); + StringAssert.Contains("no_platform_overrides", ex.Message); + + var after = AssetImportCommands.GetImportSettings(PathRef(path)); + Assert.AreEqual(meshBefore, after.Settings["meshCompression"], "A rejected platform write must change nothing"); + } + + // ------------------------------------------------------------------ dry_run + + [Test] + public void SetImportSettings_DryRun_ReportsButDoesNotChange() + { + var path = CreateTexture(); + var before = AssetImportCommands.GetImportSettings(PathRef(path)); + var readableBefore = (bool)before.Settings["isReadable"]; + + var result = AssetImportCommands.SetImportSettings( + PathRef(path), + new JObject { ["isReadable"] = !readableBefore }, + dryRun: true); + + CollectionAssert.Contains(result.Applied, "isReadable"); + + var after = AssetImportCommands.GetImportSettings(PathRef(path)); + Assert.AreEqual(readableBefore, (bool)after.Settings["isReadable"], "dry_run must not change settings"); + } + + // ------------------------------------------------------------------ unknown key handling + + [Test] + public void SetImportSettings_UnknownKey_GoesToUnknown() + { + var path = CreateTexture(); + + var result = AssetImportCommands.SetImportSettings( + PathRef(path), + new JObject { ["isReadable"] = true, ["definitelyNotAMember"] = 42 }); + + CollectionAssert.Contains(result.Applied, "isReadable"); + CollectionAssert.Contains(result.Unknown, "definitelyNotAMember"); + } + + [Test] + public void SetImportSettings_AllUnknown_Throws() + { + var path = CreateTexture(); + + Assert.Throws(() => + AssetImportCommands.SetImportSettings( + PathRef(path), + new JObject { ["definitelyNotAMember"] = 42 })); + } + + // ------------------------------------------------------------------ platform validation + + [Test] + public void SetImportSettings_UnknownPlatform_Throws() + { + var path = CreateTexture(); + + var ex = Assert.Throws(() => + AssetImportCommands.SetImportSettings( + PathRef(path), + new JObject { ["maxTextureSize"] = 512 }, + platform: "PlayStation")); + StringAssert.Contains("unknown_platform", ex.Message); + } + + [Test] + public void GetImportSettings_UnknownPlatform_Throws() + { + var path = CreateTexture(); + + Assert.Throws(() => + AssetImportCommands.GetImportSettings(PathRef(path), platform: "PlayStation")); + } + + // ------------------------------------------------------------------ sandbox guard + + [Test] + public void GetImportSettings_OutsideAuthoringRoot_Throws() + { + var path = CreateTexture(); + // Confine the authoring root to a sub-folder that does NOT contain the asset, so the asset's + // resolved path falls outside the sandbox. + ProjectPaths.AuthoringRoot = Root + "/Nested"; + + Assert.Throws(() => + AssetImportCommands.GetImportSettings(PathRef(path))); + } + + // ------------------------------------------------------------------ via client (wire) smoke test + + [Test] + public void GetImportSettings_ViaClient_Succeeds() + { + var path = CreateTexture(); + + using (var server = new PipelineTestServer()) + { + var response = server.Execute("get_import_settings", new + { + asset = new { path }, + platform = "Android" + }); + + Assert.IsTrue(response.IsSuccess, $"get_import_settings should succeed: {response.Error}"); + Assert.IsTrue(response.HasValidJson, "Response should have valid JSON"); + Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field"); + } + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/ImportSettingsCommandTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/ImportSettingsCommandTests.cs.meta new file mode 100644 index 00000000..a318d07e --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/ImportSettingsCommandTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 48fae49d74ce4b61941f9c5ff541034f diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/MaterialAssetCommandsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/MaterialAssetCommandsTests.cs new file mode 100644 index 00000000..66046da9 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/MaterialAssetCommandsTests.cs @@ -0,0 +1,182 @@ +using System.Text.RegularExpressions; +using NUnit.Framework; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Editor.Commands.Assets; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Pipeline.Tests.Editor.Assets +{ + /// + /// Tests for create_asset's Material support (CLI-221), exercised directly and via the isolated + /// . Verifies that a .mat is created with a real (non-null) shader, + /// that an unknown shader name fails clearly (no null Material / null-ref), and that the created + /// Material is loadable and assignable to a Renderer's material slot. + /// + public class MaterialAssetCommandsTests + { + private const string Root = "Assets/__CLI221_222Test"; + + // A shader that is guaranteed to exist on whatever pipeline this project runs (built-in or URP). + private string m_KnownShader; + + [SetUp] + public void SetUp() + { + ProjectPaths.ResetAuthoringRoot(); + m_KnownShader = PickKnownShader(); + } + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(); + } + } + + /// Pick a shader name that resolves on this project's active pipeline. + private static string PickKnownShader() + { + if (Shader.Find("Standard") != null) + return "Standard"; + if (Shader.Find("Universal Render Pipeline/Lit") != null) + return "Universal Render Pipeline/Lit"; + // Sprites/Default ships with every pipeline as a hard fallback. + return "Sprites/Default"; + } + + #region Direct + + [Test] + public void CreateAsset_Material_WithShader_CreatesValidMaterial() + { + var path = Root + "/Mats/Bar.mat"; + var result = AssetCommands.CreateAsset(path, "Material", shader: m_KnownShader); + + Assert.AreEqual(path, result.AssetPath); + + var loaded = AssetDatabase.LoadAssetAtPath(path); + Assert.IsNotNull(loaded, "Created .mat should load as a Material"); + Assert.IsNotNull(loaded.shader, "Material's shader must not be null"); + Assert.AreEqual(Shader.Find(m_KnownShader), loaded.shader, "Material should use the requested shader"); + } + + [Test] + public void CreateAsset_Material_DefaultShader_IsNonNull() + { + // No explicit shader: should fall back to the active pipeline's lit shader (or Standard). + var path = Root + "/Mats/Default.mat"; + AssetCommands.CreateAsset(path, "Material"); + + var loaded = AssetDatabase.LoadAssetAtPath(path); + Assert.IsNotNull(loaded, "Created .mat should load as a Material"); + Assert.IsNotNull(loaded.shader, "Default-shader Material must have a non-null shader"); + } + + [Test] + public void CreateAsset_Material_InvalidShader_ThrowsClearError() + { + var path = Root + "/Mats/Broken.mat"; + + var ex = Assert.Throws( + () => AssetCommands.CreateAsset(path, "Material", shader: "No/Such/Shader__CLI221"), + "An unknown shader name should fail with a clear ArgumentException"); + StringAssert.Contains("not found", ex.Message); + + Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "No asset should be written on a shader failure"); + } + + [Test] + public void CreateAsset_Material_DryRun_InvalidShader_ThrowsAndWritesNothing() + { + // dry_run is documented as "validate inputs", so an unknown shader must fail fast even in + // dry_run (rather than reporting a would-be success) and still write nothing. + var path = Root + "/Mats/DryRunBroken.mat"; + + var ex = Assert.Throws( + () => AssetCommands.CreateAsset(path, "Material", shader: "No/Such/Shader__CLI221", dryRun: true), + "dry_run should resolve/validate the shader and fail on an unknown one"); + StringAssert.Contains("not found", ex.Message); + + Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "dry_run must never write an asset"); + } + + [Test] + public void CreateAsset_Material_DryRun_ValidShader_WritesNothing() + { + // A valid dry_run resolves the shader (no throw) but writes no asset. + var path = Root + "/Mats/DryRunOk.mat"; + var result = AssetCommands.CreateAsset(path, "Material", shader: m_KnownShader, dryRun: true); + + Assert.AreEqual(path, result.AssetPath); + Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "dry_run must never write an asset"); + } + + [Test] + public void CreateAsset_Material_AssignableToRendererSlot() + { + var path = Root + "/Mats/Assignable.mat"; + AssetCommands.CreateAsset(path, "Material", shader: m_KnownShader); + var mat = AssetDatabase.LoadAssetAtPath(path); + + var go = new GameObject("__CLI221_Renderer"); + try + { + var renderer = go.AddComponent(); + renderer.sharedMaterial = mat; + + Assert.AreEqual(mat, renderer.sharedMaterial, "Created Material should be assignable to a Renderer slot"); + } + finally + { + Object.DestroyImmediate(go); + } + } + + #endregion + + #region ViaClient + + [Test] + public void CreateAsset_Material_ViaClient_Succeeds() + { + using (var server = new PipelineTestServer()) + { + var path = Root + "/ViaClient/Wall.mat"; + var response = server.Execute("create_asset", new { path, type = "Material", shader = m_KnownShader }); + + Assert.IsTrue(response.IsSuccess, $"create_asset (Material) should succeed: {response.Error}"); + Assert.IsTrue(response.HasValidJson, "Response should have valid JSON"); + Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field"); + + var loaded = AssetDatabase.LoadAssetAtPath(path); + Assert.IsNotNull(loaded, "Material should exist on disk"); + Assert.IsNotNull(loaded.shader, "Material's shader must not be null"); + } + } + + [Test] + public void CreateAsset_Material_ViaClient_InvalidShader_Fails() + { + using (var server = new PipelineTestServer()) + { + // The server logs the failure as a Unity [Error]; expect it so the test does not fail on + // the unexpected log. Matches the message thrown by ResolveShader ("Shader '...' not found."). + LogAssert.Expect(LogType.Error, new Regex("not found")); + + var path = Root + "/ViaClient/Broken.mat"; + var response = server.Execute("create_asset", new { path, type = "Material", shader = "No/Such/Shader__CLI221" }); + + Assert.IsFalse(response.IsSuccess, "An unknown shader should fail over the wire"); + Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "No asset should be written on a shader failure"); + } + } + + #endregion + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/MaterialAssetCommandsTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/MaterialAssetCommandsTests.cs.meta new file mode 100644 index 00000000..a6df6e4b --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/MaterialAssetCommandsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7d514449899cc0f439d287ab64433a22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/PhysicsMaterialAssetCommandsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/PhysicsMaterialAssetCommandsTests.cs new file mode 100644 index 00000000..cb36cd51 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/PhysicsMaterialAssetCommandsTests.cs @@ -0,0 +1,160 @@ +using NUnit.Framework; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Editor.Commands.Assets; +using UnityEditor; +using UnityEngine; +#if UNITY_6000_0_OR_NEWER +using PipelinePhysicsMaterial = UnityEngine.PhysicsMaterial; +#else +using PipelinePhysicsMaterial = UnityEngine.PhysicMaterial; +#endif + +namespace Unity.Pipeline.Tests.Editor.Assets +{ + /// + /// Tests for create_asset's PhysicsMaterial support (CLI-222), exercised directly and via the + /// isolated . Verifies that BOTH the Unity 6 type name + /// "PhysicsMaterial" and the legacy "PhysicMaterial" resolve to , that + /// the created asset carries the documented defaults (bounciness 0, frictions 0.6), is discoverable + /// via find_assets, and is assignable to a Collider's material slot. + /// + /// NOTE: Unity 6 renamed the class to PhysicsMaterial but the on-disk asset extension is still + /// ".physicMaterial" (AssetDatabase.CreateAsset rejects ".physicsMaterial"). create_asset normalizes + /// a ".physicsMaterial" request to ".physicMaterial", so the created path is asserted via the + /// command result rather than the requested path. + /// + public class PhysicsMaterialAssetCommandsTests + { + private const string Root = "Assets/__CLI221_222Test"; + + [SetUp] + public void SetUp() + { + ProjectPaths.ResetAuthoringRoot(); + } + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(); + } + } + + private static void AssertDefaults(PipelinePhysicsMaterial pm) + { + Assert.IsNotNull(pm, "Created physics material should load as a PhysicsMaterial"); + Assert.AreEqual(0f, pm.bounciness, 1e-4f, "bounciness default should be 0"); + Assert.AreEqual(0.6f, pm.dynamicFriction, 1e-4f, "dynamicFriction default should be 0.6"); + Assert.AreEqual(0.6f, pm.staticFriction, 1e-4f, "staticFriction default should be 0.6"); + } + + #region Direct + + [Test] + public void CreateAsset_PhysicsMaterial_CreatesWithDefaults() + { + var result = AssetCommands.CreateAsset(Root + "/Phys/Ball.physicMaterial", "PhysicsMaterial"); + + StringAssert.EndsWith(".physicMaterial", result.AssetPath); + AssertDefaults(AssetDatabase.LoadAssetAtPath(result.AssetPath)); + } + + [Test] + public void CreateAsset_PhysicsMaterial_NormalizesModernExtension() + { + // CLI-222: an agent may pass the modern ".physicsMaterial" extension; it must succeed, + // normalized to the ".physicMaterial" file Unity actually writes. + var result = AssetCommands.CreateAsset(Root + "/Phys/Modern.physicsMaterial", "PhysicsMaterial"); + + StringAssert.EndsWith(".physicMaterial", result.AssetPath); + AssertDefaults(AssetDatabase.LoadAssetAtPath(result.AssetPath)); + } + + [Test] + public void CreateAsset_PhysicsMaterial_LegacyTypeName_ResolvesToSameType() + { + // CLI-222: the legacy "PhysicMaterial" spelling must resolve to UnityEngine.PhysicsMaterial. + var result = AssetCommands.CreateAsset(Root + "/Phys/Legacy.physicMaterial", "PhysicMaterial"); + + var loaded = AssetDatabase.LoadMainAssetAtPath(result.AssetPath); + Assert.IsInstanceOf(loaded, "Legacy 'PhysicMaterial' type name should resolve to PhysicsMaterial"); + AssertDefaults(loaded as PipelinePhysicsMaterial); + } + + [Test] + public void CreateAsset_PhysicsMaterial_FoundByFindAssets() + { + var result = AssetCommands.CreateAsset(Root + "/Phys/Findable.physicMaterial", "PhysicsMaterial"); + + var found = AssetCommands.FindAssets(type: "PhysicsMaterial", searchIn: Root); + + Assert.GreaterOrEqual(found.Count, 1, "Should find the created PhysicsMaterial by type"); + CollectionAssert.Contains( + System.Linq.Enumerable.Select(found.Assets, a => a.AssetPath), + result.AssetPath); + } + + [Test] + public void CreateAsset_PhysicsMaterial_AssignableToColliderSlot() + { + var result = AssetCommands.CreateAsset(Root + "/Phys/Assignable.physicMaterial", "PhysicsMaterial"); + var pm = AssetDatabase.LoadAssetAtPath(result.AssetPath); + + var go = new GameObject("__CLI222_Collider"); + try + { + var collider = go.AddComponent(); + collider.sharedMaterial = pm; + + Assert.AreEqual(pm, collider.sharedMaterial, "Created PhysicsMaterial should be assignable to a Collider slot"); + } + finally + { + Object.DestroyImmediate(go); + } + } + + #endregion + + #region ViaClient + + [Test] + public void CreateAsset_PhysicsMaterial_ViaClient_Succeeds() + { + using (var server = new PipelineTestServer()) + { + var response = server.Execute("create_asset", + new { path = Root + "/ViaClient/Ball.physicMaterial", type = "PhysicsMaterial" }); + + Assert.IsTrue(response.IsSuccess, $"create_asset (PhysicsMaterial) should succeed: {response.Error}"); + Assert.IsTrue(response.HasValidJson, "Response should have valid JSON"); + Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field"); + + var created = (string)response.JsonResponse["result"]["assetPath"]; + AssertDefaults(AssetDatabase.LoadAssetAtPath(created)); + } + } + + [Test] + public void CreateAsset_PhysicsMaterial_ViaClient_LegacyName_Succeeds() + { + using (var server = new PipelineTestServer()) + { + var response = server.Execute("create_asset", + new { path = Root + "/ViaClient/Legacy.physicMaterial", type = "PhysicMaterial" }); + + Assert.IsTrue(response.IsSuccess, $"create_asset (legacy PhysicMaterial) should succeed: {response.Error}"); + + var created = (string)response.JsonResponse["result"]["assetPath"]; + Assert.IsInstanceOf(AssetDatabase.LoadMainAssetAtPath(created), + "Legacy 'PhysicMaterial' should resolve to PhysicsMaterial over the wire"); + } + } + + #endregion + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/PhysicsMaterialAssetCommandsTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/PhysicsMaterialAssetCommandsTests.cs.meta new file mode 100644 index 00000000..e085b833 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/PhysicsMaterialAssetCommandsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: af05410e747b85c47ab38e0bf2db7fd8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/TextFileCommandsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/TextFileCommandsTests.cs new file mode 100644 index 00000000..82d880b3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/TextFileCommandsTests.cs @@ -0,0 +1,108 @@ +using NUnit.Framework; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Editor.Commands.Assets; +using UnityEditor; + +namespace Unity.Pipeline.Tests.Editor.Assets +{ + /// + /// Tests for read_text_file / write_text_file (CLI-191), direct and via the isolated + /// . Covers round-tripping content, the overwrite-confirm guard, and + /// the sandbox guard (out-of-project writes rejected). + /// + public class TextFileCommandsTests + { + private const string Root = "Assets/__CLI191TextTest"; + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(); + } + } + + #region Direct + + [Test] + public void WriteThenRead_RoundTrips() + { + var path = Root + "/Notes/hello.txt"; + const string content = "hello cli-191"; + + var write = TextFileCommands.WriteTextFile(path, content); + Assert.AreEqual(path, write.AssetPath); + + var read = TextFileCommands.ReadTextFile(path); + Assert.AreEqual(content, read.Contents); + Assert.AreEqual(System.Text.Encoding.UTF8.GetByteCount(content), read.Bytes); + } + + [Test] + public void WriteTextFile_OverwriteWithoutConfirm_Throws() + { + var path = Root + "/Notes/dup.txt"; + TextFileCommands.WriteTextFile(path, "first"); + + Assert.Throws( + () => TextFileCommands.WriteTextFile(path, "second"), + "Overwriting an existing file without confirm should be rejected"); + + var ok = TextFileCommands.WriteTextFile(path, "second", confirm: true); + Assert.AreEqual(path, ok.AssetPath); + Assert.AreEqual("second", TextFileCommands.ReadTextFile(path).Contents); + } + + [Test] + public void WriteTextFile_OutOfProject_Throws() + { + Assert.Throws(() => TextFileCommands.WriteTextFile("../escape.txt", "x")); + Assert.Throws(() => TextFileCommands.WriteTextFile("Assets/../escape.txt", "x")); + } + + [Test] + public void ReadTextFile_Missing_Throws() + { + Assert.Throws(() => TextFileCommands.ReadTextFile(Root + "/does-not-exist.txt")); + } + + #endregion + + #region ViaClient + + [Test] + public void WriteTextFile_ViaClient_Succeeds() + { + using (var server = new PipelineTestServer()) + { + var path = Root + "/ViaClient/wire.txt"; + var response = server.Execute("write_text_file", new { path, contents = "over the wire" }); + + Assert.IsTrue(response.IsSuccess, $"write_text_file should succeed: {response.Error}"); + Assert.IsTrue(response.HasValidJson, "Response should have valid JSON"); + Assert.AreEqual("over the wire", TextFileCommands.ReadTextFile(path).Contents); + } + } + + [Test] + public void ReadTextFile_ViaClient_Succeeds() + { + var path = Root + "/ViaClient/readable.txt"; + TextFileCommands.WriteTextFile(path, "readable content"); + + using (var server = new PipelineTestServer()) + { + var response = server.Execute("read_text_file", new { path }); + + Assert.IsTrue(response.IsSuccess, $"read_text_file should succeed: {response.Error}"); + Assert.IsTrue(response.HasValidJson, "Response should have valid JSON"); + Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field"); + } + } + + #endregion + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/TextFileCommandsTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/TextFileCommandsTests.cs.meta new file mode 100644 index 00000000..8c9cb70c --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Assets/TextFileCommandsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4ede31badcf640f6acc3c5baf0fc8ef3 diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring.meta new file mode 100644 index 00000000..897d58c5 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 497149ab4929cd34483ff611ce25534a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/FolderCommandsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/FolderCommandsTests.cs new file mode 100644 index 00000000..89e3daf8 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/FolderCommandsTests.cs @@ -0,0 +1,85 @@ +using NUnit.Framework; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Editor.Commands.Assets; +using UnityEditor; + +namespace Unity.Pipeline.Tests.Editor.Authoring +{ + /// + /// Tests for the create_folder reference command (CLI-190), exercised directly and via + /// PipelineClient. Verifies the result envelope, recursive creation, Assets-relative paths, + /// and the traversal guard. + /// + public class FolderCommandsTests + { + private const string Root = "Assets/__CLI190Test"; + + [SetUp] + public void SetUp() + { + // Start from the default authoring root so a leftover EditorPref (from another test or an + // interrupted run) can't shift where bare paths resolve mid-suite. + ProjectPaths.ResetAuthoringRoot(); + } + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(); + } + } + + #region Direct + + [Test] + public void CreateFolder_ExplicitAssetsPath_CreatesFoldersAndReturnsIdentity() + { + var result = FolderCommands.CreateFolder(Root + "/Sub/Leaf"); + + Assert.IsTrue(AssetDatabase.IsValidFolder(Root + "/Sub/Leaf"), "Nested folders should exist"); + Assert.AreEqual(Root + "/Sub/Leaf", result.AssetPath); + Assert.IsNotEmpty(result.Guid, "Created folder should have a GUID"); + } + + [Test] + public void CreateFolder_BarePath_ResolvesUnderAssets() + { + // No "Assets/" prefix — should resolve under the default authoring root (Assets/). + var result = FolderCommands.CreateFolder("__CLI190Test/Bare"); + + Assert.AreEqual("Assets/__CLI190Test/Bare", result.AssetPath); + Assert.IsTrue(AssetDatabase.IsValidFolder("Assets/__CLI190Test/Bare")); + } + + [Test] + public void CreateFolder_Traversal_Throws() + { + Assert.Throws(() => FolderCommands.CreateFolder("../Outside")); + Assert.Throws(() => FolderCommands.CreateFolder("Assets/../Outside")); + } + + #endregion + + #region ViaClient + + [Test] + public void CreateFolder_ViaClient_Succeeds() + { + using (var server = new PipelineTestServer()) + { + var response = server.Execute("create_folder", new { path = Root + "/ViaClient" }); + + Assert.IsTrue(response.IsSuccess, $"create_folder should succeed: {response.Error}"); + Assert.IsTrue(response.HasValidJson, "Response should have valid JSON"); + Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field"); + Assert.IsTrue(AssetDatabase.IsValidFolder(Root + "/ViaClient"), "Folder should be created"); + } + } + + #endregion + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/FolderCommandsTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/FolderCommandsTests.cs.meta new file mode 100644 index 00000000..d202bd39 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/FolderCommandsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4653539f2cf4a1b4699bd2c7a8d35188 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ObjectRefConverterTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ObjectRefConverterTests.cs new file mode 100644 index 00000000..63b9ed46 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ObjectRefConverterTests.cs @@ -0,0 +1,223 @@ +using NUnit.Framework; +using Newtonsoft.Json; +using Unity.Pipeline; +using Unity.Pipeline.Models; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace Unity.Pipeline.Tests.Editor.Authoring +{ + /// + /// Tests for : a single canonical string handle coerces into the + /// matching ObjectRef field (mirroring ObjectResolver/ToString priority), structured objects still + /// deserialize fully, and serialization still emits a JSON object. Plus one end-to-end ViaClient + /// test proving a string target flows through the real /api/exec dispatch path. + /// + public class ObjectRefConverterTests + { + private static ObjectRef Parse(string handle) => + JsonConvert.DeserializeObject("\"" + handle + "\""); + + #region String coercion (one field per canonical form) + + [Test] + public void String_HierarchyPath_LeadingSlash() + { + var r = Parse("/Player"); + Assert.AreEqual("/Player", r.HierarchyPath); + Assert.IsNull(r.GlobalId); Assert.IsNull(r.Path); Assert.IsNull(r.Guid); + Assert.IsNull(r.InstanceId); Assert.IsNull(r.FileId); + } + + [Test] + public void String_HierarchyPath_Nested() + { + Assert.AreEqual("/Level/Floor", Parse("/Level/Floor").HierarchyPath); + } + + [Test] + public void String_GlobalId() + { + var r = Parse("GlobalObjectId_V1-2-abc123def4560000abc123def4560000-1095335325-0"); + Assert.AreEqual("GlobalObjectId_V1-2-abc123def4560000abc123def4560000-1095335325-0", r.GlobalId); + Assert.IsNull(r.HierarchyPath); + } + + [Test] + public void String_AssetPath() + { + var r = Parse("Assets/Prefabs/Enemy.prefab"); + Assert.AreEqual("Assets/Prefabs/Enemy.prefab", r.Path); + Assert.IsNull(r.HierarchyPath); + } + + [Test] + public void String_GuidWithFileId() + { + var r = Parse("guid:a1b2c3d4e5f6:123"); + Assert.AreEqual("a1b2c3d4e5f6", r.Guid); + Assert.AreEqual(123L, r.FileId); + } + + [Test] + public void String_GuidOnly_Prefixed() + { + var r = Parse("guid:a1b2c3d4e5f6"); + Assert.AreEqual("a1b2c3d4e5f6", r.Guid); + Assert.IsNull(r.FileId); + } + + [Test] + public void String_InstanceId_Prefixed_Negative() + { +#if !UNITY_6000_4_OR_NEWER + Assert.AreEqual(ObjectId.FromRaw(-3070), Parse("instanceId:-3070").InstanceId); +#else + Assert.Ignore("Negative int instance ids do not exist in the EntityId (ulong) model on 6000.4+."); +#endif + } + + [Test] + public void String_PlainNegativeInt_IsInstanceId() + { +#if !UNITY_6000_4_OR_NEWER + var r = Parse("-3070"); + Assert.AreEqual(ObjectId.FromRaw(-3070), r.InstanceId); + Assert.IsNull(r.HierarchyPath); +#else + Assert.Ignore("Negative int instance ids do not exist in the EntityId (ulong) model on 6000.4+."); +#endif + } + + [Test] + public void String_PlainPositiveInt_IsInstanceId() + { + Assert.AreEqual(ObjectId.FromRaw(48184), Parse("48184").InstanceId); + } + + [Test] + public void String_32Hex_IsGuid() + { + var r = Parse("a1b2c3d4e5f60718293a4b5c6d7e8f90"); + Assert.AreEqual("a1b2c3d4e5f60718293a4b5c6d7e8f90", r.Guid); + Assert.IsNull(r.InstanceId); + } + + [Test] + public void String_BareName_FallsBackToHierarchyPath() + { + var r = Parse("Enemy_1"); + Assert.AreEqual("Enemy_1", r.HierarchyPath); + Assert.IsNull(r.InstanceId); Assert.IsNull(r.Guid); + } + + #endregion + + #region Authoring-root-relative asset paths (AUTHAPI-9) + + [Test] + public void String_RelativePathWithExtension_IsPath() + { + // No "Assets/" prefix but a file extension: an authoring-root-relative asset path. + var r = Parse("Materials/FloorA.mat"); + Assert.AreEqual("Materials/FloorA.mat", r.Path); + Assert.IsNull(r.HierarchyPath); + } + + [Test] + public void String_BareFileWithExtension_IsPath() + { + var r = Parse("Enemy.prefab"); + Assert.AreEqual("Enemy.prefab", r.Path); + Assert.IsNull(r.HierarchyPath); + } + + [Test] + public void String_RelativeNoExtension_StaysHierarchyPath() + { + // No extension and no leading slash: still a scene hierarchy path, not an asset candidate. + var r = Parse("Root/Child"); + Assert.AreEqual("Root/Child", r.HierarchyPath); + Assert.IsNull(r.Path); + } + + [Test] + public void String_LeadingSlashWithExtension_StaysHierarchyPath() + { + // A leading slash marks a canonical hierarchy path even when a name looks dotted. + var r = Parse("/Root/Cube.001"); + Assert.AreEqual("/Root/Cube.001", r.HierarchyPath); + Assert.IsNull(r.Path); + } + + #endregion + + #region Object form, write, null + + [Test] + public void Object_Structured_DeserializesAllFields() + { + var json = "{\"globalId\":\"G\",\"path\":\"Assets/A\",\"guid\":\"a1\",\"fileId\":42,\"instanceId\":7,\"hierarchyPath\":\"/H\"}"; + var r = JsonConvert.DeserializeObject(json); + Assert.AreEqual("G", r.GlobalId); + Assert.AreEqual("Assets/A", r.Path); + Assert.AreEqual("a1", r.Guid); + Assert.AreEqual(42L, r.FileId); + Assert.AreEqual(ObjectId.FromRaw(7), r.InstanceId); + Assert.AreEqual("/H", r.HierarchyPath); + } + + [Test] + public void WriteJson_EmitsObject_NotString_AndRoundTrips() + { + var json = JsonConvert.SerializeObject(new ObjectRef { HierarchyPath = "/Player" }); + StringAssert.StartsWith("{", json, "ObjectRef must serialize as a JSON object, not a bare string"); + StringAssert.Contains("\"hierarchyPath\":\"/Player\"", json); + + var back = JsonConvert.DeserializeObject(json); + Assert.AreEqual("/Player", back.HierarchyPath); + } + + [Test] + public void Null_DeserializesToNull() + { + Assert.IsNull(JsonConvert.DeserializeObject("null")); + } + + [Test] + public void Number_BareInteger_IsInstanceId() + { + Assert.AreEqual(ObjectId.FromRaw(48184), JsonConvert.DeserializeObject("48184").InstanceId); + } + + #endregion + + #region End-to-end via the real dispatch path + + [Test] + public void SetTransform_StringTarget_ResolvesAndApplies() + { + var go = new GameObject("CLI_ObjRefDispatch"); + try + { + using (var server = new PipelineTestServer()) + { + var resp = server.Execute("set_transform", + new { target = "/CLI_ObjRefDispatch", position = new[] { 1f, 2f, 3f } }); + + Assert.IsTrue(resp.IsSuccess, + $"set_transform with a string target should succeed (string->ObjectRef coercion): {resp.Error}"); + } + + Assert.AreEqual(new Vector3(1f, 2f, 3f), go.transform.localPosition, + "The string handle should have resolved to the GameObject and applied the transform"); + } + finally + { + Object.DestroyImmediate(go); + } + } + + #endregion + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ObjectRefConverterTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ObjectRefConverterTests.cs.meta new file mode 100644 index 00000000..f22eadc1 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ObjectRefConverterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b218af7c5c4799a4ab37d7a670a6256e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ObjectResolverTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ObjectResolverTests.cs new file mode 100644 index 00000000..64b45834 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ObjectResolverTests.cs @@ -0,0 +1,145 @@ +using NUnit.Framework; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; +using Unity.Pipeline; + +namespace Unity.Pipeline.Tests.Editor.Authoring +{ + /// + /// Tests for the object-reference resolver foundation (CLI-190): each handle form resolves back + /// to the same object, and Describe produces a canonical identity. + /// + public class ObjectResolverTests + { + private const string AssetFolder = "Assets/AUTHAPI9_Res"; + + private GameObject m_SceneObject; + + [TearDown] + public void TearDown() + { + if (m_SceneObject != null) + Object.DestroyImmediate(m_SceneObject); + m_SceneObject = null; + + if (AssetDatabase.IsValidFolder(AssetFolder)) + { + AssetDatabase.DeleteAsset(AssetFolder); + AssetDatabase.Refresh(); + } + } + + private static string CreateMaterialAsset(string name) + { + if (!AssetDatabase.IsValidFolder(AssetFolder)) + AssetDatabase.CreateFolder("Assets", "AUTHAPI9_Res"); + + var shader = Shader.Find("Standard") + ?? Shader.Find("Universal Render Pipeline/Lit") + ?? Shader.Find("Sprites/Default"); + var mat = new Material(shader); + var path = $"{AssetFolder}/{name}.mat"; + AssetDatabase.CreateAsset(mat, path); + AssetDatabase.SaveAssets(); + return path; + } + + [Test] + public void Describe_SceneObject_ProducesInstanceIdAndHierarchyPath() + { + m_SceneObject = new GameObject("CLI190_Root"); + var child = new GameObject("Child"); + child.transform.SetParent(m_SceneObject.transform); + + var info = ObjectResolver.Describe(child); + + Assert.IsNotNull(info); + Assert.AreEqual(PipelineUtils.GetObjectId(child), info.InstanceId); + Assert.AreEqual("/CLI190_Root/Child", info.HierarchyPath); + Assert.AreEqual("GameObject", info.Type); + Assert.IsNull(info.AssetPath, "A scene object should not report an asset path"); + } + + [Test] + public void Resolve_ByInstanceId_ReturnsSameObject() + { + m_SceneObject = new GameObject("CLI190_ById"); + var handle = new ObjectRef { InstanceId = PipelineUtils.GetObjectId(m_SceneObject) }; + + Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error); + Assert.AreSame(m_SceneObject, obj); + } + + [Test] + public void Resolve_ByHierarchyPath_ReturnsSameObject() + { + m_SceneObject = new GameObject("CLI190_ByPath"); + var handle = new ObjectRef { HierarchyPath = "/CLI190_ByPath" }; + + Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error); + Assert.AreSame(m_SceneObject, obj); + } + + [Test] + public void Resolve_EmptyHandle_FailsWithError() + { + Assert.IsFalse(ObjectResolver.TryResolve(new ObjectRef(), out _, out var error)); + Assert.IsNotEmpty(error); + } + + [Test] + public void Resolve_UnknownGuid_FailsWithError() + { + var handle = new ObjectRef { Guid = "00000000000000000000000000000000" }; + Assert.IsFalse(ObjectResolver.TryResolve(handle, out _, out var error)); + Assert.IsNotEmpty(error); + } + + [Test] + public void Resolve_ByRelativeAssetPath_NormalizesUnderAuthoringRoot() + { + // A path without the "Assets/" prefix (AUTHAPI-9) resolves under the authoring root. + var full = CreateMaterialAsset("Floor"); + var expected = AssetDatabase.LoadMainAssetAtPath(full); + var handle = new ObjectRef { Path = "AUTHAPI9_Res/Floor.mat" }; + + Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error); + Assert.AreSame(expected, obj); + } + + [Test] + public void Resolve_ExplicitAssetsPath_StillResolves() + { + var full = CreateMaterialAsset("Wall"); + var handle = new ObjectRef { Path = full }; + + Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error); + Assert.AreSame(AssetDatabase.LoadMainAssetAtPath(full), obj); + } + + [Test] + public void Resolve_DottedGameObjectName_FallsBackToHierarchy() + { + // A dotted name (e.g. "Cube.001") is routed to Path by the string converter but is really a + // scene object; the Path branch falls back to a hierarchy lookup. + m_SceneObject = new GameObject("Cube.001"); + var handle = new ObjectRef { Path = "Cube.001" }; + + Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error); + Assert.AreSame(m_SceneObject, obj); + } + + [Test] + public void Resolve_UnresolvableRelativePath_ErrorListsEveryStrategy() + { + var handle = new ObjectRef { Path = "AUTHAPI9_Res/Missing.mat" }; + + Assert.IsFalse(ObjectResolver.TryResolve(handle, out _, out var error)); + StringAssert.Contains("no asset at", error); + StringAssert.Contains("no GameObject at hierarchy path", error); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ObjectResolverTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ObjectResolverTests.cs.meta new file mode 100644 index 00000000..2b4e94d4 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ObjectResolverTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e864b00059a57364d902089021b33742 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ProjectPathsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ProjectPathsTests.cs new file mode 100644 index 00000000..956dc8f8 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ProjectPathsTests.cs @@ -0,0 +1,84 @@ +using NUnit.Framework; +using Unity.Pipeline.Editor.Authoring; + +namespace Unity.Pipeline.Tests.Editor.Authoring +{ + /// + /// Tests for ProjectPaths (CLI-190): Assets-relative resolution, the traversal/out-of-root + /// guards, and the configurable authoring root. + /// + public class ProjectPathsTests + { + [SetUp] + public void SetUp() + { + // Reset to the default root before each test so a leftover EditorPref can't leak in. + ProjectPaths.ResetAuthoringRoot(); + } + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + } + + [Test] + public void DefaultRoot_IsAssets() + { + ProjectPaths.ResetAuthoringRoot(); + Assert.AreEqual("Assets", ProjectPaths.AuthoringRoot); + } + + [Test] + public void Resolve_BarePath_PrependsAssets() + { + var resolved = ProjectPaths.Resolve("Gameplay/Enemies", out var error); + Assert.IsNull(error, error); + Assert.AreEqual("Assets/Gameplay/Enemies", resolved); + } + + [Test] + public void Resolve_ExplicitAssetsPath_Unchanged() + { + var resolved = ProjectPaths.Resolve("Assets/Gameplay", out var error); + Assert.IsNull(error, error); + Assert.AreEqual("Assets/Gameplay", resolved); + } + + [Test] + public void Resolve_Traversal_Fails() + { + Assert.IsNull(ProjectPaths.Resolve("../Outside", out var error)); + Assert.IsNotEmpty(error); + } + + [Test] + public void Resolve_EmptyPath_Fails() + { + Assert.IsNull(ProjectPaths.Resolve("", out var error)); + Assert.IsNotEmpty(error); + } + + [Test] + public void ConfigurableRoot_ConfinesBareAndRejectsOutside() + { + ProjectPaths.AuthoringRoot = "Assets/__CLI190Root"; + + // Bare path resolves under the configured root. + var inside = ProjectPaths.Resolve("Foo/Bar", out var insideError); + Assert.IsNull(insideError, insideError); + Assert.AreEqual("Assets/__CLI190Root/Foo/Bar", inside); + + // Explicit path outside the configured root is rejected (confinement). + Assert.IsNull(ProjectPaths.Resolve("Assets/Other", out var outsideError)); + Assert.IsNotEmpty(outsideError); + } + + [Test] + public void SetAuthoringRoot_OutsideAssets_Throws() + { + Assert.Throws(() => ProjectPaths.AuthoringRoot = "Outside"); + Assert.Throws(() => ProjectPaths.AuthoringRoot = "Assets/../Escape"); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ProjectPathsTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ProjectPathsTests.cs.meta new file mode 100644 index 00000000..25c7f83e --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Authoring/ProjectPathsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9af4698d059fd9d43b72c6e1dad56424 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking.meta new file mode 100644 index 00000000..8df4238f --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5e5fd89f0ad042538cfef1bd87763a27 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/LightingBakeCommandsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/LightingBakeCommandsTests.cs new file mode 100644 index 00000000..ecf558e6 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/LightingBakeCommandsTests.cs @@ -0,0 +1,199 @@ +using System.Linq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor; +using Unity.Pipeline.Editor.Commands.Baking; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine.SceneManagement; + +namespace Unity.Pipeline.Tests.Editor.Baking +{ + /// + /// Tests for the CLI-215 lighting bake commands. These cover the parts that are deterministic + /// without waiting on a real (minutes-long) lightmap bake: command discovery/schema, the + /// settings get/set round-trip, the destructive clear guard, the idle status, the no_scene guard, + /// and dry_run being a no-op. The full bake → completed acceptance flow is exercised live against a + /// running Editor (it is too slow/platform-dependent for the unit suite). + /// + public class LightingBakeCommandsTests + { + [SetUp] + public void SetUp() => CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery()); + + [TearDown] + public void TearDown() + { + // Leave the editor with a fresh empty (untitled) scene so other tests are unaffected. + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + } + + [Test] + public void LightingCommands_AreDiscovered_WithExpectedSchema() + { + var commands = CommandRegistry.DiscoverCommands().ToList(); + + var bake = commands.FirstOrDefault(c => c.Name == "bake_lighting"); + Assert.IsNotNull(bake, "Should discover bake_lighting"); + CollectionAssert.AreEquivalent(new[] { "confirm", "dry_run" }, + bake.Parameters.Select(p => p.Name).ToList()); + + var status = commands.FirstOrDefault(c => c.Name == "lighting_bake_status"); + Assert.IsNotNull(status, "Should discover lighting_bake_status"); + Assert.IsFalse(status.MainThreadRequired, + "lighting_bake_status must be off-main-thread so it answers while a bake holds the main thread"); + Assert.AreEqual(0, status.Parameters.Count, "lighting_bake_status takes no parameters"); + + Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "cancel_lighting_bake")); + Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "clear_baked_lighting")); + Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "get_lighting_settings")); + Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "set_lighting_settings")); + } + + [Test] + public void GetLightingSettings_ReturnsCoreFields() + { + var settings = LightingBakeCommands.GetLightingSettings(); + if (!settings.Available) + { + // No LightingSettings are exposed when no scene is open (e.g. batchmode test + // projects start with an untitled empty scene). Skip rather than fail. + Assert.Pass("No active LightingSettings — skipping (no scene open in test project)."); + return; + } + Assert.IsNotNull(settings.Lightmapper, "lightmapper should be reported"); + + // directionalMode must be one of the two spec values, never a raw flags-enum string. + Assert.That(settings.DirectionalMode, Is.EqualTo("NonDirectional").Or.EqualTo("Directional"), + "directionalMode must be 'NonDirectional' or 'Directional'"); + + // lightmapCompression must be the LightmapCompression enum name, not a bool. + var validCompressions = new[] { "None", "LowQuality", "NormalQuality", "HighQuality" }; + CollectionAssert.Contains(validCompressions, settings.LightmapCompression, + "lightmapCompression must be one of the LightmapCompression enum names"); + } + + [Test] + public void SetLightingSettings_DirectionalMode_RoundTrips() + { + var before = LightingBakeCommands.GetLightingSettings(); + if (!before.Available) + { + Assert.Pass("No active LightingSettings — skipping directionalMode round-trip test."); + return; + } + + var target = before.DirectionalMode == "NonDirectional" ? "Directional" : "NonDirectional"; + try + { + var result = JObject.FromObject(LightingBakeCommands.SetLightingSettings( + new JObject { ["directionalMode"] = target })); + CollectionAssert.Contains(result["applied"].Select(t => t.Value()).ToList(), + "directionalMode", "directionalMode should be in applied[]"); + + var after = LightingBakeCommands.GetLightingSettings(); + Assert.That(after.DirectionalMode, Is.EqualTo("NonDirectional").Or.EqualTo("Directional"), + "directionalMode must remain a spec-valid string after set"); + } + finally + { + LightingBakeCommands.SetLightingSettings(new JObject { ["directionalMode"] = before.DirectionalMode }); + } + } + + [Test] + public void SetLightingSettings_RoundTrips_BouncesAndResolution() + { + var before = LightingBakeCommands.GetLightingSettings(); + var newBounces = before.Bounces == 3 ? 2 : 3; + var newResolution = before.LightmapResolution >= 40f ? 20f : 50f; + + try + { + var settings = new JObject + { + ["bounces"] = newBounces, + ["lightmapResolution"] = newResolution + }; + LightingBakeCommands.SetLightingSettings(settings); + + var after = LightingBakeCommands.GetLightingSettings(); + Assert.AreEqual(newBounces, after.Bounces, "bounces should round-trip"); + Assert.AreEqual(newResolution, after.LightmapResolution, 0.001f, "lightmapResolution should round-trip"); + } + finally + { + // Restore the prior values so the suite leaves no residue in the host project's + // lighting settings (the active settings may be a shared scene-default or asset). + LightingBakeCommands.SetLightingSettings(new JObject + { + ["bounces"] = before.Bounces, + ["lightmapResolution"] = before.LightmapResolution + }); + } + } + + [Test] + public void SetLightingSettings_ReportsUnknownKeys() + { + var settings = new JObject + { + ["bounces"] = 2, + ["totallyNotARealKey"] = 5 + }; + var result = JObject.FromObject(LightingBakeCommands.SetLightingSettings(settings)); + + var applied = result["applied"].Select(t => t.Value()).ToList(); + var unknown = result["unknown"].Select(t => t.Value()).ToList(); + CollectionAssert.Contains(applied, "bounces"); + CollectionAssert.Contains(unknown, "totallyNotARealKey"); + } + + [Test] + public void SetLightingSettings_DryRun_DoesNotChangeAnything() + { + var before = LightingBakeCommands.GetLightingSettings(); + var changed = before.Bounces == 1 ? 4 : 1; + + LightingBakeCommands.SetLightingSettings(new JObject { ["bounces"] = changed }, dryRun: true); + + var after = LightingBakeCommands.GetLightingSettings(); + Assert.AreEqual(before.Bounces, after.Bounces, "dry_run must not mutate settings"); + } + + [Test] + public void ClearBakedLighting_WithoutConfirm_Throws() + { + Assert.Throws(() => LightingBakeCommands.ClearBakedLighting()); + } + + [Test] + public void ClearBakedLighting_DryRun_DoesNotThrow_AndReportsWouldClear() + { + var result = JObject.FromObject(LightingBakeCommands.ClearBakedLighting(confirm: true, dryRun: true)); + Assert.AreEqual("dry_run", result["status"].Value()); + Assert.IsTrue(result["wouldClear"].Value()); + } + + [Test] + public void BakeLighting_NoOpenSavedScene_ReturnsNoScene() + { + // A fresh untitled scene has an empty path, so it is not a bakeable (saved) scene. + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + Assert.IsTrue(string.IsNullOrEmpty(SceneManager.GetActiveScene().path), "test precondition: untitled scene"); + + var result = JObject.FromObject(LightingBakeCommands.BakeLighting()); + Assert.AreEqual("no_scene", result["code"].Value()); + } + + [Test] + public void LightingBakeStatus_Idle_WhenNothingTriggered_OrAfterStatusFile() + { + // The status reflects either a clean idle or the result of a prior (cancelled/completed) + // bake from another test; in all cases it must be valid JSON carrying a "status" field. + var json = JObject.Parse(LightingBakeCommands.LightingBakeStatus()); + Assert.IsNotNull(json["status"], "lighting_bake_status should always include a 'status' field"); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/LightingBakeCommandsTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/LightingBakeCommandsTests.cs.meta new file mode 100644 index 00000000..f0e4f610 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/LightingBakeCommandsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bdeb4ada982746e1aa5d72b49157fd3e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/NavMeshBakeCommandsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/NavMeshBakeCommandsTests.cs new file mode 100644 index 00000000..6ce9eb65 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/NavMeshBakeCommandsTests.cs @@ -0,0 +1,125 @@ +using System.Linq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor; +using Unity.Pipeline.Editor.Commands.Baking; +using UnityEditor.SceneManagement; + +namespace Unity.Pipeline.Tests.Editor.Baking +{ + /// + /// Tests for the CLI-215 legacy NavMesh bake commands. Covers discovery/schema, the settings + /// get/set round-trip, the destructive clear guard, the idle status, the no_scene guard, dry_run + /// being a no-op, and the AI-Navigation stub returning package_not_found when the package is + /// absent. The full bake → succeeded flow is exercised live. + /// + public class NavMeshBakeCommandsTests + { + [SetUp] + public void SetUp() => CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery()); + + [TearDown] + public void TearDown() + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + } + + [Test] + public void NavMeshCommands_AreDiscovered_WithExpectedSchema() + { + var commands = CommandRegistry.DiscoverCommands().ToList(); + + Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "bake_navmesh")); + Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "cancel_navmesh_bake")); + Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "clear_navmesh")); + Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "get_navmesh_settings")); + Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "set_navmesh_settings")); + + var status = commands.FirstOrDefault(c => c.Name == "navmesh_bake_status"); + Assert.IsNotNull(status, "Should discover navmesh_bake_status"); + Assert.IsFalse(status.MainThreadRequired, "navmesh_bake_status must be off-main-thread"); + Assert.AreEqual(0, status.Parameters.Count, "navmesh_bake_status takes no parameters"); + } + + [Test] + public void GetNavMeshSettings_ReturnsAgentFields() + { + var settings = NavMeshBakeCommands.GetNavMeshSettings(); + Assert.IsTrue(settings.Available, "Legacy NavMesh settings should be available"); + Assert.Greater(settings.AgentRadius, 0f, "agentRadius should be a sensible positive default"); + Assert.Greater(settings.AgentHeight, 0f, "agentHeight should be a sensible positive default"); + } + + [Test] + public void SetNavMeshSettings_RoundTrips_AgentRadius() + { + var before = NavMeshBakeCommands.GetNavMeshSettings(); + var newRadius = before.AgentRadius >= 0.5f ? 0.35f : 0.75f; + + NavMeshBakeCommands.SetNavMeshSettings(new JObject { ["agentRadius"] = newRadius }); + + var after = NavMeshBakeCommands.GetNavMeshSettings(); + Assert.AreEqual(newRadius, after.AgentRadius, 0.001f, "agentRadius should round-trip"); + + // Restore the prior value so the suite leaves no residue in the host project's settings. + NavMeshBakeCommands.SetNavMeshSettings(new JObject { ["agentRadius"] = before.AgentRadius }); + } + + [Test] + public void SetNavMeshSettings_ReportsUnknownKeys() + { + var result = JObject.FromObject( + NavMeshBakeCommands.SetNavMeshSettings(new JObject { ["agentRadius"] = 0.5f, ["nope"] = 1 }, dryRun: true)); + + var applied = result["applied"].Select(t => t.Value()).ToList(); + var unknown = result["unknown"].Select(t => t.Value()).ToList(); + CollectionAssert.Contains(applied, "agentRadius"); + CollectionAssert.Contains(unknown, "nope"); + } + + [Test] + public void SetNavMeshSettings_DryRun_DoesNotChangeAnything() + { + var before = NavMeshBakeCommands.GetNavMeshSettings(); + var changed = before.AgentRadius >= 0.5f ? 0.2f : 0.9f; + + NavMeshBakeCommands.SetNavMeshSettings(new JObject { ["agentRadius"] = changed }, dryRun: true); + + var after = NavMeshBakeCommands.GetNavMeshSettings(); + Assert.AreEqual(before.AgentRadius, after.AgentRadius, 0.001f, "dry_run must not mutate settings"); + } + + [Test] + public void ClearNavMesh_WithoutConfirm_Throws() + { + Assert.Throws(() => NavMeshBakeCommands.ClearNavMesh()); + } + + [Test] + public void BakeNavMesh_NoOpenSavedScene_ReturnsNoScene() + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + + var result = JObject.FromObject(NavMeshBakeCommands.BakeNavMesh()); + Assert.AreEqual("no_scene", result["code"].Value()); + } + + [Test] + public void NavMeshBakeStatus_AlwaysHasStatusField() + { + var json = JObject.Parse(NavMeshBakeCommands.NavMeshBakeStatus()); + Assert.IsNotNull(json["status"]); + } + + [Test] + public void BakeNavMeshSurfaces_WithoutPackage_ReturnsPackageNotFound() + { + // This repo does not reference com.unity.ai.navigation, so the stub should report the + // package is absent (or, if the package is present in the host project, not_implemented). + var result = JObject.FromObject(NavMeshBakeCommands.BakeNavMeshSurfaces()); + var code = result["code"].Value(); + Assert.That(code, Is.EqualTo("package_not_found").Or.EqualTo("not_implemented")); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/NavMeshBakeCommandsTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/NavMeshBakeCommandsTests.cs.meta new file mode 100644 index 00000000..5114ac52 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/NavMeshBakeCommandsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d88fed4842cc4242b9e25237547c5514 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/OcclusionBakeCommandsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/OcclusionBakeCommandsTests.cs new file mode 100644 index 00000000..e86dce28 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/OcclusionBakeCommandsTests.cs @@ -0,0 +1,69 @@ +using System.Linq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor; +using Unity.Pipeline.Editor.Commands.Baking; +using UnityEditor.SceneManagement; + +namespace Unity.Pipeline.Tests.Editor.Baking +{ + /// + /// Tests for the CLI-215 occlusion-culling bake commands. Covers discovery/schema, the destructive + /// clear guard, the idle status, the no_scene guard, and dry_run being a no-op (and echoing the + /// effective parameters). The full bake → succeeded flow is exercised live. + /// + public class OcclusionBakeCommandsTests + { + [SetUp] + public void SetUp() => CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery()); + + [TearDown] + public void TearDown() + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + } + + [Test] + public void OcclusionCommands_AreDiscovered_WithExpectedSchema() + { + var commands = CommandRegistry.DiscoverCommands().ToList(); + + var bake = commands.FirstOrDefault(c => c.Name == "bake_occlusion_culling"); + Assert.IsNotNull(bake, "Should discover bake_occlusion_culling"); + CollectionAssert.AreEquivalent( + new[] { "smallest_occluder", "smallest_hole", "backface_threshold", "confirm", "dry_run" }, + bake.Parameters.Select(p => p.Name).ToList()); + + var status = commands.FirstOrDefault(c => c.Name == "occlusion_bake_status"); + Assert.IsNotNull(status, "Should discover occlusion_bake_status"); + Assert.IsFalse(status.MainThreadRequired, "occlusion_bake_status must be off-main-thread"); + Assert.AreEqual(0, status.Parameters.Count, "occlusion_bake_status takes no parameters"); + + Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "cancel_occlusion_bake")); + Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "clear_occlusion_culling")); + } + + [Test] + public void ClearOcclusion_WithoutConfirm_Throws() + { + Assert.Throws(() => OcclusionBakeCommands.ClearOcclusionCulling()); + } + + [Test] + public void BakeOcclusion_NoOpenSavedScene_ReturnsNoScene() + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + + var result = JObject.FromObject(OcclusionBakeCommands.BakeOcclusionCulling()); + Assert.AreEqual("no_scene", result["code"].Value()); + } + + [Test] + public void OcclusionBakeStatus_AlwaysHasStatusField() + { + var json = JObject.Parse(OcclusionBakeCommands.OcclusionBakeStatus()); + Assert.IsNotNull(json["status"]); + } + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/OcclusionBakeCommandsTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/OcclusionBakeCommandsTests.cs.meta new file mode 100644 index 00000000..d8a677e3 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Baking/OcclusionBakeCommandsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f65c8110a95f44cbbf3aaf0d9832aa0e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/BuildConfigCommandsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/BuildConfigCommandsTests.cs new file mode 100644 index 00000000..75500d07 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/BuildConfigCommandsTests.cs @@ -0,0 +1,105 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor; +using Unity.Pipeline.Editor.Commands.Build; +using UnityEditor; + +namespace Unity.Pipeline.Tests.Editor +{ + /// + /// Tests for the build-configuration commands (CLI-204): list_build_targets, + /// get_build_settings, set_build_settings (dry run only — no real mutation), + /// list_build_profiles, and the switch_build_target command surface. The read-only + /// commands are executed directly on the test (main) thread; nothing here triggers a build or switch. + /// + public class BuildConfigCommandsTests + { + [SetUp] + public void SetUp() => CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery()); + + [Test] + public void NewCommands_AreDiscovered() + { + var names = CommandRegistry.DiscoverCommands().Select(c => c.Name).ToList(); + + foreach (var expected in new[] + { + "list_build_targets", "get_build_settings", "set_build_settings", + "list_build_profiles", "switch_build_target", "switch_build_target_status" + }) + { + CollectionAssert.Contains(names, expected, $"Should discover the {expected} command"); + } + } + + [Test] + public void SwitchBuildTarget_RequiresTarget_AndConfirm() + { + var switchCmd = CommandRegistry.DiscoverCommands().First(c => c.Name == "switch_build_target"); + + var target = switchCmd.Parameters.First(p => p.Name == "target"); + Assert.IsTrue(target.Required, "switch_build_target 'target' must be required"); + + // Missing confirm must be refused without attempting the switch. + var result = JObjectOf(SwitchBuildTargetCommand.SwitchBuildTarget( + EditorUserBuildSettings.activeBuildTarget == UnityEditor.BuildTarget.StandaloneWindows64 + ? "StandaloneLinux64" + : "StandaloneWindows64", + confirm: false)); + + Assert.AreEqual("error", (string)result["status"]); + Assert.AreEqual(false, (bool)result["success"]); + } + + [Test] + public void ListBuildTargets_IncludesActiveTarget_AsInstalled() + { + var targets = (List)BuildConfigCommands.ListBuildTargets(); + Assert.IsNotNull(targets); + Assert.Greater(targets.Count, 0, "should enumerate at least one build target"); + + var active = EditorUserBuildSettings.activeBuildTarget.ToString(); + var entry = targets.FirstOrDefault(t => t.Name == active); + Assert.IsNotNull(entry, "the active build target should appear in the list"); + Assert.IsTrue(entry.IsInstalled, "the active build target must be installed"); + } + + [Test] + public void GetBuildSettings_ReportsActiveTargetAndScenes() + { + var settings = (BuildSettingsResult)BuildConfigCommands.GetBuildSettings(); + Assert.IsNotNull(settings); + Assert.AreEqual(EditorUserBuildSettings.activeBuildTarget.ToString(), settings.ActiveBuildTarget); + Assert.IsNotNull(settings.Scenes, "scene list should be present (may be empty)"); + Assert.IsNotNull(settings.Il2CppCodeGeneration); + } + + [Test] + public void SetBuildSettings_DryRun_ReportsPlan_WithoutMutating() + { + var before = EditorUserBuildSettings.development; + + var input = new SetBuildSettingsInput { DevelopmentBuild = !before }; + var result = (SetBuildSettingsResult)BuildConfigCommands.SetBuildSettings(input, dryRun: true); + + Assert.IsTrue(result.Success); + Assert.IsTrue(result.DryRun); + Assert.IsTrue(result.Applied.ContainsKey("developmentBuild"), + "the flipped field should appear in the dry-run 'applied' plan"); + Assert.AreEqual(before, EditorUserBuildSettings.development, + "a dry run must not change EditorUserBuildSettings"); + } + + [Test] + public void SetBuildSettings_NoSettings_Fails() + { + var result = (SetBuildSettingsResult)BuildConfigCommands.SetBuildSettings(null, dryRun: false); + Assert.IsFalse(result.Success); + } + + static Newtonsoft.Json.Linq.JObject JObjectOf(object value) => + Newtonsoft.Json.Linq.JObject.FromObject(value); + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/BuildConfigCommandsTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/BuildConfigCommandsTests.cs.meta new file mode 100644 index 00000000..f786b4a0 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/BuildConfigCommandsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f6dcfbbcfcc4e70428b62c04568efe8b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Capture.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Capture.meta new file mode 100644 index 00000000..be6ac1d7 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Capture.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c83fc76f10b96db43a56fd1bbf79c646 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Capture/CaptureCommandsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Capture/CaptureCommandsTests.cs new file mode 100644 index 00000000..2c17b939 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Capture/CaptureCommandsTests.cs @@ -0,0 +1,218 @@ +using System; +using System.IO; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Editor.Commands.Capture; +using UnityEditor; +using UnityEngine; +using UnityEngine.Rendering; + +namespace Unity.Pipeline.Tests.Editor.Capture +{ + /// + /// Tests for the visual-feedback commands (CLI-199), exercised directly and via PipelineClient. + /// Render tests are GPU-gated: under batchmode/headless the graphics device is + /// and the tests self-ignore rather than fail. + /// + public class CaptureCommandsTests + { + private const string CameraName = "CLI199_Cam"; + private const string SaveFolder = "Assets/CLI199_CaptureTests"; + + // PNG file signature: 0x89 'P' 'N' 'G' 0x0D 0x0A 0x1A 0x0A. + private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; + + private GameObject m_CameraObject; + + [SetUp] + public void SetUp() + { + m_CameraObject = new GameObject(CameraName); + m_CameraObject.AddComponent(); + } + + [TearDown] + public void TearDown() + { + if (m_CameraObject != null) + UnityEngine.Object.DestroyImmediate(m_CameraObject); + + if (AssetDatabase.IsValidFolder(SaveFolder)) + AssetDatabase.DeleteAsset(SaveFolder); + } + + private static string AbsolutePath(string projectRelative) => + Path.Combine(ProjectPaths.ProjectRoot, projectRelative); + + private static bool IsHeadless => SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null; + + private static void AssertPngSignature(byte[] bytes) + { + Assert.GreaterOrEqual(bytes.Length, PngSignature.Length, "PNG payload too short to contain a signature"); + for (var i = 0; i < PngSignature.Length; i++) + Assert.AreEqual(PngSignature[i], bytes[i], $"PNG signature mismatch at byte {i}"); + } + + #region Direct + + [Test] + public void CaptureGameView_NamedCamera_ReturnsPng() + { + if (IsHeadless) + Assert.Ignore("No GPU in batchmode"); + + var result = CaptureCommands.CaptureGameView(64, 64, CameraName); + + Assert.IsNotNull(result, "Capture should return a result"); + Assert.IsNotEmpty(result.Base64, "Base64 payload should be non-empty"); + Assert.AreEqual(64, result.Width, "Width should match the requested size"); + Assert.AreEqual(64, result.Height, "Height should match the requested size"); + Assert.AreEqual("png", result.Encoding); + Assert.AreEqual($"camera:{CameraName}", result.Source); + Assert.IsNull(result.SavedPath, "No savePath was requested"); + + var bytes = Convert.FromBase64String(result.Base64); + AssertPngSignature(bytes); + Assert.AreEqual(bytes.Length, result.Bytes, "Reported byte length should match decoded payload"); + } + + [Test] + public void CaptureSceneView_ReturnsPng() + { + if (IsHeadless) + Assert.Ignore("No GPU in batchmode"); + + var sv = SceneView.lastActiveSceneView; + if (sv == null) + Assert.Ignore("No Scene View"); + + var result = CaptureCommands.CaptureSceneView(64, 64); + + Assert.IsNotNull(result, "Capture should return a result"); + Assert.IsNotEmpty(result.Base64, "Base64 payload should be non-empty"); + Assert.AreEqual("sceneView", result.Source); + + var bytes = Convert.FromBase64String(result.Base64); + AssertPngSignature(bytes); + } + + [Test] + public void CaptureGameView_SavePath_OmitsBase64_AndWritesPng() + { + if (IsHeadless) + Assert.Ignore("No GPU in batchmode"); + + var result = CaptureCommands.CaptureGameView(64, 64, CameraName, $"{SaveFolder}/path_only.png"); + + Assert.IsNull(result.Base64, "save_path without include_inline_image should omit the base64 payload"); + Assert.IsNotNull(result.SavedPath, "SavedPath should be set"); + + var fileBytes = File.ReadAllBytes(AbsolutePath(result.SavedPath)); + AssertPngSignature(fileBytes); + Assert.AreEqual(fileBytes.Length, result.Bytes, "Reported byte length should match the file"); + } + + [Test] + public void CaptureGameView_SavePath_IncludeImage_ReturnsFileAndBase64() + { + if (IsHeadless) + Assert.Ignore("No GPU in batchmode"); + + var result = CaptureCommands.CaptureGameView(64, 64, CameraName, $"{SaveFolder}/both.png", includeInlineImage: true); + + Assert.IsNotEmpty(result.Base64, "include_inline_image=true should return the base64 payload"); + Assert.IsNotNull(result.SavedPath, "SavedPath should be set"); + AssertPngSignature(Convert.FromBase64String(result.Base64)); + AssertPngSignature(File.ReadAllBytes(AbsolutePath(result.SavedPath))); + } + + [Test] + public void CaptureGameView_MaxResolution_ClampsInlineRender() + { + if (IsHeadless) + Assert.Ignore("No GPU in batchmode"); + + // No save_path: the inline image is the only artifact, so the cap applies to the render. + var result = CaptureCommands.CaptureGameView(128, 64, CameraName, maxResolution: 32); + + Assert.AreEqual(32, result.Width, "Long edge should be clamped to max_resolution"); + Assert.AreEqual(16, result.Height, "Short edge should keep the aspect ratio"); + AssertPngSignature(Convert.FromBase64String(result.Base64)); + } + + [Test] + public void CaptureGameView_SavePath_MaxResolution_DownscalesInlineOnly() + { + if (IsHeadless) + Assert.Ignore("No GPU in batchmode"); + + var result = CaptureCommands.CaptureGameView(128, 64, CameraName, $"{SaveFolder}/full.png", + includeInlineImage: true, maxResolution: 32); + + Assert.AreEqual(128, result.Width, "The saved file keeps the requested resolution"); + Assert.AreEqual(32, result.InlineWidth, "The inline copy is downscaled to max_resolution"); + Assert.AreEqual(16, result.InlineHeight, "The inline copy keeps the aspect ratio"); + AssertPngSignature(Convert.FromBase64String(result.Base64)); + AssertPngSignature(File.ReadAllBytes(AbsolutePath(result.SavedPath))); + } + + [Test] + public void CaptureSceneView_SavePath_OmitsBase64_AndWritesPng() + { + if (IsHeadless) + Assert.Ignore("No GPU in batchmode"); + + var sv = SceneView.lastActiveSceneView; + if (sv == null) + Assert.Ignore("No Scene View"); + + var result = CaptureCommands.CaptureSceneView(64, 64, $"{SaveFolder}/scene_path_only.png"); + + Assert.IsNull(result.Base64, "save_path without include_inline_image should omit the base64 payload"); + Assert.IsNotNull(result.SavedPath, "SavedPath should be set"); + AssertPngSignature(File.ReadAllBytes(AbsolutePath(result.SavedPath))); + } + + #endregion + + #region ViaClient + + [Test] + public void CaptureGameView_ViaClient_Succeeds() + { + if (IsHeadless) + Assert.Ignore("No GPU in batchmode"); + + using (var server = new PipelineTestServer()) + { + var response = server.Execute("capture_game_view", new { width = 64, height = 64, camera = CameraName }); + + Assert.IsTrue(response.IsSuccess, $"capture_game_view should succeed: {response.Error}"); + Assert.IsTrue(response.HasValidJson, "Response should have valid JSON"); + Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field"); + } + } + + [Test] + public void CaptureGameView_ViaClient_SavePath_SerializedResultOmitsBase64Key() + { + if (IsHeadless) + Assert.Ignore("No GPU in batchmode"); + + using (var server = new PipelineTestServer()) + { + var response = server.Execute("capture_game_view", + new { width = 64, height = 64, camera = CameraName, save_path = $"{SaveFolder}/via_client.png" }); + + Assert.IsTrue(response.IsSuccess, $"capture_game_view should succeed: {response.Error}"); + var result = (JObject)response.JsonResponse["result"]; + Assert.IsFalse(result.ContainsKey("base64"), + "A path-only result must not carry a base64 key on the wire (AUTHAPI-8)"); + Assert.IsNotNull(result["savedPath"]?.ToString(), "savedPath should be present"); + } + } + + #endregion + } +} diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Capture/CaptureCommandsTests.cs.meta b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Capture/CaptureCommandsTests.cs.meta new file mode 100644 index 00000000..0ce465b8 --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/Capture/CaptureCommandsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7469ce0ae01ac444280398a19366fdfc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/CaptureVisualElementCommandsTests.cs b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/CaptureVisualElementCommandsTests.cs new file mode 100644 index 00000000..525a557a --- /dev/null +++ b/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Tests/Editor/CaptureVisualElementCommandsTests.cs @@ -0,0 +1,243 @@ +#if UNITY_6000_7_OR_NEWER +using System.IO; +using System.Linq; +using NUnit.Framework; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor; +using Unity.Pipeline.Editor.Commands; +using Unity.Pipeline.Runtime.Commands; +using UnityEditor; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.UIElements; + +namespace Unity.Pipeline.Tests.Editor +{ + /// + /// Tests for the visual-element capture commands. The selector resolver + /// () is the + /// riskiest new logic and is GPU-independent, so it gets the most coverage; the + /// capture_editor_element command is covered for its validation/miss branches plus a + /// GPU-gated happy path. + /// + public class CaptureVisualElementCommandsTests + { + // -- Selector resolver ------------------------------------------------------------------- + + static VisualElement BuildTree() + { + // root + // #toolbar .toolbar + // #search Button .primary (direct child of toolbar) + // #content + // #title Label .big (disabled) + var root = new VisualElement { name = "root" }; + + var toolbar = new VisualElement { name = "toolbar" }; + toolbar.AddToClassList("toolbar"); + var search = new Button { name = "search" }; + search.AddToClassList("primary"); + toolbar.Add(search); + + var content = new VisualElement { name = "content" }; + var title = new Label { name = "title" }; + title.AddToClassList("big"); + title.SetEnabled(false); // sets the :disabled pseudo-state + content.Add(title); + + root.Add(toolbar); + root.Add(content); + return root; + } + + [Test] + public void Resolve_ById() + { + var root = BuildTree(); + var e = VisualElementCaptureSupport.ResolveElement(root, "#search"); + Assert.IsNotNull(e); + Assert.AreEqual("search", e.name); + } + + [Test] + public void Resolve_ByClass() + { + var root = BuildTree(); + var e = VisualElementCaptureSupport.ResolveElement(root, ".toolbar"); + Assert.IsNotNull(e); + Assert.AreEqual("toolbar", e.name); + } + + [Test] + public void Resolve_ByType() + { + var root = BuildTree(); + var e = VisualElementCaptureSupport.ResolveElement(root, "Label"); + Assert.IsNotNull(e); + Assert.AreEqual("title", e.name); + Assert.IsInstanceOf