commit f3f66ab06357c054de743b0d2d7920ba2fa0e0bf Author: udx Date: Mon Sep 21 14:21:07 2026 +0800 Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling. Co-authored-by: Cursor diff --git a/.claude/skills/unity-pipeline/SKILL.md b/.claude/skills/unity-pipeline/SKILL.md new file mode 100644 index 0000000..c4e4ab2 --- /dev/null +++ b/.claude/skills/unity-pipeline/SKILL.md @@ -0,0 +1,173 @@ +--- +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 code-reload files at runtime. Use when an agent needs to control a live Unity instance, automate Unity tests, recompile scripts headlessly, or apply runtime 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. + +**Reading exit codes.** They separate "rewrite the invocation" from "the Editor failed", which +is the distinction this loop depends on: + +| Exit | Meaning | What to do | +|------|---------|------------| +| `2` | Bad arguments — a misspelled flag, a wrong type, too many positionals. Nothing ran. | Fix the command line and retry. The error names the problem and often suggests the right flag. | +| `6` | The command ran and failed, or the Editor could not service it. | Read the error; retrying the same invocation will not help. | + +Do not retry an exit 2 unchanged, and do not rewrite a command line on an exit 6. + +## 3. Runtime code 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 +The runtime Pipeline driver (enabled via Project Settings > Pipeline > Runtime) 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. + +### `reload_file` + +Edit the method body directly; no separate file, no boilerplate. + +1. Tag the method `[CodeReload]` 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). + +`unity command codereload_status` shows active overrides. `reload_file` options: `--timeout ` +(default 30000), `--assemblyDir ` (persist DLLs instead of in-memory); +`cleanup_codereload --assemblyDir ` clears old DLLs. + +## 4. Bulk construction: `run_script` (the builder pattern) + +For bulk work — creating many objects, wiring fields, generating content — put the code in a +**versioned project script** and run a named static entry point with `run_script`. No domain +reload, no code carried through the protocol; iterating costs an in-memory compile (< ~2s), not +a 15–20s recompile. + +```bash +# 1. Write the builder OUTSIDE Assets/ (so the write triggers no asset import / domain reload), +# e.g. AgentScripts/Build.cs: public static class Build { public static int All() { ... } } +# 2. Run it — relative paths resolve against the project root (the parent of Assets/): +unity command run_script --file AgentScripts/Build.cs --entry Build.All +# 3. Iterate: edit the file, re-run. Useful extras: +unity command run_script --file AgentScripts/Build.cs --dry_run true # compile-only check: diagnostics, nothing loaded or executed +unity command run_script --file AgentScripts/Build.cs --entry Build.All --args '[3, "Green"]' +``` + +Rules of thumb: + +- **Code goes in files on disk via `run_script`; `eval` is for genuinely ad-hoc one-liners.** + Never ship multi-line escaped C# strings through `eval` — write the file, run the entry. +- Compiles see the project's **active editor defines** (`UNITY_EDITOR`, version/platform symbols); + `--defines` appends extra symbols on top. +- Entry points may be `async Task`/`Task` — they are awaited asynchronously (the editor keeps + pumping, so awaits resuming on Unity's context work naturally) and `Task.Result` is returned. + The wait is bounded by `timeout_ms`; on expiry the task keeps running detached. +- Runtime exceptions come back with `file:line` mapped to your source (a source-mapped PDB is + always emitted for executing runs; they compile unoptimized). +- `--mode hotpatch` instead applies `[CodeReload]` in-place method replacements (delegates to + `reload_file`); `entry`/`args`/`dry_run` are rejected there and `references`/`defines` don't apply. + +## 5. Quick C# eval (Runtime) + +For genuinely ad-hoc one-liners only — anything longer belongs in a file run via `run_script`. + +```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 +``` + +## 5. Project audit (Editor) + +Static-analysis scan via Project Auditor, producing a CSV of issues to fix. Trigger, poll, read. + +```bash +unity command audit # optional: --categories Code,ProjectSetting --output my.csv +unity command audit_status # repeat until terminal status +``` + +`audit_status` is terminal on `completed` (with `csvPath` + `issueCount`), `failed`, `unavailable`, or +`interrupted` (a domain reload killed the scan — just re-run `audit`). Polling stays responsive while a +Code-category scan compiles assemblies and holds the main thread. One scan at a time: a second `audit` +returns `busy`. There is no cancel — stop polling to abandon a scan. + +CSV columns: `Category, Severity, Areas, Description, RelativePath, Line, DescriptorId, Recommendation` +(diagnostics only, so every row is something to fix; `Recommendation` says how). + +> **Requires Project Auditor plus its rules.** `unavailable` means the Editor has no Project Auditor, +> or it has no analysis rules — in a built-in-module Editor the rules live in the separate +> `com.unity.project-auditor-rules` package (`unity command package_add --identifier +> com.unity.project-auditor-rules --confirm true`). Read the `message` field; the command never reports +> an empty `completed` scan that would look like a clean project. + +## 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. +- **A stuck command may mean a modal dialog is open**, not a hang — a dialog blocks the main + thread until dismissed. If a command runs long, check `unity command editor_status` (answers + instantly even when blocked); `status: "blocked_by_dialog"` means stop retrying and tell the + human what's blocking (its `dialog.title`/`message`/`buttons`) — it can't be clicked over CLI. +- **Code reload needs the game running.** `reload_file` applies 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/.cursor/rules/aigame-basics.mdc b/.cursor/rules/aigame-basics.mdc new file mode 100644 index 0000000..da50c2f --- /dev/null +++ b/.cursor/rules/aigame-basics.mdc @@ -0,0 +1,16 @@ +--- +description: AIGame 项目基础规则(资源登记等必须遵守) +alwaysApply: true +--- + +# AIGame 项目说明 + +## 基础规则 + +1. **资源登记(必须)**:所有资源生成完成后,必须按类型追加到对应描述文件,并写明简要说明(用途、路径、生成方式等)。未登记视为流程未完成。 + - 索引:`Doc/ResDesc.md` + - model → `Doc/ResDesc_Model.md` + - animation → `Doc/ResDesc_Animation.md` + - picture → `Doc/ResDesc_Picture.md` + - sound → `Doc/ResDesc_Sound.md` + - effect → `Doc/ResDesc_Effect.md` diff --git a/.cursor/skills/unity-pipeline/SKILL.md b/.cursor/skills/unity-pipeline/SKILL.md new file mode 100644 index 0000000..c4e4ab2 --- /dev/null +++ b/.cursor/skills/unity-pipeline/SKILL.md @@ -0,0 +1,173 @@ +--- +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 code-reload files at runtime. Use when an agent needs to control a live Unity instance, automate Unity tests, recompile scripts headlessly, or apply runtime 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. + +**Reading exit codes.** They separate "rewrite the invocation" from "the Editor failed", which +is the distinction this loop depends on: + +| Exit | Meaning | What to do | +|------|---------|------------| +| `2` | Bad arguments — a misspelled flag, a wrong type, too many positionals. Nothing ran. | Fix the command line and retry. The error names the problem and often suggests the right flag. | +| `6` | The command ran and failed, or the Editor could not service it. | Read the error; retrying the same invocation will not help. | + +Do not retry an exit 2 unchanged, and do not rewrite a command line on an exit 6. + +## 3. Runtime code 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 +The runtime Pipeline driver (enabled via Project Settings > Pipeline > Runtime) 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. + +### `reload_file` + +Edit the method body directly; no separate file, no boilerplate. + +1. Tag the method `[CodeReload]` 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). + +`unity command codereload_status` shows active overrides. `reload_file` options: `--timeout ` +(default 30000), `--assemblyDir ` (persist DLLs instead of in-memory); +`cleanup_codereload --assemblyDir ` clears old DLLs. + +## 4. Bulk construction: `run_script` (the builder pattern) + +For bulk work — creating many objects, wiring fields, generating content — put the code in a +**versioned project script** and run a named static entry point with `run_script`. No domain +reload, no code carried through the protocol; iterating costs an in-memory compile (< ~2s), not +a 15–20s recompile. + +```bash +# 1. Write the builder OUTSIDE Assets/ (so the write triggers no asset import / domain reload), +# e.g. AgentScripts/Build.cs: public static class Build { public static int All() { ... } } +# 2. Run it — relative paths resolve against the project root (the parent of Assets/): +unity command run_script --file AgentScripts/Build.cs --entry Build.All +# 3. Iterate: edit the file, re-run. Useful extras: +unity command run_script --file AgentScripts/Build.cs --dry_run true # compile-only check: diagnostics, nothing loaded or executed +unity command run_script --file AgentScripts/Build.cs --entry Build.All --args '[3, "Green"]' +``` + +Rules of thumb: + +- **Code goes in files on disk via `run_script`; `eval` is for genuinely ad-hoc one-liners.** + Never ship multi-line escaped C# strings through `eval` — write the file, run the entry. +- Compiles see the project's **active editor defines** (`UNITY_EDITOR`, version/platform symbols); + `--defines` appends extra symbols on top. +- Entry points may be `async Task`/`Task` — they are awaited asynchronously (the editor keeps + pumping, so awaits resuming on Unity's context work naturally) and `Task.Result` is returned. + The wait is bounded by `timeout_ms`; on expiry the task keeps running detached. +- Runtime exceptions come back with `file:line` mapped to your source (a source-mapped PDB is + always emitted for executing runs; they compile unoptimized). +- `--mode hotpatch` instead applies `[CodeReload]` in-place method replacements (delegates to + `reload_file`); `entry`/`args`/`dry_run` are rejected there and `references`/`defines` don't apply. + +## 5. Quick C# eval (Runtime) + +For genuinely ad-hoc one-liners only — anything longer belongs in a file run via `run_script`. + +```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 +``` + +## 5. Project audit (Editor) + +Static-analysis scan via Project Auditor, producing a CSV of issues to fix. Trigger, poll, read. + +```bash +unity command audit # optional: --categories Code,ProjectSetting --output my.csv +unity command audit_status # repeat until terminal status +``` + +`audit_status` is terminal on `completed` (with `csvPath` + `issueCount`), `failed`, `unavailable`, or +`interrupted` (a domain reload killed the scan — just re-run `audit`). Polling stays responsive while a +Code-category scan compiles assemblies and holds the main thread. One scan at a time: a second `audit` +returns `busy`. There is no cancel — stop polling to abandon a scan. + +CSV columns: `Category, Severity, Areas, Description, RelativePath, Line, DescriptorId, Recommendation` +(diagnostics only, so every row is something to fix; `Recommendation` says how). + +> **Requires Project Auditor plus its rules.** `unavailable` means the Editor has no Project Auditor, +> or it has no analysis rules — in a built-in-module Editor the rules live in the separate +> `com.unity.project-auditor-rules` package (`unity command package_add --identifier +> com.unity.project-auditor-rules --confirm true`). Read the `message` field; the command never reports +> an empty `completed` scan that would look like a clean project. + +## 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. +- **A stuck command may mean a modal dialog is open**, not a hang — a dialog blocks the main + thread until dismissed. If a command runs long, check `unity command editor_status` (answers + instantly even when blocked); `status: "blocked_by_dialog"` means stop retrying and tell the + human what's blocking (its `dialog.title`/`message`/`buttons`) — it can't be clicked over CLI. +- **Code reload needs the game running.** `reload_file` applies 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/.gitignore b/.gitignore new file mode 100644 index 0000000..cbc6039 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Python / tools +Tools/__pycache__/ +Tools/**/__pycache__/ +Tools/**/*.pyc + +# 混元3D 登录凭证与任务中间文件(勿提交) +Tools/data/hunyuan/storage_state.json +Tools/data/hunyuan/status.json +Tools/data/hunyuan/jobs/ + +# model_tools 中间产物(按需) +Tools/model_tools/out_bake/ +Tools/model_tools/**/_low_work/ + +# 本地输出(按需取消忽略) +# Tools/output/ + +# ===== Unity ===== +AIClient/[Ll]ibrary/ +AIClient/[Tt]emp/ +AIClient/[Oo]bj/ +AIClient/[Bb]uild/ +AIClient/[Bb]uilds/ +AIClient/[Ll]ogs/ +AIClient/[Uu]ser[Ss]ettings/ +AIClient/[Mm]emoryCaptures/ +AIClient/[Rr]ecordings/ + +# Unity generated IDE / project files +AIClient/*.csproj +AIClient/*.sln +AIClient/*.suo +AIClient/*.user +AIClient/*.userprefs +AIClient/*.pidb +AIClient/*.booproj +AIClient/*.svd +AIClient/*.pdb +AIClient/*.mdb +AIClient/*.opendb +AIClient/*.VC.db +AIClient/.vs/ +AIClient/.gradle/ + +# HybridCLR intermediate clone repos (LocalIl2Cpp 可按需提交) +AIClient/HybridCLRData/hybridclr_repo/ +AIClient/HybridCLRData/il2cpp_plus_repo/ + +# Crash / OS +*.stackdump +Thumbs.db +Desktop.ini +.DS_Store diff --git a/AIClient/.vsconfig b/AIClient/.vsconfig new file mode 100644 index 0000000..f019fd0 --- /dev/null +++ b/AIClient/.vsconfig @@ -0,0 +1,6 @@ +{ + "version": "1.0", + "components": [ + "Microsoft.VisualStudio.Workload.ManagedGame" + ] +} diff --git a/AIClient/Assets/Editor.meta b/AIClient/Assets/Editor.meta new file mode 100644 index 0000000..72c71b0 --- /dev/null +++ b/AIClient/Assets/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1f9e1fb3ff717414ab8d0cf27dbc6f3d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/Editor/SetupHybridClr.cs b/AIClient/Assets/Editor/SetupHybridClr.cs new file mode 100644 index 0000000..133f013 --- /dev/null +++ b/AIClient/Assets/Editor/SetupHybridClr.cs @@ -0,0 +1,22 @@ +#if UNITY_EDITOR +using HybridCLR.Editor.Installer; + +public static class SetupHybridClr +{ + public static string Install() + { + var c = new InstallerController(); + if (c.HasInstalledHybridCLR()) + return $"already_installed pkg={c.PackageVersion} local={c.InstalledLibil2cppVersion}"; + + c.InstallDefaultHybridCLR(); + return $"installed pkg={c.PackageVersion} has={c.HasInstalledHybridCLR()} local={c.InstalledLibil2cppVersion}"; + } + + public static string Status() + { + var c = new InstallerController(); + return $"has={c.HasInstalledHybridCLR()} pkg={c.PackageVersion} local={c.InstalledLibil2cppVersion}"; + } +} +#endif diff --git a/AIClient/Assets/Editor/SetupHybridClr.cs.meta b/AIClient/Assets/Editor/SetupHybridClr.cs.meta new file mode 100644 index 0000000..1e4a09a --- /dev/null +++ b/AIClient/Assets/Editor/SetupHybridClr.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b09764b3bf807f248b2a73e9dc8f04da +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/Editor/SetupUrp.cs b/AIClient/Assets/Editor/SetupUrp.cs new file mode 100644 index 0000000..7e3cae6 --- /dev/null +++ b/AIClient/Assets/Editor/SetupUrp.cs @@ -0,0 +1,72 @@ +#if UNITY_EDITOR +using System.Text; +using UnityEditor; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.Universal; + +public static class SetupUrp +{ + // Map Quality level name -> URP asset under Assets/Settings/URP + static readonly (string qualityName, string assetPath)[] QualityPipelineMap = + { + ("Very Low", "Assets/Settings/URP/UniversalRP-0VeryLowQuality.asset"), + ("Low", "Assets/Settings/URP/UniversalRP-1LowQuality.asset"), + ("Medium", "Assets/Settings/URP/UniversalRP-2MediumQuality.asset"), + ("High", "Assets/Settings/URP/UniversalRP-3HighQuality.asset"), + ("Very High", "Assets/Settings/URP/UniversalRP-4VeryHighQuality.asset"), + ("Ultra", "Assets/Settings/URP/UniversalRP-4VeryHighQuality.asset"), + }; + + const string FallbackPipeline = "Assets/Settings/URP/UniversalRP-3HighQuality.asset"; + + /// + /// Assign a URP asset to every Quality level and set Graphics default pipeline. + /// Fixes: "platforms have assets in its associated Quality levels that belong to different render pipelines" + /// + public static string FixQualityPipelines() + { + var log = new StringBuilder(); + var names = QualitySettings.names; + UniversalRenderPipelineAsset fallback = AssetDatabase.LoadAssetAtPath(FallbackPipeline); + if (fallback == null) + return "ERROR: missing fallback URP asset at " + FallbackPipeline; + + for (int i = 0; i < names.Length; i++) + { + var path = ResolvePipelinePath(names[i]); + var asset = AssetDatabase.LoadAssetAtPath(path) ?? fallback; + QualitySettings.SetQualityLevel(i, applyExpensiveChanges: false); + QualitySettings.renderPipeline = asset; + log.AppendLine($"{i}:{names[i]} -> {AssetDatabase.GetAssetPath(asset)}"); + } + + // Prefer High-tier as project default (Graphics Settings) + GraphicsSettings.renderPipelineAsset = + AssetDatabase.LoadAssetAtPath(FallbackPipeline) ?? fallback; + + // Restore previous current quality if possible, else Ultra/last + int restore = System.Array.IndexOf(names, "Ultra"); + if (restore < 0) restore = names.Length - 1; + QualitySettings.SetQualityLevel(restore, applyExpensiveChanges: true); + + AssetDatabase.SaveAssets(); + EditorUtility.SetDirty(AssetDatabase.LoadAllAssetsAtPath("ProjectSettings/QualitySettings.asset")[0]); + log.AppendLine("Graphics default -> " + AssetDatabase.GetAssetPath(GraphicsSettings.renderPipelineAsset)); + log.AppendLine("Current quality -> " + QualitySettings.names[QualitySettings.GetQualityLevel()]); + return log.ToString(); + } + + static string ResolvePipelinePath(string qualityName) + { + foreach (var entry in QualityPipelineMap) + { + if (entry.qualityName == qualityName) + return entry.assetPath; + } + return FallbackPipeline; + } + + public static string Execute() => FixQualityPipelines(); +} +#endif diff --git a/AIClient/Assets/Editor/SetupUrp.cs.meta b/AIClient/Assets/Editor/SetupUrp.cs.meta new file mode 100644 index 0000000..d5efecf --- /dev/null +++ b/AIClient/Assets/Editor/SetupUrp.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9e35ed0006e33004a987f71bac5117bf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/Scenes.meta b/AIClient/Assets/Scenes.meta new file mode 100644 index 0000000..8f68d9f --- /dev/null +++ b/AIClient/Assets/Scenes.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7a5f0c1a941fd9c4fa22ba5b2e81514e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/Scenes/SampleScene.unity b/AIClient/Assets/Scenes/SampleScene.unity new file mode 100644 index 0000000..2221b04 --- /dev/null +++ b/AIClient/Assets/Scenes/SampleScene.unity @@ -0,0 +1,267 @@ +%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: 9 + 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: 705507994} + m_IndirectSpecularColor: {r: 0, g: 0, b: 0, a: 1} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 12 + m_GIWorkflowMode: 1 + 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_FinalGather: 0 + m_FinalGatherFiltering: 1 + m_FinalGatherRayCount: 256 + m_ReflectionCompression: 2 + m_MixedBakeMode: 2 + m_BakeBackend: 1 + m_PVRSampling: 1 + m_PVRDirectSampleCount: 32 + m_PVRSampleCount: 500 + m_PVRBounces: 2 + m_PVREnvironmentSampleCount: 500 + m_PVREnvironmentReferencePointCount: 2048 + m_PVRFilteringMode: 2 + m_PVRDenoiserTypeDirect: 0 + m_PVRDenoiserTypeIndirect: 0 + m_PVRDenoiserTypeAO: 0 + m_PVRFilterTypeDirect: 0 + m_PVRFilterTypeIndirect: 0 + m_PVRFilterTypeAO: 0 + m_PVREnvironmentMIS: 0 + m_PVRCulling: 1 + m_PVRFilteringGaussRadiusDirect: 1 + m_PVRFilteringGaussRadiusIndirect: 5 + m_PVRFilteringGaussRadiusAO: 2 + m_PVRFilteringAtrousPositionSigmaDirect: 0.5 + m_PVRFilteringAtrousPositionSigmaIndirect: 2 + m_PVRFilteringAtrousPositionSigmaAO: 1 + m_ExportTrainingData: 0 + m_TrainingDataDestination: TrainingData + m_LightProbeSampleCountMultiplier: 4 + m_LightingDataAsset: {fileID: 0} + m_LightingSettings: {fileID: 0} +--- !u!196 &4 +NavMeshSettings: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_BuildSettings: + serializedVersion: 2 + 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 + accuratePlacement: 0 + debug: + m_Flags: 0 + m_NavMeshData: {fileID: 0} +--- !u!1 &705507993 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInternal: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 705507995} + - component: {fileID: 705507994} + 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 &705507994 +Light: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInternal: {fileID: 0} + m_GameObject: {fileID: 705507993} + m_Enabled: 1 + serializedVersion: 8 + 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_CookieSize: 10 + 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_Cookie: {fileID: 0} + m_DrawHalo: 0 + m_Flare: {fileID: 0} + m_RenderMode: 0 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_Lightmapping: 1 + m_LightShadowCasterMode: 0 + m_AreaSize: {x: 1, y: 1} + m_BounceIntensity: 1 + m_ColorTemperature: 6570 + m_UseColorTemperature: 0 + m_ShadowRadius: 0 + m_ShadowAngle: 0 +--- !u!4 &705507995 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInternal: {fileID: 0} + m_GameObject: {fileID: 705507993} + 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_Children: [] + m_Father: {fileID: 0} + m_RootOrder: 1 + m_LocalEulerAnglesHint: {x: 50, y: -30, z: 0} +--- !u!1 &963194225 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInternal: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 963194228} + - component: {fileID: 963194227} + - component: {fileID: 963194226} + 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 &963194226 +AudioListener: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInternal: {fileID: 0} + m_GameObject: {fileID: 963194225} + m_Enabled: 1 +--- !u!20 &963194227 +Camera: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInternal: {fileID: 0} + m_GameObject: {fileID: 963194225} + 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_SensorSize: {x: 36, y: 24} + m_LensShift: {x: 0, y: 0} + m_GateFitMode: 2 + m_FocalLength: 50 + 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 &963194228 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInternal: {fileID: 0} + m_GameObject: {fileID: 963194225} + 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_Children: [] + m_Father: {fileID: 0} + m_RootOrder: 0 + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} diff --git a/AIClient/Assets/Scenes/SampleScene.unity.meta b/AIClient/Assets/Scenes/SampleScene.unity.meta new file mode 100644 index 0000000..952bd1e --- /dev/null +++ b/AIClient/Assets/Scenes/SampleScene.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9fc0d4010bbf28b4594072e72b8655ab +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/Settings.meta b/AIClient/Assets/Settings.meta new file mode 100644 index 0000000..edc2ef0 --- /dev/null +++ b/AIClient/Assets/Settings.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1345306f7b23afb4bb7701f34d02bae3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/Settings/URP.meta b/AIClient/Assets/Settings/URP.meta new file mode 100644 index 0000000..1d7c601 --- /dev/null +++ b/AIClient/Assets/Settings/URP.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1424677654ae2a5429cb4ba77e52c1cf +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/Settings/URP/ForwardRenderer 0VeryLowQuality.asset b/AIClient/Assets/Settings/URP/ForwardRenderer 0VeryLowQuality.asset new file mode 100644 index 0000000..191a8c1 --- /dev/null +++ b/AIClient/Assets/Settings/URP/ForwardRenderer 0VeryLowQuality.asset @@ -0,0 +1,386 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &-2127331727782644450 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 88650573, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: CharacterRenderFeature + m_EditorClassIdentifier: + m_Active: 1 +--- !u!114 &-277282247946242087 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1749369493, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: 0f60273b12723a047a568dca50178e02, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 0 + UberEffectPostShaderName: Engine/Effect/UberEffectPost +--- !u!114 &-172830343826181105 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 09d572d36a83daa4bb576ae814b31f16, type: 3} + m_Name: TAA + m_EditorClassIdentifier: + m_Active: 1 + PreviewInSceneView: 0 + UseMotionVector: 0 + RenderingMode: 0 + WorkOnPrepass: 0 + IgonreTransparentObject: 1 + Use32Bit: 0 + Shader: {fileID: 4800000, guid: 8c0d9373e5d33404ba27de953fd7a309, type: 3} +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: de640fe3d0db1804a85f9fc8f5cadab6, type: 3} + m_Name: ForwardRenderer 0VeryLowQuality + m_EditorClassIdentifier: + debugShaders: + debugReplacementPS: {fileID: 4800000, guid: cf852408f2e174538bcd9b7fda1c5ae7, type: 3} + hdrDebugViewPS: {fileID: 4800000, guid: 573620ae32aec764abd4d728906d2587, type: 3} + m_RendererFeatures: [] + m_RendererFeatureMap: + m_UseNativeRenderPass: 0 + postProcessData: {fileID: 0} + xrSystemData: {fileID: 11400000, guid: 60e1133243b97e347b653163a8c01b64, type: 2} + shaders: + blitPS: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + copyDepthPS: {fileID: 4800000, guid: d6dae50ee9e1bfa4db75f19f99355220, type: 3} + screenSpaceShadowPS: {fileID: 4800000, guid: 0f854b35a0cf61a429bd5dcfea30eddd, type: 3} + samplingPS: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + stencilDeferredPS: {fileID: 4800000, guid: e9155b26e1bc55942a41e518703fe304, type: 3} + fallbackErrorPS: {fileID: 4800000, guid: e6e9a19c3678ded42a3bc431ebef7dbd, type: 3} + fallbackLoadingPS: {fileID: 4800000, guid: 7f888aff2ac86494babad1c2c5daeee2, type: 3} + materialErrorPS: {fileID: 4800000, guid: 5fd9a8feb75a4b5894c241777f519d4e, type: 3} + coreBlitPS: {fileID: 4800000, guid: 93446b5c5339d4f00b85c159e1159b7c, type: 3} + coreBlitColorAndDepthPS: {fileID: 4800000, guid: d104b2fc1ca6445babb8e90b0758136b, type: 3} + blitHDROverlay: {fileID: 4800000, guid: a89bee29cffa951418fc1e2da94d1959, type: 3} + cameraMotionVector: {fileID: 4800000, guid: c56b7e0d4c7cb484e959caeeedae9bbf, type: 3} + objectMotionVector: {fileID: 4800000, guid: 7b3ede40266cd49a395def176e1bc486, type: 3} + dataDrivenLensFlare: {fileID: 4800000, guid: 6cda457ac28612740adb23da5d39ea92, type: 3} + terrainDetailLitPS: {fileID: 4800000, guid: f6783ab646d374f94b199774402a5144, type: 3} + terrainDetailGrassPS: {fileID: 4800000, guid: e507fdfead5ca47e8b9a768b51c291a1, type: 3} + terrainDetailGrassBillboardPS: {fileID: 4800000, guid: 29868e73b638e48ca99a19ea58c48d90, type: 3} + m_AssetVersion: 2 + m_OpaqueLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_TransparentLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_DefaultStencilState: + overrideStencilState: 0 + stencilReference: 0 + stencilCompareFunction: 8 + passOperation: 2 + failOperation: 0 + zFailOperation: 0 + m_ShadowTransparentReceive: 0 + m_RenderingMode: 0 + m_DepthPrimingMode: 0 + m_CopyDepthMode: 0 + m_AccurateGbufferNormals: 0 + m_IntermediateTextureMode: 1 +--- !u!114 &721371497446299689 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1181071007, guid: 58d7e77cafa53d146af19f551ff0a3f7, type: 3} + m_Name: NiloToonCharacterRendererFeature + m_EditorClassIdentifier: + m_Active: 1 + mCharacterRenderSetting: + mOutlineSetting: + ShouldRenderOutline: 1 + UseBigSceneOutline: 0 + BigSceneOutlineAtten: 250 + outlineWidthMultiplier: 4 + outlineTintColor: {r: 1, g: 1, b: 1, a: 1} + outlineWidthAutoAdjustToCameraDistanceAndFOV: 1 + mSelfShadowSetting: + mEnableSelfShadow: 0 + mShadowUDAngle: 20 + mShadowLRAngle: 1.4 + mShadowMapSize: 2048 + mMaxShadowDistance: 100 + mShadowDepthBias: 0.78 + mUseNDotLFix: 1 + mPerfectCullingForShadowCasters: 0 + mDebugShadowCameraVisibleBox: 0 + mMiscParamsSetting: + mFinalColorMultiplier: {r: 1, g: 1, b: 1, a: 1} + mRenderDepthNormal: 1 + mFinalColorIntensity: 1 + mCharacterValueMultiplier: 1 + mCharacterSaturateMultiplier: 0.2 + mGlobalMainDirectionalLightMultiplier: 0.7 + mOverridedLightUpDownAngle: -30 + mOverridedLightLRAngle: 30 + mOverrideLightDirectionIntensity: 1 + mSpecularIntensity: 1 + mSpecularColor: {r: 1, g: 1, b: 1, a: 1} + mUseMainLightDirForSpecular: 1 + mIndirecLightMinColor: {r: 0.01, g: 0.01, b: 0.01, a: 1} + mIndirecLightMultiplier: 1 + mOverridedRimLightUpDownAngle: 180 + mOverridedRimLightLRAngle: -66 + mOverrideRimLightDirectionIntensity: 1 + mGlobalRimLightTintColor: {r: 1, g: 1, b: 1, a: 1} + mGlobalRimLightMultipiler: 4 + mRimLightStartFadeIn: 0 + mRimLightStartFadeOut: 100 + mRimLightAndShadowWidthMultiplier: 2 + mRimLightDepthDiffThresholdOffset: 0.5 + mAddLightMultipiler: {r: 1, g: 1, b: 1, a: 0.5} + mAddLightMaxContribution: {r: 1, g: 1, b: 1, a: 1} + mShadowColor: {r: 0, g: 0, b: 0, a: 0.5} + mShadowAngle: 311 + mShadowLength: 0.89 + mShadowHeight: 0.1 +--- !u!114 &858565357924166977 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1137749194, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: OutlineFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: + profilerTag: Selection Outline + targetLayer: + serializedVersion: 2 + m_Bits: 2048 + outlineMaterial: {fileID: 2100000, guid: 19d53d4eaf4ccbf479c715921ed8d533, type: 2} + outlineColor: {r: 1, g: 1, b: 1, a: 1} + outlineWidth: 1.5 + renderPassEvent: 550 +--- !u!114 &1787417439418964875 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1467291068, guid: 58d7e77cafa53d146af19f551ff0a3f7, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: e4dc0f5e3a376594b807a6e398748f7b, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 1 + UberEffectPostShaderName: GYGame/Effect/UberEffectPost +--- !u!114 &3022920579844600396 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1741494071, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: GlobalScanFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: + scanMaterial: {fileID: 2100000, guid: d67115296155f3c41ab11dbf400eaadf, type: 2} + renderPassEvent: 500 +--- !u!114 &4726037285063046694 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1608182031, guid: 58d7e77cafa53d146af19f551ff0a3f7, type: 3} + m_Name: SceneRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + Settings: + SceneParameters: + CopyColor: 1 + ShadowColor: {r: 0.38492838, g: 0.3236027, b: 0.6792453, a: 1} + BakedShadowStrength: 0.807 + GlobalBrightness: 1 + SceneDebug: 0 + NormalDebug: 0 + VertexColorDebug: 0 + DebugCustomValue: + FogParameters: + OpenFog: 1 + LineFogColor: {r: 0.9811321, g: 0.8294182, b: 0.5784977, a: 1} + LineFogStartDistance: 77.46 + LineFogEndDistance: 144.2 + LineFogStrength: 0.846 + HeightFogColor: {r: 0.12116811, g: 0.56545126, b: 0.65199995, a: 1} + HeightFogStartHeight: -2.77 + HeightFogFalloff: 1.974 + HeightFogDensity: 0.0116 + HeightFogStartDistance: 55.1 + HeightFogMinOpacity: 1 + FogAmbient: 1 +--- !u!114 &4957971812801212640 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1749369493, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: 0f60273b12723a047a568dca50178e02, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 0 + UberEffectPostShaderName: Engine/Effect/UberEffectPost +--- !u!114 &5078528552640289789 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a5c14bbf9359ff549afb7ea0f09037df, type: 3} + m_Name: NiloToonCharacterRendererFeature + m_EditorClassIdentifier: + m_Active: 1 + mCharacterRenderSetting: + mOutlineSetting: + ShouldRenderOutline: 1 + UseBigSceneOutline: 0 + BigSceneOutlineAtten: 250 + outlineWidthMultiplier: 1 + outlineTintColor: {r: 1, g: 1, b: 1, a: 1} + outlineWidthAutoAdjustToCameraDistanceAndFOV: 1 + mSelfShadowSetting: + mEnableSelfShadow: 1 + mShadowUDAngle: 30 + mShadowLRAngle: 0 + mShadowMapSize: 512 + mMaxShadowDistance: 50 + mShadowDepthBias: 1 + mUseNDotLFix: 1 + mPerfectCullingForShadowCasters: 0 + mDebugShadowCameraVisibleBox: 0 + mMiscParamsSetting: + mFinalColorMultiplier: {r: 1, g: 1, b: 1, a: 1} + mRenderDepthNormal: 0 + mFinalColorIntensity: 1 + mCharacterValueMultiplier: 1 + mCharacterSaturateMultiplier: 1 + mGlobalMainDirectionalLightMultiplier: 1 + mOverridedLightUpDownAngle: 0 + mOverridedLightLRAngle: 0 + mOverrideLightDirectionIntensity: 0 + mSpecularIntensity: 1 + mSpecularColor: {r: 1, g: 1, b: 1, a: 1} + mUseMainLightDirForSpecular: 1 + mIndirecLightMinColor: {r: 0.01, g: 0.01, b: 0.01, a: 1} + mIndirecLightMultiplier: 1 + mOverridedRimLightUpDownAngle: 0 + mOverridedRimLightLRAngle: 0 + mOverrideRimLightDirectionIntensity: 0 + mGlobalRimLightTintColor: {r: 1, g: 1, b: 1, a: 1} + mGlobalRimLightMultipiler: 1 + mRimLightStartFadeIn: 1 + mRimLightStartFadeOut: 50 + mRimLightAndShadowWidthMultiplier: 1 + mRimLightDepthDiffThresholdOffset: 0.5 + mAddLightMultipiler: {r: 1, g: 1, b: 1, a: 0.5} + mAddLightMaxContribution: {r: 1, g: 1, b: 1, a: 1} + mShadowColor: {r: 0, g: 0, b: 0, a: 0.5} + mShadowAngle: 70 + mShadowLength: 1 + mShadowHeight: 0.1 +--- !u!114 &5744085369761597830 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -929177382, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: SceneRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + SSRComputeShader: {fileID: 7200000, guid: cf8964bc444045b4c830d86205bf1838, type: 3} + Settings: + SceneParameters: + CopyColor: 0 + ShadowColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + BakedShadowStrength: 1 + GlobalBrightness: 1 + SceneDebug: 0 + NormalDebug: 0 + VertexColorDebug: 0 + DebugCustomValue: + FogParameters: + OpenFog: 1 + LineFogColor: {r: 0.5, g: 0, b: 0, a: 1} + LineFogStartDistance: 10 + LineFogEndDistance: 100 + LineFogStrength: 0 + HeightFogColor: {r: 0.5, g: 0, b: 0, a: 1} + HeightFogStartHeight: 0 + HeightFogFalloff: 0 + HeightFogDensity: 0 + HeightFogStartDistance: 0 + HeightFogMinOpacity: 0 + FogAmbient: 0 diff --git a/AIClient/Assets/Settings/URP/ForwardRenderer 0VeryLowQuality.asset.meta b/AIClient/Assets/Settings/URP/ForwardRenderer 0VeryLowQuality.asset.meta new file mode 100644 index 0000000..e35619e --- /dev/null +++ b/AIClient/Assets/Settings/URP/ForwardRenderer 0VeryLowQuality.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 39a007eb403d850479271fda4ed87835 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/Settings/URP/ForwardRenderer 1LowQuality.asset b/AIClient/Assets/Settings/URP/ForwardRenderer 1LowQuality.asset new file mode 100644 index 0000000..7b6dcaa --- /dev/null +++ b/AIClient/Assets/Settings/URP/ForwardRenderer 1LowQuality.asset @@ -0,0 +1,386 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &-8140000362427506868 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1749369493, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: 0f60273b12723a047a568dca50178e02, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 0 + UberEffectPostShaderName: Engine/Effect/UberEffectPost +--- !u!114 &-7290361237560686537 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1181071007, guid: 58d7e77cafa53d146af19f551ff0a3f7, type: 3} + m_Name: NiloToonCharacterRendererFeature + m_EditorClassIdentifier: + m_Active: 1 + mCharacterRenderSetting: + mOutlineSetting: + ShouldRenderOutline: 1 + UseBigSceneOutline: 0 + BigSceneOutlineAtten: 250 + outlineWidthMultiplier: 1 + outlineTintColor: {r: 1, g: 1, b: 1, a: 1} + outlineWidthAutoAdjustToCameraDistanceAndFOV: 1 + mSelfShadowSetting: + mEnableSelfShadow: 1 + mShadowUDAngle: 20 + mShadowLRAngle: 0 + mShadowMapSize: 2048 + mMaxShadowDistance: 100 + mShadowDepthBias: 1 + mUseNDotLFix: 1 + mPerfectCullingForShadowCasters: 1 + mDebugShadowCameraVisibleBox: 0 + mMiscParamsSetting: + mFinalColorMultiplier: {r: 1, g: 1, b: 1, a: 1} + mRenderDepthNormal: 1 + mFinalColorIntensity: 1 + mCharacterValueMultiplier: 1 + mCharacterSaturateMultiplier: 1 + mGlobalMainDirectionalLightMultiplier: 0.72 + mOverridedLightUpDownAngle: -30 + mOverridedLightLRAngle: -20 + mOverrideLightDirectionIntensity: 1 + mSpecularIntensity: 1.2 + mSpecularColor: {r: 1, g: 1, b: 1, a: 1} + mUseMainLightDirForSpecular: 1 + mIndirecLightMinColor: {r: 0.01, g: 0.01, b: 0.01, a: 1} + mIndirecLightMultiplier: 1 + mOverridedRimLightUpDownAngle: 4 + mOverridedRimLightLRAngle: 47 + mOverrideRimLightDirectionIntensity: 1 + mGlobalRimLightTintColor: {r: 1, g: 0.6084906, b: 0.68266994, a: 1} + mGlobalRimLightMultipiler: 8.3 + mRimLightStartFadeIn: 1 + mRimLightStartFadeOut: 50 + mRimLightAndShadowWidthMultiplier: 1 + mRimLightDepthDiffThresholdOffset: 0.5 + mAddLightMultipiler: {r: 1, g: 1, b: 1, a: 0.5} + mAddLightMaxContribution: {r: 1, g: 1, b: 1, a: 1} + mShadowColor: {r: 0, g: 0, b: 0, a: 0.5} + mShadowAngle: 70 + mShadowLength: 1 + mShadowHeight: 0.1 +--- !u!114 &-5507381615842164399 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 88650573, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: CharacterRenderFeature + m_EditorClassIdentifier: + m_Active: 1 +--- !u!114 &-1672808413483951387 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1749369493, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: 0f60273b12723a047a568dca50178e02, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 0 + UberEffectPostShaderName: Engine/Effect/UberEffectPost +--- !u!114 &-1190632597373315364 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -929177382, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: SceneRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + SSRComputeShader: {fileID: 7200000, guid: cf8964bc444045b4c830d86205bf1838, type: 3} + Settings: + SceneParameters: + CopyColor: 0 + ShadowColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + BakedShadowStrength: 1 + GlobalBrightness: 1 + SceneDebug: 0 + NormalDebug: 0 + VertexColorDebug: 0 + DebugCustomValue: + FogParameters: + OpenFog: 1 + LineFogColor: {r: 0.5, g: 0, b: 0, a: 1} + LineFogStartDistance: 10 + LineFogEndDistance: 100 + LineFogStrength: 0 + HeightFogColor: {r: 0.5, g: 0, b: 0, a: 1} + HeightFogStartHeight: 0 + HeightFogFalloff: 0 + HeightFogDensity: 0 + HeightFogStartDistance: 0 + HeightFogMinOpacity: 0 + FogAmbient: 0 +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: de640fe3d0db1804a85f9fc8f5cadab6, type: 3} + m_Name: ForwardRenderer 1LowQuality + m_EditorClassIdentifier: + debugShaders: + debugReplacementPS: {fileID: 4800000, guid: cf852408f2e174538bcd9b7fda1c5ae7, type: 3} + hdrDebugViewPS: {fileID: 4800000, guid: 573620ae32aec764abd4d728906d2587, type: 3} + m_RendererFeatures: [] + m_RendererFeatureMap: + m_UseNativeRenderPass: 0 + postProcessData: {fileID: 0} + xrSystemData: {fileID: 11400000, guid: 60e1133243b97e347b653163a8c01b64, type: 2} + shaders: + blitPS: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + copyDepthPS: {fileID: 4800000, guid: d6dae50ee9e1bfa4db75f19f99355220, type: 3} + screenSpaceShadowPS: {fileID: 4800000, guid: 0f854b35a0cf61a429bd5dcfea30eddd, type: 3} + samplingPS: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + stencilDeferredPS: {fileID: 4800000, guid: e9155b26e1bc55942a41e518703fe304, type: 3} + fallbackErrorPS: {fileID: 4800000, guid: e6e9a19c3678ded42a3bc431ebef7dbd, type: 3} + fallbackLoadingPS: {fileID: 4800000, guid: 7f888aff2ac86494babad1c2c5daeee2, type: 3} + materialErrorPS: {fileID: 4800000, guid: 5fd9a8feb75a4b5894c241777f519d4e, type: 3} + coreBlitPS: {fileID: 4800000, guid: 93446b5c5339d4f00b85c159e1159b7c, type: 3} + coreBlitColorAndDepthPS: {fileID: 4800000, guid: d104b2fc1ca6445babb8e90b0758136b, type: 3} + blitHDROverlay: {fileID: 4800000, guid: a89bee29cffa951418fc1e2da94d1959, type: 3} + cameraMotionVector: {fileID: 4800000, guid: c56b7e0d4c7cb484e959caeeedae9bbf, type: 3} + objectMotionVector: {fileID: 4800000, guid: 7b3ede40266cd49a395def176e1bc486, type: 3} + dataDrivenLensFlare: {fileID: 4800000, guid: 6cda457ac28612740adb23da5d39ea92, type: 3} + terrainDetailLitPS: {fileID: 4800000, guid: f6783ab646d374f94b199774402a5144, type: 3} + terrainDetailGrassPS: {fileID: 4800000, guid: e507fdfead5ca47e8b9a768b51c291a1, type: 3} + terrainDetailGrassBillboardPS: {fileID: 4800000, guid: 29868e73b638e48ca99a19ea58c48d90, type: 3} + m_AssetVersion: 2 + m_OpaqueLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_TransparentLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_DefaultStencilState: + overrideStencilState: 0 + stencilReference: 0 + stencilCompareFunction: 8 + passOperation: 2 + failOperation: 0 + zFailOperation: 0 + m_ShadowTransparentReceive: 0 + m_RenderingMode: 0 + m_DepthPrimingMode: 0 + m_CopyDepthMode: 0 + m_AccurateGbufferNormals: 0 + m_IntermediateTextureMode: 1 +--- !u!114 &1558999346734371754 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1741494071, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: GlobalScanFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: + scanMaterial: {fileID: 2100000, guid: d67115296155f3c41ab11dbf400eaadf, type: 2} + renderPassEvent: 500 +--- !u!114 &1682763883357394284 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1137749194, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: OutlineFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: + profilerTag: Selection Outline + targetLayer: + serializedVersion: 2 + m_Bits: 2048 + outlineMaterial: {fileID: 2100000, guid: 19d53d4eaf4ccbf479c715921ed8d533, type: 2} + outlineColor: {r: 1, g: 1, b: 1, a: 1} + outlineWidth: 1.5 + renderPassEvent: 550 +--- !u!114 &3537890718694377718 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a5c14bbf9359ff549afb7ea0f09037df, type: 3} + m_Name: NiloToonCharacterRendererFeature + m_EditorClassIdentifier: + m_Active: 1 + mCharacterRenderSetting: + mOutlineSetting: + ShouldRenderOutline: 1 + UseBigSceneOutline: 0 + BigSceneOutlineAtten: 250 + outlineWidthMultiplier: 1 + outlineTintColor: {r: 1, g: 1, b: 1, a: 1} + outlineWidthAutoAdjustToCameraDistanceAndFOV: 1 + mSelfShadowSetting: + mEnableSelfShadow: 1 + mShadowUDAngle: 30 + mShadowLRAngle: 0 + mShadowMapSize: 512 + mMaxShadowDistance: 50 + mShadowDepthBias: 1 + mUseNDotLFix: 1 + mPerfectCullingForShadowCasters: 0 + mDebugShadowCameraVisibleBox: 0 + mMiscParamsSetting: + mFinalColorMultiplier: {r: 1, g: 1, b: 1, a: 1} + mRenderDepthNormal: 0 + mFinalColorIntensity: 1 + mCharacterValueMultiplier: 1 + mCharacterSaturateMultiplier: 1 + mGlobalMainDirectionalLightMultiplier: 1 + mOverridedLightUpDownAngle: 0 + mOverridedLightLRAngle: 0 + mOverrideLightDirectionIntensity: 0 + mSpecularIntensity: 1 + mSpecularColor: {r: 1, g: 1, b: 1, a: 1} + mUseMainLightDirForSpecular: 1 + mIndirecLightMinColor: {r: 0.01, g: 0.01, b: 0.01, a: 1} + mIndirecLightMultiplier: 1 + mOverridedRimLightUpDownAngle: 0 + mOverridedRimLightLRAngle: 0 + mOverrideRimLightDirectionIntensity: 0 + mGlobalRimLightTintColor: {r: 1, g: 1, b: 1, a: 1} + mGlobalRimLightMultipiler: 1 + mRimLightStartFadeIn: 1 + mRimLightStartFadeOut: 50 + mRimLightAndShadowWidthMultiplier: 1 + mRimLightDepthDiffThresholdOffset: 0.5 + mAddLightMultipiler: {r: 1, g: 1, b: 1, a: 0.5} + mAddLightMaxContribution: {r: 1, g: 1, b: 1, a: 1} + mShadowColor: {r: 0, g: 0, b: 0, a: 0.5} + mShadowAngle: 70 + mShadowLength: 1 + mShadowHeight: 0.1 +--- !u!114 &3542269519574187309 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1467291068, guid: 58d7e77cafa53d146af19f551ff0a3f7, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: e4dc0f5e3a376594b807a6e398748f7b, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 1 + UberEffectPostShaderName: GYGame/Effect/UberEffectPost +--- !u!114 &7530585995642756810 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 09d572d36a83daa4bb576ae814b31f16, type: 3} + m_Name: TAA + m_EditorClassIdentifier: + m_Active: 1 + PreviewInSceneView: 0 + UseMotionVector: 0 + RenderingMode: 0 + WorkOnPrepass: 0 + IgonreTransparentObject: 1 + Use32Bit: 0 + Shader: {fileID: 4800000, guid: 8c0d9373e5d33404ba27de953fd7a309, type: 3} +--- !u!114 &7600997400158244659 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1608182031, guid: 58d7e77cafa53d146af19f551ff0a3f7, type: 3} + m_Name: SceneRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + Settings: + SceneParameters: + CopyColor: 0 + ShadowColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + BakedShadowStrength: 1 + GlobalBrightness: 1 + SceneDebug: 0 + NormalDebug: 0 + VertexColorDebug: 0 + DebugCustomValue: + FogParameters: + OpenFog: 1 + LineFogColor: {r: 0.5, g: 0, b: 0, a: 1} + LineFogStartDistance: 10 + LineFogEndDistance: 100 + LineFogStrength: 0 + HeightFogColor: {r: 0.5, g: 0, b: 0, a: 1} + HeightFogStartHeight: 0 + HeightFogFalloff: 0 + HeightFogDensity: 0 + HeightFogStartDistance: 0 + HeightFogMinOpacity: 0 + FogAmbient: 0 diff --git a/AIClient/Assets/Settings/URP/ForwardRenderer 1LowQuality.asset.meta b/AIClient/Assets/Settings/URP/ForwardRenderer 1LowQuality.asset.meta new file mode 100644 index 0000000..7a2d5eb --- /dev/null +++ b/AIClient/Assets/Settings/URP/ForwardRenderer 1LowQuality.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ab43e90c602bfdb4e97e042f15d35169 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/Settings/URP/ForwardRenderer 2MediumQuality.asset b/AIClient/Assets/Settings/URP/ForwardRenderer 2MediumQuality.asset new file mode 100644 index 0000000..2288a51 --- /dev/null +++ b/AIClient/Assets/Settings/URP/ForwardRenderer 2MediumQuality.asset @@ -0,0 +1,386 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &-7718913652599309650 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -929177382, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: SceneRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + SSRComputeShader: {fileID: 7200000, guid: cf8964bc444045b4c830d86205bf1838, type: 3} + Settings: + SceneParameters: + CopyColor: 0 + ShadowColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + BakedShadowStrength: 1 + GlobalBrightness: 1 + SceneDebug: 0 + NormalDebug: 0 + VertexColorDebug: 0 + DebugCustomValue: + FogParameters: + OpenFog: 1 + LineFogColor: {r: 0.5, g: 0, b: 0, a: 1} + LineFogStartDistance: 10 + LineFogEndDistance: 100 + LineFogStrength: 0 + HeightFogColor: {r: 0.5, g: 0, b: 0, a: 1} + HeightFogStartHeight: 0 + HeightFogFalloff: 0 + HeightFogDensity: 0 + HeightFogStartDistance: 0 + HeightFogMinOpacity: 0 + FogAmbient: 0 +--- !u!114 &-6991330952846024812 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1181071007, guid: 58d7e77cafa53d146af19f551ff0a3f7, type: 3} + m_Name: NiloToonCharacterRendererFeature + m_EditorClassIdentifier: + m_Active: 1 + mCharacterRenderSetting: + mOutlineSetting: + ShouldRenderOutline: 1 + UseBigSceneOutline: 0 + BigSceneOutlineAtten: 250 + outlineWidthMultiplier: 4 + outlineTintColor: {r: 1, g: 1, b: 1, a: 1} + outlineWidthAutoAdjustToCameraDistanceAndFOV: 1 + mSelfShadowSetting: + mEnableSelfShadow: 0 + mShadowUDAngle: 20 + mShadowLRAngle: 1.4 + mShadowMapSize: 2048 + mMaxShadowDistance: 100 + mShadowDepthBias: 0.78 + mUseNDotLFix: 1 + mPerfectCullingForShadowCasters: 0 + mDebugShadowCameraVisibleBox: 0 + mMiscParamsSetting: + mFinalColorMultiplier: {r: 1, g: 1, b: 1, a: 1} + mRenderDepthNormal: 1 + mFinalColorIntensity: 1 + mCharacterValueMultiplier: 1 + mCharacterSaturateMultiplier: 0.2 + mGlobalMainDirectionalLightMultiplier: 0.7 + mOverridedLightUpDownAngle: -30 + mOverridedLightLRAngle: 30 + mOverrideLightDirectionIntensity: 1 + mSpecularIntensity: 1 + mSpecularColor: {r: 1, g: 1, b: 1, a: 1} + mUseMainLightDirForSpecular: 1 + mIndirecLightMinColor: {r: 0.01, g: 0.01, b: 0.01, a: 1} + mIndirecLightMultiplier: 1 + mOverridedRimLightUpDownAngle: 180 + mOverridedRimLightLRAngle: -66 + mOverrideRimLightDirectionIntensity: 1 + mGlobalRimLightTintColor: {r: 1, g: 1, b: 1, a: 1} + mGlobalRimLightMultipiler: 4 + mRimLightStartFadeIn: 0 + mRimLightStartFadeOut: 100 + mRimLightAndShadowWidthMultiplier: 2 + mRimLightDepthDiffThresholdOffset: 0.5 + mAddLightMultipiler: {r: 1, g: 1, b: 1, a: 0.5} + mAddLightMaxContribution: {r: 1, g: 1, b: 1, a: 1} + mShadowColor: {r: 0, g: 0, b: 0, a: 0.5} + mShadowAngle: 311 + mShadowLength: 0.89 + mShadowHeight: 0.1 +--- !u!114 &-5455261652415300069 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1467291068, guid: 58d7e77cafa53d146af19f551ff0a3f7, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: e4dc0f5e3a376594b807a6e398748f7b, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 1 + UberEffectPostShaderName: GYGame/Effect/UberEffectPost +--- !u!114 &-2003660147899227534 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1741494071, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: GlobalScanFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: + scanMaterial: {fileID: 2100000, guid: d67115296155f3c41ab11dbf400eaadf, type: 2} + renderPassEvent: 500 +--- !u!114 &-847716761063671179 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 88650573, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: CharacterRenderFeature + m_EditorClassIdentifier: + m_Active: 1 +--- !u!114 &-490646824858370943 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1749369493, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: 0f60273b12723a047a568dca50178e02, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 0 + UberEffectPostShaderName: Engine/Effect/UberEffectPost +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: de640fe3d0db1804a85f9fc8f5cadab6, type: 3} + m_Name: ForwardRenderer 2MediumQuality + m_EditorClassIdentifier: + debugShaders: + debugReplacementPS: {fileID: 4800000, guid: cf852408f2e174538bcd9b7fda1c5ae7, type: 3} + hdrDebugViewPS: {fileID: 4800000, guid: 573620ae32aec764abd4d728906d2587, type: 3} + m_RendererFeatures: [] + m_RendererFeatureMap: + m_UseNativeRenderPass: 0 + postProcessData: {fileID: 11400000, guid: 41439944d30ece34e96484bdb6645b55, type: 2} + xrSystemData: {fileID: 11400000, guid: 60e1133243b97e347b653163a8c01b64, type: 2} + shaders: + blitPS: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + copyDepthPS: {fileID: 4800000, guid: d6dae50ee9e1bfa4db75f19f99355220, type: 3} + screenSpaceShadowPS: {fileID: 4800000, guid: 0f854b35a0cf61a429bd5dcfea30eddd, type: 3} + samplingPS: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + stencilDeferredPS: {fileID: 4800000, guid: e9155b26e1bc55942a41e518703fe304, type: 3} + fallbackErrorPS: {fileID: 4800000, guid: e6e9a19c3678ded42a3bc431ebef7dbd, type: 3} + fallbackLoadingPS: {fileID: 4800000, guid: 7f888aff2ac86494babad1c2c5daeee2, type: 3} + materialErrorPS: {fileID: 4800000, guid: 5fd9a8feb75a4b5894c241777f519d4e, type: 3} + coreBlitPS: {fileID: 4800000, guid: 93446b5c5339d4f00b85c159e1159b7c, type: 3} + coreBlitColorAndDepthPS: {fileID: 4800000, guid: d104b2fc1ca6445babb8e90b0758136b, type: 3} + blitHDROverlay: {fileID: 4800000, guid: a89bee29cffa951418fc1e2da94d1959, type: 3} + cameraMotionVector: {fileID: 4800000, guid: c56b7e0d4c7cb484e959caeeedae9bbf, type: 3} + objectMotionVector: {fileID: 4800000, guid: 7b3ede40266cd49a395def176e1bc486, type: 3} + dataDrivenLensFlare: {fileID: 4800000, guid: 6cda457ac28612740adb23da5d39ea92, type: 3} + terrainDetailLitPS: {fileID: 4800000, guid: f6783ab646d374f94b199774402a5144, type: 3} + terrainDetailGrassPS: {fileID: 4800000, guid: e507fdfead5ca47e8b9a768b51c291a1, type: 3} + terrainDetailGrassBillboardPS: {fileID: 4800000, guid: 29868e73b638e48ca99a19ea58c48d90, type: 3} + m_AssetVersion: 2 + m_OpaqueLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_TransparentLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_DefaultStencilState: + overrideStencilState: 0 + stencilReference: 0 + stencilCompareFunction: 8 + passOperation: 2 + failOperation: 0 + zFailOperation: 0 + m_ShadowTransparentReceive: 1 + m_RenderingMode: 0 + m_DepthPrimingMode: 0 + m_CopyDepthMode: 0 + m_AccurateGbufferNormals: 0 + m_IntermediateTextureMode: 1 +--- !u!114 &5699326553694581020 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1137749194, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: OutlineFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: + profilerTag: Selection Outline + targetLayer: + serializedVersion: 2 + m_Bits: 2048 + outlineMaterial: {fileID: 2100000, guid: 19d53d4eaf4ccbf479c715921ed8d533, type: 2} + outlineColor: {r: 1, g: 1, b: 1, a: 1} + outlineWidth: 1.5 + renderPassEvent: 550 +--- !u!114 &6943902112415961173 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1749369493, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: 0f60273b12723a047a568dca50178e02, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 0 + UberEffectPostShaderName: Engine/Effect/UberEffectPost +--- !u!114 &7161363195401994471 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a5c14bbf9359ff549afb7ea0f09037df, type: 3} + m_Name: NiloToonCharacterRendererFeature + m_EditorClassIdentifier: + m_Active: 1 + mCharacterRenderSetting: + mOutlineSetting: + ShouldRenderOutline: 1 + UseBigSceneOutline: 0 + BigSceneOutlineAtten: 250 + outlineWidthMultiplier: 1 + outlineTintColor: {r: 1, g: 1, b: 1, a: 1} + outlineWidthAutoAdjustToCameraDistanceAndFOV: 1 + mSelfShadowSetting: + mEnableSelfShadow: 1 + mShadowUDAngle: 30 + mShadowLRAngle: 0 + mShadowMapSize: 512 + mMaxShadowDistance: 50 + mShadowDepthBias: 1 + mUseNDotLFix: 1 + mPerfectCullingForShadowCasters: 0 + mDebugShadowCameraVisibleBox: 0 + mMiscParamsSetting: + mFinalColorMultiplier: {r: 1, g: 1, b: 1, a: 1} + mRenderDepthNormal: 0 + mFinalColorIntensity: 1 + mCharacterValueMultiplier: 1 + mCharacterSaturateMultiplier: 1 + mGlobalMainDirectionalLightMultiplier: 1 + mOverridedLightUpDownAngle: 0 + mOverridedLightLRAngle: 0 + mOverrideLightDirectionIntensity: 0 + mSpecularIntensity: 1 + mSpecularColor: {r: 1, g: 1, b: 1, a: 1} + mUseMainLightDirForSpecular: 1 + mIndirecLightMinColor: {r: 0.01, g: 0.01, b: 0.01, a: 1} + mIndirecLightMultiplier: 1 + mOverridedRimLightUpDownAngle: 0 + mOverridedRimLightLRAngle: 0 + mOverrideRimLightDirectionIntensity: 0 + mGlobalRimLightTintColor: {r: 1, g: 1, b: 1, a: 1} + mGlobalRimLightMultipiler: 1 + mRimLightStartFadeIn: 1 + mRimLightStartFadeOut: 50 + mRimLightAndShadowWidthMultiplier: 1 + mRimLightDepthDiffThresholdOffset: 0.5 + mAddLightMultipiler: {r: 1, g: 1, b: 1, a: 0.5} + mAddLightMaxContribution: {r: 1, g: 1, b: 1, a: 1} + mShadowColor: {r: 0, g: 0, b: 0, a: 0.5} + mShadowAngle: 70 + mShadowLength: 1 + mShadowHeight: 0.1 +--- !u!114 &8305250864628069769 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1608182031, guid: 58d7e77cafa53d146af19f551ff0a3f7, type: 3} + m_Name: SceneRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + Settings: + SceneParameters: + CopyColor: 1 + ShadowColor: {r: 0.38492838, g: 0.3236027, b: 0.6792453, a: 1} + BakedShadowStrength: 0.807 + GlobalBrightness: 1 + SceneDebug: 0 + NormalDebug: 0 + VertexColorDebug: 0 + DebugCustomValue: + FogParameters: + OpenFog: 1 + LineFogColor: {r: 0.9811321, g: 0.8294182, b: 0.5784977, a: 1} + LineFogStartDistance: 77.46 + LineFogEndDistance: 144.2 + LineFogStrength: 0.846 + HeightFogColor: {r: 0.12116811, g: 0.56545126, b: 0.65199995, a: 1} + HeightFogStartHeight: -2.77 + HeightFogFalloff: 1.974 + HeightFogDensity: 0.0116 + HeightFogStartDistance: 55.1 + HeightFogMinOpacity: 1 + FogAmbient: 1 +--- !u!114 &8504462900099931217 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 09d572d36a83daa4bb576ae814b31f16, type: 3} + m_Name: TAA + m_EditorClassIdentifier: + m_Active: 1 + PreviewInSceneView: 0 + UseMotionVector: 0 + RenderingMode: 0 + WorkOnPrepass: 0 + IgonreTransparentObject: 1 + Use32Bit: 0 + Shader: {fileID: 4800000, guid: 8c0d9373e5d33404ba27de953fd7a309, type: 3} diff --git a/AIClient/Assets/Settings/URP/ForwardRenderer 2MediumQuality.asset.meta b/AIClient/Assets/Settings/URP/ForwardRenderer 2MediumQuality.asset.meta new file mode 100644 index 0000000..983345e --- /dev/null +++ b/AIClient/Assets/Settings/URP/ForwardRenderer 2MediumQuality.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f454285041681a44999cc258865df961 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/Settings/URP/ForwardRenderer 3HighQuality.asset b/AIClient/Assets/Settings/URP/ForwardRenderer 3HighQuality.asset new file mode 100644 index 0000000..a468a9e --- /dev/null +++ b/AIClient/Assets/Settings/URP/ForwardRenderer 3HighQuality.asset @@ -0,0 +1,386 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &-7801194434031207102 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1181071007, guid: 58d7e77cafa53d146af19f551ff0a3f7, type: 3} + m_Name: NiloToonCharacterRendererFeature + m_EditorClassIdentifier: + m_Active: 1 + mCharacterRenderSetting: + mOutlineSetting: + ShouldRenderOutline: 1 + UseBigSceneOutline: 0 + BigSceneOutlineAtten: 250 + outlineWidthMultiplier: 1 + outlineTintColor: {r: 1, g: 1, b: 1, a: 1} + outlineWidthAutoAdjustToCameraDistanceAndFOV: 1 + mSelfShadowSetting: + mEnableSelfShadow: 1 + mShadowUDAngle: 0 + mShadowLRAngle: 0 + mShadowMapSize: 512 + mMaxShadowDistance: 50 + mShadowDepthBias: 1 + mUseNDotLFix: 1 + mPerfectCullingForShadowCasters: 1 + mDebugShadowCameraVisibleBox: 0 + mMiscParamsSetting: + mFinalColorMultiplier: {r: 1, g: 1, b: 1, a: 1} + mRenderDepthNormal: 1 + mFinalColorIntensity: 1 + mCharacterValueMultiplier: 1 + mCharacterSaturateMultiplier: 1 + mGlobalMainDirectionalLightMultiplier: 1 + mOverridedLightUpDownAngle: 0 + mOverridedLightLRAngle: 0 + mOverrideLightDirectionIntensity: 0 + mSpecularIntensity: 1 + mSpecularColor: {r: 1, g: 1, b: 1, a: 1} + mUseMainLightDirForSpecular: 1 + mIndirecLightMinColor: {r: 0.01, g: 0.01, b: 0.01, a: 1} + mIndirecLightMultiplier: 1 + mOverridedRimLightUpDownAngle: -93 + mOverridedRimLightLRAngle: -66 + mOverrideRimLightDirectionIntensity: 1 + mGlobalRimLightTintColor: {r: 1, g: 1, b: 1, a: 1} + mGlobalRimLightMultipiler: 1 + mRimLightStartFadeIn: 1 + mRimLightStartFadeOut: 50 + mRimLightAndShadowWidthMultiplier: 1 + mRimLightDepthDiffThresholdOffset: 0.5 + mAddLightMultipiler: {r: 1, g: 1, b: 1, a: 0.5} + mAddLightMaxContribution: {r: 1, g: 1, b: 1, a: 1} + mShadowColor: {r: 0, g: 0, b: 0, a: 0.5} + mShadowAngle: 70 + mShadowLength: 1 + mShadowHeight: 0.1 +--- !u!114 &-6694115839964173624 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1137749194, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: OutlineFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: + profilerTag: Selection Outline + targetLayer: + serializedVersion: 2 + m_Bits: 2048 + outlineMaterial: {fileID: 2100000, guid: 19d53d4eaf4ccbf479c715921ed8d533, type: 2} + outlineColor: {r: 1, g: 1, b: 1, a: 1} + outlineWidth: 1.5 + renderPassEvent: 550 +--- !u!114 &-4899579376300271201 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1741494071, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: GlobalScanFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: + scanMaterial: {fileID: 2100000, guid: d67115296155f3c41ab11dbf400eaadf, type: 2} + renderPassEvent: 500 +--- !u!114 &-3248482767401251789 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1749369493, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: 0f60273b12723a047a568dca50178e02, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 0 + UberEffectPostShaderName: Engine/Effect/UberEffectPost +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: de640fe3d0db1804a85f9fc8f5cadab6, type: 3} + m_Name: ForwardRenderer 3HighQuality + m_EditorClassIdentifier: + debugShaders: + debugReplacementPS: {fileID: 4800000, guid: cf852408f2e174538bcd9b7fda1c5ae7, type: 3} + hdrDebugViewPS: {fileID: 4800000, guid: 573620ae32aec764abd4d728906d2587, type: 3} + m_RendererFeatures: [] + m_RendererFeatureMap: + m_UseNativeRenderPass: 0 + postProcessData: {fileID: 11400000, guid: 41439944d30ece34e96484bdb6645b55, type: 2} + xrSystemData: {fileID: 11400000, guid: 60e1133243b97e347b653163a8c01b64, type: 2} + shaders: + blitPS: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + copyDepthPS: {fileID: 4800000, guid: d6dae50ee9e1bfa4db75f19f99355220, type: 3} + screenSpaceShadowPS: {fileID: 4800000, guid: 0f854b35a0cf61a429bd5dcfea30eddd, type: 3} + samplingPS: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + stencilDeferredPS: {fileID: 4800000, guid: e9155b26e1bc55942a41e518703fe304, type: 3} + fallbackErrorPS: {fileID: 4800000, guid: e6e9a19c3678ded42a3bc431ebef7dbd, type: 3} + fallbackLoadingPS: {fileID: 4800000, guid: 7f888aff2ac86494babad1c2c5daeee2, type: 3} + materialErrorPS: {fileID: 4800000, guid: 5fd9a8feb75a4b5894c241777f519d4e, type: 3} + coreBlitPS: {fileID: 4800000, guid: 93446b5c5339d4f00b85c159e1159b7c, type: 3} + coreBlitColorAndDepthPS: {fileID: 4800000, guid: d104b2fc1ca6445babb8e90b0758136b, type: 3} + blitHDROverlay: {fileID: 4800000, guid: a89bee29cffa951418fc1e2da94d1959, type: 3} + cameraMotionVector: {fileID: 4800000, guid: c56b7e0d4c7cb484e959caeeedae9bbf, type: 3} + objectMotionVector: {fileID: 4800000, guid: 7b3ede40266cd49a395def176e1bc486, type: 3} + dataDrivenLensFlare: {fileID: 4800000, guid: 6cda457ac28612740adb23da5d39ea92, type: 3} + terrainDetailLitPS: {fileID: 4800000, guid: f6783ab646d374f94b199774402a5144, type: 3} + terrainDetailGrassPS: {fileID: 4800000, guid: e507fdfead5ca47e8b9a768b51c291a1, type: 3} + terrainDetailGrassBillboardPS: {fileID: 4800000, guid: 29868e73b638e48ca99a19ea58c48d90, type: 3} + m_AssetVersion: 2 + m_OpaqueLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_TransparentLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_DefaultStencilState: + overrideStencilState: 0 + stencilReference: 0 + stencilCompareFunction: 8 + passOperation: 2 + failOperation: 0 + zFailOperation: 0 + m_ShadowTransparentReceive: 1 + m_RenderingMode: 0 + m_DepthPrimingMode: 0 + m_CopyDepthMode: 0 + m_AccurateGbufferNormals: 0 + m_IntermediateTextureMode: 1 +--- !u!114 &2306520689226812427 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1749369493, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: 0f60273b12723a047a568dca50178e02, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 0 + UberEffectPostShaderName: Engine/Effect/UberEffectPost +--- !u!114 &3389092417788614536 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 88650573, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: CharacterRenderFeature + m_EditorClassIdentifier: + m_Active: 1 +--- !u!114 &4194082081604926240 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1467291068, guid: 58d7e77cafa53d146af19f551ff0a3f7, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: e4dc0f5e3a376594b807a6e398748f7b, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 0 + UberEffectPostShaderName: GYGame/Effect/UberEffectPost +--- !u!114 &4324850898653140070 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a5c14bbf9359ff549afb7ea0f09037df, type: 3} + m_Name: NiloToonCharacterRendererFeature + m_EditorClassIdentifier: + m_Active: 1 + mCharacterRenderSetting: + mOutlineSetting: + ShouldRenderOutline: 1 + UseBigSceneOutline: 0 + BigSceneOutlineAtten: 250 + outlineWidthMultiplier: 1 + outlineTintColor: {r: 1, g: 1, b: 1, a: 1} + outlineWidthAutoAdjustToCameraDistanceAndFOV: 1 + mSelfShadowSetting: + mEnableSelfShadow: 1 + mShadowUDAngle: 30 + mShadowLRAngle: 0 + mShadowMapSize: 512 + mMaxShadowDistance: 50 + mShadowDepthBias: 1 + mUseNDotLFix: 1 + mPerfectCullingForShadowCasters: 0 + mDebugShadowCameraVisibleBox: 0 + mMiscParamsSetting: + mFinalColorMultiplier: {r: 1, g: 1, b: 1, a: 1} + mRenderDepthNormal: 0 + mFinalColorIntensity: 1 + mCharacterValueMultiplier: 1 + mCharacterSaturateMultiplier: 1 + mGlobalMainDirectionalLightMultiplier: 1 + mOverridedLightUpDownAngle: 0 + mOverridedLightLRAngle: 0 + mOverrideLightDirectionIntensity: 0 + mSpecularIntensity: 1 + mSpecularColor: {r: 1, g: 1, b: 1, a: 1} + mUseMainLightDirForSpecular: 1 + mIndirecLightMinColor: {r: 0.01, g: 0.01, b: 0.01, a: 1} + mIndirecLightMultiplier: 1 + mOverridedRimLightUpDownAngle: 0 + mOverridedRimLightLRAngle: 0 + mOverrideRimLightDirectionIntensity: 0 + mGlobalRimLightTintColor: {r: 1, g: 1, b: 1, a: 1} + mGlobalRimLightMultipiler: 1 + mRimLightStartFadeIn: 1 + mRimLightStartFadeOut: 50 + mRimLightAndShadowWidthMultiplier: 1 + mRimLightDepthDiffThresholdOffset: 0.5 + mAddLightMultipiler: {r: 1, g: 1, b: 1, a: 0.5} + mAddLightMaxContribution: {r: 1, g: 1, b: 1, a: 1} + mShadowColor: {r: 0, g: 0, b: 0, a: 0.5} + mShadowAngle: 70 + mShadowLength: 1 + mShadowHeight: 0.1 +--- !u!114 &4815503470696360763 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 09d572d36a83daa4bb576ae814b31f16, type: 3} + m_Name: TAA + m_EditorClassIdentifier: + m_Active: 1 + PreviewInSceneView: 0 + UseMotionVector: 0 + RenderingMode: 0 + WorkOnPrepass: 0 + IgonreTransparentObject: 1 + Use32Bit: 0 + Shader: {fileID: 4800000, guid: 8c0d9373e5d33404ba27de953fd7a309, type: 3} +--- !u!114 &7287650270393091027 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1608182031, guid: 58d7e77cafa53d146af19f551ff0a3f7, type: 3} + m_Name: SceneRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + Settings: + SceneParameters: + CopyColor: 0 + ShadowColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + BakedShadowStrength: 1 + GlobalBrightness: 1 + SceneDebug: 0 + NormalDebug: 0 + VertexColorDebug: 0 + DebugCustomValue: + FogParameters: + OpenFog: 1 + LineFogColor: {r: 0.5, g: 0, b: 0, a: 1} + LineFogStartDistance: 10 + LineFogEndDistance: 100 + LineFogStrength: 0 + HeightFogColor: {r: 0.5, g: 0, b: 0, a: 1} + HeightFogStartHeight: 0 + HeightFogFalloff: 0 + HeightFogDensity: 0 + HeightFogStartDistance: 0 + HeightFogMinOpacity: 0 + FogAmbient: 0 +--- !u!114 &8702178449611039958 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -929177382, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: SceneRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + SSRComputeShader: {fileID: 7200000, guid: cf8964bc444045b4c830d86205bf1838, type: 3} + Settings: + SceneParameters: + CopyColor: 0 + ShadowColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + BakedShadowStrength: 1 + GlobalBrightness: 1 + SceneDebug: 0 + NormalDebug: 0 + VertexColorDebug: 0 + DebugCustomValue: + FogParameters: + OpenFog: 1 + LineFogColor: {r: 0.5, g: 0, b: 0, a: 1} + LineFogStartDistance: 10 + LineFogEndDistance: 100 + LineFogStrength: 0 + HeightFogColor: {r: 0.5, g: 0, b: 0, a: 1} + HeightFogStartHeight: 0 + HeightFogFalloff: 0 + HeightFogDensity: 0 + HeightFogStartDistance: 0 + HeightFogMinOpacity: 0 + FogAmbient: 0 diff --git a/AIClient/Assets/Settings/URP/ForwardRenderer 3HighQuality.asset.meta b/AIClient/Assets/Settings/URP/ForwardRenderer 3HighQuality.asset.meta new file mode 100644 index 0000000..4517c7e --- /dev/null +++ b/AIClient/Assets/Settings/URP/ForwardRenderer 3HighQuality.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ad8f6453bdbe88e409d8fd14942f700d +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/Settings/URP/ForwardRenderer 4VeryHighQuality.asset b/AIClient/Assets/Settings/URP/ForwardRenderer 4VeryHighQuality.asset new file mode 100644 index 0000000..ba3c96c --- /dev/null +++ b/AIClient/Assets/Settings/URP/ForwardRenderer 4VeryHighQuality.asset @@ -0,0 +1,575 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &-6867683717461137263 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1137749194, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: OutlineFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: + profilerTag: Selection Outline + targetLayer: + serializedVersion: 2 + m_Bits: 2048 + outlineMaterial: {fileID: 2100000, guid: 19d53d4eaf4ccbf479c715921ed8d533, type: 2} + outlineColor: {r: 1, g: 1, b: 1, a: 1} + outlineWidth: 1.5 + renderPassEvent: 550 +--- !u!114 &-5451962332241528892 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 09d572d36a83daa4bb576ae814b31f16, type: 3} + m_Name: TAA + m_EditorClassIdentifier: + m_Active: 1 + PreviewInSceneView: 0 + UseMotionVector: 0 + RenderingMode: 0 + WorkOnPrepass: 0 + IgonreTransparentObject: 1 + Use32Bit: 0 + Shader: {fileID: 4800000, guid: 8c0d9373e5d33404ba27de953fd7a309, type: 3} +--- !u!114 &-5192553248712847449 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a5c14bbf9359ff549afb7ea0f09037df, type: 3} + m_Name: NiloToonCharacterRendererFeature + m_EditorClassIdentifier: + m_Active: 1 + mCharacterRenderSetting: + mOutlineSetting: + ShouldRenderOutline: 1 + UseBigSceneOutline: 0 + BigSceneOutlineAtten: 250 + outlineWidthMultiplier: 1 + outlineTintColor: {r: 1, g: 1, b: 1, a: 1} + outlineWidthAutoAdjustToCameraDistanceAndFOV: 1 + mSelfShadowSetting: + mEnableSelfShadow: 1 + mShadowUDAngle: 30 + mShadowLRAngle: 0 + mShadowMapSize: 512 + mMaxShadowDistance: 50 + mShadowDepthBias: 1 + mUseNDotLFix: 1 + mPerfectCullingForShadowCasters: 0 + mDebugShadowCameraVisibleBox: 0 + mMiscParamsSetting: + mFinalColorMultiplier: {r: 1, g: 1, b: 1, a: 1} + mRenderDepthNormal: 0 + mFinalColorIntensity: 1 + mCharacterValueMultiplier: 1 + mCharacterSaturateMultiplier: 1 + mGlobalMainDirectionalLightMultiplier: 1 + mOverridedLightUpDownAngle: 0 + mOverridedLightLRAngle: 0 + mOverrideLightDirectionIntensity: 0 + mSpecularIntensity: 1 + mSpecularColor: {r: 1, g: 1, b: 1, a: 1} + mUseMainLightDirForSpecular: 1 + mIndirecLightMinColor: {r: 0.01, g: 0.01, b: 0.01, a: 1} + mIndirecLightMultiplier: 1 + mOverridedRimLightUpDownAngle: 0 + mOverridedRimLightLRAngle: 0 + mOverrideRimLightDirectionIntensity: 0 + mGlobalRimLightTintColor: {r: 1, g: 1, b: 1, a: 1} + mGlobalRimLightMultipiler: 1 + mRimLightStartFadeIn: 1 + mRimLightStartFadeOut: 50 + mRimLightAndShadowWidthMultiplier: 1 + mRimLightDepthDiffThresholdOffset: 0.5 + mAddLightMultipiler: {r: 1, g: 1, b: 1, a: 0.5} + mAddLightMaxContribution: {r: 1, g: 1, b: 1, a: 1} + mShadowColor: {r: 0, g: 0, b: 0, a: 0.5} + mShadowAngle: 70 + mShadowLength: 1 + mShadowHeight: 0.1 +--- !u!114 &-4124891324505445878 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1733187461, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: FogOfWarRendererFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: + material: {fileID: 2100000, guid: 7fbd1a5d99ab35f40b6c59ea18b4d4f6, type: 2} + renderPassEvent: 500 +--- !u!114 &-2417408525049291374 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1918578592, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: UIGrabPassFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: + renderScale: 1 + uiCameraName: CameraUi + blurredMaterial: {fileID: 0} +--- !u!114 &-2278940370034410917 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 770177407, guid: 38d7f5c1b6979824ab378dfa57dcc73e, type: 3} + m_Name: GlobalScanFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: {scanMaterial: {fileID: 2100000, guid: d67115296155f3c41ab11dbf400eaadf, type: 2}, renderPassEvent: 500} +--- !u!114 &-1168925892722199119 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1749369493, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: 0f60273b12723a047a568dca50178e02, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 0 + UberEffectPostShaderName: Engine/Effect/UberEffectPost +--- !u!114 &-448008844506635923 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1729826526, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: AfterTransparentColorAndCloakFeature + m_EditorClassIdentifier: + m_Active: 1 + m_Settings: + afterTransparentColorTextureName: _AfterTransparentColorTex + downsample: 0.5 + skipSceneView: 1 +--- !u!114 &-386890362932611403 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1918578592, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: UIGrabPassFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: + renderScale: 1 + uiCameraName: CameraUi + blurredMaterial: {fileID: 0} +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: de640fe3d0db1804a85f9fc8f5cadab6, type: 3} + m_Name: ForwardRenderer 4VeryHighQuality + m_EditorClassIdentifier: + debugShaders: + debugReplacementPS: {fileID: 4800000, guid: cf852408f2e174538bcd9b7fda1c5ae7, type: 3} + hdrDebugViewPS: {fileID: 4800000, guid: 573620ae32aec764abd4d728906d2587, type: 3} + m_RendererFeatures: [] + m_RendererFeatureMap: + m_UseNativeRenderPass: 0 + postProcessData: {fileID: 11400000, guid: 41439944d30ece34e96484bdb6645b55, type: 2} + xrSystemData: {fileID: 11400000, guid: 60e1133243b97e347b653163a8c01b64, type: 2} + shaders: + blitPS: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + copyDepthPS: {fileID: 4800000, guid: d6dae50ee9e1bfa4db75f19f99355220, type: 3} + screenSpaceShadowPS: {fileID: 4800000, guid: 0f854b35a0cf61a429bd5dcfea30eddd, type: 3} + samplingPS: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + stencilDeferredPS: {fileID: 4800000, guid: e9155b26e1bc55942a41e518703fe304, type: 3} + fallbackErrorPS: {fileID: 4800000, guid: e6e9a19c3678ded42a3bc431ebef7dbd, type: 3} + fallbackLoadingPS: {fileID: 4800000, guid: 7f888aff2ac86494babad1c2c5daeee2, type: 3} + materialErrorPS: {fileID: 4800000, guid: 5fd9a8feb75a4b5894c241777f519d4e, type: 3} + coreBlitPS: {fileID: 4800000, guid: 93446b5c5339d4f00b85c159e1159b7c, type: 3} + coreBlitColorAndDepthPS: {fileID: 4800000, guid: d104b2fc1ca6445babb8e90b0758136b, type: 3} + blitHDROverlay: {fileID: 4800000, guid: a89bee29cffa951418fc1e2da94d1959, type: 3} + cameraMotionVector: {fileID: 4800000, guid: c56b7e0d4c7cb484e959caeeedae9bbf, type: 3} + objectMotionVector: {fileID: 4800000, guid: 7b3ede40266cd49a395def176e1bc486, type: 3} + dataDrivenLensFlare: {fileID: 4800000, guid: 6cda457ac28612740adb23da5d39ea92, type: 3} + terrainDetailLitPS: {fileID: 4800000, guid: f6783ab646d374f94b199774402a5144, type: 3} + terrainDetailGrassPS: {fileID: 4800000, guid: e507fdfead5ca47e8b9a768b51c291a1, type: 3} + terrainDetailGrassBillboardPS: {fileID: 4800000, guid: 29868e73b638e48ca99a19ea58c48d90, type: 3} + m_AssetVersion: 2 + m_OpaqueLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_TransparentLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_DefaultStencilState: + overrideStencilState: 0 + stencilReference: 0 + stencilCompareFunction: 8 + passOperation: 2 + failOperation: 0 + zFailOperation: 0 + m_ShadowTransparentReceive: 1 + m_RenderingMode: 0 + m_DepthPrimingMode: 0 + m_CopyDepthMode: 0 + m_AccurateGbufferNormals: 0 + m_IntermediateTextureMode: 1 +--- !u!114 &82592129937281022 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 88650573, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: CharacterRenderFeature + m_EditorClassIdentifier: + m_Active: 1 +--- !u!114 &204869185421428586 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1749369493, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: 0f60273b12723a047a568dca50178e02, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 0 + UberEffectPostShaderName: Engine/Effect/UberEffectPost +--- !u!114 &725774101322325403 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1749369493, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: 0f60273b12723a047a568dca50178e02, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 0 + UberEffectPostShaderName: Engine/Effect/UberEffectPost +--- !u!114 &1268361228661498312 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1741494071, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: GlobalScanFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: + scanMaterial: {fileID: 2100000, guid: d67115296155f3c41ab11dbf400eaadf, type: 2} + renderPassEvent: 500 +--- !u!114 &1282094438684808781 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: de6450284375eea4aa7e52135fd4e340, type: 3} + m_Name: GlobalScanFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: {scanMaterial: {fileID: 2100000, guid: d67115296155f3c41ab11dbf400eaadf, type: 2}, renderPassEvent: 500} +--- !u!114 &1305107786418458812 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1181071007, guid: 58d7e77cafa53d146af19f551ff0a3f7, type: 3} + m_Name: NiloToonCharacterRendererFeature + m_EditorClassIdentifier: + m_Active: 1 + mCharacterRenderSetting: + mOutlineSetting: + ShouldRenderOutline: 1 + UseBigSceneOutline: 0 + BigSceneOutlineAtten: 250 + outlineWidthMultiplier: 4 + outlineTintColor: {r: 1, g: 1, b: 1, a: 1} + outlineWidthAutoAdjustToCameraDistanceAndFOV: 1 + mSelfShadowSetting: + mEnableSelfShadow: 0 + mShadowUDAngle: 20 + mShadowLRAngle: 1.4 + mShadowMapSize: 2048 + mMaxShadowDistance: 100 + mShadowDepthBias: 0.78 + mUseNDotLFix: 1 + mPerfectCullingForShadowCasters: 0 + mDebugShadowCameraVisibleBox: 0 + mMiscParamsSetting: + mFinalColorMultiplier: {r: 1, g: 1, b: 1, a: 1} + mRenderDepthNormal: 1 + mFinalColorIntensity: 1 + mCharacterValueMultiplier: 1 + mCharacterSaturateMultiplier: 0.2 + mGlobalMainDirectionalLightMultiplier: 0.75 + mOverridedLightUpDownAngle: -30 + mOverridedLightLRAngle: 30 + mOverrideLightDirectionIntensity: 1 + mSpecularIntensity: 1 + mSpecularColor: {r: 1, g: 1, b: 1, a: 1} + mUseMainLightDirForSpecular: 1 + mIndirecLightMinColor: {r: 0.01, g: 0.01, b: 0.01, a: 1} + mIndirecLightMultiplier: 1 + mOverridedRimLightUpDownAngle: 180 + mOverridedRimLightLRAngle: -66 + mOverrideRimLightDirectionIntensity: 1 + mGlobalRimLightTintColor: {r: 1, g: 1, b: 1, a: 1} + mGlobalRimLightMultipiler: 4 + mRimLightStartFadeIn: 0 + mRimLightStartFadeOut: 100 + mRimLightAndShadowWidthMultiplier: 2 + mRimLightDepthDiffThresholdOffset: 0.5 + mAddLightMultipiler: {r: 1, g: 1, b: 1, a: 0.5} + mAddLightMaxContribution: {r: 1, g: 1, b: 1, a: 1} + mShadowColor: {r: 0, g: 0, b: 0, a: 0.5} + mShadowAngle: 311 + mShadowLength: 0.89 + mShadowHeight: 0.1 +--- !u!114 &1346129852619499677 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1467291068, guid: 58d7e77cafa53d146af19f551ff0a3f7, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: e4dc0f5e3a376594b807a6e398748f7b, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 0 + UberEffectPostShaderName: GYGame/Effect/UberEffectPost +--- !u!114 &2194802274330149393 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1918578592, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: UIGrabPassFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: + renderScale: 1 + uiCameraName: CameraUi + blurredMaterial: {fileID: 0} +--- !u!114 &3140139178374968523 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 88650573, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: CharacterRenderFeature + m_EditorClassIdentifier: + m_Active: 1 +--- !u!114 &3838470855858450216 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a319187780713924f9f1ad5521975bbe, type: 3} + m_Name: AfterTransparentColorAndCloakFeature + m_EditorClassIdentifier: + m_Active: 1 + m_Settings: + afterTransparentColorTextureName: _AfterTransparentColorTex + downsample: 0.5 + skipSceneView: 1 +--- !u!114 &5386121282085986449 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1749369493, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: EffectRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + RenderTargetName: _ScreenColorTexture + SamplingShader: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + BlitShader: {fileID: 4800000, guid: c17132b1f77d20942aa75f8429c0f8bc, type: 3} + UberEffectPostShader: {fileID: 4800000, guid: 0f60273b12723a047a568dca50178e02, type: 3} + UberEffectPostMaterial: {fileID: 0} + RenderTargetSampling: 0 + UberEffectPostShaderName: Engine/Effect/UberEffectPost +--- !u!114 &6856233958429584178 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1608182031, guid: 58d7e77cafa53d146af19f551ff0a3f7, type: 3} + m_Name: SceneRenderFeature + m_EditorClassIdentifier: + m_Active: 1 + Settings: + SceneParameters: + CopyColor: 1 + ShadowColor: {r: 0.2719384, g: 0.35710105, b: 0.5943396, a: 1} + BakedShadowStrength: 0.822 + GlobalBrightness: 1 + SceneDebug: 0 + NormalDebug: 0 + VertexColorDebug: 0 + DebugCustomValue: + FogParameters: + OpenFog: 1 + LineFogColor: {r: 0.2776789, g: 0.7451238, b: 0.9056604, a: 1} + LineFogStartDistance: 75.6 + LineFogEndDistance: 163.8 + LineFogStrength: 0 + HeightFogColor: {r: 0.19215687, g: 0.5803922, b: 0.6117647, a: 1} + HeightFogStartHeight: -0.62 + HeightFogFalloff: 1.974 + HeightFogDensity: 0.0072 + HeightFogStartDistance: 75.25 + HeightFogMinOpacity: 1 + FogAmbient: 1 +--- !u!114 &6876166720411419037 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1918578592, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: UIGrabPassFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: + renderScale: 1 + uiCameraName: CameraUi + blurredMaterial: {fileID: 0} +--- !u!114 &7225584861622257479 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 88650573, guid: d69daf564475a6b48a9faa2993a03538, type: 3} + m_Name: CharacterRenderFeature + m_EditorClassIdentifier: + m_Active: 1 +--- !u!114 &9176359278972220443 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 0ef3e8d453aa10e459b89fc52b664d0e, type: 3} + m_Name: GlobalScanFeature + m_EditorClassIdentifier: + m_Active: 1 + settings: {scanMaterial: {fileID: 2100000, guid: d67115296155f3c41ab11dbf400eaadf, type: 2}, renderPassEvent: 500} diff --git a/AIClient/Assets/Settings/URP/ForwardRenderer 4VeryHighQuality.asset.meta b/AIClient/Assets/Settings/URP/ForwardRenderer 4VeryHighQuality.asset.meta new file mode 100644 index 0000000..1c8b1ea --- /dev/null +++ b/AIClient/Assets/Settings/URP/ForwardRenderer 4VeryHighQuality.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8ba4e2173e1767649a1a39195e744d4f +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/Settings/URP/UniversalRP-0VeryLowQuality.asset b/AIClient/Assets/Settings/URP/UniversalRP-0VeryLowQuality.asset new file mode 100644 index 0000000..63e41de --- /dev/null +++ b/AIClient/Assets/Settings/URP/UniversalRP-0VeryLowQuality.asset @@ -0,0 +1,114 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: bf2edee5c58d82540a51f03df9d42094, type: 3} + m_Name: UniversalRP-0VeryLowQuality + m_EditorClassIdentifier: + k_AssetVersion: 11 + k_AssetPreviousVersion: 11 + m_RendererType: 1 + m_RendererData: {fileID: 0} + m_RendererDataList: + - {fileID: 11400000, guid: 39a007eb403d850479271fda4ed87835, type: 2} + m_DefaultRendererIndex: 0 + m_RequireDepthTexture: 0 + m_RequireOpaqueTexture: 0 + m_OpaqueDownsampling: 1 + m_SupportsTerrainHoles: 0 + m_SupportsHDR: 0 + m_HDRColorBufferPrecision: 0 + m_MSAA: 1 + m_RenderScale: 0.85 + m_UpscalingFilter: 0 + m_FsrOverrideSharpness: 0 + m_FsrSharpness: 0.92 + m_EnableLODCrossFade: 1 + m_LODCrossFadeDitheringType: 1 + m_ShEvalMode: 0 + m_MainLightRenderingMode: 1 + m_MainLightShadowsSupported: 0 + m_MainLightShadowmapResolution: 1024 + m_AdditionalLightsRenderingMode: 2 + m_AdditionalLightsPerObjectLimit: 4 + m_AdditionalLightShadowsSupported: 1 + m_AdditionalLightsShadowmapResolution: 1024 + m_AdditionalLightsShadowResolutionTierLow: 256 + m_AdditionalLightsShadowResolutionTierMedium: 512 + m_AdditionalLightsShadowResolutionTierHigh: 1024 + m_ReflectionProbeBlending: 0 + m_ReflectionProbeBoxProjection: 0 + m_ShadowDistance: 10 + m_ShadowCascadeCount: 1 + m_Cascade2Split: 0.16666667 + m_Cascade3Split: {x: 0.25800002, y: 0.5776} + m_Cascade4Split: {x: 0.067, y: 0.2, z: 0.46700004} + m_CascadeBorder: 0 + m_ShadowDepthBias: 1 + m_ShadowNormalBias: 1 + m_AnyShadowsSupported: 1 + m_SoftShadowsSupported: 0 + m_ConservativeEnclosingSphere: 0 + m_NumIterationsEnclosingSphere: 64 + m_SoftShadowQuality: 2 + m_AdditionalLightsCookieResolution: 256 + m_AdditionalLightsCookieFormat: 3 + m_UseSRPBatcher: 1 + m_SupportsDynamicBatching: 0 + m_MixedLightingSupported: 1 + m_SupportsLightCookies: 1 + m_SupportsLightLayers: 0 + m_DebugLevel: 0 + m_StoreActionsOptimization: 0 + m_EnableRenderGraph: 0 + m_UseAdaptivePerformance: 1 + m_ColorGradingMode: 0 + m_ColorGradingLutSize: 32 + m_UseFastSRGBLinearConversion: 0 + m_SupportDataDrivenLensFlare: 1 + m_ShadowType: 1 + m_LocalShadowsSupported: 0 + m_LocalShadowsAtlasResolution: 256 + m_MaxPixelLights: 0 + m_ShadowAtlasResolution: 256 + m_VolumeFrameworkUpdateMode: 0 + m_Textures: + blueNoise64LTex: {fileID: 2800000, guid: e3d24661c1e055f45a7560c033dbb837, type: 3} + bayerMatrixTex: {fileID: 2800000, guid: f9ee4ed84c1d10c49aabb9b210b0fc44, type: 3} + m_PrefilteringModeMainLightShadows: 0 + m_PrefilteringModeAdditionalLight: 1 + m_PrefilteringModeAdditionalLightShadows: 0 + m_PrefilterXRKeywords: 1 + m_PrefilteringModeForwardPlus: 0 + m_PrefilteringModeDeferredRendering: 0 + m_PrefilteringModeScreenSpaceOcclusion: 0 + m_PrefilterDebugKeywords: 1 + m_PrefilterWriteRenderingLayers: 1 + m_PrefilterHDROutput: 1 + m_PrefilterSSAODepthNormals: 1 + m_PrefilterSSAOSourceDepthLow: 1 + m_PrefilterSSAOSourceDepthMedium: 1 + m_PrefilterSSAOSourceDepthHigh: 1 + m_PrefilterSSAOInterleaved: 1 + m_PrefilterSSAOBlueNoise: 1 + m_PrefilterSSAOSampleCountLow: 1 + m_PrefilterSSAOSampleCountMedium: 1 + m_PrefilterSSAOSampleCountHigh: 1 + m_PrefilterDBufferMRT1: 1 + m_PrefilterDBufferMRT2: 1 + m_PrefilterDBufferMRT3: 1 + m_PrefilterSoftShadowsQualityLow: 1 + m_PrefilterSoftShadowsQualityMedium: 1 + m_PrefilterSoftShadowsQualityHigh: 1 + m_PrefilterSoftShadows: 0 + m_PrefilterScreenCoord: 1 + m_PrefilterNativeRenderPass: 1 + m_ShaderVariantLogLevel: 0 + m_ShadowCascades: 1 diff --git a/AIClient/Assets/Settings/URP/UniversalRP-0VeryLowQuality.asset.meta b/AIClient/Assets/Settings/URP/UniversalRP-0VeryLowQuality.asset.meta new file mode 100644 index 0000000..a058c0b --- /dev/null +++ b/AIClient/Assets/Settings/URP/UniversalRP-0VeryLowQuality.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3d8cd8eb10307ce4b9685eccaedfb19d +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/Settings/URP/UniversalRP-1LowQuality.asset b/AIClient/Assets/Settings/URP/UniversalRP-1LowQuality.asset new file mode 100644 index 0000000..44e6467 --- /dev/null +++ b/AIClient/Assets/Settings/URP/UniversalRP-1LowQuality.asset @@ -0,0 +1,114 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: bf2edee5c58d82540a51f03df9d42094, type: 3} + m_Name: UniversalRP-1LowQuality + m_EditorClassIdentifier: + k_AssetVersion: 11 + k_AssetPreviousVersion: 11 + m_RendererType: 1 + m_RendererData: {fileID: 0} + m_RendererDataList: + - {fileID: 11400000, guid: ab43e90c602bfdb4e97e042f15d35169, type: 2} + m_DefaultRendererIndex: 0 + m_RequireDepthTexture: 0 + m_RequireOpaqueTexture: 0 + m_OpaqueDownsampling: 1 + m_SupportsTerrainHoles: 0 + m_SupportsHDR: 1 + m_HDRColorBufferPrecision: 0 + m_MSAA: 1 + m_RenderScale: 1 + m_UpscalingFilter: 0 + m_FsrOverrideSharpness: 0 + m_FsrSharpness: 0.92 + m_EnableLODCrossFade: 1 + m_LODCrossFadeDitheringType: 1 + m_ShEvalMode: 0 + m_MainLightRenderingMode: 1 + m_MainLightShadowsSupported: 0 + m_MainLightShadowmapResolution: 1024 + m_AdditionalLightsRenderingMode: 1 + m_AdditionalLightsPerObjectLimit: 2 + m_AdditionalLightShadowsSupported: 1 + m_AdditionalLightsShadowmapResolution: 1024 + m_AdditionalLightsShadowResolutionTierLow: 256 + m_AdditionalLightsShadowResolutionTierMedium: 512 + m_AdditionalLightsShadowResolutionTierHigh: 1024 + m_ReflectionProbeBlending: 0 + m_ReflectionProbeBoxProjection: 0 + m_ShadowDistance: 10 + m_ShadowCascadeCount: 1 + m_Cascade2Split: 0.16666667 + m_Cascade3Split: {x: 0.25800002, y: 0.5776} + m_Cascade4Split: {x: 0.067, y: 0.2, z: 0.46700004} + m_CascadeBorder: 0 + m_ShadowDepthBias: 1 + m_ShadowNormalBias: 1 + m_AnyShadowsSupported: 1 + m_SoftShadowsSupported: 1 + m_ConservativeEnclosingSphere: 0 + m_NumIterationsEnclosingSphere: 64 + m_SoftShadowQuality: 2 + m_AdditionalLightsCookieResolution: 512 + m_AdditionalLightsCookieFormat: 3 + m_UseSRPBatcher: 1 + m_SupportsDynamicBatching: 0 + m_MixedLightingSupported: 1 + m_SupportsLightCookies: 1 + m_SupportsLightLayers: 0 + m_DebugLevel: 0 + m_StoreActionsOptimization: 0 + m_EnableRenderGraph: 0 + m_UseAdaptivePerformance: 1 + m_ColorGradingMode: 1 + m_ColorGradingLutSize: 32 + m_UseFastSRGBLinearConversion: 0 + m_SupportDataDrivenLensFlare: 1 + m_ShadowType: 1 + m_LocalShadowsSupported: 0 + m_LocalShadowsAtlasResolution: 256 + m_MaxPixelLights: 0 + m_ShadowAtlasResolution: 256 + m_VolumeFrameworkUpdateMode: 0 + m_Textures: + blueNoise64LTex: {fileID: 2800000, guid: e3d24661c1e055f45a7560c033dbb837, type: 3} + bayerMatrixTex: {fileID: 2800000, guid: f9ee4ed84c1d10c49aabb9b210b0fc44, type: 3} + m_PrefilteringModeMainLightShadows: 0 + m_PrefilteringModeAdditionalLight: 3 + m_PrefilteringModeAdditionalLightShadows: 2 + m_PrefilterXRKeywords: 1 + m_PrefilteringModeForwardPlus: 0 + m_PrefilteringModeDeferredRendering: 0 + m_PrefilteringModeScreenSpaceOcclusion: 0 + m_PrefilterDebugKeywords: 1 + m_PrefilterWriteRenderingLayers: 1 + m_PrefilterHDROutput: 1 + m_PrefilterSSAODepthNormals: 1 + m_PrefilterSSAOSourceDepthLow: 1 + m_PrefilterSSAOSourceDepthMedium: 1 + m_PrefilterSSAOSourceDepthHigh: 1 + m_PrefilterSSAOInterleaved: 1 + m_PrefilterSSAOBlueNoise: 1 + m_PrefilterSSAOSampleCountLow: 1 + m_PrefilterSSAOSampleCountMedium: 1 + m_PrefilterSSAOSampleCountHigh: 1 + m_PrefilterDBufferMRT1: 1 + m_PrefilterDBufferMRT2: 1 + m_PrefilterDBufferMRT3: 1 + m_PrefilterSoftShadowsQualityLow: 1 + m_PrefilterSoftShadowsQualityMedium: 1 + m_PrefilterSoftShadowsQualityHigh: 1 + m_PrefilterSoftShadows: 0 + m_PrefilterScreenCoord: 1 + m_PrefilterNativeRenderPass: 1 + m_ShaderVariantLogLevel: 0 + m_ShadowCascades: 1 diff --git a/AIClient/Assets/Settings/URP/UniversalRP-1LowQuality.asset.meta b/AIClient/Assets/Settings/URP/UniversalRP-1LowQuality.asset.meta new file mode 100644 index 0000000..8d33b7e --- /dev/null +++ b/AIClient/Assets/Settings/URP/UniversalRP-1LowQuality.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 81260592605bd4049b035d0093204b55 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/Settings/URP/UniversalRP-2MediumQuality.asset b/AIClient/Assets/Settings/URP/UniversalRP-2MediumQuality.asset new file mode 100644 index 0000000..fb45080 --- /dev/null +++ b/AIClient/Assets/Settings/URP/UniversalRP-2MediumQuality.asset @@ -0,0 +1,114 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: bf2edee5c58d82540a51f03df9d42094, type: 3} + m_Name: UniversalRP-2MediumQuality + m_EditorClassIdentifier: + k_AssetVersion: 11 + k_AssetPreviousVersion: 11 + m_RendererType: 1 + m_RendererData: {fileID: 0} + m_RendererDataList: + - {fileID: 11400000, guid: f454285041681a44999cc258865df961, type: 2} + m_DefaultRendererIndex: 0 + m_RequireDepthTexture: 0 + m_RequireOpaqueTexture: 0 + m_OpaqueDownsampling: 1 + m_SupportsTerrainHoles: 0 + m_SupportsHDR: 1 + m_HDRColorBufferPrecision: 0 + m_MSAA: 1 + m_RenderScale: 1 + m_UpscalingFilter: 0 + m_FsrOverrideSharpness: 0 + m_FsrSharpness: 0.92 + m_EnableLODCrossFade: 1 + m_LODCrossFadeDitheringType: 1 + m_ShEvalMode: 0 + m_MainLightRenderingMode: 1 + m_MainLightShadowsSupported: 0 + m_MainLightShadowmapResolution: 512 + m_AdditionalLightsRenderingMode: 1 + m_AdditionalLightsPerObjectLimit: 4 + m_AdditionalLightShadowsSupported: 0 + m_AdditionalLightsShadowmapResolution: 512 + m_AdditionalLightsShadowResolutionTierLow: 256 + m_AdditionalLightsShadowResolutionTierMedium: 512 + m_AdditionalLightsShadowResolutionTierHigh: 1024 + m_ReflectionProbeBlending: 0 + m_ReflectionProbeBoxProjection: 0 + m_ShadowDistance: 15 + m_ShadowCascadeCount: 1 + m_Cascade2Split: 0.16666667 + m_Cascade3Split: {x: 0.25800002, y: 0.5776} + m_Cascade4Split: {x: 0.067, y: 0.2, z: 0.46700004} + m_CascadeBorder: 0 + m_ShadowDepthBias: 1 + m_ShadowNormalBias: 1 + m_AnyShadowsSupported: 1 + m_SoftShadowsSupported: 0 + m_ConservativeEnclosingSphere: 0 + m_NumIterationsEnclosingSphere: 64 + m_SoftShadowQuality: 2 + m_AdditionalLightsCookieResolution: 512 + m_AdditionalLightsCookieFormat: 3 + m_UseSRPBatcher: 1 + m_SupportsDynamicBatching: 0 + m_MixedLightingSupported: 1 + m_SupportsLightCookies: 1 + m_SupportsLightLayers: 0 + m_DebugLevel: 0 + m_StoreActionsOptimization: 0 + m_EnableRenderGraph: 0 + m_UseAdaptivePerformance: 1 + m_ColorGradingMode: 1 + m_ColorGradingLutSize: 32 + m_UseFastSRGBLinearConversion: 0 + m_SupportDataDrivenLensFlare: 1 + m_ShadowType: 1 + m_LocalShadowsSupported: 0 + m_LocalShadowsAtlasResolution: 256 + m_MaxPixelLights: 0 + m_ShadowAtlasResolution: 256 + m_VolumeFrameworkUpdateMode: 0 + m_Textures: + blueNoise64LTex: {fileID: 2800000, guid: e3d24661c1e055f45a7560c033dbb837, type: 3} + bayerMatrixTex: {fileID: 2800000, guid: f9ee4ed84c1d10c49aabb9b210b0fc44, type: 3} + m_PrefilteringModeMainLightShadows: 0 + m_PrefilteringModeAdditionalLight: 3 + m_PrefilteringModeAdditionalLightShadows: 0 + m_PrefilterXRKeywords: 1 + m_PrefilteringModeForwardPlus: 0 + m_PrefilteringModeDeferredRendering: 0 + m_PrefilteringModeScreenSpaceOcclusion: 0 + m_PrefilterDebugKeywords: 1 + m_PrefilterWriteRenderingLayers: 1 + m_PrefilterHDROutput: 1 + m_PrefilterSSAODepthNormals: 1 + m_PrefilterSSAOSourceDepthLow: 1 + m_PrefilterSSAOSourceDepthMedium: 1 + m_PrefilterSSAOSourceDepthHigh: 1 + m_PrefilterSSAOInterleaved: 1 + m_PrefilterSSAOBlueNoise: 1 + m_PrefilterSSAOSampleCountLow: 1 + m_PrefilterSSAOSampleCountMedium: 1 + m_PrefilterSSAOSampleCountHigh: 1 + m_PrefilterDBufferMRT1: 1 + m_PrefilterDBufferMRT2: 1 + m_PrefilterDBufferMRT3: 1 + m_PrefilterSoftShadowsQualityLow: 1 + m_PrefilterSoftShadowsQualityMedium: 1 + m_PrefilterSoftShadowsQualityHigh: 1 + m_PrefilterSoftShadows: 0 + m_PrefilterScreenCoord: 1 + m_PrefilterNativeRenderPass: 1 + m_ShaderVariantLogLevel: 0 + m_ShadowCascades: 1 diff --git a/AIClient/Assets/Settings/URP/UniversalRP-2MediumQuality.asset.meta b/AIClient/Assets/Settings/URP/UniversalRP-2MediumQuality.asset.meta new file mode 100644 index 0000000..4e6a027 --- /dev/null +++ b/AIClient/Assets/Settings/URP/UniversalRP-2MediumQuality.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7c88b4d5ed0b098458268b323ad17abb +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/Settings/URP/UniversalRP-3HighQuality.asset b/AIClient/Assets/Settings/URP/UniversalRP-3HighQuality.asset new file mode 100644 index 0000000..5886c4c --- /dev/null +++ b/AIClient/Assets/Settings/URP/UniversalRP-3HighQuality.asset @@ -0,0 +1,114 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: bf2edee5c58d82540a51f03df9d42094, type: 3} + m_Name: UniversalRP-3HighQuality + m_EditorClassIdentifier: + k_AssetVersion: 11 + k_AssetPreviousVersion: 11 + m_RendererType: 1 + m_RendererData: {fileID: 0} + m_RendererDataList: + - {fileID: 11400000, guid: ad8f6453bdbe88e409d8fd14942f700d, type: 2} + m_DefaultRendererIndex: 0 + m_RequireDepthTexture: 1 + m_RequireOpaqueTexture: 1 + m_OpaqueDownsampling: 0 + m_SupportsTerrainHoles: 0 + m_SupportsHDR: 1 + m_HDRColorBufferPrecision: 0 + m_MSAA: 1 + m_RenderScale: 1 + m_UpscalingFilter: 0 + m_FsrOverrideSharpness: 0 + m_FsrSharpness: 0.92 + m_EnableLODCrossFade: 1 + m_LODCrossFadeDitheringType: 1 + m_ShEvalMode: 0 + m_MainLightRenderingMode: 1 + m_MainLightShadowsSupported: 1 + m_MainLightShadowmapResolution: 1024 + m_AdditionalLightsRenderingMode: 1 + m_AdditionalLightsPerObjectLimit: 4 + m_AdditionalLightShadowsSupported: 0 + m_AdditionalLightsShadowmapResolution: 1024 + m_AdditionalLightsShadowResolutionTierLow: 256 + m_AdditionalLightsShadowResolutionTierMedium: 512 + m_AdditionalLightsShadowResolutionTierHigh: 1024 + m_ReflectionProbeBlending: 0 + m_ReflectionProbeBoxProjection: 0 + m_ShadowDistance: 15 + m_ShadowCascadeCount: 2 + m_Cascade2Split: 0.26666668 + m_Cascade3Split: {x: 0.25800002, y: 0.5776} + m_Cascade4Split: {x: 0.067, y: 0.2, z: 0.46700004} + m_CascadeBorder: 0 + m_ShadowDepthBias: 1 + m_ShadowNormalBias: 1 + m_AnyShadowsSupported: 1 + m_SoftShadowsSupported: 0 + m_ConservativeEnclosingSphere: 0 + m_NumIterationsEnclosingSphere: 64 + m_SoftShadowQuality: 2 + m_AdditionalLightsCookieResolution: 1024 + m_AdditionalLightsCookieFormat: 3 + m_UseSRPBatcher: 1 + m_SupportsDynamicBatching: 0 + m_MixedLightingSupported: 1 + m_SupportsLightCookies: 1 + m_SupportsLightLayers: 0 + m_DebugLevel: 0 + m_StoreActionsOptimization: 0 + m_EnableRenderGraph: 0 + m_UseAdaptivePerformance: 1 + m_ColorGradingMode: 1 + m_ColorGradingLutSize: 32 + m_UseFastSRGBLinearConversion: 0 + m_SupportDataDrivenLensFlare: 1 + m_ShadowType: 1 + m_LocalShadowsSupported: 0 + m_LocalShadowsAtlasResolution: 256 + m_MaxPixelLights: 0 + m_ShadowAtlasResolution: 256 + m_VolumeFrameworkUpdateMode: 0 + m_Textures: + blueNoise64LTex: {fileID: 2800000, guid: e3d24661c1e055f45a7560c033dbb837, type: 3} + bayerMatrixTex: {fileID: 2800000, guid: f9ee4ed84c1d10c49aabb9b210b0fc44, type: 3} + m_PrefilteringModeMainLightShadows: 3 + m_PrefilteringModeAdditionalLight: 3 + m_PrefilteringModeAdditionalLightShadows: 0 + m_PrefilterXRKeywords: 1 + m_PrefilteringModeForwardPlus: 0 + m_PrefilteringModeDeferredRendering: 0 + m_PrefilteringModeScreenSpaceOcclusion: 0 + m_PrefilterDebugKeywords: 1 + m_PrefilterWriteRenderingLayers: 1 + m_PrefilterHDROutput: 1 + m_PrefilterSSAODepthNormals: 1 + m_PrefilterSSAOSourceDepthLow: 1 + m_PrefilterSSAOSourceDepthMedium: 1 + m_PrefilterSSAOSourceDepthHigh: 1 + m_PrefilterSSAOInterleaved: 1 + m_PrefilterSSAOBlueNoise: 1 + m_PrefilterSSAOSampleCountLow: 1 + m_PrefilterSSAOSampleCountMedium: 1 + m_PrefilterSSAOSampleCountHigh: 1 + m_PrefilterDBufferMRT1: 1 + m_PrefilterDBufferMRT2: 1 + m_PrefilterDBufferMRT3: 1 + m_PrefilterSoftShadowsQualityLow: 1 + m_PrefilterSoftShadowsQualityMedium: 1 + m_PrefilterSoftShadowsQualityHigh: 1 + m_PrefilterSoftShadows: 0 + m_PrefilterScreenCoord: 1 + m_PrefilterNativeRenderPass: 1 + m_ShaderVariantLogLevel: 0 + m_ShadowCascades: 1 diff --git a/AIClient/Assets/Settings/URP/UniversalRP-3HighQuality.asset.meta b/AIClient/Assets/Settings/URP/UniversalRP-3HighQuality.asset.meta new file mode 100644 index 0000000..f186cc0 --- /dev/null +++ b/AIClient/Assets/Settings/URP/UniversalRP-3HighQuality.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 76ed49667b45f35418709c0859652e70 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/Settings/URP/UniversalRP-4VeryHighQuality.asset b/AIClient/Assets/Settings/URP/UniversalRP-4VeryHighQuality.asset new file mode 100644 index 0000000..772f4f5 --- /dev/null +++ b/AIClient/Assets/Settings/URP/UniversalRP-4VeryHighQuality.asset @@ -0,0 +1,114 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: bf2edee5c58d82540a51f03df9d42094, type: 3} + m_Name: UniversalRP-4VeryHighQuality + m_EditorClassIdentifier: + k_AssetVersion: 11 + k_AssetPreviousVersion: 11 + m_RendererType: 1 + m_RendererData: {fileID: 0} + m_RendererDataList: + - {fileID: 11400000, guid: 8ba4e2173e1767649a1a39195e744d4f, type: 2} + m_DefaultRendererIndex: 0 + m_RequireDepthTexture: 1 + m_RequireOpaqueTexture: 1 + m_OpaqueDownsampling: 0 + m_SupportsTerrainHoles: 0 + m_SupportsHDR: 1 + m_HDRColorBufferPrecision: 0 + m_MSAA: 1 + m_RenderScale: 1 + m_UpscalingFilter: 0 + m_FsrOverrideSharpness: 0 + m_FsrSharpness: 0.92 + m_EnableLODCrossFade: 1 + m_LODCrossFadeDitheringType: 1 + m_ShEvalMode: 0 + m_MainLightRenderingMode: 1 + m_MainLightShadowsSupported: 1 + m_MainLightShadowmapResolution: 2048 + m_AdditionalLightsRenderingMode: 1 + m_AdditionalLightsPerObjectLimit: 4 + m_AdditionalLightShadowsSupported: 1 + m_AdditionalLightsShadowmapResolution: 1024 + m_AdditionalLightsShadowResolutionTierLow: 256 + m_AdditionalLightsShadowResolutionTierMedium: 512 + m_AdditionalLightsShadowResolutionTierHigh: 1024 + m_ReflectionProbeBlending: 0 + m_ReflectionProbeBoxProjection: 0 + m_ShadowDistance: 30 + m_ShadowCascadeCount: 2 + m_Cascade2Split: 0.2 + m_Cascade3Split: {x: 0.10000001, y: 0.3} + m_Cascade4Split: {x: 0, y: 0.001, z: 0.24375} + m_CascadeBorder: 0 + m_ShadowDepthBias: 1 + m_ShadowNormalBias: 0 + m_AnyShadowsSupported: 1 + m_SoftShadowsSupported: 1 + m_ConservativeEnclosingSphere: 0 + m_NumIterationsEnclosingSphere: 64 + m_SoftShadowQuality: 2 + m_AdditionalLightsCookieResolution: 1024 + m_AdditionalLightsCookieFormat: 3 + m_UseSRPBatcher: 1 + m_SupportsDynamicBatching: 0 + m_MixedLightingSupported: 1 + m_SupportsLightCookies: 1 + m_SupportsLightLayers: 0 + m_DebugLevel: 0 + m_StoreActionsOptimization: 0 + m_EnableRenderGraph: 0 + m_UseAdaptivePerformance: 1 + m_ColorGradingMode: 1 + m_ColorGradingLutSize: 32 + m_UseFastSRGBLinearConversion: 0 + m_SupportDataDrivenLensFlare: 1 + m_ShadowType: 1 + m_LocalShadowsSupported: 0 + m_LocalShadowsAtlasResolution: 256 + m_MaxPixelLights: 0 + m_ShadowAtlasResolution: 256 + m_VolumeFrameworkUpdateMode: 0 + m_Textures: + blueNoise64LTex: {fileID: 2800000, guid: e3d24661c1e055f45a7560c033dbb837, type: 3} + bayerMatrixTex: {fileID: 2800000, guid: f9ee4ed84c1d10c49aabb9b210b0fc44, type: 3} + m_PrefilteringModeMainLightShadows: 3 + m_PrefilteringModeAdditionalLight: 3 + m_PrefilteringModeAdditionalLightShadows: 2 + m_PrefilterXRKeywords: 1 + m_PrefilteringModeForwardPlus: 0 + m_PrefilteringModeDeferredRendering: 0 + m_PrefilteringModeScreenSpaceOcclusion: 0 + m_PrefilterDebugKeywords: 1 + m_PrefilterWriteRenderingLayers: 1 + m_PrefilterHDROutput: 1 + m_PrefilterSSAODepthNormals: 1 + m_PrefilterSSAOSourceDepthLow: 1 + m_PrefilterSSAOSourceDepthMedium: 1 + m_PrefilterSSAOSourceDepthHigh: 1 + m_PrefilterSSAOInterleaved: 1 + m_PrefilterSSAOBlueNoise: 1 + m_PrefilterSSAOSampleCountLow: 1 + m_PrefilterSSAOSampleCountMedium: 1 + m_PrefilterSSAOSampleCountHigh: 1 + m_PrefilterDBufferMRT1: 1 + m_PrefilterDBufferMRT2: 1 + m_PrefilterDBufferMRT3: 1 + m_PrefilterSoftShadowsQualityLow: 1 + m_PrefilterSoftShadowsQualityMedium: 1 + m_PrefilterSoftShadowsQualityHigh: 1 + m_PrefilterSoftShadows: 0 + m_PrefilterScreenCoord: 1 + m_PrefilterNativeRenderPass: 1 + m_ShaderVariantLogLevel: 0 + m_ShadowCascades: 1 diff --git a/AIClient/Assets/Settings/URP/UniversalRP-4VeryHighQuality.asset.meta b/AIClient/Assets/Settings/URP/UniversalRP-4VeryHighQuality.asset.meta new file mode 100644 index 0000000..f3748a9 --- /dev/null +++ b/AIClient/Assets/Settings/URP/UniversalRP-4VeryHighQuality.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f3546fc18618b9a488f828377c92aa47 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/UniversalRenderPipelineGlobalSettings.asset b/AIClient/Assets/UniversalRenderPipelineGlobalSettings.asset new file mode 100644 index 0000000..783520f --- /dev/null +++ b/AIClient/Assets/UniversalRenderPipelineGlobalSettings.asset @@ -0,0 +1,34 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 2ec995e51a6e251468d2a3fd8a686257, type: 3} + m_Name: UniversalRenderPipelineGlobalSettings + m_EditorClassIdentifier: + k_AssetVersion: 3 + m_RenderingLayerNames: + - Default + m_ValidRenderingLayers: 1 + lightLayerName0: + lightLayerName1: + lightLayerName2: + lightLayerName3: + lightLayerName4: + lightLayerName5: + lightLayerName6: + lightLayerName7: + m_StripDebugVariants: 1 + m_StripUnusedPostProcessingVariants: 0 + m_StripUnusedVariants: 1 + m_StripUnusedLODCrossFadeVariants: 1 + m_StripScreenCoordOverrideVariants: 1 + supportRuntimeDebugDisplay: 0 + m_ShaderVariantLogLevel: 0 + m_ExportShaderVariants: 1 diff --git a/AIClient/Assets/UniversalRenderPipelineGlobalSettings.asset.meta b/AIClient/Assets/UniversalRenderPipelineGlobalSettings.asset.meta new file mode 100644 index 0000000..fcb16d1 --- /dev/null +++ b/AIClient/Assets/UniversalRenderPipelineGlobalSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d71d555e4685d9542882aaf4f6b61df8 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Assets/_PipelineRefresh.stamp.txt b/AIClient/Assets/_PipelineRefresh.stamp.txt new file mode 100644 index 0000000..3d7fb24 --- /dev/null +++ b/AIClient/Assets/_PipelineRefresh.stamp.txt @@ -0,0 +1 @@ +refresh 2026-09-21T11:42:00.4196961+08:00 diff --git a/AIClient/Assets/_PipelineRefresh.stamp.txt.meta b/AIClient/Assets/_PipelineRefresh.stamp.txt.meta new file mode 100644 index 0000000..d641399 --- /dev/null +++ b/AIClient/Assets/_PipelineRefresh.stamp.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 617dcf84faf5e0f40920c08af2539308 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/.attestation.p7m b/AIClient/Packages/com.unity.pipeline/.attestation.p7m new file mode 100644 index 0000000..cd27434 Binary files /dev/null and b/AIClient/Packages/com.unity.pipeline/.attestation.p7m differ diff --git a/AIClient/Packages/com.unity.pipeline/.claude/skills/unity-pipeline/SKILL.md b/AIClient/Packages/com.unity.pipeline/.claude/skills/unity-pipeline/SKILL.md new file mode 100644 index 0000000..c4e4ab2 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/.claude/skills/unity-pipeline/SKILL.md @@ -0,0 +1,173 @@ +--- +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 code-reload files at runtime. Use when an agent needs to control a live Unity instance, automate Unity tests, recompile scripts headlessly, or apply runtime 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. + +**Reading exit codes.** They separate "rewrite the invocation" from "the Editor failed", which +is the distinction this loop depends on: + +| Exit | Meaning | What to do | +|------|---------|------------| +| `2` | Bad arguments — a misspelled flag, a wrong type, too many positionals. Nothing ran. | Fix the command line and retry. The error names the problem and often suggests the right flag. | +| `6` | The command ran and failed, or the Editor could not service it. | Read the error; retrying the same invocation will not help. | + +Do not retry an exit 2 unchanged, and do not rewrite a command line on an exit 6. + +## 3. Runtime code 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 +The runtime Pipeline driver (enabled via Project Settings > Pipeline > Runtime) 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. + +### `reload_file` + +Edit the method body directly; no separate file, no boilerplate. + +1. Tag the method `[CodeReload]` 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). + +`unity command codereload_status` shows active overrides. `reload_file` options: `--timeout ` +(default 30000), `--assemblyDir ` (persist DLLs instead of in-memory); +`cleanup_codereload --assemblyDir ` clears old DLLs. + +## 4. Bulk construction: `run_script` (the builder pattern) + +For bulk work — creating many objects, wiring fields, generating content — put the code in a +**versioned project script** and run a named static entry point with `run_script`. No domain +reload, no code carried through the protocol; iterating costs an in-memory compile (< ~2s), not +a 15–20s recompile. + +```bash +# 1. Write the builder OUTSIDE Assets/ (so the write triggers no asset import / domain reload), +# e.g. AgentScripts/Build.cs: public static class Build { public static int All() { ... } } +# 2. Run it — relative paths resolve against the project root (the parent of Assets/): +unity command run_script --file AgentScripts/Build.cs --entry Build.All +# 3. Iterate: edit the file, re-run. Useful extras: +unity command run_script --file AgentScripts/Build.cs --dry_run true # compile-only check: diagnostics, nothing loaded or executed +unity command run_script --file AgentScripts/Build.cs --entry Build.All --args '[3, "Green"]' +``` + +Rules of thumb: + +- **Code goes in files on disk via `run_script`; `eval` is for genuinely ad-hoc one-liners.** + Never ship multi-line escaped C# strings through `eval` — write the file, run the entry. +- Compiles see the project's **active editor defines** (`UNITY_EDITOR`, version/platform symbols); + `--defines` appends extra symbols on top. +- Entry points may be `async Task`/`Task` — they are awaited asynchronously (the editor keeps + pumping, so awaits resuming on Unity's context work naturally) and `Task.Result` is returned. + The wait is bounded by `timeout_ms`; on expiry the task keeps running detached. +- Runtime exceptions come back with `file:line` mapped to your source (a source-mapped PDB is + always emitted for executing runs; they compile unoptimized). +- `--mode hotpatch` instead applies `[CodeReload]` in-place method replacements (delegates to + `reload_file`); `entry`/`args`/`dry_run` are rejected there and `references`/`defines` don't apply. + +## 5. Quick C# eval (Runtime) + +For genuinely ad-hoc one-liners only — anything longer belongs in a file run via `run_script`. + +```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 +``` + +## 5. Project audit (Editor) + +Static-analysis scan via Project Auditor, producing a CSV of issues to fix. Trigger, poll, read. + +```bash +unity command audit # optional: --categories Code,ProjectSetting --output my.csv +unity command audit_status # repeat until terminal status +``` + +`audit_status` is terminal on `completed` (with `csvPath` + `issueCount`), `failed`, `unavailable`, or +`interrupted` (a domain reload killed the scan — just re-run `audit`). Polling stays responsive while a +Code-category scan compiles assemblies and holds the main thread. One scan at a time: a second `audit` +returns `busy`. There is no cancel — stop polling to abandon a scan. + +CSV columns: `Category, Severity, Areas, Description, RelativePath, Line, DescriptorId, Recommendation` +(diagnostics only, so every row is something to fix; `Recommendation` says how). + +> **Requires Project Auditor plus its rules.** `unavailable` means the Editor has no Project Auditor, +> or it has no analysis rules — in a built-in-module Editor the rules live in the separate +> `com.unity.project-auditor-rules` package (`unity command package_add --identifier +> com.unity.project-auditor-rules --confirm true`). Read the `message` field; the command never reports +> an empty `completed` scan that would look like a clean project. + +## 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. +- **A stuck command may mean a modal dialog is open**, not a hang — a dialog blocks the main + thread until dismissed. If a command runs long, check `unity command editor_status` (answers + instantly even when blocked); `status: "blocked_by_dialog"` means stop retrying and tell the + human what's blocking (its `dialog.title`/`message`/`buttons`) — it can't be clicked over CLI. +- **Code reload needs the game running.** `reload_file` applies 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/AIClient/Packages/com.unity.pipeline/.signature b/AIClient/Packages/com.unity.pipeline/.signature new file mode 100644 index 0000000..cc80ac6 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/.signature @@ -0,0 +1 @@ +{"timestamp":1789141928,"signature":"crpfmI3LnQCNoOwEW623mwuNDp5ggQAsQ4rqDDXqjY2D8pW41oRle0sYLtZo+w4nEX5ZFjoD5ByM4oJ0obxDdR00UH7Nk+vvh92pckG22pfhohvztPpleny6pFGnTJYaM6smhnyzV5nsA6kplXA0IATP/tWZC/ufFD6L7jrUr/ySyc1n1R0WwIjDyweod+RIrSNZovC7c7j5UFOprIBRqFMr606qLiEkW0vioMNDAOIZ6smTPml6a/s/CJvSr4FzddWGnj76cWWfBoxlhjICwlJ5Wbzr2G44AXLLTAK+1VP8McmY080Nq0eb2OMOUYdWE2DeQ/Wowe+Yo958pOYGe5KNvi2GtlyfFVbHzCCTsUlyZ3+D7XFya/uXZ4+cLacgSHZl9kOSPWskVuyNR30iG9vV24gXyecxJhKbO4orFUb0kUz/DRT1Iq8HMV4U54DAfa6Se0l8lAXbq2g79/NwTMkzVUwKy8bSCrwF9WtWLctAPRN5yetOMpBWDjSDtp0I","publicKey":"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQm9qQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FZOEFNSUlCaWdLQ0FZRUFzdUhXYUhsZ0I1cVF4ZEJjTlJKSAordHR4SmoxcVY1NTdvMlZaRE1XaXhYRVBkRTBEMVFkT1JIRXNSS1RscmplUXlERU83ZlNQS0ZwZ1A3MU5TTnJCCkFHM2NFSU45aHNQVDhOVmllZmdWem5QTkVMenFkVmdEbFhpb2VpUnV6OERKWFgvblpmU1JWKytwbk9ySTRibG4KS0twelJlNW14OTc1SjhxZ1FvRktKT0NNRlpHdkJMR2MxSzZZaEIzOHJFODZCZzgzbUovWjBEYkVmQjBxZm13cgo2ZDVFUXFsd0E5Y3JZT1YyV1VpWXprSnBLNmJZNzRZNmM1TmpBcEFKeGNiaTFOaDlRVEhUcU44N0ZtMDF0R1ZwCjVNd1pXSWZuYVRUemEvTGZLelR5U0pka0tldEZMVGdkYXpMYlpzUEE2aHBSK0FJRTJhc0tLTi84UUk1N3UzU2cKL2xyMnZKS1IvU2l5eEN1Q20vQWJkYnJMbXk0WjlSdm1jMGdpclA4T0lLQWxBRWZ2TzV5Z2hSKy8vd1RpTFlzUQp1SllDM0V2UE16ZGdKUzdGR2FscnFLZzlPTCsxVzROY05yNWdveVdSUUJ0cktKaWlTZEJVWmVxb0RvSUY5NHpCCndGbzJJT1JFdXFqcU51M3diMWZIM3p1dGdtalFra3IxVjJhd3hmcExLWlROQWdNQkFBRT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg"} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/CHANGELOG.md b/AIClient/Packages/com.unity.pipeline/CHANGELOG.md new file mode 100644 index 0000000..c2f009a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/CHANGELOG.md @@ -0,0 +1,158 @@ +# 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.1.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.7.0-exp.1] - 2026-09-10 + +### Changed + +- [AUTHAPI-71] The Pipeline runtime assemblies and the bundled Roslyn DLLs are no longer included in non-development Player builds. Enabling the runtime server for a release build now has no effect instead of warning. Add the `ENABLE_RUNTIME_PIPELINE` scripting define symbol to include them in a build that is not a Development Build. +- [AUTHAPI-71] The code reload attributes moved to their own `Unity.Pipeline.Attributes` assembly, so code tagged with them still compiles for a release build, where they do nothing. An assembly definition that referenced `Unity.Pipeline` to get the attributes must add `Unity.Pipeline.Attributes` to its references; scripts that are not in an assembly definition need no change. +- [UUM-151473] `console` is now robust across domain reloads, Editor restarts and play-mode transitions. It reports the capture session alongside its cursor and refuses a cursor it cannot honor rather than returning an empty result; it backfills from the Editor console's own store, so compile errors logged before capture started are visible; and it re-arms itself when Unity drops the log callback. Every response also carries the Editor's own console counts and compile-failure flag, so a client can tell in one call when the buffer and the Console window disagree. +- [UUM-151473] `clear_console` moved to the runtime assembly beside `console` and `console_status`, so all three console commands live in one file and `clear_console` now works in player builds too (where it clears the captured buffer; there is no Editor console to clear). + +### Added + +- [AUTHAPI-25] Added server-side condition waiting: a wait command that polls an editor/object state until it holds (or times out), synchronously or in the background with status polling and cancelation. +- [AUTHAPI-51] `CommandRegistry.RegisterCommand`/`UnregisterCommand`: register commands at runtime without a `[CliCommand]` attribute, for command sets that depend on runtime state. A dynamic registration behaves exactly like an attribute-declared one — same `/api/commands` metadata, same `[CliArg]` parameter discovery on the handler, same `/api/exec` execution — and additionally accepts an instance-bound delegate, so a command can run against a specific object. Registrations survive a discovery-mechanism swap and last until unregistered or the domain reloads — one made while in Play Mode is dropped when Play Mode exits, so a handler registered from `Awake` re-registers cleanly on the next entry even with domain reload disabled. A name already in use throws rather than shadowing the existing command. See `Documentation~/creating-commands.md`. +- [UUM-151473] `console` responses carry `counts` (entries the buffer retains per severity) and `groundTruth` (the Editor's own `compilationFailed`, `compiling`, console entry counts, whether the buffer was seeded, and the sample's `ageMs`), so a client can spot the buffer disagreeing with the Console window in one round trip. +- [UUM-151473] New `console_status` command: the same counters and compile-failure flag without the entries, cheap enough to poll while a compile runs. +- [UUM-151473] `console` entries carry `logType` (Unity's exact `LogType` name, keeping the Exception/Assert distinction that `level` collapses) and `seeded` (read from the Editor console store rather than captured live, so the timestamp is the read time). +- [UUM-151473] `recompile_status` reports `compilationFailed`. + +### Fixed + +- [UUM-151473] Fixed `console` returning an empty result that was indistinguishable from "nothing new" when the `since` cursor came from an earlier Editor session. Sequence numbers restart with the process (the buffer is persisted under `Temp/`, which Unity deletes at every launch), so such a cursor filtered out every entry held. Responses now carry a `session`, `console` accepts `since_session`, and a cursor that cannot be honored returns the tail with `reset=true` and `dropped=true`. +- [UUM-151473] Fixed `ConsoleLogBuffer.Load` discarding entries logged since the snapshot it restores, and lowering the sequence counter when the snapshot was stale. It now merges by sequence number and never rewinds a cursor already handed out. +- [UUM-151473] Fixed `console` never reporting Editor compile errors logged before capture started. Those are logged as sticky entries that survive every console clear until the next compile completes, so they can stand in the Console window indefinitely; the buffer is now backfilled once per session from the Editor console's own store. +- [UUM-151473] Fixed console capture staying dead after Unity unregistered the native log callback with no observable symptom. The Editor now samples the console's own counts and re-arms capture when they move while the buffer's cursor does not. +- [UUM-151473] Fixed `recompile` reporting `up_to_date` while compile errors were still standing. A failed editor compile suppresses the domain reload and leaves the errors in place, so a repeat call with nothing changed compiled nothing and erased the recorded failure. Both `recompile` and `recompile_status` now consult Unity's native compile-failure flag, which also covers the status file being deleted with `Temp/` at every Editor launch. +- [UUM-151473] Fixed `clear_console` clearing only one of the package's two console buffers, so `console` kept returning entries that had been cleared. +- [UUM-151968] The runtime instance descriptor now writes to `Application.persistentDataPath` on Android instead of beside `Application.dataPath`, which is read-only there. +- [AUTHAPI-49] `Pipeline_SessionStopped` now fires on `EditorApplication.wantsToQuit` instead of `EditorApplication.quitting`, since the Analytics service is already shutting down by the time `quitting` runs and the event was never actually sent. + +### Removed + +- [UUM-151473] Removed `get_console_logs`, which duplicated `console` over a second buffer of its own — every log line was captured twice, and `clear_console` cleared only one of them. Use `console`; its new `logType` field carries the exact log type `get_console_logs` reported. + +## [0.6.0-exp.1] - 2026-08-31 + +### Changed +- Hot reload is now called **code reload** throughout. The attributes are `[CodeReload]`, `[OnCodeReload]` and `[CodeReloadOverrideMethod]` in namespace `Unity.Pipeline.CodeReload`; the registry is `CodeReloadRegistry`; the commands `hotreload_status` and `cleanup_hotreload` are now `codereload_status` and `cleanup_codereload` (tag `scripts/codereload`); the sample lives under `Samples~/CodeReload`; the documentation page is `code-reload.md`. The watcher and the folder push now pick up `.cs` files that mention `CodeReload` instead of `HotReload`. The `[HotReload]` family no longer exists, so user code must be updated. +- [AUTHAPI-49] `BasePipelineServer.OnTransactionProcessed(requestJson, responseJson)` is replaced by `OnCommandDone(in CommandExecutionInfo)`, which also carries the command that ran, whether it succeeded, and how long it took. A detached job now reports when it actually completes, not only when its job handle was returned. +- [UUM-149580] Reorganized the Pipeline Runtime settings page (Project Settings > Pipeline > Runtime) into "Server" and "Runtime Behavior" groups, moved the status/security HelpBox to the top, and added an always-visible feature description when there's nothing to warn about. All fields except Max Work Items Per Frame are now read-only while in Play Mode, since edits to them would silently not apply until the next Play session or build; Max Work Items Per Frame stays live-editable — `RuntimePipelineDriver` polls it fresh every frame (cheaply, gated on the settings file's timestamp), matching the pre-redesign component's behavior for that field. +- [UUM-149580] Every field except Enable In Builds itself is now greyed out on the Pipeline Runtime settings page while Enable In Builds is off, instead of staying editable for a server that will not run. +- `reload_file` result contract, stated: a reload that compiles but applies NO methods now fails with the per-method reasons (previously a silent success); a file whose `[HotReload]` methods all match the compiled baseline reports an explicit "up to date" success — naming any stale overrides it reverted — instead of "hot reload successful with 0 methods". +- [AUTHAPI-26] A non-positive `timeout`/`timeout_ms` on `eval`, `eval_file`, or `run_script` now returns a clear `400 Bad Request` instead of an opaque dispatcher timeout. +- Reformatted CHANGELOG.md to more accurately track [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +- [AUTHAPI-59] Test fixtures and test-only helper classes under `Tests/` are now `internal` instead of `public` — they were never part of the package's public API surface. +- [AUTHAPI-59] Command handler classes and their result/DTO types under `Editor/Commands/` and `Runtime/Commands/` are now `internal` instead of `public`. Command registration and dispatch work via reflection regardless of accessibility (see `Documentation~/creating-commands.md`), so this was never functionally required. +- [AUTHAPI-59] `EditorPipelineManager`, `EditorPipelineManagerEditor`, `PipelineServerStartup`, `EditorPipelineServer`, `PipelineRuntimeBuildProcessor`, `HotReloadInPlaceILPostProcessor`, and `SecurityTokenManager` are now `internal` instead of `public`. `[CustomEditor]`, `[InitializeOnLoad]`, the build pipeline, and `ILPostProcessor` discovery all work via reflection regardless of accessibility, and none of these types were referenced outside the package. +- [AUTHAPI-59] `RuntimePipelineConfigEditor`, `RuntimePipelineSettingsProvider`, `PushEnvelope`, `PushVerifier`, `PushVerifyStatus`, `RotatingFileBackup`, and the `Unity.Pipeline.Telemetry` eval-usage helper types are now `internal` instead of `public` — same rationale: `[CustomEditor]`/`[SettingsProvider]` discovery is reflection-based, and none of these are referenced outside the package. + +### Removed +- The separate-override-file hot reload workflow: the `reload_file_override` command, the `[HotReloadWithOverrides]` attribute, `HotReloadHelper`, and the `HotReload_WithHelpers` sample. Tag methods `[HotReload]` and edit them in place with `reload_file` instead (see `Documentation~/hot-reload.md`). The unused `[HotReloadableComponent]`, `[HotReloadComponent]` and `[HotReloadTarget]` attributes are gone too. + +### Fixed +- Code reload: a `var` local whose inferred type is nested in the reloaded class (`var inp = new BufferedInput { ... }`) no longer fails to compile with "The type name 'var' does not exist in the type ..."; a `using` alias for such a type is left alone too. +- Code reload: when a reload fails to compile, each diagnostic now names the user's file and line, the compiler error code, and the generated line that failed, and the full generated source is written to `Temp/CodeReload/.failed.cs` for inspection. +- Code reload in the Editor no longer depends on the runtime Pipeline server being enabled. `[CodeReload]` targets were only discovered by the runtime driver, which is not created when `enableInBuilds` is off (the default), so an in-editor reload compiled but bound 0 methods with a misleading "added after the running build" reason. Entering Play Mode now registers the targets either way, and an empty registry is reported as such. +- Interpreter backend: `new S { x = 1 }` and `default(S)` on a host struct bound boxed (the auto-bind default, e.g. a `struct` nested in the reloaded MonoBehaviour) failed at runtime with "Non-static field requires a target". The default box is now created, both for `initobj` and for the initobj-free form Roslyn emits when every field is assigned (`S s; s.x = 1; s.y = 2;`), so object initializers, field-by-field construction and nullable-struct fields work in reloaded bodies. Reading or writing a host field on a null object now reports the field name and source line instead of the bare reflection error, and an exception escaping a reloaded body is logged with its script location before it propagates. +- `build`: a scripted build with `Development` in `options` no longer trips the "enableInBuilds without a Development Build" security dialog (the guard only looked at the Build Settings toggle, so a `build` command from the CLI stalled on a modal, or was cancelled). +- `reload_file_player_interpreter`: a push made before the player's signing handshake has arrived now waits up to 10 s for it instead of 2 s; the first push to a freshly connected IL2CPP player was dropped at 2 s. +- [UUM-149991] AutoStaticsCleanup: Fixed usage-analysis findings. +- [UUM-149991] Fixed `CommandRegistry` staying on reflection-based command discovery after exiting Play Mode without a domain reload; `PipelineServerStartup` now restores TypeCache discovery on `EnteredEditMode`. +- [UUM-149977] Removed the "Editor is not in automated mode" console warning. Now surfaced as `info` on the instance descriptor instead. +- [AUTHAPI-36] Fixed `TestResultCollector.RunFinished` throwing `InvalidOperationException` (double-complete) when a run had already been completed by `SetError`/`Cancel` (e.g. a timed-out or errored run): a late `RunFinished` is now a no-op and the original error/cancellation is preserved. +- [UUM-149580] The Pipeline server no longer permanently enables Player Settings "Run In Background" on start; the runtime driver now saves and restores the project's original value around start/stop. +- [UUM-149580] Transient `RuntimePipelineConfig`/`RuntimePipelineBuildInfo` assets left behind by an interrupted build are now purged at the start of every build, so a disabled build can no longer ship the runtime Pipeline server. +- [UUM-149580] Reading Runtime settings (Project Settings page, `get_runtime_pipeline_settings`, or a refused/dry-run `set_runtime_pipeline_settings`) no longer writes the settings file. +- [UUM-149580] `set_runtime_pipeline_settings` now rejects a `port`, `requestTimeoutMs`, or `maxWorkItemsPerFrame` outside the same bounds the Project Settings page enforces, instead of silently persisting an invalid value (a `maxWorkItemsPerFrame` of 0 permanently starved the dispatcher's work queue). + +### Added + +- [AUTHAPI-68] `POST /api/exec` accepts an unparsed command line as `commandLine` (a string the server tokenizes) or `argv` (pre-split tokens), binding positional and `--flag` arguments against the command's declared parameters server-side. Exactly one of `command`, `commandLine` or `argv` may be present, and `parameters` cannot accompany the raw forms. Argument failures return `400` with `errorCode: "INVALID_COMMAND_ARGS"`, a machine-readable `argProblems` array, and the command's `commandSchema`; successful raw-form replies echo the bound `parameters`. Servers advertise `exec.argv` / `exec.commandLine` in `capabilities` on `/api/status` and in the port descriptor. `command` is no longer a JSON-required property, so a body carrying none of the three forms is now rejected by request validation as `400 {"error":"Invalid Request","errorDetails":"Command name is required"}` instead of by deserialization as `400 {"error":"Invalid JSON"}`. +- [AUTHAPI-64] Added a new endpoint reporting currently-open modal dialogs, and command execution now reports any dialogs shown while it ran. Main-thread commands are rejected with a busy status while a dialog is open, and status polling reports that a dialog is blocking it. Requires a recent enough trunk build. +- [AUTHAPI-29] Instrument `eval`/`eval_file` usage: each call logs a JSONL record (timing, success, statement/expression shape, and an API fingerprint of top-level member accesses) to `/Library/Pipeline/eval-usage.jsonl`, editor-only and local-first — raw source isn't stored unless `Store Eval Source` is enabled. Toggle via `Eval Telemetry Enabled`. +- [AUTHAPI-29] New `report_evals` command: ranks logged eval fingerprints by frequency and cross-references the command catalog to surface patterns not yet covered by an existing command. +- [AUTHAPI-49] Editor analytics: `Pipeline_SessionStarted` on the first command a client executes, `Pipeline_CommandExecuted` per command executed, and `Pipeline_SessionStopped` when the Editor quits. `isEvalWithExistingCommandAvailable` reuses the AUTHAPI-29 eval fingerprints and coverage map, so it agrees with `report_evals` by construction. Only commands shipped in the package report their name and tags; a project-declared command reports `` and no tags. See `Documentation~/analytics.md`. +- [UUM-149580] `runtime_status` now reports `pipelineDriver` (isServerRunning, actualPort, and the enableInBuilds/requestTimeoutMs/enableAuditLogging/autoStart/maxWorkItemsPerFrame values actually governing the running driver, not just what Project Settings currently shows) — `null` if no driver was ever bootstrapped. +- [UUM-149580] New `get_runtime_pipeline_settings`/`set_runtime_pipeline_settings` commands: structured, `confirm`/`dry_run`-gated read/write access to Pipeline Runtime settings (Project Settings > Pipeline > Runtime), matching the existing Audio/Graphics/Input/Physics/Player/Quality/Time settings command family. Refused while in Play Mode. +- [AUTHAPI-27] New `batch` command: run up to 200 ordered operations in one request, with later ops referencing earlier results. Transactional by default (single Undo step, rolled back on failure); supports `on_error`, `dry_run`, `time_budget_ms`, and detached `job` execution. See `Documentation~/commands/batch.md` for details. +- [AUTHAPI-26] New `run_script` command: compile a single C# file in memory (no domain reload) and execute a named static entry point. Supports `ephemeral` (compile→run→discard) and `hotpatch` modes, JSON-coerced args, custom references/defines, and `dry_run` compilation. Returns `{ result, diagnostics, compileMs, executeMs, assemblyName }`; runtime errors include source `file:line`. +- [AUTHAPI-21] Leaner `/api/exec` responses by default: compact JSON, no null envelope keys, no always-on metadata — a minimal success is just `{"success":true,"result":...}`. Use `"verbose": true` to restore the full envelope, or `"omitNulls": true` to also drop nulls inside `result`. Added `format="value"` to `get_serialized_fields` to return raw values instead of a full descriptor. +- [AUTHAPI-21] New `warnings` array on responses for non-fatal corrective guidance (e.g. an ignored/invalid query parameter). +- [AUTHAPI-21] `/api/job`, `/api/job/cancel`, and `/api/progress` now include null keys explicitly; pass `omit_nulls=true` to drop them. +- [AUTHAPI-21] `POST /api/exec` now rejects a literal JSON `null` body with a structured 400 instead of an internal error. +- Add an IL interpreter (IlInterpreter) that works in IL2CPP players and the editor. Opt in with the `reload_file_editor_interpreter` command or by pushing to a connected device with `reload_file_player_interpreter`; the hot-reload watch always uses it. A shipped Roslyn analyzer (MS002–MS023) marks the supported C# subset in `[HotReload]` method bodies. +- Add an `[OnHotReload]` attribute to react to code being hot-reloaded. +- Add a `.cs` change watcher, enabled from the Pipeline settings, that hot-reloads changed code without a domain reload — in editor play mode, or in a player connected via the profiler connection. + +## [0.5.0-exp.1] - 2026-08-10 + +### Added +- [CLI-488] New `GET /api/progress` endpoint reporting the currently executing command's progress, served off the main thread so it responds even during a blocking command. Command authors report via `CliProgress.Report(...)` or `CliEditorProgress.DisplayProgressBar`; `UnityEditor.Progress` items are mirrored automatically. Powers live progress bars in the `unity` CLI. (with CLI-335) +- [CLI-335] Detached command jobs: `POST /api/exec` with `"job": true` runs in the background and returns a job id immediately; poll `GET /api/job?id=…` for state/progress/result. `POST /api/job/cancel` cancels a queued job or requests cooperative cancellation of a running one via the new `PipelineCancellation` API. Results are retained for 1 hour / last 100 jobs. +- [CLI-488] New `audit`/`audit_status` commands: run a Project Auditor static-analysis scan and poll it, producing an issue CSV. Works via reflection so the package still runs without Project Auditor installed; reports `unavailable` with install guidance when analysis rules are missing. +- [AUTHAPI-35] Report `settling` on `/api/status` and reject main-thread commands with `503 Server Busy` while the Editor is still importing/compiling after a cold start. Background commands (`recompile_status`, `package_status`, `console`, `editor_status`) stay servable throughout. + +### Changed +- [CLI-335] Raised the `eval` timeout cap from 30 seconds to 24 hours. +- [CLI-335] The HTTP server now processes requests concurrently — `/api/status` and `/api/progress` answer immediately even during a long-running command. Command execution itself remains strictly serialized. +- [CLI-335] Hardening: a command's own timeout (not a fixed 60s) governs `/api/exec`'s wait; `editor_status`/`test_status` queue behind the exec gate; progress/job state is scoped per server instance; detached jobs are capped at 100 concurrent (further submissions get `429`). + +### Fixed +- Fixed `executedAt` returning a zero-value instead of the actual execution time for `eval`/`eval_file`, `hot-reload`, and `run_tests`/`test_status`. +- [UUM-148802] Fixed `get_console_logs`/`clear_console`/`console` losing console capture after exiting Play Mode by re-subscribing to Unity's log callback on every play-mode transition. +- [UUM-148605] `set_autotick`'s enabled/interval state now persists across domain reloads via SessionState. Added a `persist` flag (default `true`) to opt out. +- [UUM-149016] Fixed `InvalidOperationException`s after repeated `run_tests` runs caused by a stale `TestResultCollector` still registered with Unity's TestRunnerApi. + +## [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` gains a `source` parameter: `camera` (default, unchanged) or `screen`. `source=screen` captures the composited game view backbuffer so Screen Space - Overlay UI (HUDs, menus) is included — the camera path never sees overlay canvases. Screen capture requires Play Mode; in Edit Mode it returns a clear error. (AUTHAPI-10) +- `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/AIClient/Packages/com.unity.pipeline/CHANGELOG.md.meta b/AIClient/Packages/com.unity.pipeline/CHANGELOG.md.meta new file mode 100644 index 0000000..5411dc5 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/CHANGELOG.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 75cd0dccc034c6543afb4f969beca6af +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/CLAUDE.md b/AIClient/Packages/com.unity.pipeline/CLAUDE.md new file mode 100644 index 0000000..c271ad0 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/CLAUDE.md @@ -0,0 +1,64 @@ +# 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#, code-reload files, play-mode control, status/heartbeat. + +## Layout (standard UPM package) + +| Folder | Assembly | Notes | +|--------|----------|-------| +| `Runtime/` | `Unity.Pipeline` | Ships in **development** player builds only (`defineConstraints: UNITY_EDITOR \|\| DEVELOPMENT_BUILD \|\| ENABLE_RUNTIME_PIPELINE`). HTTP server base, command registry, models, runtime commands, code reload, player server. | +| `Runtime/Attributes/` | `Unity.Pipeline.Attributes` | The `[CodeReload]` / `[OnCodeReload]` attributes, **unconstrained** so user code tagged with them still compiles for release builds (where they are inert — nothing weaves them). | +| `Runtime/IlInterpreter/` | `Unity.Pipeline.IlInterpreter` | IL interpreter (IlInterpreter VM) that executes eval snippets and reloaded method bodies where `Reflection.Emit` is unavailable (IL2CPP). Referenced by both `Unity.Pipeline` and `Unity.Pipeline.Editor`. | +| `Runtime/Analyzers/` | — | Shipped `IlInterpreterAnalyzer.dll` — Roslyn analyzer (MS002–MS023) surfacing the interpreter's C# subset on `[CodeReload]` method bodies. Built from `IlInterpreter/analyzer/` at the repo root (see its `README.md`); auto-synced on Release builds — never edit the DLL by hand. | +| `Editor/` | `Unity.Pipeline.Editor` | Editor-only. Live server + its owner, settings asset, editor commands, test runner, code-reload watcher, build processors (`Editor/BuildProcessors/`, incl. the code-reload `link.xml` generator). | +| `CodeGen/` | `Unity.Pipeline.CodeGen` | ILPostProcessor (Mono.Cecil) for in-place code 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~/` | — | Code-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 (`Window/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/`. +- **Interpreter path (IL2CPP-safe eval & code reload)** — `Runtime/IlInterpreter/ScriptInterpreter.cs` + is the VM core; `Runtime/Compilation/IlInterpreterHostBindings.cs` (`CreateStandard()`) builds the + host surface it may call — consumed by eval, code reload, and the `link.xml` generator + (`Editor/BuildProcessors/CodeReloadLinkXmlGenerator.cs`); see the repo-root `CONTEXT.md` glossary + for the drift invariant. `Runtime/Compilation/InterpreterCodeReloadExecutor.cs` routes reloaded + method bodies into the VM; `Editor/EditorCodeReloadWatcher.cs` owns the file-watch → push-reload + loop. +- **`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 instance fields: `m_PascalCase`. Private static fields: `s_PascalCase`. Consts: `PascalCase`. + Exception: `Runtime/IlInterpreter/` keeps its `_camelCase` style. +- Don't `git commit`/`push` without an explicit request. +- Changelog entries: `[JIRA-KEY] ` in Unreleased. No justification/why — state what changed, stop. + Plain English, minimal code snippets/API names — describe the change, don't quote the implementation. + diff --git a/AIClient/Packages/com.unity.pipeline/CLAUDE.md.meta b/AIClient/Packages/com.unity.pipeline/CLAUDE.md.meta new file mode 100644 index 0000000..f7d3930 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/CLAUDE.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6433e36ff8e1bc44fb3cde740ee86080 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/CODEOWNERS b/AIClient/Packages/com.unity.pipeline/CODEOWNERS new file mode 100644 index 0000000..3c2cce5 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/CODEOWNERS.meta b/AIClient/Packages/com.unity.pipeline/CODEOWNERS.meta new file mode 100644 index 0000000..629037e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/CODEOWNERS.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: cbc57dbad3c6f114d94ced75a1f9737d +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/CodeGen.meta b/AIClient/Packages/com.unity.pipeline/CodeGen.meta new file mode 100644 index 0000000..1713e89 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/CodeGen.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8b1d193bfb6e52d44975ae298a3c060a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/CodeGen/CodeReloadInPlaceILPostProcessor.cs b/AIClient/Packages/com.unity.pipeline/CodeGen/CodeReloadInPlaceILPostProcessor.cs new file mode 100644 index 0000000..0187c64 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/CodeGen/CodeReloadInPlaceILPostProcessor.cs @@ -0,0 +1,460 @@ +using System; +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 code-reload dispatch prologue into every method tagged [CodeReload] at compile + /// time, so a running method can route to a reloaded override with no domain reload. The + /// injected prologue is: + /// + /// if (CodeReloadRegistry.TryInvokeCodeReload("Type.Method", this, args)) return; + /// // ... original body ... + /// + /// Weaves instance AND static methods (a static method dispatches with a null instance), + /// returning void, a value (via TryInvokeCodeReloadResult), or a System.Collections.IEnumerator + /// coroutine. Parameters are supported (boxed into object[]). Only IEnumerable/generic + /// iterator returns are skipped with a diagnostic. + /// + class CodeReloadInPlaceILPostProcessor : ILPostProcessor + { + private const string RuntimeAssemblyName = "Unity.Pipeline"; + // Full name: a user attribute merely named CodeReloadAttribute must not get a dispatch + // prologue — runtime discovery checks the real type, so its overrides would never bind. + private const string AttributeFullName = "Unity.Pipeline.CodeReload.CodeReloadAttribute"; + private const string RegistryTypeFullName = "Unity.Pipeline.CodeReload.CodeReloadRegistry"; + private const string DispatchMethodName = "TryInvokeCodeReload"; + private const string ResultDispatchMethodName = "TryInvokeCodeReloadResult"; + private const string HasOverrideMethodName = "HasOverride"; + private const string ThrowNullResultMethodName = "ThrowOverrideReturnedNull"; + + /// Unity requires a fresh instance per compilation; this processor is stateless, so returns itself. + /// This instance. + public override ILPostProcessor GetInstance() => this; + + /// Whether this assembly should be woven: only those referencing the runtime assembly can use [CodeReload]. + /// The assembly about to be processed. + /// True if the assembly references the Unity.Pipeline runtime assembly. + public override bool WillProcess(ICompiledAssembly compiledAssembly) + { + // Only assemblies that reference the runtime assembly can use [CodeReload] 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); + } + + /// Weave the code-reload dispatch prologue into every [CodeReload] method in the assembly. + /// The assembly to process. + /// The woven assembly bytes plus any diagnostics. + 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.FullName == AttributeFullName)) + .ToList(); + + // A user attribute merely NAMED CodeReloadAttribute never weaves (full-name match + // above) — say so, or the author of a [CodeReload] imported from the wrong + // namespace debugs a silent no-op. Runs before the targets early-out: a module + // whose only tagged methods carry the impostor still gets the warning. + WarnForeignCodeReloadAttributes(module, diagnostics); + + if (targets.Count == 0) + return new ILPostProcessResult(null, diagnostics); + + // Resolve failures below leave every [CodeReload] method unwoven — the per-cause + // warning alone reads like a detail, so state the consequence explicitly. + var dispatch = ResolveDispatchMethod(module, resolver, diagnostics); + var hasOverride = dispatch == null ? null : ResolveHasOverrideMethod(module, resolver, diagnostics); + if (dispatch == null || hasOverride == null) + { + diagnostics.Add(Warn($"{targets.Count} [CodeReload] method(s) in {module.Name} were NOT woven — " + + "code reload will not work for this assembly (see the preceding warning for the cause).")); + return new ILPostProcessResult(null, diagnostics); + } + + // Value-returning [CodeReload] methods dispatch through the out-result overload; may be + // absent in an older runtime, in which case those methods are skipped (below). + var resultDispatch = ResolveResultDispatchMethod(module, resolver, diagnostics); + + // Guard helper for value-type returns (see WeaveDispatchPrologue); absent in an + // older runtime, in which case the unguarded (pre-existing) IL is woven. + var throwNullResult = ResolveThrowNullResultMethod(module, resolver, diagnostics); + + int wovenCount = 0; + foreach (var method in targets) + { + bool isVoid = method.ReturnType.MetadataType == MetadataType.Void; + + // Coroutines returning System.Collections.IEnumerator weave like any other + // value-returning method: the woven stub dispatches at iterator CREATION + // (per StartCoroutine call), and the override's state machine — compiled + // (Mono) or interpreted via the ScriptEnumerator bridge (IL2CPP) — is what + // the scheduler drives. IEnumerable/generic iterator returns stay excluded: + // nothing in the coroutine workflow produces them and the interpreter bridge + // only implements non-generic IEnumerator. + if (!isVoid && IsExcludedIteratorReturn(method.ReturnType)) + { + diagnostics.Add(Warn($"[CodeReload] '{method.FullName}' returns an IEnumerable or generic iterator and was skipped (coroutines must return System.Collections.IEnumerator).")); + continue; + } + + if (!isVoid && resultDispatch == null) + { + diagnostics.Add(Warn($"[CodeReload] '{method.FullName}' returns a value but the runtime has no {ResultDispatchMethodName}; skipped.")); + continue; + } + + WeaveDispatchPrologue(method, dispatch, resultDispatch, hasOverride, throwNullResult); + wovenCount++; + } + + if (wovenCount == 0) + return new ILPostProcessResult(null, diagnostics); + + return new ILPostProcessResult(WriteAssembly(assembly), diagnostics); + } + } + + /// + /// Warn for every method tagged with an attribute that shares the simple name + /// CodeReloadAttribute but is not the Unity.Pipeline one: it gets no dispatch prologue by + /// design (runtime discovery checks the real type), and without the warning that reads as + /// code reload silently not working. + /// + private static void WarnForeignCodeReloadAttributes(ModuleDefinition module, List diagnostics) + { + foreach (var method in module.GetTypes().SelectMany(t => t.Methods)) + { + if (!method.HasCustomAttributes) + continue; + var impostor = method.CustomAttributes.FirstOrDefault(a => + a.AttributeType.Name == "CodeReloadAttribute" && + a.AttributeType.FullName != AttributeFullName); + if (impostor != null) + diagnostics.Add(Warn($"'{method.FullName}' is tagged [{impostor.AttributeType.FullName}], " + + $"which is not {AttributeFullName} — no dispatch prologue was woven, code reload will not apply to it.")); + } + } + + /// True for System.Nullable`1, whose unbox.any accepts null (empty Nullable). + private static bool IsNullable(TypeReference rt) + => rt is GenericInstanceType git && git.ElementType.FullName == "System.Nullable`1"; + + /// True for the iterator returns that stay excluded from weaving: IEnumerable + /// (both) and generic IEnumerator`1. Plain System.Collections.IEnumerator — the Unity + /// coroutine shape — weaves through the result dispatch (see the weave site). + private static bool IsExcludedIteratorReturn(TypeReference rt) + { + var name = rt.GetElementType().FullName; + return name == "System.Collections.IEnumerable" + || name == "System.Collections.Generic.IEnumerator`1" + || name == "System.Collections.Generic.IEnumerable`1"; + } + + /// + /// Inject, at the very top of the method: + /// if (CodeReloadRegistry.HasOverride("Type.Method") + /// && CodeReloadRegistry.TryInvokeCodeReload("Type.Method", this, args)) return; + /// + /// The HasOverride guard is a cheap dictionary lookup that runs first, so the (boxed) argument + /// array is only built when an override is actually active — a woven method with no override + /// allocates nothing per call. + /// + private static void WeaveDispatchPrologue( + MethodDefinition method, MethodReference dispatch, MethodReference resultDispatch, + MethodReference hasOverride, MethodReference throwNullResult) + { + var body = method.Body; + body.SimplifyMacros(); + + var il = body.GetILProcessor(); + var first = body.Instructions[0]; + var methodId = $"{method.DeclaringType.Name}.{method.Name}"; + bool isVoid = method.ReturnType.MetadataType == MetadataType.Void; + + // A value-returning method holds the dispatched result in a local, then unboxes it. + VariableDefinition resultLocal = null; + if (!isVoid) + { + resultLocal = new VariableDefinition(method.Module.TypeSystem.Object); + body.Variables.Add(resultLocal); + } + + var prologue = new List + { + // Guard: skip straight to the original body (no allocation) when no override is active. + Instruction.Create(OpCodes.Ldstr, methodId), + Instruction.Create(OpCodes.Call, hasOverride), + Instruction.Create(OpCodes.Brfalse, first), + // An override exists: build the dispatch call and its argument array. + Instruction.Create(OpCodes.Ldstr, methodId), + // The dispatch's instance argument: `this`, or null for a static method (the + // override ABI keeps a leading instance parameter either way; it arrives null). + method.IsStatic + ? Instruction.Create(OpCodes.Ldnull) + : Instruction.Create(OpCodes.Ldarg_0), + }; + + // 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)); + } + } + + if (isVoid) + { + // if (TryInvokeCodeReload(id, this, args)) return; else + 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 + } + else + { + // if (TryInvokeCodeReloadResult(id, this, args, out object r)) return (TRet)r; else + prologue.Add(Instruction.Create(OpCodes.Ldloca, resultLocal)); // out object result + prologue.Add(Instruction.Create(OpCodes.Call, resultDispatch)); + prologue.Add(Instruction.Create(OpCodes.Brfalse, first)); // no override -> run original body + var loadResult = Instruction.Create(OpCodes.Ldloc, resultLocal); + // A null result cannot unbox to a value type — without this guard the unbox.any + // below throws an opaque NullReferenceException at the call site. Nullable + // stays unguarded: unbox.any legitimately converts null to an empty Nullable. + if (throwNullResult != null && method.ReturnType.IsValueType && !IsNullable(method.ReturnType)) + { + prologue.Add(Instruction.Create(OpCodes.Ldloc, resultLocal)); + prologue.Add(Instruction.Create(OpCodes.Brtrue, loadResult)); + prologue.Add(Instruction.Create(OpCodes.Ldstr, methodId)); + prologue.Add(Instruction.Create(OpCodes.Call, throwNullResult)); // never returns + } + prologue.Add(loadResult); + // unbox.any handles both value types (unbox) and reference types (castclass). + prologue.Add(Instruction.Create(OpCodes.Unbox_Any, method.ReturnType)); + prologue.Add(Instruction.Create(OpCodes.Ret)); // override ran -> return its result + } + + foreach (var instr in prologue) + il.InsertBefore(first, instr); + + body.OptimizeMacros(); + } + + /// + /// Resolve a CodeReloadRegistry method (static, non-generic; picks + /// the overload) from the runtime assembly and import it into the target module. + /// is the warning added when the registry or the method + /// can't be found; null makes absence silent — for overloads an older runtime may not have. + /// + private static MethodReference ResolveRegistryMethod( + ModuleDefinition module, PostProcessorAssemblyResolver resolver, List diagnostics, + Func match, string missingMessage) + { + var runtimeRef = module.AssemblyReferences.FirstOrDefault(a => a.Name == RuntimeAssemblyName); + if (runtimeRef == null) + { + if (missingMessage != 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) + { + if (missingMessage != null) + diagnostics.Add(Warn($"Could not resolve {RegistryTypeFullName} in {RuntimeAssemblyName}.")); + return null; + } + + var method = registry.Methods.FirstOrDefault(m => m.IsStatic && !m.HasGenericParameters && match(m)); + if (method == null) + { + if (missingMessage != null) + diagnostics.Add(Warn(missingMessage)); + return null; + } + + return module.ImportReference(method); + } + + /// + /// Resolve CodeReloadRegistry.TryInvokeCodeReload(string, object, object[]) from the runtime + /// assembly and import it into the target module. + /// + private static MethodReference ResolveDispatchMethod( + ModuleDefinition module, PostProcessorAssemblyResolver resolver, List diagnostics) + { + return ResolveRegistryMethod(module, resolver, diagnostics, + m => m.Name == DispatchMethodName && + m.Parameters.Count == 3 && + m.Parameters[0].ParameterType.MetadataType == MetadataType.String && + m.Parameters[1].ParameterType.MetadataType == MetadataType.Object, + $"Could not find non-generic {DispatchMethodName}(string, object, object[]) on {RegistryTypeFullName}."); + } + + /// + /// Resolve CodeReloadRegistry.TryInvokeCodeReloadResult(string, object, object[], out object) + /// -> bool for value-returning methods. Returns null (not an error) when the runtime predates + /// the overload — callers then skip value-returning methods with a diagnostic. + /// + private static MethodReference ResolveResultDispatchMethod( + ModuleDefinition module, PostProcessorAssemblyResolver resolver, List diagnostics) + { + return ResolveRegistryMethod(module, resolver, diagnostics, + m => m.Name == ResultDispatchMethodName && + m.Parameters.Count == 4 && + m.Parameters[0].ParameterType.MetadataType == MetadataType.String && + m.Parameters[1].ParameterType.MetadataType == MetadataType.Object && + m.Parameters[3].ParameterType.IsByReference, + missingMessage: null); + } + + /// + /// Resolve CodeReloadRegistry.ThrowOverrideReturnedNull(string) — the woven null guard for + /// value-type returns. Resolved from the runtime like the dispatch methods (importing + /// exception types from the post-processor's own corlib would not resolve in a player). + /// Returns null (not an error) when the runtime predates the helper — the unguarded IL is + /// woven then, matching the old behavior. + /// + private static MethodReference ResolveThrowNullResultMethod( + ModuleDefinition module, PostProcessorAssemblyResolver resolver, List diagnostics) + { + return ResolveRegistryMethod(module, resolver, diagnostics, + m => m.Name == ThrowNullResultMethodName && + m.Parameters.Count == 1 && + m.Parameters[0].ParameterType.MetadataType == MetadataType.String, + missingMessage: null); + } + + /// + /// Resolve CodeReloadRegistry.HasOverride(string) -> bool from the runtime assembly and import + /// it into the target module (used by the woven prologue's no-allocation guard). + /// + private static MethodReference ResolveHasOverrideMethod( + ModuleDefinition module, PostProcessorAssemblyResolver resolver, List diagnostics) + { + return ResolveRegistryMethod(module, resolver, diagnostics, + m => m.Name == HasOverrideMethodName && + m.Parameters.Count == 1 && + m.Parameters[0].ParameterType.MetadataType == MetadataType.String && + m.ReturnType.MetadataType == MetadataType.Boolean, + $"Could not find {HasOverrideMethodName}(string) on {RegistryTypeFullName}."); + } + + 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 = "CodeReloadInPlace: " + 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/AIClient/Packages/com.unity.pipeline/CodeGen/CodeReloadInPlaceILPostProcessor.cs.meta b/AIClient/Packages/com.unity.pipeline/CodeGen/CodeReloadInPlaceILPostProcessor.cs.meta new file mode 100644 index 0000000..3bdc215 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/CodeGen/CodeReloadInPlaceILPostProcessor.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/AIClient/Packages/com.unity.pipeline/CodeGen/Unity.Pipeline.CodeGen.asmdef b/AIClient/Packages/com.unity.pipeline/CodeGen/Unity.Pipeline.CodeGen.asmdef new file mode 100644 index 0000000..535ad30 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/CodeGen/Unity.Pipeline.CodeGen.asmdef.meta b/AIClient/Packages/com.unity.pipeline/CodeGen/Unity.Pipeline.CodeGen.asmdef.meta new file mode 100644 index 0000000..2a94d5b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/CodeGen/Unity.Pipeline.CodeGen.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3d86918fcc1e40b8a17542a4bacf8dbc +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Documentation~/TableOfContents.md b/AIClient/Packages/com.unity.pipeline/Documentation~/TableOfContents.md new file mode 100644 index 0000000..1d5add2 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Documentation~/TableOfContents.md @@ -0,0 +1,28 @@ +* [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) + * [Reloading code](code-reload.md) + * [Tests architecture](testing.md) + * [Analytics](analytics.md) +* Command reference + * [Batch command](commands/batch.md) + * [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) + * [Wait commands](commands/wait.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/AIClient/Packages/com.unity.pipeline/Documentation~/analytics.md b/AIClient/Packages/com.unity.pipeline/Documentation~/analytics.md new file mode 100644 index 0000000..86f7604 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Documentation~/analytics.md @@ -0,0 +1,138 @@ +# Analytics + +The Editor server reports how the pipeline is used through Unity's editor analytics +(`EditorAnalytics.SendAnalytic`). The data answers questions the package has no other way to +answer: whether sessions are interactive or automated, which commands clients actually reach for, +how often they fail, how long they take, and how much traffic is raw `eval` rather than a +concrete command. + +Three things bound what is collected: + +- **Editor only.** A server running in a development Player reports nothing — `EditorAnalytics` + does not exist there. +- **The editor's own opt-out applies.** With analytics disabled in Unity preferences, + `SendAnalytic` does nothing and no event leaves the machine. There is no separate pipeline + switch to turn off. +- **Nothing about a command's content is collected.** Not its parameters, not the code passed to + `eval`, not paths, not results. +- **Nothing a project declared about its own commands is collected.** Only commands shipped in the + package report their name and tags; anything a project declared itself reports + `` and no tags — see + [What is and is not collected](#what-is-and-is-not-collected). + +## Events + +### `Pipeline_SessionStarted` + +Sent once when a client executes its first command, which is the moment the pipeline actually +started handling work — not when the Editor launched or the server bound its port. + +| Field | Meaning | +|-------|---------| +| `batchmode` | The Editor was launched with `-batchmode`. | +| `automated` | The Editor was launched with `-automated`. | +| `browserAllowed` | The server accepts sandboxed browser clients (the limited-CORS path). Off unless enabled in the Pipeline settings. | +| `dynamicPortAssignation` | The port was auto-assigned from the server's range rather than pinned in the settings asset. | +| `hotReloadWatcherEnabled` | The `.cs` change watcher is running. | + +A session spans a Unity **process**. The latch lives in `SessionState`, which survives domain +reloads, so a script recompile does not open a second session. + +### `Pipeline_CommandExecuted` + +Sent once per command a client executes. + +| Field | Meaning | +|-------|---------| +| `commandName` | The registered command name — but only for commands that ship with the package. A project-declared command reports the literal `` instead. | +| `commandTags` | The command's tags, in declaration order — but only for commands that ship with the package. Empty for an untagged command, and always empty for a project-declared one. | +| `commandSuccess` | Whether the command succeeded. False both when it threw and when it returned a result carrying its own failure — `eval`, `reload_file`, `run_script` and `run_tests` all report failure the second way. | +| `commandDuration` | Milliseconds spent inside the command, excluding the wait for the one-command-at-a-time gate. | +| `isUserDefinedCommand` | The command was declared by the project rather than shipped in the package. | +| `isEval` | The command is a code evaluation (tagged `scripts/eval`: `eval`, `eval_file`, `run_script`). | +| `isEvalWithExistingCommandAvailable` | Whether the evaluated snippet touches an API that a shipped command already covers — the "you did not need eval for this" signal. Same judgement `report_evals` makes, applied to one invocation: see [How the eval-coverage flag is decided](#how-the-eval-coverage-flag-is-decided). | + +What counts as one execution: + +- One event per `/api/exec` call, including a `job: true` submission — reported when the job + actually **finishes**, not when its job handle is returned. +- A `batch` reports one event, for `batch` itself. Its sub-operations are not reported + individually. +- A request rejected before anything ran (oversized body, malformed JSON, unknown command, + settling host, full job queue) reports nothing: no command executed. +- The internal executions behind `/api/editor_status` and `/api/test-status` report nothing. + Clients poll those endpoints, and the polling would swamp everything else. + +### `Pipeline_SessionStopped` + +Sent when the Editor quits, and only if `Pipeline_SessionStarted` was sent — an Editor that never +served a command closes no session. + +| Field | Meaning | +|-------|---------| +| `sessionDuration` | Milliseconds between the session starting and the Editor quitting. | + +## How the eval-coverage flag is decided + +`isEvalWithExistingCommandAvailable` reuses the eval instrumentation from AUTHAPI-29 rather than +inventing a second opinion. Per invocation: + +1. `EvalUsageFingerprinter.Analyze` turns the snippet into API fingerprints — dotted member-access + paths, with no literals or raw source in them. +2. Each fingerprint is looked up in `EvalCoverageMap.Default`, a small curated hint map from API to + the command that would cover it. +3. The flag is true only if a mapped command is **actually in the live catalog**. A mapping to a + command that does not exist yet (`AssetDatabase.Refresh` → `refresh_assets`) is a gap for + `report_evals` to surface, not an avoidable eval. + +This is deliberately the same rule as `report_evals`' `covered` list, so the two can never disagree. +Two consequences worth knowing when reading the data: + +- **It is a floor, not a measure.** The map has a handful of entries and matching is on the whole + fingerprint, so `PlayerSettings.GetScriptingBackend(...)` matches while + `UnityEditor.PlayerSettings.GetScriptingBackend(...)` and + `PlayerSettings.GetScriptingBackend(...).ToString()` do not — they fingerprint as different paths. + A `false` means "no mapped-and-present command matched", never "no command could cover this". +- **`run_script` always reports `false`.** It runs a project file through a named entry point rather + than a snippet, and the fingerprinter parses script-kind bodies, so it is not fingerprinted. + `eval` (inline body) and `eval_file` (body read back from its file) are judged. + +## What is and is not collected + +A command's name and tags are text the project's own authors wrote, so either can name an +unannounced feature or an internal system. Tags are constrained in shape +(`^[a-z0-9_]+(/[a-z0-9_]+)*$`) and shipped commands must use the documented taxonomy, but nothing +holds a project to it, so a tag carries a project-chosen word exactly as a name does. + +So both are withheld. `commandName` and `commandTags` describe only commands that ship in the +package (`Unity.Pipeline`, `Unity.Pipeline.Editor`); every other `[CliCommand]` reports the literal +`` and an empty tag array. The remaining fields — success, duration, `isEval` — +are facts about the execution, not about what the project called it, so they are reported for every +command. + +What this costs: project-declared traffic is countable but not breakable down by area. That is the +deliberate trade — `isUserDefinedCommand` answers how much of the surface is project-declared, which +is the question worth asking, and nothing identifying is needed to answer it. + +Command **arguments** are never collected. Neither is anything a command reads or writes. + +## Adding post-command work + +Analytics is one consumer of a single seam rather than a special case. When a command finishes, +`BasePipelineServer.OnCommandDone` receives a `CommandExecutionInfo` describing both the raw +transaction and the execution. It runs on the HTTP thread, so `EditorPipelineServer` splits by what +each consumer needs: the transaction log is appended there directly (thread-safe file I/O by +design), and anything needing Unity APIs is handed to the server dispatcher with +`Dispatcher.Post`, which runs it in `OnCommandDoneMainThread` on the next main-thread pump. + +New post-command work goes in one of those two, depending on which thread it needs. `Post` is the +general fire-and-forget counterpart to `Dispatcher.Invoke`: it returns immediately instead of +parking the request thread until the next pump, and swallows-and-logs failures because there is no +caller left to throw to. Work still queued when the server stops is dropped, so a report can be +lost in the frame the Editor quits or reloads its domain. + +## See also + +- [Creating commands](creating-commands.md) — where a command's name and tags come from. +- [Connectivity](connectivity.md) — the port and CORS settings the session event reports. +- [Code reload](code-reload.md) — the watcher whose state the session event reports. diff --git a/AIClient/Packages/com.unity.pipeline/Documentation~/authoring-commands.md b/AIClient/Packages/com.unity.pipeline/Documentation~/authoring-commands.md new file mode 100644 index 0000000..42fc2da --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Documentation~/code-reload.md b/AIClient/Packages/com.unity.pipeline/Documentation~/code-reload.md new file mode 100644 index 0000000..0c81380 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Documentation~/code-reload.md @@ -0,0 +1,177 @@ +# Reloading Code + +Update C# methods with the `[CodeReload]` attribute without restarting Play Mode in the editor or rebuilding the Player. Two backends are supported: + +| Command | Backend | Where | Language | Pipeline server needed in player | +|---------|---------|-------|----------|----------------------------------| +| `reload_file` | `Assembly.Load` (default) | Editor Play Mode, Desktop Mono dev builds | Full C# | yes | +| `reload_file_editor_interpreter`, `reload_file_player_interpreter` | IlInterpreter | Any dev build, **including IL2CPP** | A C# subset (see [constraints](#interpreter-constraints)) | no | + +The watch UI and device pushes always use the interpreter: an IL2CPP player has no Roslyn and no `Assembly.Load`, so the editor compiles and the device interprets. Code reload is gated to the editor and development builds (`UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE`) — the whole `Unity.Pipeline` assembly carries that define constraint, so a release build contains none of it unless you opt in with `ENABLE_RUNTIME_PIPELINE` (see `runtime-setup.md`). `[CodeReload]` itself lives in the unconstrained `Unity.Pipeline.Attributes` assembly, so a tagged method still compiles for a release build; it simply runs its original body, with no dispatch prologue woven in. + +## Tagging a method + +Tag the method you want to reload with `[CodeReload]`: + +```csharp +using Unity.Pipeline.CodeReload; +using UnityEngine; + +public class Spinner : MonoBehaviour +{ + public float rotationSpeed = 90f; + + [CodeReload] + public void Update() + { + transform.Rotate(Vector3.up, rotationSpeed * Time.deltaTime); + } +} +``` + +The attribute must be there before entering Play Mode or building a player, because the dispatch check is woven in at compile time (see [How it works](#how-it-works)). Edit the body of `Update`, then apply the file with one of the commands below. The running instance picks up the new body. + +**Constraints.** The `[CodeReload]` method must be **`public`** (instance **or static**) and must return **`void` or `System.Collections.IEnumerator`** (a coroutine). Methods returning anything else are skipped at build time with a warning. The `Assembly.Load` backend adds one more rule, described in its section: the body may only touch public members of its host type. + +### Supported changes + +**New methods:** a reload can declare a method the compiled type doesn't have yet and call it from a reloaded body (instance and static methods, expression bodies included; not generics, properties, or fields). A new method cannot be a reload *entry point* — nothing compiled calls it until the next real compile — so tagging it `[CodeReload]` registers nothing for it (the reload response says so) while the rest of the file still applies. + +**Method groups and delegates:** a reloaded body can pass a method as a callback — `_root.Add(new Button(Click))`, `button.clicked += Click`, `RegisterCallback(OnClick)` — whether the method already exists compiled or was just added, and event `-=` unsubscribes as expected. Lambdas and closures work too, and delegate *values* combine (`a += b` on an `Action` local/field). Limits: at most 4 delegate parameters and no ref/out parameters. One caveat for a method group over a *new* method: the receiver is evaluated when the delegate fires, not captured at subscription, and `-=` of such a group won't match the `+=`. + +**Coroutines:** a `[CodeReload] IEnumerator` method picks up the new body at iterator **creation** — each `StartCoroutine` call uses the latest reload, while coroutines already running keep executing the body they were created from (use `[OnCodeReload]` to stop/restart long-lived ones). `yield return null`, `WaitForSeconds`, and nested coroutines all work; `IEnumerable`/generic iterator returns are not supported. Two caveats: an exception thrown inside a reloaded coroutine body surfaces from the coroutine scheduler with no automatic fallback to the original body (unlike void methods), and on the interpreter backend a `try/finally` around a `yield` runs its `finally` on the normal path and on `Dispose`, but **not** when the body throws. Coroutines are the main use case for the `[OnCodeReload]` callbacks described below. + +## Reload callbacks — `[OnCodeReload]` + +Tag a **parameterless instance method** with `[OnCodeReload]` to run code after a reload lands — a place to re-initialize state the swapped code depends on (think a domain-reload-free `OnEnable`): + +```csharp +[OnCodeReload] +void OnCodeReloaded() +{ + StopAllCoroutines(); + StartCoroutine(MainLoop()); // restart so the loop picks up the new body +} +``` + +It fires once per reload that changes at least one method on the declaring type, on **every live instance** (`UnityEngine.Object`-derived types only), on the main thread. Exceptions are logged, not propagated. Both backends and the watcher trigger it. Typical uses: restarting long-lived coroutines, disabling and re-enabling MonoBehaviours, and refreshing caches computed by the old code. + +## `Assembly.Load` backend: the `reload_file` command + +This backend compiles the edited C# in the process that runs it, so reloading in a player does not need a running editor. It does need the runtime Pipeline server in the player, since the command arrives over HTTP. + +```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 `[CodeReload]` 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. | + +**Public members only.** The reloaded body may only access public members of its host type. The compiled code loads as a separate assembly and Mono enforces accessibility, so a private field or method would fail at dispatch; the reload rejects it up front with the offending members listed. + +## Interpreter backend: the `reload_file_*_interpreter` commands + +The editor compiles the edited file to IL, and the IlInterpreter runs that IL where the reload lands: in the editor process, or in a connected player. No `Assembly.Load` is involved, so this backend works in IL2CPP players. Both commands compile in the editor, so reloading a player needs the editor running and the player connected. + +### `reload_file_editor_interpreter` + +Same as `reload_file`, but the reloaded methods run through the interpreter in the editor process. Use it to check that an edit stays within the interpreter's subset and host surface before pushing it to a device. + +```bash +unity command reload_file_editor_interpreter "/Spinner.cs" +``` + +| Arg | Default | Meaning | +|-----|---------|---------| +| `filename` | *(required)* | Source file containing `[CodeReload]` methods. | +| `timeout` | `30000` | Compilation timeout (ms). | +| `assemblyDir` | `null` | Optional directory to also save the compiled IL to disk (default: in-memory only). | +| `pdb` | `false` | Emit a portable PDB mapped to the original source. Runtime errors already report source lines on this backend, so this only matters if you also want the `.pdb` on disk. | + +The public-members-only constraint of `reload_file` does not apply: the interpreter reaches private members by reflection. + +### `reload_file_player_interpreter` + +Compiles a file's (or a folder's) `[CodeReload]` methods in the editor and pushes the IL to connected development players over PlayerConnection (the Profiler's channel, so no open port is needed and it tunnels over USB). Folders are walked recursively; a `.cs` file is only compiled if it mentions `CodeReload`, and files that don't are skipped rather than reported as errors. + +```bash +# Compile a file's [CodeReload] methods and push to every connected player +unity command reload_file_player_interpreter "Assets/Scripts/Player.cs" + +# Push a whole folder, targeting one player by id (-1 = broadcast, the default) +unity command reload_file_player_interpreter "Assets/Scripts" --player 2 +``` + +| Arg | Default | Meaning | +|-----|---------|---------| +| `filename` | *(required)* | Source `.cs` file or a folder containing `[CodeReload]` methods (e.g. `Assets/pong.cs` or `Assets/Scripts`). | +| `player` | `-1` | Target connected player id. `-1` broadcasts to all connected players. | + +Points to know: + +- The push compiles with the **player's** preprocessor defines, not the editor's. +- The player needs the runtime Pipeline driver enabled (see [Runtime connection & setup](runtime-setup.md)); the HTTP server is not required. +- The response reports the compile result and any host-surface members the pushed code uses that the player does not expose (see [Available APIs](#interpreter-constraints)). The player acks the apply asynchronously to the editor console. +- Unlike the watcher, the command pushes every tagged method in the file, not just the ones that changed. That is the escape hatch when the connected player was not built from the sources you are editing. + +### Interpreter constraints + +The `Assembly.Load` backend runs plain C# — anything Roslyn compiles works. The interpreter backend does not: it executes a **C# subset** against a **fixed host surface**. The subset is checked before the code runs; the host surface only bites at runtime, on a device. + +**Language subset.** The shipped Roslyn analyzer (diagnostics **MS002–MS023**) marks violations in your IDE, on `[CodeReload]` method bodies only. Not supported: + +- `await` / `Task`, `try`/`catch`/`finally`, `lock`, LINQ query syntax (`from x in …`), `checked` +- `decimal`, `dynamic`, reflection types (`Type`, `MethodInfo`, …), threading types +- `ref`/`out`/`in` parameters, `ref` returns and locals, multi-dimensional arrays (`int[,]` — jagged `int[][]` is fine), generic methods +- declaring static fields, value-type nullables (`T?`), new type declarations inside the file, `yield` in a local function + +`long`, `ulong`, and `double` work (the VM runs them in 64-bit slots). Lambdas, closures, delegates, `goto`, string interpolation, and coroutine `yield` all work — see the delegate and coroutine notes above for their edges. + +**Available APIs.** Interpreted code can only call the UnityEngine/BCL members the interpreter exposes, plus the reloaded type's own members. In the editor, missing members bind on demand; on a device they cannot (IL2CPP strips unreferenced code), so an unexposed member throws when the reloaded code reaches it. A device push checks this up front and lists the missing members as warnings in the push response. + +### Watching for changes + +Instead of running `reload_file_player_interpreter` or `reload_file_editor_interpreter` by hand, let the editor watch for saves: **Window → Pipeline → Settings… → Code Reload Interpreter Watch → Start Watching**. The watch covers the whole Assets tree; a saved `.cs` only compiles if it mentions `CodeReload`. + +![Watcher panel](./images/codereload-watcher-panel.png) + +Each save picks its target automatically: + +- **A development player is connected** (Autoconnect Profiler, or attached via Profiler/Console) → the save compiles in the editor and is pushed to every connected player over PlayerConnection, always on the interpreter backend. +- **No player** → the save applies in this editor process, also on the interpreter backend. + +Plugging in or disconnecting a device mid-watch reroutes the next save; no restart needed. + +The workflow: start the watch, enter Play Mode, edit `[CodeReload]` bodies, save. Points to know: + +- **Auto-refresh is off while watching**, so a save reloads instead of triggering a domain reload. Other assets won't import until you refresh manually (Cmd/Ctrl+R). **Stop Watching** restores your setting. +- Only methods that **changed since the watch started** reload; untouched ones keep running compiled. +- The watch **survives domain reloads**, re-applies reloads that a Play Mode domain reload wiped, and re-pushes state to a player that reconnects (a restarted player boots the original code) — governed by the **Re-push On Connect** setting. +- The inspector shows liveness: watcher event count, last apply, and the resolved target. Compile errors and binding warnings land in the Console. + +To push once without a watch, run [`reload_file_player_interpreter`](#reload_file_player_interpreter) directly. + +## How it works + +Every `[CodeReload]` method gets a small check woven in when your project builds: on each call it runs the latest reloaded body if one exists, otherwise the original. That is why edits apply without touching call sites, and why a reload only ever changes tagged methods. + +An applied reload compiles the edited source **in memory**; nothing is written to disk unless you pass `assemblyDir`. The result then either loads next to the original assembly (`Assembly.Load` backend, Mono only) or is executed by the interpreter (works everywhere, including IL2CPP). + +## Supporting commands + +- `codereload_status` — list which methods currently have a reload applied. +- `cleanup_codereload` — delete saved reload assemblies and revert every method to its compiled body. + +## See also + +- [Runtime connection & setup](runtime-setup.md) — enabling code reload in a Player build. +- [Command reference](commands/runtime.md) diff --git a/AIClient/Packages/com.unity.pipeline/Documentation~/commands/animation.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/animation.md new file mode 100644 index 0000000..e983ada --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Documentation~/commands/assets-and-files.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/assets-and-files.md new file mode 100644 index 0000000..50ee6f0 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Documentation~/commands/baking.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/baking.md new file mode 100644 index 0000000..a716346 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Documentation~/commands/batch.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/batch.md new file mode 100644 index 0000000..91d0799 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/batch.md @@ -0,0 +1,102 @@ +# Batch command + +The `batch` command runs several existing pipeline commands in a single request, with cross-operation result references, atomic Undo, and dry-run validation. It exists to collapse the round-trips of a "create → add components → wire fields" job (otherwise 10–90 sequential calls) into one request, and to add what `eval` structurally lacks: **transactionality and rollback**, so a failure mid-way never leaves partial state on a user-owned scene. + +**Scope guard:** `batch` is transactional object CRUD with references. It intentionally has **no** `invoke`/loop/conditional op types — arbitrary logic belongs in code-on-disk (`run_script`/`eval`), not in JSON. This keeps the command surface auditable and allowlistable. + +### `batch` +Run multiple registered commands in one transactional request. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `operations` | yes | – | Ordered list of operations, each `{ id?, command, params }`. Max 200. | +| `transactional` | no | `true` | Group every op into one Editor Undo step and revert all applied ops if any op fails. Forced `false` when `on_error=continue`. Rejects commands whose mutations Undo cannot revert (see [Transactional restrictions](#transactional-restrictions)). | +| `on_error` | no | `abort` | `abort`: stop at the first failing op. `continue`: run every op, collecting per-op errors (forces `transactional=false`). | +| `dry_run` | no | `false` | Validate command names, parameters, reference topology, and excluded/non-batchable commands without mutating anything. | +| `result_fields` | no | – | Optional per-op result projection: a map of op id-or-index → array of result field paths to keep (context economy). | +| `time_budget_ms` | no | `50000` | Cooperative time budget for the whole batch, in milliseconds (max 3600000). Checked before each op after the first; when exhausted, the remaining ops are skipped and a transactional batch rolls back. | + +**Returns:** `BatchResult` — `{ results: [{ id?, command, success, result? | error?, skipped?, resultTruncated?, revertible? }], applied, reverted, transactional, onError, error?, undoGroup?, dryRun?, valid? }`. The batch-level `error` is set only for aborts not attributable to a single op ("batch time budget exceeded", "batch canceled"). + +**Notes:** Runs on the main thread. Each sub-operation goes through the same dispatch path as `/api/exec`, so its result is shaped identically to running that command standalone. + +#### Operation shape + +Each entry of `operations` is: + +```json +{ "id": "createHead", "command": "add_component", "params": { "target": "$root.instanceId", "type": "Rigidbody" } } +``` + +- `id` (optional) — a name for referencing this op's result from a later op. Must be unique within the batch and match `^[A-Za-z_][A-Za-z0-9_-]*$` (purely numeric ids would be ambiguous with index selectors, and `.` collides with the reference path separator). +- `command` — the registered command to run (any command except the non-batchable ones below). +- `params` — the command's parameters, same shape as `/api/exec` `parameters`. Operations are **always** `command` + `params`; the `argv`/`commandLine` raw forms are exec-only and are not accepted inside a batch operation. + +#### Cross-operation references + +Any **string** value in a later op's `params` may reference an earlier op's result: + +``` +"$." +``` + +- `` names an earlier op by its `id` or its 0-based index (an `id` match takes precedence). +- `` is a Newtonsoft SelectToken path into that op's result, e.g. `instanceId`, `components[0].instanceId`. +- `"$$"` escapes a literal `$` (so `"$$5"` is the literal string `"$5"`). +- References are **backward-only** and resolved as **whole values** (the entire string must be the reference). The substituted JSON token keeps its type, so a numeric `instanceId` deserializes into an `ObjectRef` parameter exactly as a literal would. References may appear anywhere in the parameter tree (including inside nested objects/arrays). +- Referencing an op that **failed** or was **skipped** errors explicitly (this can happen under `on_error=continue`); referencing a succeeded op that **returned no result** errors with "returned no result". A reference never silently resolves to `null`. +- References always resolve against the **full** result of the earlier op, even when the reported copy was truncated or projected via `result_fields`. +- **Result size bounds** (both measured in serialized UTF-8 bytes, i.e. wire size): a single op's reported result above **16 KiB** is replaced by a truncation marker (`resultTruncated: true` + a preview), and the **aggregate** of all reported results in one batch is bounded to **256 KiB** — past that, later results are elided the same way (the ops still execute; only the echoed copy is dropped). Use `result_fields` to project results down when a batch reads large objects. +- A malformed `result_fields` path never fails its op (the op has already executed); the bad path is reported inside the projection as `invalidResultFields`. + +Example — create a GameObject, add a Rigidbody to it, then set the Rigidbody's mass: + +```json +{ + "operations": [ + { "command": "create_gameobject", "params": { "name": "Ball" } }, + { "command": "add_component", "params": { "target": "$0.instanceId", "type": "Rigidbody" } }, + { "command": "set_serialized_field", "params": { "target": "$1.instanceId", "field": "m_Mass", "value": 2.5 } } + ] +} +``` + +#### Atomicity + +The whole batch runs inside one `AuthoringUndoScope`, so every op (each of which registers its own Undo operations) collapses into a **single** Editor Undo step. When `transactional` (the default) and the batch aborts — an op fails under `on_error=abort`, the time budget runs out, or the job is canceled — every applied op is reverted with `Undo.RevertAllDownToGroup` (including partial mutations registered by the failing op itself), the scene returns to its pre-batch state, and nothing is left on the Undo stack. `reverted: true` and the failing op's `error` are reported. + +#### Transactional restrictions + +Rollback is only real for **Undo-tracked** mutations (scene objects, components, serialized fields). Commands that mutate state **outside** the Undo system — asset and file writes, scene saves, Build Settings, project settings, baked data — cannot be rolled back, so a transactional batch **rejects** them with a structured `not_batchable_transactional` error. Run them with `transactional: false`, or call them standalone. Their per-op result reports `revertible: false` so the limitation is visible. + +The non-revertible set (v1 — a curated list, pending per-command `Revertible` metadata): + +- **Assets & files:** `create_asset`, `import_asset`, `move_asset`, `copy_asset`, `rename_asset`, `delete_asset`, `create_folder`, `set_import_settings`, `write_text_file`, `create_script` +- **Prefab assets:** `create_prefab`, `create_prefab_variant`, `apply_prefab_overrides`, `save_prefab_contents` (scene-side prefab ops — `instantiate_prefab`, `unpack_prefab`, `revert_prefab_overrides` — are Undo-tracked and stay fully batchable) +- **Scene persistence & build list:** `save_scene`, `save_all`, `add_scene_to_build`, `remove_scene_from_build`, `set_build_settings` +- **Settings:** `set_audio_settings`, `set_graphics_settings`, `set_input_settings`, `set_lighting_settings`, `set_navmesh_settings`, `set_physics_settings`, `set_player_settings`, `set_quality_settings`, `set_tags_layers`, `set_time_settings`, `set_authoring_root` +- **Animation / Timeline assets:** `create_animation_clip`, `set_animation_curve`, `remove_animation_curve`, `create_animator_controller`, `add_animator_layer`, `add_animator_parameter`, `add_animator_state`, `add_animator_transition`, `create_timeline`, `add_timeline_track`, `add_timeline_clip` +- **Materials:** `set_material_properties` (persists the `.mat` asset to disk) +- **Baking:** `bake_lighting`, `clear_baked_lighting`, `bake_navmesh`, `bake_navmesh_surfaces`, `clear_navmesh`, `bake_occlusion_culling`, `clear_occlusion_culling` + +`open_scene` and `create_scene` are **always** rejected inside a batch (even with `transactional: false`): they clear the Editor Undo stack, which would silently destroy the batch's ability to revert anything that ran before them. + +#### Timing, budget and cancellation + +The batch executes in a **single main-thread turn** — the Editor UI is frozen for its duration, bounded by `time_budget_ms` (frame-spanning execution is future work). The elapsed time is checked cooperatively before each op after the first; when the budget is exhausted, the remaining ops are reported `skipped: true`, the batch-level `error` is set to "batch time budget exceeded", and a transactional batch rolls back. For long batches, submit the request as a detached job (`"job": true` on `/api/exec`) and raise `time_budget_ms` — jobs also make the batch cancelable: cooperative cancellation (`POST /api/job/cancel`) is checked between ops and aborts the batch the same way ("batch canceled"). Per-op progress is reported (`op i/n: `) and visible via `GET /api/progress`. + +#### Dry run + +`dry_run: true` validates without executing anything: it catches unknown commands, excluded/non-batchable (`not_batchable`) commands, transactional rejections (`not_batchable_transactional`), unknown/missing parameters, and unresolvable reference topology (unknown or forward references). It mutates nothing and reports `valid` plus a per-op `success`/`error`. + +#### Excluded commands + +The following are rejected with a structured `not_batchable` error: + +- **Out of scope:** `build`, `switch_build_target`, `package_*`, `editor_play`/`editor_stop`/`editor_pause`, `recompile` +- **Arbitrary code:** `eval`, `eval_file`, `run_script`, and nested `batch` +- **Modal-dialog hazard:** `menu` (menu items can open modal dialogs that wedge an unattended batch) +- **Cannot make progress:** synchronous `wait_for` — the batch holds the main thread for its whole turn, so the editor state a condition watches cannot change; the wait would just burn its timeout inside the batch. Run an async `wait_for` before/after the batch instead (`wait_status`/`wait_cancel` are quick reads and stay batchable) +- **Undo-stack wipe:** `open_scene`, `create_scene` +- **Async commands** (`run_tests`, `list_tests`, and any command returning `Task`): they are completed by `EditorApplication.update` callbacks, which cannot fire while the batch holds the main thread — dispatching one would freeze the Editor permanently +- **Runtime-only commands** (e.g. `set_timescale`): they belong to the Player server surface and cannot execute on the editor server diff --git a/AIClient/Packages/com.unity.pipeline/Documentation~/commands/build-and-compilation.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/build-and-compilation.md new file mode 100644 index 0000000..7ae55d4 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Documentation~/commands/capture.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/capture.md new file mode 100644 index 0000000..ccc222c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/capture.md @@ -0,0 +1,61 @@ +# 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 the game view 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. + +`source` selects what is captured. `camera` (default) renders a camera and **does not include Screen Space - Overlay UI** (HUDs, menus, damage numbers on overlay canvases) — overlay canvases composite straight to the screen and never render through a camera. `screen` captures the composited game view backbuffer, so overlay UI is included; because overlay UI only exists at runtime, `source=screen` **requires Play Mode** (in Edit Mode it returns a clear error — use `source=camera`). The screen capture reads the game view at its true pixel resolution — independent of OS display scaling — and preserves the on-screen color tone, so the PNG matches what the Game window shows. + +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`. `camera` is ignored when `source=screen`. + +| 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 (`source=camera` only); 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`. | +| `source` | no | `camera` | `camera` renders a camera (misses Screen Space - Overlay UI); `screen` captures the composited game view incl. overlay canvases (**Play Mode only**). | + +**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/AIClient/Packages/com.unity.pipeline/Documentation~/commands/editor-lifecycle-and-observability.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/editor-lifecycle-and-observability.md new file mode 100644 index 0000000..32de8ea --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/editor-lifecycle-and-observability.md @@ -0,0 +1,187 @@ +# 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` +Toggle pause state of Unity Editor play mode (calling it again while paused resumes 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). | +| `persist` | no | `true` | Persist this choice to SessionState so it survives a domain reload. Set `false` for a one-off/expensive setting (e.g. `interval_ms=0`) that should revert to the last persisted choice (or the default) after the next recompile instead of sticking for the rest of the session. | + +**Returns:** `string` +**Notes:** `MainThreadRequired = true`. Persisted in SessionState by default: your enabled/interval choice survives a domain reload (dies with the editor process/session). + +Use `persist=false` for a change you only want in effect until the next unrelated recompile — e.g. a short profiling run at `interval_ms=0` (pegs a CPU core) or a one-off test/CI script — so it can't outlive its purpose and silently burn CPU for the rest of the session. Leave `persist` at its default `true` for a setting you want to remain in effect across recompiles, such as the normal always-on 16ms tick. + +### `get_performance_stats` +Read render, memory, and frame-timing stats (structured, read-only). + +No parameters. + +**Returns:** `PerformanceStats` + +### `audit` +Run a Project Auditor static-analysis scan. Returns immediately; poll `audit_status` until status is `completed`, then read the CSV. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `categories` | no | `–` | Comma-separated issue categories to scan (e.g. `Code,ProjectSetting,Texture`). Default: all categories. An unknown name is rejected with an error listing the valid values. | +| `output` | no | `–` | CSV output path (absolute, or relative to the project root). Defaults to `/Temp/pipeline-audit/.csv`. | + +**Returns:** `object` — `{ status, scanId, csvPath }` on start, or `{ status: "unavailable" | "error" | "busy", message }`. +**Notes:** Requires Project Auditor in the Editor. Only one scan runs at a time (a second call returns `busy`). Not cancellable: stop polling to abandon a scan. + +### `audit_status` +Get the status of the last audit: `idle` | `scanning` | `completed` | `failed` | `interrupted` | `unavailable`. + +No parameters. + +**Returns:** `AuditStatus` (JSON) — `issueCount` and `csvPath` are populated when `completed`; `error` when `failed`; `message` when `unavailable`. +**Notes:** `MainThreadRequired = false`, so it answers while a scan holds the main thread (Code analysis compiles assemblies). `interrupted` means a domain reload killed an in-flight scan — re-run `audit`. + +#### Project Auditor prerequisites + +`audit` needs both Project Auditor **and** its rules: + +- **Project Auditor absent** — `audit` returns `unavailable` immediately. +- **Rules absent** — when Project Auditor ships as a built-in editor module, its rules (descriptors, + API/obsolete databases, Roslyn analyzers) come from the separate `com.unity.project-auditor-rules` + package. Without it Project Auditor registers no analysis modules and cannot analyze anything, so + `audit` returns `scanning` and the first `audit_status` poll reports `unavailable` with a message + naming the package to install. It never reports an empty `completed` scan, which would read as a + clean project. + +The CSV columns are `Category, Severity, Areas, Description, RelativePath, Line, DescriptorId, Recommendation`; +only diagnostics (things to fix) are emitted, not raw inventory rows. + +### `report_evals` +Aggregate the local eval-usage telemetry into a ranked report: API fingerprint frequency, one-liner percentage, error rate, and command-coverage suggestions (read-only). See [Eval usage telemetry](#eval-usage-telemetry) below. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `top` | no | `50` | Maximum entries in each ranked list: the fingerprint ranking and the uncovered patterns suggested as commands. Accepted range: 0 or greater; negative values fall back to the default. | + +**Returns:** `EvalUsageReport` +**Notes:** `MainThreadRequired = false` (stays servable while the Editor is settling). Reads `/Library/Pipeline/eval-usage.jsonl` merged with the rotated `eval-usage.old.jsonl` (oldest first), so the report covers the full retained history — bounded to roughly 2× the size cap. Returns a zeroed report if no evals have been recorded. The report's `distinctFingerprints` field carries the uncapped total of distinct fingerprints so truncation of the ranked list is always visible; coverage suggestions are computed from the full ranking, not the capped list. + +## Eval usage telemetry + +The `eval`/`eval_file` commands are the workhorse of autonomous agent sessions, but they are opaque: raw C# through the protocol, unauditable, and hard to displace with typed commands without knowing what agents actually run. This telemetry closes that loop by recording, **locally**, what each eval does — so the [eval-displacement epic (AUTHAPI-24)](https://jira.unity3d.com/browse/AUTHAPI-24) can be triaged from data instead of anecdotes. + +### What is recorded, and where + +Every `eval`/`eval_file` invocation appends one JSON object (one line, JSONL) to `/Library/Pipeline/eval-usage.jsonl`: + +| Field | Meaning | +|-------|---------| +| `time` | ISO-8601 UTC timestamp. | +| `command` | `eval` or `eval_file`. | +| `success` / `error` | Whether it succeeded; short error category on failure. | +| `payloadLength` / `lineCount` | Size/shape of the eval body. | +| `executionTimeMs` | How long it took. | +| `classification` | `single-expression` (a one-liner: one invoke / read / assignment) or `statements` (multi-statement bodies, e.g. polling loops, bulk setup). | +| `fingerprints` | The top-level member-access paths from the syntax tree — e.g. `AssetDatabase.Refresh`, `PlayerSettings.SetScriptingBackend`, `Object.FindFirstObjectByType`, `MatchManager.Instance.State`. Rendering is a strict whitelist: the only source text that can appear in a fingerprint is an identifier, type name, or member name. Literals, interpolated strings, and any other expression form are replaced by the neutral `` placeholder (`"…".Length` records as `.Length`); object creations record as `new TypeName` (constructor arguments never appear); casts unwrap to the inner expression; generic type arguments normalise to ``. A fingerprint that would be nothing but the placeholder is dropped. | +| `source` | The raw eval source — **absent by default**; present only when the opt-in below is enabled, and truncated at 64 K characters with an explicit `…[truncated]` marker (`payloadLength` still reports the full length). | + +**Local-first, privacy-first, editor-only.** Nothing is transmitted off the machine, and nothing is recorded outside the Editor: player builds — including development players, where eval itself is available — never write telemetry (recording compiles to a no-op there). The fingerprint is derived from the C# syntax tree, so the log captures *which APIs* agents reach for without capturing *what data* they operate on. Conditional-access chains (`a?.B.C`) are fingerprinted with `?.` normalised to `.` so they rank with their unconditional form. The fingerprint deliberately undercounts a few forms (arguments nested inside a chain's receiver call, bare object creations) — see the `EvalUsageFingerprinter` class doc for the full blind-spot list before treating absence as proof of non-use. + +**Zero eval latency.** Recording is fire-and-forget: when an eval completes, the source and outcome plus the current settings are snapshotted, and the parse → fingerprint → append runs on a background thread from that snapshot — the eval response is never delayed by telemetry. The JSONL is bounded by a size cap and rotates to `eval-usage.old.jsonl`; the rotated file is merged back into `report_evals`, so retained history is bounded to roughly 2× the cap. + +### Settings + +Configured on the `EditorPipelineManager` settings asset (`Window/Pipeline/Settings...`). Edits apply live from the inspector, and the stored values are re-applied on every server start: + +- **Eval Telemetry Enabled** (default on) — master switch. When off, nothing is recorded (and, as always, nothing is transmitted). +- **Store Eval Source** (default off) — the explicit opt-in to also persist the raw eval `source` in each record, for local debugging only. + +### The feedback loop: report → backlog triage + +Run `report_evals` to aggregate the log into an `EvalUsageReport`: + +- **Ranked fingerprints** with counts — what agents actually eval, most-used first. +- **One-liner percentage** and **error rate** — the shape of eval usage (the evidence base for AUTHAPI-24 is that ~80% of eval calls are one-liners). +- **Coverage suggestions** — each fingerprint is cross-referenced against the live command catalog: + - `covered`: patterns already served by an existing command (e.g. `PlayerSettings.SetScriptingBackend` → `set_player_settings`) — these are habit, and should move off eval. + - `topUncovered`: the highest-frequency patterns with no covering command (e.g. `AssetDatabase.Refresh` → proposed `refresh_assets`) — these are the next typed commands to build ([AUTHAPI-17](https://jira.unity3d.com/browse/AUTHAPI-17)). + +This turns backlog triage into a lookup: "12× `AssetDatabase.Refresh`, no command → build it; 60× state-read loops → `wait_for`". Re-run across sessions to demonstrate eval's shrinking share as typed commands land. + +### `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/AIClient/Packages/com.unity.pipeline/Documentation~/commands/gameobjects-and-components.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/gameobjects-and-components.md new file mode 100644 index 0000000..ca1e4cc --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Documentation~/commands/materials.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/materials.md new file mode 100644 index 0000000..7cb352d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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, isBuiltin, isSupported }]` — `assetPath` is omitted for built-in shaders in the default lean reply (pass `verbose: true` on the request for an explicit `assetPath: null`); use `isBuiltin` to classify. + +| 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/AIClient/Packages/com.unity.pipeline/Documentation~/commands/navigation.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/navigation.md new file mode 100644 index 0000000..fed671f --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Documentation~/commands/package-manager.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/package-manager.md new file mode 100644 index 0000000..d84010e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Documentation~/commands/prefabs.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/prefabs.md new file mode 100644 index 0000000..9f5c13a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Documentation~/commands/project-settings.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/project-settings.md new file mode 100644 index 0000000..0c398a4 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/project-settings.md @@ -0,0 +1,270 @@ +# Project settings commands + +Read and change a representative slice of Unity's project-wide settings (Audio, Graphics, Input, Physics, Player, Quality, Runtime Pipeline, 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_runtime_pipeline_settings` +Read Pipeline Runtime settings (enableInBuilds, port, requestTimeoutMs, enableAuditLogging, autoStart, maxWorkItemsPerFrame). Reports the settings in effect, falling back to built-in defaults when none have been authored; never creates the settings file. + +No parameters. + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. + +### `set_runtime_pipeline_settings` +Change Pipeline Runtime 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 | +|-------|----------|---------|-------------| +| `enableInBuilds` | no | `–` | Enable the Pipeline HTTP server in Player builds. SECURITY WARNING: only enable in development/QA builds. | +| `port` | no | `–` | HTTP port for the Pipeline server. 0 = auto-assign from range 7900-7999. | +| `requestTimeoutMs` | no | `–` | Request timeout in milliseconds. | +| `enableAuditLogging` | no | `–` | Log every remote request for security auditing. | +| `autoStart` | no | `–` | Start the server automatically when the Player boots (or Play Mode is entered). | +| `maxWorkItemsPerFrame` | no | `–` | Maximum work items the dispatcher processes per frame. | + +**Returns:** `ProjectSettingsResponse` +**Notes:** `MainThreadRequired = true`. `confirm=true` required to apply; supports `dry_run`. Refused while in Play Mode — the running driver already loaded its own settings, so a change would silently not apply until the next Play session or build. `port` (0-65535), `requestTimeoutMs` (1000-60000), and `maxWorkItemsPerFrame` (1-50) are rejected outright with a `Fail` response if outside these bounds — the same ones the Project Settings page enforces — rather than being clamped or silently applied. + +### `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/AIClient/Packages/com.unity.pipeline/Documentation~/commands/runtime.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/runtime.md new file mode 100644 index 0000000..0966019 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/runtime.md @@ -0,0 +1,213 @@ +# Runtime commands + +Commands served by the Player / dev-build server. Many require a Development build with the runtime Pipeline enabled — 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. | +| `since_session` | no | `–` | Session the `since` cursor came from: pass the `session` from a previous response. A cursor from another session is refused and the tail returned with `reset=true`. | + +**Returns:** `ConsoleLogResponse` +**Notes:** `MainThreadRequired = false`. Available in both the Editor and player builds. + +A cursor is only meaningful paired with the `session` that issued it. Sequence numbers restart from +zero when the Editor restarts (the buffer is persisted under `Temp/`, which Unity deletes at every +launch), so a cursor from an earlier session names different entries. Always echo `session` back as +`since_session`; when the pair cannot be honored the response sets `reset=true`, returns the tail +instead of nothing, and gives you a fresh `cursor`/`session` to adopt. + +Beyond the entries, the response carries: + +- `counts` — errors/warnings/logs the buffer currently retains, before the level filter and `tail`. +- `groundTruth` — what the Editor itself reports: `compilationFailed`, `compiling`, the Editor + console's own `consoleErrors`/`consoleWarnings`/`consoleLogs`, whether the buffer has been + `seeded` from the console's store, and `ageMs` for how stale the sample is. Null in a player. + +`groundTruth.consoleErrors` exceeding `counts.error` means the Editor console is showing entries the +buffer does not hold. That is expected for output produced before the package was first imported; +otherwise it is the signal that something was missed. + +### `console_status` +Console ground truth and buffer counters without pulling entries: compile-failure flag, Editor +console counts, and the buffer's retained counts and cursor. + +No parameters. + +**Returns:** `ConsoleLogResponse` with an empty `entries` array. +**Notes:** `MainThreadRequired = false`. Cheap enough to poll in a loop while a compile runs; use +`console` when you want the entries themselves. + +### `clear_console` +Clear the captured log buffer and the Unity Editor console. + +No parameters. + +**Returns:** `object` +**Notes:** Available in both the Editor and player builds; in a player only the captured buffer is +cleared, since there is no Editor console. Editor compile errors are logged as sticky entries, which +Unity's own clear skips, so they remain in the Console window until the next compile completes. +Clearing keeps the cursor, so a client following `console` sees `dropped=true` on its next poll +rather than silently skipping the window that was cleared. + +### `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. + +> For anything beyond an ad-hoc one-liner — bulk construction, builder scripts — prefer the +> Editor-side [`run_script`](scripts.md#run_script) command, which compiles a versioned project +> `.cs` file in memory and runs a named entry point instead of carrying code through the protocol. + +### `reload_file` +Compile and apply [CodeReload] edits from a source file. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `filename` | yes | `–` | Source file containing [CodeReload] 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:** `CodeReloadResponse` +**Notes:** `MainThreadRequired = true`. See [Reloading code](../code-reload.md). + +### `reload_file_editor_interpreter` +Compile [CodeReload] edits and run them through the IlInterpreter VM in this process instead of `Assembly.Load` (IL2CPP-safe; only a minimal host API + the target type are available). + +Same parameters as [`reload_file`](#reload_file). + +**Returns:** `CodeReloadResponse` +**Notes:** `MainThreadRequired = true`. See [Reloading code](../code-reload.md). + +### `reload_file_player_interpreter` +Compile a file's (or folder's) [CodeReload] methods in the Editor and push the IL to connected development players over PlayerConnection (IL2CPP-safe). Folders are walked recursively; files that do not mention `CodeReload` are skipped. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `filename` | yes | `–` | Source `.cs` file or a folder containing [CodeReload] methods (e.g. Assets/pong.cs or Assets/Scripts) | +| `player` | no | `-1` | Target connected player id; `-1` broadcasts to all connected players | + +**Returns:** `{ success, message, diagnostics }` for a file; `{ success, message, pushed, failed }` for a folder. +**Notes:** `MainThreadRequired = true`. Served by the **Editor** server (it needs Roslyn and PlayerConnection), not the player; the player only needs the runtime Pipeline driver, not its HTTP server. See [Reloading code](../code-reload.md#reload_file_player_interpreter). + +### `codereload_status` +Show current code reload registry status and statistics. + +No parameters. + +**Returns:** `CodeReloadResponse` +**Notes:** `MainThreadRequired = true`, `RuntimeOnly`. + +### `cleanup_codereload` +Remove old code 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:** `CodeReloadResponse` +**Notes:** `MainThreadRequired = true`, `RuntimeOnly`. + +See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md). diff --git a/AIClient/Packages/com.unity.pipeline/Documentation~/commands/scenes.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/scenes.md new file mode 100644 index 0000000..5d5e5af --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Documentation~/commands/scripts.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/scripts.md new file mode 100644 index 0000000..9c7a85e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/scripts.md @@ -0,0 +1,111 @@ +# Script commands + +Commands for creating C# script files, attaching MonoBehaviours to GameObjects, reading/writing serialized fields, and running compiled project entry points in memory. 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`. For bulk construction that does **not** need a new persistent project type, skip the recompile entirely: [`run_script`](#run_script) compiles a file in memory and runs it with no asset import and no domain reload. + +### `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` + +### `run_script` +Compile a single project `.cs` file in memory (no domain reload, no code carried through the +protocol) and execute a named `static` entry point. This is the **builder-pattern** path for bulk +construction — see the note below. Editor command; still arbitrary code execution, so it is grouped +under the same `scripts/eval` tag as `eval` and is disabled wherever the eval family is disabled. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `file` | yes | `–` | Path to a `.cs` file. Relative paths resolve against the **project root** (the parent of `Assets/`), not the process working directory. May live **outside `Assets/`** (e.g. `AgentScripts/`) so writing it never triggers an asset import or domain reload. | +| `entry` | no | `–` | Entry point as `Namespace.Type.Method` (or `Type.Method`, or a bare `Method`). Nested types may be written with dots (`Outer.Inner.Method`). Default: the single public static method if unambiguous, else a method named `Main`. Static, non-generic methods only in v1. Ephemeral mode only — rejected with `mode=hotpatch`. | +| `args` | no | `–` | JSON array of arguments, coerced to the entry's parameter types: primitives, enum names, `string[]`, and a string/object handle (`ObjectRef`) for `UnityEngine.Object` parameters. JSON `null` is only valid for reference-type and nullable parameters (never a silent `default(T)`). Ephemeral mode only — rejected with `mode=hotpatch`. | +| `mode` | no | `ephemeral` | `ephemeral`: compile to an in-memory assembly, run the entry, discard. `hotpatch`: apply in-place `[CodeReload]` method replacements to already-loaded types (delegates to `reload_file`). | +| `references` | no | `–` | Extra assembly name prefixes to reference. In the Editor all loaded assemblies are already referenced, so this is effectively a no-op there. Ephemeral mode only; **not applied** by hotpatch (the compile is `reload_file`'s). | +| `defines` | no | `–` | Extra scripting define symbols, **appended to the project's active editor defines** (`EditorUserBuildSettings.activeScriptCompilationDefines`) — so `UNITY_EDITOR`, version and platform symbols are already set and sources behave like project code; your defines extend that set rather than replace it. Ephemeral mode only; **not applied** by hotpatch. | +| `pdb` | no | `false` | Emit a portable PDB mapped to the source file so breakpoints bind. Ephemeral runs always emit source-mapped symbols so exception stack traces carry `file:line`. Compiles unoptimized. | +| `timeout_ms` | no | `30000` | Timeout in milliseconds (governs the main-thread wait budget end-to-end, like `eval`). | +| `dry_run` | no | `false` | Compile only: return diagnostics; the emitted assembly is **not loaded** into the domain and nothing executes (the response carries no `assemblyName`). Rejected with `mode=hotpatch` — a hotpatch "dry run" would still apply live method replacements. | + +**Returns:** `RunScriptResponse` — `{ result, diagnostics, compileMs, executeMs, assemblyName }` on top +of the standard envelope. The result carries no echo of the source (path + diagnostics only). Compile +errors return full Roslyn diagnostics (with line/column) and execute nothing; runtime exceptions +surface as structured errors with `file:line` mapped to the source file. +**Notes:** `MainThreadRequired = true`. Editor command. + +- Executing runs always compile **unoptimized (Debug codegen)** so the emitted symbols keep full + sequence points for `file:line` mapping — expect the generated code to run somewhat slower than + regular project assemblies. +- **Async entry points** (`async Task` / `Task`) are awaited to completion; `Task.Result` + becomes the command result and exceptions in the async work surface as runtime errors. The await + is **asynchronous** — the Editor main thread keeps pumping, so awaits that resume on Unity's + main-thread context (`Task.Yield()`, `Task.Delay()`, Awaitable, …) work naturally; no + `ConfigureAwait(false)` contortions are required. The wait is bounded by what remains of + `timeout_ms`; on expiry the command returns a `Timeout` error and the task keeps running + detached (its effects may still land), like an abandoned `eval`. +- **hotpatch specifics:** `entry`, `args` and `dry_run` are rejected (Bad Request); `references` and + `defines` are not applied. Path resolution follows `reload_file`, which probes under `Assets/` + for bare filenames (unlike ephemeral's project-root resolution). Timing: `executeMs` carries the + whole reload duration (compile + apply, not split by `reload_file`); `compileMs` is `0`. Advisory + reload diagnostics are surfaced as severity-`warning` entries on success. +- Under Unity's Mono runtime the in-memory assembly emitted by an executing `ephemeral` run is + **not** collectible/unloadable, so each run retains one small assembly (the same behavior as + `eval`); prefer a single builder entry over many tiny runs. `dry_run` loads nothing. + +#### The builder pattern (bulk construction) + +For bulk construction — creating many objects, wiring private fields, generating content — put the +logic in a **versioned project script** and invoke it with `run_script`. Write the file with +`write_text_file` (ideally under a non-`Assets/` folder such as `AgentScripts/` so the write triggers +no asset import), then call `run_script --file AgentScripts/Build.cs --entry Build.All`. Iterating the +script and re-running it costs an in-memory compile (< ~2s), **not** a 15–20s domain reload. + +The anti-patterns this replaces: carrying construction code as escaped C# strings through `eval` +(verbose, unreviewable, shell-escaping hazards), and editing a builder under `Assets/` in a loop +(each edit pays a full domain reload). Hold the line: code goes in files on disk via `run_script`; +`eval` stays for genuinely ad-hoc one-liners. + +See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md). diff --git a/AIClient/Packages/com.unity.pipeline/Documentation~/commands/wait.md b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/wait.md new file mode 100644 index 0000000..2586727 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Documentation~/commands/wait.md @@ -0,0 +1,169 @@ +# Wait commands + +Server-side condition waiting (AUTHAPI-25). `wait_for` waits until a runtime/editor member condition holds — evaluated on the Editor's per-frame loop — and can act in the **same frame** the condition first becomes true (e.g. screenshot the moment a match-end screen appears). It replaces the dominant agent anti-pattern of client-side polling loops: read a value via `eval`, sleep in the client, repeat. That loop is token-expensive and slow, and its client round-trips can miss short-lived states entirely (a 2-second screen is easy to miss at ~1–2 s per poll). A server-side condition is a cheap per-frame delegate that can act atomically the frame it fires. + +## Sync vs async: pick the right mode first + +`/api/exec` executes **one command at a time** — a synchronous `wait_for` holds that queue for its entire duration. The Editor itself never freezes (each poll is marshaled onto the main thread between sleeps), but **every other command queues behind the wait until it resolves**. That includes any command whose effect your condition is waiting for: if the condition needs a mutation you haven't issued yet, the mutation can never run, and the wait self-deadlocks until its timeout. + +Use a **synchronous** wait only when BOTH hold: + +- the wait is short — comfortably under your client's HTTP timeout (typically **< 30 s**), and +- the condition does **not** depend on any command you still need to issue (it will be satisfied by something already in motion: play-mode gameplay, a running bake, a timer, ...). + +For **everything else**, pass `async=true`: you get a `waitId` back immediately, other commands keep executing, and you poll `wait_status` (and cancel with `wait_cancel`). This is the default choice for long waits and for any "issue command, then wait for its effect" flow. + +A synchronous wait submitted as a detached job (`POST /api/exec` with `"job": true`) remains cancelable: `POST /api/job/cancel` interrupts the sleep loop and resolves the wait as `canceled`. + +> Member resolution, coercion, and the read-only security policy are intended to be shared with the invoke/query surface (AUTHAPI-16). Until that shared resolver lands, `wait_for` ships a self-contained resolver with the same rules that the two will converge on. Type and member-chain resolution is cached per wait — only the live target instance (an `ObjectRef` target or a `findType` instance) is re-resolved each poll. + +### `wait_for` +Wait until a member condition holds, then optionally act in the same frame. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `condition` | yes | `–` | The condition object (see below). | +| `timeout_s` | no | `30` | Maximum seconds to wait (clamped to 0–600; NaN/Infinity are rejected). | +| `poll_interval_ms` | no | `100` | How often to re-check (clamped to 16–5000 ms; NaN/Infinity are rejected). The sleep never exceeds the remaining budget, so a large interval cannot stretch a short timeout. | +| `on_met` | no | `–` | Follow-up run in the same frame the condition first holds: `{ capture: { view, source, save_path, width, height, include_image }, pause }`. `capture.source` (game view only): `camera` (default) renders a camera and **misses Screen Space - Overlay UI**; `screen` captures the composited game view including overlay canvases (HUDs), Play Mode only. | +| `return_history` | no | `false` | Include the observed `{ value, elapsedMs }` samples. A ring buffer keeps the **last 200** samples, so the tail near the transition is retained (the interesting part), not the first 200 identical observations. | +| `tolerate_missing` | no | `false` | Treat resolution failures (type/member/instance not found) as condition-not-met and retry until timeout, instead of failing fast. Enables "wait until X exists" — e.g. waiting for a spawned object via `findType`. On timeout the last resolution error is included. | +| `async` | no | `false` | Return a `waitId` immediately and evaluate in the background; poll `wait_status`, cancel with `wait_cancel`. **Use for long waits and whenever the condition depends on a command you still need to issue.** | + +**The `condition` object** + +| Field | Required | Description | +|-------|----------|-------------| +| `member` | yes | Dotted member path. With no `target`/`findType` it is a fully-qualified **static** path (e.g. `UnityEditor.EditorApplication.isPlaying`); with `target`/`findType` it walks **instance** members (e.g. `State`, `Health.CurrentHealth`). Read-only fields/properties only — write-only members and methods are rejected. | +| `target` | no | Object handle (`instanceId` / `hierarchyPath` / `guid` / `path` / `globalId`) to read instance members from. Re-resolved every poll, so the referenced object may appear mid-wait (combine with `tolerate_missing`). | +| `findType` | no | Fully-qualified `UnityEngine.Object` type name; the member walks from a **live** instance. Persistent objects (prefab/asset files on disk) and hidden editor internals (`hideFlags != None`) are excluded, so the wait never silently binds to a prefab asset instead of the scene instance. Scene instances are preferred; if several candidates remain, the lowest instance id is chosen deterministically and a `note` on the result reports the ambiguity — pass `target` to select one explicitly. | +| `op` | no | `equals` (default) \| `notEquals` \| `greaterThan` \| `lessThan` \| `contains` (strings) \| `changed` (met on the first change from the initially observed value; needs no `value`). | +| `value` | conditional | Comparison operand, coerced to the member's type (primitive, enum name, or string). Required for every op except `changed`. When either side is a `float`, `equals`/`notEquals` compare at **float precision** (widening a float to double manufactures noise digits — `0.9f` would otherwise never equal `0.9`). | + +**`op: changed` is for value-like members** — primitives, enums, and strings. It compares the member's current value against the first observed value, so content mutations *inside* a reference-typed member (a list gaining an element, a field on a referenced object changing) are **not** detected: the reference itself must change. Likewise, `value`/`initialValue`/`history` snapshots of mutable reference types reflect the object's state at serialization time, not at observation time. + +**Returns:** `WaitResult` + +``` +{ + "state": "met", // pending | met | timedOut | interrupted | canceled | failed | not_found + "met": true, + "timedOut": false, + "member": "MatchManager.Instance.State", + "op": "equals", + "value": "Ended", // most recently observed value + "initialValue": "Playing", // first observed value (baseline for op=changed) + "elapsedMs": 512, // frozen at the terminal transition + "framesObserved": 6, + "note": "…", // present only for non-fatal resolution notes (e.g. ambiguous findType) + "capture": { "savedPath": "Screenshots/victory.png" }, // present only with on_met.capture + "waitId": "…" // present only for async submissions / status snapshots +} +``` + +- A **timeout** returns `met:false, timedOut:true` with the last observed `value` — distinct from a resolution/evaluation error, which returns `state:"failed"` with an `error` message (unless `tolerate_missing` turns resolution errors into retries; the wait then times out with the last resolution error attached). +- If the main thread stalls past the per-poll marshal budget (~60 s: a huge import, a modal bake), the poll is counted as **missed** and the wait keeps going until its own budget expires — a stall does not turn the wait into a generic error. +- The sync result is well under 2 KB without `history`. + +### `wait_status` +Get the status/result of an async wait (`wait_for` with `async=true`). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `wait_id` | yes | `–` | The id returned by `wait_for` when `async=true`. | + +**Returns:** `WaitResult` (`state:"not_found"` if the id is unknown — async waits do not survive a domain reload; completed waits are retained up to a bounded count and pruned oldest-first the next time a `wait_for` is submitted, so a session that never submits another wait keeps its last results queryable). + +### `wait_cancel` +Cancel an async wait. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `wait_id` | yes | `–` | The id to cancel. | + +**Returns:** `WaitResult`. If the wait was still active, it resolves to `state:"canceled"`. If it had already reached a terminal state (`met`/`timedOut`/`failed`/`interrupted`) before the cancel request arrived, that actual state is returned unchanged — the cancel had no effect, and reporting a fake `canceled` here would misreport whether an `on_met` follow-up (capture/pause) actually ran. + +## Migration: replace a client polling loop with one call + +Before — an agent screenshots a match-end screen by polling with `eval` (dozens of round-trips, and the 2-second screen is easy to miss): + +``` +# ~60 client round-trips, each ~1–2s: +loop: + state = eval "MatchManager.Instance.State" # read + if state == "Ended": break + sleep 1 # client-side sleep +capture_game_view --source screen --save_path Screenshots/victory.png +``` + +After — one **async** wait that captures atomically in the frame the condition first holds, while other commands stay available (the match may take minutes, and you may still need to issue commands that drive it): + +``` +# 1. Arm the wait; returns { waitId } immediately: +wait_for --condition '{"member":"MatchManager.Instance.State","op":"equals","value":"Ended"}' \ + --timeout_s 300 \ + --on_met '{"capture":{"view":"game","source":"screen","save_path":"Screenshots/victory.png"}}' \ + --async true + +# 2. Keep working: start the match, drive gameplay, run other commands... + +# 3. Collect the result (poll until state != "pending"): +wait_status --wait_id +``` + +Only use the synchronous form when the wait is short and self-contained — e.g. play mode is already running and the state flips within a few seconds: + +``` +wait_for --condition '{"member":"MatchManager.Instance.State","op":"equals","value":"Ended"}' --timeout_s 10 +``` + +## Waiting for something to exist (spawn waits) + +By default the first failed resolution fails the wait (`state:"failed"`) — that catches typos fast. To wait for an object or member that does not exist *yet*, pass `tolerate_missing:true`: + +``` +wait_for --condition '{"findType":"MyGame.BossController","member":"IsActivated","op":"equals","value":true}' \ + --tolerate_missing true --timeout_s 120 --async true +``` + +Resolution failures are then treated as condition-not-met and retried every poll until the object appears (or the wait times out, with the last resolution error attached). + +## Interruption and lifecycle + +- Concurrent waits are capped (8); a further submission is rejected with a structured error telling you to `wait_cancel` one or let one finish. +- A **domain reload** (`beforeAssemblyReload`) or **exiting play mode** resolves every active wait to `state:"interrupted"` rather than hanging or leaking. **Entering play mode does not interrupt**: with domain reload disabled ("Enter Play Mode Options"), a wait armed in edit mode survives into play mode — the natural way to arm a capture before pressing play. (With domain reload enabled, the reload interrupts it anyway.) +- `on_met.pause` pauses play mode on the frame the condition holds (no-op outside play mode). +- If the server is stopping while a synchronous wait is pending, the wait resolves as `interrupted` ("server stopping") instead of evaluating against a shutting-down editor. + +## Result contract details + +- `error` is set only when the wait itself ends abnormally (`failed`, `interrupted`, `canceled`, + or the surfaced last resolution error on a tolerated timeout). A failed `on_met` follow-up + (capture/pause) is reported separately as **`onMetError`** while `state` stays `met` — the + condition did hold; check `onMetError` before trusting `capture`. +- `condition.target` and `condition.findType` are **mutually exclusive** (rejected up front). +- Member paths resolve **public** readable fields/properties only. +- A **destroyed** Unity object mid-path fails structurally; as the **leaf** value it is observed + as `null` (Unity's own `==` semantics), so "wait until destroyed" is + `{ "op": "equals", "value": null }` — the `value` key must be present and explicitly `null`; + omitting it is rejected for every op except `changed`. +- `tolerate_missing` retries the evaluator's STRUCTURED failures: resolution failures + (type/member/instance not found, unless permanently malformed) and evaluation errors + ("cannot compare yet" — e.g. an ordering op while the member is still null); the last error is + surfaced if the wait times out. A member getter that THROWS fails the wait immediately, + tolerated or not. +- A bare type name matching several loaded types binds deterministically (lowest full name) and + surfaces a `note` — use the fully-qualified name to disambiguate. + +## Interaction with `batch` + +`wait_for` is rejected inside a `batch` (`not_batchable`): the batch holds the main thread for its +whole turn, so the editor state a condition watches cannot change while the wait polls — it would +just burn its timeout. Run an async `wait_for` before or after the batch and poll `wait_status` +(both `wait_status` and `wait_cancel` are quick reads and stay batchable). + +## Interaction with the startup settle gate + +`wait_for` is a background command (`MainThreadRequired=false`), so it is **exempt from the settling busy-gate** (AUTHAPI-35): it is accepted even while the Editor is still importing/compiling after a cold start. A wait polling during that window observes a half-ready editor — types may not be loaded and objects may not exist yet. Combine with `tolerate_missing:true` for a deliberate wait-until-ready pattern, or gate on `/api/status` reporting `ready` first if you want fully settled reads. + +See [Creating commands](../creating-commands.md) and [Connectivity](../connectivity.md). diff --git a/AIClient/Packages/com.unity.pipeline/Documentation~/connectivity.md b/AIClient/Packages/com.unity.pipeline/Documentation~/connectivity.md new file mode 100644 index 0000000..55caf12 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Documentation~/connectivity.md @@ -0,0 +1,541 @@ +# 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. The Editor server can opt into the single exception: enabling **Allow Browser Clients** in the pipeline settings asset (`Window/Pipeline/Settings...`, applies on next start) admits `Origin: null`, the opaque origin a sandboxed browser frame reports, and answers that client's CORS preflight. An ordinary page sends its real origin and is still refused, and a sandboxed one still needs the bearer token. + +## 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). +- **Android** — `Application.dataPath` points inside the read-only APK install directory, so the descriptor is written to `Application.persistentDataPath` instead: `/.unity-pipeline-runtime-port`. + +> 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": "", + "capabilities": ["exec.argv", "exec.commandLine"], + "info": "This editor has not been opened in automated mode and therefore could get stuck on modal dialogs. Human intervention may be required." +} +``` + +| 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. | +| `capabilities` | Optional wire features this server understands (see [Capability negotiation](#capability-negotiation)). **An absent key means the server predates the field** — treat it as supporting none of them. | +| `info` | Corrective guidance for the client, omitted when there is nothing to say. Currently only set when the Editor is interactive and wasn't launched with `-automated`: a blocking modal dialog can stall the Editor's main-thread update loop (and therefore all pipeline command processing) until a human dismisses it. Read this before issuing commands — it's the one field every client sees, since reading the descriptor is a mandatory first step just to connect. | + +### 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 also carries `capabilities`, with the same meaning and the same absent-key rule. + +(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/dialog`, `/api/commands` (lists available commands), `/api/exec` (POST — runs a command), `/api/test-status`, and `/api/progress`. + +### Command progress (`GET /api/progress`) + +While a command is executing over `/api/exec`, clients can poll `GET /api/progress` for the +task's live progress — the `unity` CLI uses this to render terminal progress bars, mirroring +`EditorUtility.DisplayProgressBar`. The endpoint is served off the Editor main thread, so it +answers even while a long synchronous command has the Editor blocked. Response +(`pct` is 0–1; all `progress` fields optional; `progress` is omitted when nothing is reported): + +```json +{ + "active": true, + "progress": { + "title": "Generating World", + "info": "Processing 42/100", + "current": 42, + "total": 100, + "pct": 0.42 + } +} +``` + +Progress sources, in order of precedence: + +1. `CliProgress.Report(title, info, current, total, progress)` — explicit reporting from + command code; thread-safe and works from a blocked main thread. +2. Running `UnityEditor.Progress` items, mirrored automatically. + +### Detached jobs (`/api/exec` with `"job": true`, `/api/job`, `/api/job/cancel`) + +A long command can outlive the client's HTTP timeout. Submitting it as a **detached job** +returns a job id immediately; the command runs in the background (still one at a time, in +arrival order) and the client polls for the result — reattaching at any point: + +1. `POST /api/exec` with body `{"command": "…", "parameters": {…}, "job": true}` → the + standard exec envelope, with the job handle as its `result`: + `{"success": true, "result": {"jobId": "…", "state": "queued"}}` (lean by default — + add `"verbose": true` to get the `command`/`executedAt` metadata back, see AUTHAPI-21). +2. `GET /api/job?id=` → `{"jobId", "command", "state": + "queued|running|completed|failed|canceled", "progress": {…}, "result", "error", …}`. + `progress` mirrors `/api/progress` while the job runs; `result` is retained after + completion (last 100 terminal jobs, 1 hour) so it can be fetched repeatedly. +3. `POST /api/job/cancel` with body `{"id": ""}` — a queued job is canceled before it + starts; a running job gets a cooperative cancellation flag that command or `eval` code can + observe via `Unity.Pipeline.PipelineCancellation.ThrowIfCancellationRequested()` (arbitrary + synchronous code cannot be aborted from outside). + +At most 100 jobs may be queued or running at once (jobs execute strictly serially, so a deeper +backlog is pure queued work with no benefit) — submitting one beyond that returns `429`. + +Jobs live in Editor memory: they do not survive domain reloads (script recompilation). + +Editor code that already calls `EditorUtility.DisplayProgressBar` can switch to the drop-in +`CliEditorProgress.DisplayProgressBar` / `ClearProgressBar` wrappers to keep the Editor dialog +and gain CLI visibility. See [Creating commands](creating-commands.md). + +### Readiness and the settle window + +On a **cold project import** the editor server comes up (and its descriptor is written) while the Editor is still importing assets and compiling scripts, so the Editor is not yet able to service commands. Until the Editor is first seen idle after server start: + +- `/api/status` reports `"status": "settling"` instead of `"ready"`. Wait for `ready` before issuing commands. +- `/api/exec` rejects **main-thread** commands with **HTTP 503** and a structured, retryable envelope — distinguishable from a genuine command failure. The gate applies before execution *and* before a detached job (`"job": true`) is created, so a job can't run into the half-ready Editor in the background either. The busy reply is a standard exec envelope and follows the request's reply-shape flags: lean (the default, shown here) drops the envelope metadata; `"verbose": true` restores the `command` echo and `executedAt`: + + ```json + { + "success": false, + "error": "Server Busy", + "errorDetails": "The Editor is still settling after startup (importing assets / compiling scripts), so main-thread commands are not serviceable yet. Retry shortly, or poll /api/status until it reports 'ready'.", + "status": "busy", + "busyReason": "settling", + "retryable": true + } + ``` + + `busyReason` is the specific cause, matching `/api/status`'s own `status` values for the same conditions (`"settling"`, `"blocked_by_dialog"`) — branch on it directly rather than inferring the cause from whether `dialogs` happens to be populated. + +- Background commands (`recompile_status`, `package_status`, `console`, ...) and `editor_status` stay servable throughout, so progress remains observable. + +The settle gate is one-way and scoped to the **editor session**: once the Editor has been idle once after startup, the server reports `ready` and the gate never arms again for that session — including for server instances recreated by domain reloads and for servers started while a mid-session compile/import happens to be in flight. Warm starts settle immediately; only the cold-import window gates. + +### Modal dialogs and the dialog-busy gate + +A native modal dialog (e.g. "Save changes before quitting?") or an `EditorWindow` modal blocks the Editor's main thread inside a nested OS message loop, so no main-thread command can run until it's dismissed. The server mirrors these dialogs from `UnityEditor.EditorDialogEvents`: + +- `GET /api/dialog` reports the currently open dialog(s): `{"active": bool, "dialogs": [...]}`, each entry shaped `{id, source, title, message, level, buttons, openedAt, dismissedAt}`. +- `/api/status` reports `"status": "blocked_by_dialog"` (instead of `"ready"`) with a `"dialog"` key carrying that same shape for the first open dialog — so the cheapest, most commonly polled probe doesn't claim readiness while every main-thread command is actually gated. +- `/api/exec` rejects any **main-thread** command with **HTTP 503**, the same busy envelope shape as the settle gate's but with `"busyReason": "blocked_by_dialog"` and a `"dialogs"` array attached. + + **Exception: `editor_status`.** Unlike every other main-thread command, `editor_status` still returns a normal **200** while a dialog is open, served from a snapshot taken the instant before the dialog blocked the thread — nothing it reports (compiling, domain reload, play mode) can change while the thread that would update it is stuck. The response carries `"status": "blocked_by_dialog"` and the same `"dialog"` payload as `/api/status`, so a caller can still tell it apart from a genuinely idle Editor. + +- A successful `/api/exec` response may carry `"dialogsDuringExecution"`: dialog(s) that opened and/or closed while the command was actually *executing* (not while it sat queued behind another command), so a caller learns about them without polling `/api/dialog` concurrently. + +Coverage is mechanism-based: native message boxes and `EditorWindow` modals are covered; OS file/folder pickers and other dialog mechanisms are not — a caller still needs a fallback signal (e.g. a command timeout) for uncovered cases. + +`/api/commands` accepts optional query parameters for discovery: + +- `detail` — `full` (the **default**) returns the complete command metadata including `parameters` and the generated `schema`; `compact` returns a lightweight index per command (`name`, `description`, `tags`, `package`). The recommended discovery flow is to browse the compact index first, then request full detail only for the few commands you intend to invoke. +- `query` — case-insensitive substring match on a command's name, description, or any tag. +- `tag` — scope results to a tag subtree via segment-aware prefix match (`tag=assets` matches `assets` and `assets/import`, not `assetsx`). +- `group_by` — `flat` (the default) returns a `commands` array; `package` or `tag` return a `groups` array instead (`tag` is a nested tree mirroring the tag/subtag hierarchy; untagged commands land in a node with an empty tag). +- `sort` — `name` (the default) or `package` (originating package, with name as tiebreak). +- `order` — `asc` (the default) or `desc`. Applies to the chosen `sort`; sorting happens on the flat list before pagination and grouping. +- `offset` / `limit` — paginate the filtered, sorted list. Both apply to the flat list *before* grouping, so pages stay deterministic. `offset` skips that many matches (default `0`); `limit` caps how many are returned (default: no cap). A client has seen everything once `offset + count` reaches `total`. + +Filters combine with AND; a filter that matches nothing returns an empty result, not an error. An invalid `detail`, `group_by`, `sort`, `order`, `offset`, or `limit` value is rejected with `400` naming the accepted values. + +### `/api/commands` response shape + +Every response carries the pagination counters plus a `server` block. The command payload is either a flat `commands` array or — under `group_by` — a `groups` array in its place: + +| Field | Meaning | +|---|---| +| `commands` | The page of commands. Present only when `group_by=flat` (the default). | +| `groups` | Present *instead of* `commands` when `group_by=package` or `group_by=tag`. | +| `count` | How many commands this page actually returned. | +| `total` | How many commands matched the filters, **before** `offset`/`limit` were applied. | +| `offset` | Echo of the requested `offset` (`0` when not supplied). | +| `limit` | Echo of the requested `limit` (`null` when not supplied — no cap). | +| `server` | The responding server's `version`, `port`, and `startTime`. | + +`GET /api/commands?detail=compact&tag=baking/lighting&limit=2` — `count` is the 2 returned here, while `total` is all 6 commands under `baking/lighting`, so the next page is `offset=2`: + +```json +{ + "commands": [ + { + "name": "bake_lighting", + "description": "Trigger an async lightmap bake of the open scene(s) via Lightmapping.BakeAsync(). Returns immediately; poll lighting_bake_status until completed.", + "tags": ["baking/lighting"], + "package": "Unity.Pipeline.Editor" + }, + { + "name": "cancel_lighting_bake", + "description": "Cancel an in-progress lighting bake (Lightmapping.Cancel()).", + "tags": ["baking/lighting"], + "package": "Unity.Pipeline.Editor" + } + ], + "count": 2, + "total": 6, + "offset": 0, + "limit": 2, + "server": { + "version": "0.0.1", + "port": 54321, + "startTime": "2026-07-29T09:14:22.113Z" + } +} +``` + +Under `group_by=tag` the same envelope carries a nested `groups` tree instead. Two things to note. A node's `commands` holds only the commands tagged *exactly* at that node — so `baking`, whose commands all live in subtags, reports an empty array — while its `count` covers the node's whole **subtree**. And because pagination happens before grouping, group counts describe the returned page rather than every match: `GET /api/commands?detail=compact&tag=baking&group_by=tag&limit=2` groups only the 2 commands on this page, while `total` still reports all 17 matches under `baking`: + +```json +{ + "groups": [ + { + "tag": "baking", + "count": 2, + "commands": [], + "children": [ + { + "tag": "baking/lighting", + "count": 1, + "commands": [ + { + "name": "bake_lighting", + "description": "Trigger an async lightmap bake of the open scene(s) via Lightmapping.BakeAsync(). Returns immediately; poll lighting_bake_status until completed.", + "tags": ["baking/lighting"], + "package": "Unity.Pipeline.Editor" + } + ], + "children": [] + }, + { + "tag": "baking/navmesh", + "count": 1, + "commands": [ + { + "name": "bake_navmesh", + "description": "Trigger an async legacy NavMesh bake of the open scene(s) via UnityEditor.AI.NavMeshBuilder. Returns immediately; poll navmesh_bake_status until completed.", + "tags": ["baking/navmesh"], + "package": "Unity.Pipeline.Editor" + } + ], + "children": [] + } + ] + } + ], + "count": 2, + "total": 17, + "offset": 0, + "limit": 2, + "server": { + "version": "0.0.1", + "port": 54321, + "startTime": "2026-07-29T09:14:22.113Z" + } +} +``` + +`group_by=package` uses the same envelope with flatter nodes — `{ "package": "Unity.Pipeline.Editor", "count": 2, "commands": [ ... ] }`, with no `children`. + +## Executing a command (`POST /api/exec`) + +A request carries the command in **exactly one** of three forms: + +```jsonc +{"command": "reload_file", "parameters": {"path": "Assets/A.cs"}} // structured +{"commandLine": "reload_file Assets/A.cs"} // server tokenizes, then binds +{"argv": ["eval", "return 2+2;"]} // server binds directly +``` + +For the two raw forms the **first token is the command name**, and the server binds the rest +against the command's declared parameters. This is what lets a client execute a command in a +single request without knowing its schema — the structured form needs `GET /api/commands` first, +the raw forms need nothing. + +**Prefer `argv` whenever you already have split tokens.** On POSIX a CLI never holds the original +command *string* — the shell already split and unquoted argv — so re-quoting those tokens for the +server to re-split is a lossy round-trip across two independently-versioned quoting dialects. Use +`commandLine` only when you genuinely hold raw text (a chat message, an MCP argument, a pasted +line). + +Supplying more than one form is an error, and so is sending `parameters` alongside `commandLine` +or `argv`. The latter is **rejected rather than ignored**: silently dropping a payload makes "you +sent it and we discarded it" indistinguishable from "you never sent it". + +`argv` and `commandLine` are exec-only. `batch` operations keep `command` + `params`. + +### Tokenizer dialect (`commandLine` only) + +This is a **versioned contract**, not an implementation detail. It is the pipeline's own dialect — +identical on every OS, and deliberately *not* the caller's shell — so the same string tokenizes +the same way from cmd.exe, bash, an MCP client, or a chat box. + +| Rule | Example | Tokens | +|---|---|---| +| Whitespace separates tokens; runs collapse | `a b` | `a`, `b` | +| `"…"` groups, `\` escapes the next character | `"a b"`, `"a\"b"` | `a b`, `a"b` | +| `'…'` groups, with **no** escapes at all | `'a\b'` | `a\b` | +| `\` escapes outside quotes | `a\ b` | `a b` | +| Adjacent spans concatenate | `a"b c"d` | `ab cd` | +| `""` yields one **empty** token | `--message ""` | `--message`, *(empty)* | +| Unbalanced quote or dangling `\` | `"oops` | **error** | + +There is **no expansion of any kind** — no `$VAR`, globbing, `~`, comments, or operators. `&&`, +`|` and `>` are ordinary characters. + +Two consequences worth stating explicitly: + +- **Single quotes are load-bearing.** They pass double quotes through untouched, which is the only + practical way to send `eval 'obj.name = "x";'` or `batch --operations '[{…}]'`. +- **Windows `CommandLineToArgvW` rules do not apply.** `"a""b"` is `ab` here, not `a"b`. The usual + argument for Windows rules — that `\` is the path separator — does not hold, because every path + this API accepts is a forward-slashed Unity asset path. + +### Argument grammar (both raw forms) + +- `--key value`, `--key=value`, or a bare `--key` (which means `true`, and is an error on a + non-boolean parameter). +- A follower is consumed as the value **unless it starts with `--`**, so `--path -foo` works. +- `--` ends flag parsing; everything after it is a positional value, exempt from the `-foo` and + `key=value` checks. +- Positionals fill **required parameters in declaration order, then optional parameters in + declaration order**. A slot already filled by an explicit flag is skipped, not overwritten. +- Not supported, by design: short flags, `-abc` bundling, `--no-x`, repeated-flag-as-array, + flag-name abbreviation, and `@argfile`. + +> **Declaration order is wire API for raw clients.** Reordering a command's parameters — or +> flipping one between required and optional — silently changes what a positional binds to. Append +> new optional parameters at the end. See [Creating commands](creating-commands.md). + +### Successful replies echo the bound parameters + +```json +{"success": true, "result": "pong", "parameters": {"message": "hi"}} +``` + +`parameters` is present **only** for `argv`/`commandLine` requests, so structured replies are +unchanged. It reports exactly what the binder stored, so a client that no longer binds locally can +still show the user what the server understood. + +### Argument errors + +```json +{ + "success": false, + "error": "Invalid Command Arguments", + "errorCode": "INVALID_COMMAND_ARGS", + "errorDetails": "log_editor has no parameter --mesage. Did you mean --message?", + "argProblems": [{"kind": "unknownName", "name": "mesage", "suggestion": "message"}], + "commandSchema": {"name": "log_editor", "parameters": [{"name": "message", "type": "String", "required": true}]} +} +``` + +**`errorCode` is the discriminator, not the HTTP status.** Every `/api/exec` failure is already +`400`, so status alone cannot separate "your arguments are wrong" from "the command threw". + +| `errorCode` | Meaning | +|---|---| +| `INVALID_COMMAND_ARGS` | The command resolved, but its arguments could not be bound. Nothing executed. | +| *(absent)* | Any other failure — an unknown command, a validation error, or the command itself throwing. | + +A malformed *request shape* — two command forms at once, `parameters` beside `argv`, an empty +`argv`, a `commandLine` that does not tokenize (unbalanced quote, dangling backslash), or a body +using none of the three forms — is a `400` with `error: "Invalid Request"` and **no** `errorCode`, +because no command was ever resolved. Such a reply carries neither `argProblems` nor +`commandSchema`: there is no command to describe. + +`argProblems` is machine-readable and **accumulated**: every defect is reported, not just the +first, so a user fixing a command line sees all of its problems at once. It is deliberately not +pre-rendered English — the `unity` CLI renders *and localizes* these itself. Use `errorDetails` as +the English fallback, and for any `kind` your client does not recognize. + +| `kind` | Meaning | +|---|---| +| `emptyName` | A flag with no name: `--=` or `--=value`. A bare `--` is the end-of-flags separator, never this. | +| `emptyValue` | `--key=` with nothing after it, or a bare `--key` on a non-boolean parameter. | +| `singleDash` | A `-x` token in positional position. | +| `duplicate` | The same `--key` supplied more than once. | +| `unknownName` | No such parameter. Carries `suggestion` when a close match exists. | +| `bareAssignment` | A `key=value` positional whose left side names *the parameter that positional fills* — a misremembered `--key value`. A left side naming some other parameter is a value: `eval "timeout=5;"` binds as `code`. | +| `excessPositional` | More positionals than there are slots left for them. `capacity` is the slots still free after the flags took theirs and `given` is the positionals supplied; `token` is the first one with no slot. | +| `positionalConflict` | A positional whose own slot a flag already filled. Carries `name` (that parameter) and `token`. | +| `typeMismatch` | The declared type cannot accept the token; carries `expectedType`. | + +`commandSchema` is the command's catalog entry, shaped like a `/api/commands` `commands[]` +element (without the generated `schema`). It lets a client print usage without a separate schema +fetch. + +### Capability negotiation + +Servers advertise optional wire features in `capabilities`, both on `GET /api/status` and in the +**port descriptor**: + +```json +"capabilities": ["exec.argv", "exec.commandLine"] +``` + +The descriptor is the carrier that matters: clients already read it locally to obtain `evalToken`, +so negotiation costs **zero extra requests**. + +The rule for clients: + +- Token present → send that form. +- Descriptor present but **no `capabilities` key at all** → the server predates this feature. Do + not send raw forms; tell the user to update the package. +- Never sniff first. An older server receiving `{"argv":[…]}` fails deserialization (its `command` + field is required) and answers `400 {"error":"Invalid JSON"}` — which a client would otherwise + surface as a generic internal failure, making version skew look like a client bug. + +Because the descriptor file is writable by anything running as the user, treat a capability claim +as a hint rather than a guarantee: a `400` carrying `error: "Invalid JSON"` or `"Invalid Request"` +with **no** `errorCode`, in response to a request that used `argv`, means the same thing as an +absent key. Degrade, don't crash. + +## 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/AIClient/Packages/com.unity.pipeline/Documentation~/creating-commands.md b/AIClient/Packages/com.unity.pipeline/Documentation~/creating-commands.md new file mode 100644 index 0000000..b91c6ea --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Documentation~/creating-commands.md @@ -0,0 +1,347 @@ +# 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`.** | +| `Tags` | Optional hierarchical tags for grouping/browsing commands. Path-style: a `/` separates tag from subtag (e.g. `"assets"`, `"assets/import"`). **Default: empty.** | + +Every discovered command also carries the **package** it originates from (derived from the declaring assembly, e.g. `Unity.Pipeline.Editor`). Tags and package appear in both the compact and full `/api/commands` listings — `detail=full` (the default) includes the parameters and generated JSON schema, while `detail=compact` returns just the lightweight index (`name`, `description`, `tags`, `package`). Tags also drive the endpoint's `tag` filter and its `group_by=tag` tree (see [Connectivity](connectivity.md)). + +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. To expose an instance method as a command, register it dynamically instead (see [Registering a command dynamically](#registering-a-command-dynamically)). + +### Tag taxonomy + +Every shipped command carries at least one tag (enforced by `CommandRegistrationTests.CommandRegistry_DiscoverCommands_AllShippedCommandsCarryTags`). Tags are lowercase `/`-separated paths. Pick from the established top-level tags before inventing a new one, and add a subtag when a family is large enough to browse on its own: + +| Top-level tag | Covers | Subtags in use | +|---------------|--------|----------------| +| `animation` | Animation authoring | `animator`, `clip`, `timeline` | +| `assets` | Asset CRUD and search | `import`, `text` | +| `authoring` | Authoring-root configuration | — | +| `batch` | Transactional multi-command execution (`batch`) | — | +| `baking` | Bake pipelines | `lighting`, `navmesh`, `occlusion` | +| `build` | Player builds | `settings`, `targets` | +| `capture` | Screenshots and view/element capture | — | +| `editor` | Editor application control | `playmode` | +| `gameobjects` | Scene GameObject manipulation | `components` | +| `materials` | Materials | `shaders` | +| `navigation` | Selection and search | — | +| `observability` | Logs and diagnostics | `console`, `performance` | +| `packages` | UPM package management | — | +| `prefabs` | Prefab workflows | — | +| `runtime` | Player-only application control | `application`, `input` | +| `scenes` | Scene lifecycle and hierarchy | — | +| `scripts` | C# source workflows | `compile`, `eval`, `codereload` | +| `settings` | Project settings | `audio`, `graphics`, `input`, `physics`, `player`, `quality`, `tags_layers`, `time` | +| `tests` | Test runner | — | +| `ui` | UI element workflows | — | +| `wait` | Server-side condition waiting | — | + +A command may carry multiple tags when it genuinely belongs to two families (e.g. `add_scene_to_build` is tagged `scenes` and `build/settings`). + +### 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?". + +#### Declaration order and `Required` are wire API + +Clients may send an unparsed command line (`argv`/`commandLine` on `/api/exec`), and the server +binds positionals into **required parameters in declaration order, then optional parameters in +declaration order**. Three consequences for command authors: + +- **Reordering parameters, or flipping one between required and optional, is a breaking change** + for those clients even though the C# signature still compiles. Append new optional parameters at + the end. +- **Enum parameters are validated by name** (case-insensitively), and an invalid value is rejected + with the legal set echoed back — you get that for free, no attribute needed. A `[Flags]` enum + also takes a comma-separated combination (`--channels Info,Warning`), and a numeric value is + accepted, because validation defers to the same converter the executor uses. +- **Structured (`IStructuredCommandInput` / `JObject`) parameters are reachable from a command + line only as a JSON-valued flag**, e.g. `--payload '{"name":"x"}'`. Single quotes are the + practical form; see the tokenizer dialect in [Connectivity](connectivity.md). + +A token the declared type cannot accept is now a hard `400` on the raw path, rather than the +parameter silently dropping out and the command running with a default. + +## 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. Extending `CommandExecutionResponse` lets you populate response fields directly; you can also return any plain serializable model (like `AuthoringResult`) and let the server wrap it. + +```csharp +using System; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Models; + +public class WordCountResponse : CommandExecutionResponse +{ + public int WordCount { get; set; } + + public static WordCountResponse Counted(int wordCount) => new WordCountResponse + { + Success = true, + WordCount = wordCount + }; + + public static WordCountResponse Failed(string error) => new WordCountResponse + { + Success = false, + Error = error + }; +} + +public static class TextCommands +{ + [CliCommand("word_count", "Count the words in a string")] + public static WordCountResponse CountWords( + [CliArg("text", "Text to count words in", Required = true)] string text) + { + if (string.IsNullOrWhiteSpace(text)) + return WordCountResponse.Failed("text parameter is required and cannot be empty"); + + return WordCountResponse.Counted(text.Split((char[])null, StringSplitOptions.RemoveEmptyEntries).Length); + } +} +``` + +`WordCountResponse` adds `wordCount` 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 CommandExecutionResponse Set( + [CliArg("settings", "Fields to change; omitted fields are left unchanged.")] TimeSettingsArgs 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 TimeSettingsArgs : 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 | In lean reply? | +|-------|------|---------|----------------| +| `success` | `bool` | Whether the command ran without throwing. | always | +| `result` | `object` | Your handler's return value (a string, model, anonymous object, or `null`). | always on success, explicitly `null` when null; omitted on failure | +| `error` | `string` | Error summary when `success` is `false`. | when non-null | +| `errorDetails` | `string` | Extra error context. | when non-null | +| `warnings` | `string[]` | Corrective guidance the server wants the caller to see (e.g. an unrecognized option value). | when non-null (omitted when there is nothing to say, in every mode) | +| `command` | `string` | The command name. | verbose only | +| `executedAt` | `DateTime` | When the reply was produced. | verbose only | +| `executionTimeMs` | `long?` | How long the command took. | verbose only | + +The envelope is **lean by default** (AUTHAPI-21): serialized compact (no indentation), with the +envelope's own null keys omitted and the always-on metadata (`command`, `executedAt`, +`executionTimeMs`) dropped. A minimal success is just `{"success":true,"result":...}`. Agents +consume the JSON directly; a client can pretty-print for a human if needed. + +The boundaries of the lean contract, worth knowing: + +- **Only the envelope's nulls are omitted; your `result` payload keeps explicit nulls.** The + envelope's null keys are redundant (`success` already disambiguates: `error` is null on success), + so they are dropped. Inside `result` the opposite holds — a scene object's `AuthoringResult` + still carries `"assetPath": null` — because an absent payload key would be indistinguishable + from a nonexistent or misspelled one. +- **A successful reply always carries `result`, even when its value is null — in every mode.** A + null result on success is the command's actual value (e.g. a `format="value"` read of an + unassigned object-reference field), never a droppable envelope null. Only a *failure* omits the + `result` key. +- **The metadata gating applies to the whole graph.** A command whose result is itself a response + model (e.g. `eval` returns an `EvalResponse` nested under `result`) has its nested + `executedAt`/`executionTimeMs` stripped in lean mode too — and restored by verbose along with + everything else. + +Two independent request flags adjust the shape: + +- `"verbose": true` — full fidelity back: every envelope field, explicit nulls, and nested response + metadata, for debugging or correlation. Honored on request-validation failures too. +- `"omitNulls": true` — drop null keys from the whole reply **inside `result`** (the success + `result` key itself always survives, see above). Opt in only when the result schema is already + known and the nulls are pure bytes — the payoff case is bulk list reads (e.g. `list_shaders` + over hundreds of built-ins repeating `"assetPath": null` per item). + +The `GET`-style endpoints (`/api/job`, `/api/progress`) have no envelope/payload split — every field +is payload — so they include null keys explicitly by default and accept `omit_nulls=true` as a query +parameter instead. An unrecognized value (e.g. `omit_nulls=1`) is not silently coerced: the reply +keeps its nulls and carries a `warnings` array naming the accepted values, so an agent can correct +itself instead of guessing. + +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. + +## Registering a command dynamically + +`[CliCommand]` covers commands you know about at compile time. When the set of commands depends on runtime state — a tool that exposes one command per loaded document, a plugin host, a test that needs a throwaway command — register them directly instead: + +```csharp +CommandRegistry.RegisterCommand( + "my_dynamic_command", + "What this command does", + new Func(MyHandler), + mainThreadRequired: true, + runtimeOnly: false, + tags: new[] { "mytool" }); +``` + +The result is an ordinary command: it appears in `/api/commands`, carries the same metadata, and executes through `/api/exec` exactly like an attribute-declared one. + +**Parameters still come from `[CliArg]`.** The registry reads the handler's own parameters, so a handler method annotates them exactly as an attribute-declared command does: + +```csharp +private static object MyHandler( + [CliArg("text", "Some input", Required = true)] string text, + [CliArg("count", "How many times")] int count = 1) +{ + return new { echoed = text, count }; +} +``` + +A lambda cannot carry `[CliArg]` on its parameters, so registering one yields default metadata (the C# parameter name, required only when there is no default value). Register a named method when you want full metadata. + +**Instance methods are allowed.** Unlike the attribute path, the handler is a delegate, so it may be bound to an instance — the command then runs against that instance: + +```csharp +var session = new MySession(documentId); +CommandRegistry.RegisterCommand("read_document", "Read the open document", + new Func(session.Read), tags: new[] { "mytool" }); +``` + +**Removing a command:** + +```csharp +bool removed = CommandRegistry.UnregisterCommand("my_dynamic_command"); +``` + +`UnregisterCommand` returns `false` when nothing was registered under that name. Attribute-declared commands are part of the code and are never removable, so passing one of their names also returns `false`. + +**Lifetime and collisions.** Registrations live until they are unregistered or the domain reloads — statics reset on reload, so re-register from wherever your code initializes (e.g. `[InitializeOnLoadMethod]`). + +A registration made **while in Play Mode** is also dropped when Play Mode exits, so a script registering from `Awake` or `OnEnable` starts each Play session with a clean registry. This matters when Enter Play Mode has domain reload disabled: without it, the second entry would throw (the name would still be taken by the first entry) and a surviving registration would hold a delegate bound to the previous session's destroyed object. A registration made in **Edit Mode** survives a Play session — an `[InitializeOnLoadMethod]` runs once per domain, so nothing would restore it. + +All registrations survive a discovery-mechanism swap. Registering a name that is already taken, by an attribute-declared command or an earlier dynamic one, throws `InvalidOperationException` rather than shadowing it; unregister first if you mean to replace it. + +## 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/AIClient/Packages/com.unity.pipeline/Documentation~/filter.yml b/AIClient/Packages/com.unity.pipeline/Documentation~/filter.yml new file mode 100644 index 0000000..ab48510 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Documentation~/filter.yml @@ -0,0 +1,20 @@ +apiRules: + # Everything under Unity.Pipeline.Tests.* (Tests/Editor, Tests/Runtime, and their + # sub-namespaces) is test-only. Some of it is forced `public` for a compiler reason, not + # because it's consumable package API: e.g. code-reload/IL-interpreter probe classes are named + # literally by SourceCodeTransformer in a freshly-generated override that Roslyn compiles into + # a separate, uniquely named in-memory assembly (no InternalsVisibleTo exists for those + # ephemeral assemblies), so `internal` fails the compile with CS0122. Exempting the whole test + # namespace tree means a future probe/fixture that needs the same `public` workaround doesn't + # need a new filter.yml entry. This does not affect PVP-150/151 for the shipped Runtime/Editor + # assemblies — only Unity.Pipeline.Tests.* is excluded. + - exclude: + uidRegex: ^Unity\.Pipeline\.Tests\..+$ + type: Type + + # Decoy attribute (CodeReloadInPlaceTests.cs) that only exists to prove the IL weaver matches + # by full name, not simple name — not consumable API, test-only despite living outside the + # Unity.Pipeline.Tests namespace. + - exclude: + uidRegex: ^FakeReload\.CodeReloadAttribute$ + type: Type diff --git a/AIClient/Packages/com.unity.pipeline/Documentation~/images/codereload-watcher-panel.png b/AIClient/Packages/com.unity.pipeline/Documentation~/images/codereload-watcher-panel.png new file mode 100644 index 0000000..9980fbc Binary files /dev/null and b/AIClient/Packages/com.unity.pipeline/Documentation~/images/codereload-watcher-panel.png differ diff --git a/AIClient/Packages/com.unity.pipeline/Documentation~/index.md b/AIClient/Packages/com.unity.pipeline/Documentation~/index.md new file mode 100644 index 0000000..a6188ec --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Documentation~/index.md @@ -0,0 +1,272 @@ +# 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. + +## 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 Project Settings → Pipeline → Runtime and the dev-build gating. | +| [Reloading code](code-reload.md) | Updating `[CodeReload]` methods in a running game: the `Assembly.Load` and interpreter backends, supported changes, the watcher, and how the woven dispatch works. | +| [Tests architecture](testing.md) | Writing command tests via `PipelineClient` (over HTTP) and via direct command calls. | +| [Analytics](analytics.md) | The three usage events the Editor server reports, what each field means, and what is deliberately not collected. | + +## 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; compile and run project entry points in memory (`run_script`). | +| [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, code 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. | +| [`run_script`](commands/scripts.md#run_script) | Compile a project `.cs` file in memory (no domain reload) and run a named static entry point — the builder-pattern path for bulk construction. | + +### 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) | Toggle play mode pause. | +| [`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_performance_stats`](commands/editor-lifecycle-and-observability.md#get_performance_stats) | Read render / memory / frame stats. | +| [`audit`](commands/editor-lifecycle-and-observability.md#audit) | Run a Project Auditor scan, producing a CSV of issues. | +| [`audit_status`](commands/editor-lifecycle-and-observability.md#audit_status) | Poll the audit scan; returns the CSV path + issue count. | +| [`report_evals`](commands/editor-lifecycle-and-observability.md#report_evals) | Ranked report of local eval-usage telemetry + command-coverage suggestions. | +| [`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. | +| [`console_status`](commands/runtime.md#console_status) | Console counters and compile-failure flag, without the entries. | +| [`clear_console`](commands/runtime.md#clear_console) | Clear the console / log buffer. | +| [`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 [CodeReload] edits from a file. | +| [`reload_file_editor_interpreter`](commands/runtime.md#reload_file_editor_interpreter) | Same, on the IlInterpreter backend (IL2CPP-safe). | +| [`reload_file_player_interpreter`](commands/runtime.md#reload_file_player_interpreter) | Compile in the Editor and push [CodeReload] edits to connected players (IL2CPP-safe). | +| [`codereload_status`](commands/runtime.md#codereload_status) | Code reload registry status. | +| [`cleanup_codereload`](commands/runtime.md#cleanup_codereload) | Clear old code reload DLLs / registry. | diff --git a/AIClient/Packages/com.unity.pipeline/Documentation~/runtime-setup.md b/AIClient/Packages/com.unity.pipeline/Documentation~/runtime-setup.md new file mode 100644 index 0000000..7a946dd --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Documentation~/runtime-setup.md @@ -0,0 +1,80 @@ +# 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 package's runtime assemblies (`Unity.Pipeline`, `Unity.Pipeline.IlInterpreter`) and its bundled Roslyn DLLs carry the define constraint `UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE`, so Unity does not compile or package them for a non-development Player build. A release build contains no Pipeline code regardless of how `enableInBuilds` is set. + +To ship the Pipeline in a build that is *not* a Development Build, add `ENABLE_RUNTIME_PIPELINE` to the scripting define symbols for that build (Project Settings → Player → Scripting Define Symbols, or `BuildPlayerOptions.extraScriptingDefines`). The build then contains the same Pipeline assemblies a development build would, and `enableInBuilds` still decides whether the server actually starts. Only do this deliberately: such a build carries a local HTTP server that executes commands. + +## Configuring the runtime server + +The runtime server is configured entirely through **Project Settings → Pipeline → Runtime**. No GameObject or component needs to be added to any scene — `RuntimePipelineBootstrap` creates the runtime driver automatically at boot (Player start, or entering Play Mode in the Editor) if settings exist and `enableInBuilds` is on. + +Your settings are never written as a file under `Assets/` — they live in `ProjectSettings/Packages/com.unity.pipeline/RuntimePipelineConfig.json`, a normal, tracked part of `ProjectSettings/` (the same place Unity itself keeps most other project-wide settings). Merely viewing the Project Settings page, or reading via `get_runtime_pipeline_settings`, shows built-in defaults but never creates this file — it's written the first time you actually change a setting (through the page, or a confirmed `set_runtime_pipeline_settings`). + +**Security warning:** whenever `enableInBuilds` is on *and* Development Build is checked, the settings page shows a persistent warning — that build really will carry the HTTP server and remote code execution. With Development Build unchecked the page instead notes that the setting has no effect, since the Pipeline assemblies are excluded from that build. + +### Settings + +| 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 when the Player boots (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, drive the server manually via `RuntimePipelineBootstrap.Instance` — the driver `RuntimeInitializeOnLoadMethod` already created at boot — and call `StartServer()`/`StopServer()` on it. `RuntimePipelineBootstrap.Bootstrap()` is idempotent (a second call returns the existing instance rather than creating a duplicate), so it's also safe to call directly if you're not sure whether bootstrap has run yet. + +### Generated build-only assets + +Each build generates two **transient** assets under `Assets/Settings/Pipeline/Resources/`, both deleted again once the build finishes: + +- `RuntimePipelineConfig.asset` — your current settings, baked in so the Player can find them (the authored copy stays in `ProjectSettings/`, never here). +- `RuntimePipelineBuildInfo.asset` — the code-reload allowlist: absolute paths to `Assets/` plus every loaded package's resolved location, captured at build time because a running Player can't resolve the project layout on its own. Read by `RuntimePipelineBootstrap` at boot; absent (e.g. Play Mode without ever having built) just means no code-reload roots are allowed, not an error. + +Neither should ever be committed — add this to your project's `.gitignore`: + +``` +/Assets/Settings/Pipeline/Resources/RuntimePipelineConfig.asset* +/Assets/Settings/Pipeline/Resources/RuntimePipelineBuildInfo.asset* +``` + +A build interrupted mid-way (crash, force-quit) can leave either behind; if you see them in `git status` after such an interruption, it's safe to delete them. The next build you run will also delete them itself, at the very start of preprocessing, before anything else — so a leftover from an interrupted build can never get packaged into a later Player, even one built with `enableInBuilds` off. + +## Development-build gating + +The runtime server, code evaluation, and code 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/code-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` in Project Settings → Pipeline → Runtime. + +## Step-by-step: enable in a dev build + +1. Open **Project Settings → Pipeline → Runtime**. +2. Tick **`enableInBuilds`**. 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 bootstrap 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 code 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. +- [Code reload](code-reload.md) — applying live code changes to a running Player. diff --git a/AIClient/Packages/com.unity.pipeline/Documentation~/safety-and-mutations.md b/AIClient/Packages/com.unity.pipeline/Documentation~/safety-and-mutations.md new file mode 100644 index 0000000..8acfd21 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Documentation~/testing.md b/AIClient/Packages/com.unity.pipeline/Documentation~/testing.md new file mode 100644 index 0000000..5825b2f --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor.meta b/AIClient/Packages/com.unity.pipeline/Editor.meta new file mode 100644 index 0000000..ac21aa2 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5144d7ecd5530424ab66f205802d8c9e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Authoring.meta b/AIClient/Packages/com.unity.pipeline/Editor/Authoring.meta new file mode 100644 index 0000000..266f586 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Authoring.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 113bc2bcfe487eb469aa654e6718f78b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Authoring/AuthoringUndoScope.cs b/AIClient/Packages/com.unity.pipeline/Editor/Authoring/AuthoringUndoScope.cs new file mode 100644 index 0000000..68480a7 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Authoring/AuthoringUndoScope.cs @@ -0,0 +1,58 @@ +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; + private bool m_Canceled; + + /// Start a new collapsible Undo group. + /// Name shown for the collapsed Undo step. + public AuthoringUndoScope(string name) + { + Undo.IncrementCurrentGroup(); + m_Group = Undo.GetCurrentGroup(); + if (!string.IsNullOrEmpty(name)) + Undo.SetCurrentGroupName(name); + } + + /// + /// Skip the collapse on dispose. Call after the scope's registered operations have been + /// reverted wholesale (e.g. Undo.RevertAllDownToGroup(group) on a transactional batch + /// abort): the group id no longer refers to this scope's operations on the Undo stack, and + /// collapsing a discarded id could fold whatever now sits at that index into a stray group. + /// + public void Cancel() => m_Canceled = true; + + /// Collapse all Undo operations registered during this scope into one step, unless was called. + public void Dispose() + { + if (!m_Canceled) + Undo.CollapseUndoOperations(m_Group); + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Authoring/AuthoringUndoScope.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Authoring/AuthoringUndoScope.cs.meta new file mode 100644 index 0000000..a0ef9b1 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Authoring/ObjectResolver.cs b/AIClient/Packages/com.unity.pipeline/Editor/Authoring/ObjectResolver.cs new file mode 100644 index 0000000..eddac8f --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Authoring/ObjectResolver.cs @@ -0,0 +1,256 @@ +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. + /// + /// The agent-supplied object reference. + /// The resolved object, or null on failure. + /// A human-readable reason on failure, or null on success. + /// True if the handle resolved. + 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. + /// + /// The object to describe. + /// The object's canonical identity, or null if is null. + 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/AIClient/Packages/com.unity.pipeline/Editor/Authoring/ObjectResolver.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Authoring/ObjectResolver.cs.meta new file mode 100644 index 0000000..a33169d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Authoring/ProjectPaths.cs b/AIClient/Packages/com.unity.pipeline/Editor/Authoring/ProjectPaths.cs new file mode 100644 index 0000000..f5bf5e8 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Authoring/ProjectPaths.cs @@ -0,0 +1,148 @@ +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. + /// + /// The agent-supplied path to resolve. + /// A human-readable reason on failure, or null on success. + /// The normalized, project-relative path, or null on failure. + 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/AIClient/Packages/com.unity.pipeline/Editor/Authoring/ProjectPaths.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Authoring/ProjectPaths.cs.meta new file mode 100644 index 0000000..528dcdf --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/BuildProcessors.meta b/AIClient/Packages/com.unity.pipeline/Editor/BuildProcessors.meta new file mode 100644 index 0000000..b17ac39 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/BuildProcessors.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6a4d02bf29da41d499ea151c638ee920 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/BuildProcessors/CodeReloadLinkXmlGenerator.cs b/AIClient/Packages/com.unity.pipeline/Editor/BuildProcessors/CodeReloadLinkXmlGenerator.cs new file mode 100644 index 0000000..faa7fb3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/BuildProcessors/CodeReloadLinkXmlGenerator.cs @@ -0,0 +1,355 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; +using UnityEditor.Compilation; +using UnityEditor.UnityLinker; +using UnityEngine; +using Unity.Pipeline.Compilation; +using Unity.Pipeline.CodeReload; +using IlInterpreter.Interpreter; +using Assembly = System.Reflection.Assembly; + +namespace Unity.Pipeline.Editor.BuildProcessors +{ + /// + /// Contributes an auto-generated link.xml that preserves the host-binding surface the + /// interpreter exposes to reloaded / eval'd code, so IL2CPP managed stripping cannot remove + /// members the built game never referenced (symptom: "Host method '…' is not registered in the + /// binding", on device only). + /// + /// Unity's own generated link.xml can't cover this: it preserves what the build *references*, + /// and the whole point of code reload is to call members the build never referenced. So preserve + /// (a) every type in with preserve="all", and + /// (b) — because demand-time auto-bind () lets reloaded + /// code reach ANY user or UnityEngine type — the build's user assemblies and UnityEngine modules + /// wholesale. + /// + /// Development builds only: the interpreter reload/eval sinks compile out of release players. + /// Also preserves the interpreter engine itself (IlInterpreter + its reflection/metadata + /// dependencies) — kept here, rather than in a static Runtime/link.xml, precisely so + /// release players don't pay the size cost for machinery they compile out. + /// + class CodeReloadLinkXmlGenerator : IUnityLinkerProcessor + { + public int callbackOrder => 0; + + public string GenerateAdditionalLinkXmlFile(BuildReport report, UnityLinkerBuildPipelineData data) + { + // Only development builds carry the interpreter; release builds need no extra preservation. + if (report == null || !report.summary.options.HasFlag(BuildOptions.Development)) + return string.Empty; + + // The managed assemblies staged for this player build — the universe for the wholesale + // preservation pass. See CollectPlayerAssemblyNames: it unions the linker input directory + // with the editor's own player-assembly view, because on some build backends the input + // directory is empty at IUnityLinkerProcessor time and relying on it alone silently + // collapsed preservation to the explicit per-type surface — stripping everything + // demand-time auto-bind reaches (e.g. a live-added `new Slider()`, whose UITK ctors the + // built game never referenced) on device only. + // inputDirectory is obsolete on the incremental build pipeline (it comes back empty there), + // which is exactly the gap the union above closes; on the classic pipeline it is still the + // exact staged set, so keep reading it. +#pragma warning disable CS0618 + var playerAssemblies = CollectPlayerAssemblyNames(data.inputDirectory); +#pragma warning restore CS0618 + + var path = Path.GetFullPath(FileUtil.GetUniqueTempPathInProject() + "-hostbindings.link.xml"); + File.WriteAllText(path, GenerateLinkXml(playerAssemblies, out var stats)); + // The stats line is the diagnostic: if a game assembly with [CodeReload] types is missing + // from the reloadable-target list (e.g. its name matches the engine-assembly skip + // prefixes), its private state can be stripped and binding fails on device only. + Debug.Log($"Pipeline: generated code-reload host-binding link.xml at {path} — {stats}"); + return path; + } + + /// + /// The simple names of the managed assemblies that end up in this player build — the universe + /// the wholesale-preservation pass draws from. Unions two sources so a gap in either can't + /// silently collapse preservation: + /// + /// the linker input directory (the exact staged set, incl. test assemblies for a test + /// player) — recursively, since some backends nest the managed DLLs in a subfolder; + /// the editor's own view — player assemblies (user + /// code) plus every loaded UnityEngine* module (the precompiled engine modules + /// demand-time auto-bind can construct from) — which stays populated even when the input + /// directory is empty at time. + /// + /// A union only adds; it never drops an assembly the input scan found, so a test player keeps + /// its test assemblies. Returns null only if every source came up empty (preserve per-type only). + /// + internal static string[] CollectPlayerAssemblyNames(string inputDirectory) + { + var names = new HashSet(StringComparer.Ordinal); + + try + { + if (!string.IsNullOrEmpty(inputDirectory) && Directory.Exists(inputDirectory)) + foreach (var dll in Directory.GetFiles(inputDirectory, "*.dll", SearchOption.AllDirectories)) + names.Add(Path.GetFileNameWithoutExtension(dll)); + } + catch (Exception ex) + { + Debug.LogWarning($"Pipeline: could not enumerate linker input assemblies " + + $"('{inputDirectory}'): {ex.Message} — falling back to the editor's " + + "player-assembly view for wholesale preservation."); + } + + try + { + foreach (var a in CompilationPipeline.GetAssemblies(AssembliesType.Player)) + if (!string.IsNullOrEmpty(a.name)) + names.Add(a.name); + } + catch { /* editor API unavailable in this context — the UnityEngine sweep still runs */ } + + foreach (var a in PipelineUtils.GetLoadedAssemblies()) + { + var n = a.GetName().Name; + if (!string.IsNullOrEmpty(n) && n.StartsWith("UnityEngine", StringComparison.Ordinal)) + names.Add(n); + } + + return names.Count > 0 ? new List(names).ToArray() : null; + } + + /// + /// Build the interpreter's default host binding and emit a link.xml preserving every + /// registered type, grouped by owning assembly. Pure and deterministic — directly unit-testable. + /// Derives from — the builder the code-reload + /// executor runs — so the preservation set cannot drift from the runtime surface. The per-reload + /// AllowType(targetType) overlay is excluded: the target is the user's own component, + /// which the built player references and IL2CPP won't strip. + /// + public static string GenerateLinkXml() => GenerateLinkXml(null, out _); + + public static string GenerateLinkXml(out string stats) => GenerateLinkXml(null, out stats); + + /// : one-line summary of what was preserved, logged at build + /// time so a missing game assembly is visible in the build log. + /// : simple names of the managed assemblies staged for + /// the player build; when provided, user assemblies and UnityEngine modules from that set are + /// preserved wholesale (including engine-named assemblies carrying [CodeReload] members — the + /// attribute marks them as user code), and reload-target entries are limited to assemblies in + /// the set (editor-only and test assemblies drop out of game builds); when null, only per-type + /// preservation is emitted, unfiltered. + public static string GenerateLinkXml(IEnumerable playerAssemblyNames, out string stats) + { + var binding = IlInterpreterHostBindings.CreateStandard(); + + // SortedSet/SortedDictionary give a stable, diffable ordering and dedupe (closed generics + // collapsing onto their definition can produce repeats). + var byAssembly = new SortedDictionary>(StringComparer.Ordinal); + foreach (var t in binding.RegisteredTypes) + { + // Preserve the open definition for constructed generics (a closed-generic fullname is + // assembly-qualified and won't match in link.xml). + var def = t.IsGenericType && !t.IsGenericTypeDefinition ? t.GetGenericTypeDefinition() : t; + + var asm = def.Assembly.GetName().Name; + var full = def.FullName; + if (string.IsNullOrEmpty(asm) || string.IsNullOrEmpty(full)) + continue; + + full = full.Replace('+', '/'); // link.xml uses '/' as the nested-type separator + + if (!byAssembly.TryGetValue(asm, out var types)) + byAssembly[asm] = types = new SortedSet(StringComparer.Ordinal); + types.Add(full); + } + + // Also preserve the reloadable target components. The target type IS referenced by the + // built player, but a PRIVATE field only the interpreter touches can still be stripped, + // breaking the reflection walk; preserve="all" keeps every member so + // AllowNonPublicInstanceMembers(targetType) can resolve private state on device. + int hostTypeCount = 0; + foreach (var kv in byAssembly) + hostTypeCount += kv.Value.Count; + + // The AppDomain scan below also sees editor-only and test assemblies that are not part + // of the player build; when the staged set is known, reload targets outside it are + // dropped as noise. A test-player build stages its test assemblies, so fixtures keep + // their entries exactly where they matter. + HashSet staged = null; + if (playerAssemblyNames != null) + { + staged = new HashSet(StringComparer.Ordinal); + foreach (var name in playerAssemblyNames) + if (!string.IsNullOrEmpty(name)) + staged.Add(name); + } + + int targetTypeCount = 0; + var targetAssemblies = new SortedSet(StringComparer.Ordinal); + var engineNamedTargetAssemblies = new SortedSet(StringComparer.Ordinal); + foreach (var t in CollectReloadableTargetTypes()) + { + var asm = t.Assembly.GetName().Name; + var full = t.FullName; + if (string.IsNullOrEmpty(asm) || string.IsNullOrEmpty(full)) + continue; + if (staged != null && !staged.Contains(asm)) + continue; + full = full.Replace('+', '/'); + if (!byAssembly.TryGetValue(asm, out var types)) + byAssembly[asm] = types = new SortedSet(StringComparer.Ordinal); + types.Add(full); + targetTypeCount++; + targetAssemblies.Add(asm); + if (IsNonUserAssembly(asm)) + engineNamedTargetAssemblies.Add(asm); + } + + // Dev-build widening: demand-time auto-bind (HostBinding.AutoBindResolver) lets a reloaded + // or eval'd body call into ANY user or UnityEngine type, so RegisteredTypes is not the full + // callable set — preserve the build's user assemblies and UnityEngine modules wholesale. + // Unity.* packages (Burst, Collections, …) stay per-type: AOT-heavy and rarely touched from + // reload bodies. (The project-extension seam that could promote a specific type was + // deferred to a future release, so on-device reload bodies can only reach package types + // the stripper kept.) + var wholesale = new SortedSet(StringComparer.Ordinal); + // The interpreter engine itself: it parses pushed override IL via + // System.Reflection.Metadata and resolves host members by name, so those paths look + // unused to the stripper. Emitted here (dev builds only) rather than in a static + // Runtime/link.xml, which would bloat release players that compile the interpreter out. + foreach (var name in InterpreterEngineAssemblies) + wholesale.Add(name); + if (staged != null) + { + foreach (var name in staged) + { + if (name.StartsWith("UnityEngine", StringComparison.Ordinal)) wholesale.Add(name); + else if (!IsNonUserAssembly(name)) wholesale.Add(name); + } + + // A staged, engine-named assembly carrying [CodeReload] members has identified itself + // as user code participating in code reload — a package-like name (e.g. "Unity.MyGame") + // must not demote it. Preserving only its [CodeReload] types would leave the rest of + // it strippable (demand-time auto-bind would hit stripped members on device only), + // so promote it into the wholesale set like any other user assembly. + foreach (var asm in engineNamedTargetAssemblies) + wholesale.Add(asm); + } + + stats = $"{hostTypeCount} host-binding type(s) and {targetTypeCount} reloadable target type(s) " + + $"across {byAssembly.Count} assembly(ies); target assemblies: " + + (targetAssemblies.Count > 0 ? string.Join(", ", targetAssemblies) : "none") + + $"; {wholesale.Count} assembly(ies) preserved wholesale for demand-time auto-bind"; + + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(" "); + // Wholesale assemblies first (their per-type entries are subsumed), then per-type blocks. + foreach (var name in wholesale) + sb.AppendLine($" "); + foreach (var kv in byAssembly) + { + if (wholesale.Contains(kv.Key)) continue; + sb.AppendLine($" "); + foreach (var type in kv.Value) + sb.AppendLine($" "); + sb.AppendLine(" "); + } + sb.AppendLine(""); + return sb.ToString(); + } + + // The IlInterpreter engine and its reflection/metadata dependencies — always preserved + // wholesale so pushed code reload keeps working on device. + static readonly string[] InterpreterEngineAssemblies = + { + "Unity.Pipeline.IlInterpreter", + "UnityPipeline.System.Reflection.Metadata", + "UnityPipeline.System.Collections.Immutable", + }; + + // Engine/BCL/tooling assembly names — NOT user code. Decides which staged assemblies are + // preserved wholesale, and which reload-target assemblies get the engine-named warning. + // Matching is deliberately precise (exact "Unity"/"System" or a dotted prefix, never the + // bare word) so a game assembly that merely starts with the word — "UnityChanDemo", + // "SystemsRuntime" — is still preserved wholesale. + static bool IsNonUserAssembly(string name) => + name == "Unity" || + name.StartsWith("Unity.", StringComparison.Ordinal) || + name.StartsWith("UnityEngine", StringComparison.Ordinal) || + name.StartsWith("UnityEditor", StringComparison.Ordinal) || + name == "System" || + name.StartsWith("System.", StringComparison.Ordinal) || + name.StartsWith("Microsoft.", StringComparison.Ordinal) || + name.StartsWith("Mono.", StringComparison.Ordinal) || + name == "mscorlib" || + name == "netstandard" || + name.StartsWith("nunit.", StringComparison.Ordinal); + + /// + /// User-code types that carry a class-level [CodeReload] or any [CodeReload] + /// method — i.e. every type whose members can be reloaded through + /// the interpreter and therefore needs its (possibly private) fields kept under stripping. + /// The attributes live in Unity.Pipeline, so only assemblies referencing it can carry + /// them — a cheap, metadata-only check that gates the expensive type scan. Assembly *names* + /// play no part here, so game assemblies named like packages ("Unity.MyGame") are covered. + /// + static IEnumerable CollectReloadableTargetTypes() + { + var results = new List(); + foreach (var asm in PipelineUtils.GetLoadedAssemblies()) + { + if (!ReferencesPipelineRuntime(asm)) + continue; + + Type[] types; + try { types = asm.GetTypes(); } + catch (ReflectionTypeLoadException ex) { types = ex.Types; } + catch { continue; } + + foreach (var t in types) + { + if (t == null) continue; + try + { + if (t.IsDefined(typeof(CodeReloadAttribute), inherit: false) || HasReloadableMethod(t)) + results.Add(t); + } + catch { /* type that can't be inspected — skip */ } + } + } + return results; + } + + static bool ReferencesPipelineRuntime(Assembly asm) + { + var pipeline = typeof(CodeReloadAttribute).Assembly.GetName().Name; + try + { + foreach (var reference in asm.GetReferencedAssemblies()) + if (string.Equals(reference.Name, pipeline, StringComparison.Ordinal)) + return true; + } + catch { /* dynamic/unloadable assembly — treat as not referencing */ } + return false; + } + + static bool HasReloadableMethod(Type t) + { + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic + | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + foreach (var m in t.GetMethods(flags)) + { + if (m.IsDefined(typeof(CodeReloadAttribute), inherit: false)) + return true; + } + return false; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/BuildProcessors/CodeReloadLinkXmlGenerator.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/BuildProcessors/CodeReloadLinkXmlGenerator.cs.meta new file mode 100644 index 0000000..613526c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/BuildProcessors/CodeReloadLinkXmlGenerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b802ce727e209d74bbbda4467462840a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/BuildProcessors/PipelineRuntimeBuildProcessor.cs b/AIClient/Packages/com.unity.pipeline/Editor/BuildProcessors/PipelineRuntimeBuildProcessor.cs new file mode 100644 index 0000000..50d28a3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/BuildProcessors/PipelineRuntimeBuildProcessor.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Unity.Pipeline.Config; +using UnityEngine; +using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; + +namespace Unity.Pipeline.Editor.BuildProcessors +{ + /// + /// Build processor for runtime Pipeline support. Three responsibilities: + /// - Validates the RuntimePipelineConfig settings (JSON-authored, read via + /// RuntimePipelineConfig.Load()) before allowing builds with runtime Pipeline enabled. + /// - Bakes the current settings into a transient RuntimePipelineConfig Resources asset so a + /// Player build can find them (the authored copy stays in ProjectSettings/, never here). + /// - Bakes the project's code reload scope (Assets + loaded package locations) into a + /// generated RuntimePipelineBuildInfo asset. A running Player cannot resolve the project + /// layout, so the absolute roots it is allowed to code reload from must be captured at build + /// time. + /// Both generated assets are purged at the very start of every build (before either is + /// possibly rewritten) and deleted again after a successful build, so machine-specific/ + /// duplicated data never lingers in the project — including a stale, possibly-enabled asset + /// left behind by a build that was interrupted before OnPostprocessBuild could run (Unity + /// never invokes it for a failed or cancelled build). + /// +#if UNITY_6000_3_OR_NEWER + class PipelineRuntimeBuildProcessor : IPreprocessBuildWithContext, IPostprocessBuildWithReport +#else + class PipelineRuntimeBuildProcessor : IPreprocessBuildWithReport, IPostprocessBuildWithReport +#endif + { + /// Callback ordering relative to other build processors (0 = default). + public int callbackOrder => 0; + + private const string ConfigAssetPath = "Assets/Settings/Pipeline/Resources/RuntimePipelineConfig.asset"; + private const string BuildInfoAssetPath = "Assets/Settings/Pipeline/Resources/RuntimePipelineBuildInfo.asset"; + + // Opt-in symbol that puts the Pipeline into a build that is not a Development Build. Kept in + // step with the defineConstraints on Unity.Pipeline / Unity.Pipeline.IlInterpreter and on the + // bundled DLLs under Runtime/Plugins/CodeAnalysis: if this file and those disagree, a player + // gets settings assets no code can read, or Pipeline code with no settings to start from. + private const string EnableSymbol = "ENABLE_RUNTIME_PIPELINE"; + + /// Verify bundled DLL integrity and validate runtime pipeline configuration before a build. +#if UNITY_6000_3_OR_NEWER + /// The build callback context. + public void OnPreprocessBuild(BuildCallbackContext ctx) +#else + /// The build report. + public void OnPreprocessBuild(BuildReport report) +#endif + { + // Purge any leftovers from a previous, interrupted build before anything else in this + // method — every exit path below (no config, disabled, not a development build, + // validation failure) must start from a clean slate. OnPostprocessBuild is not invoked + // for a failed/cancelled build, so it can never be relied on for this; + // this purge is the only mechanism that actually guarantees it. Without it, a stale + // *enabled* asset left by an earlier interrupted build would still get packaged into a + // build that is supposed to have Pipeline disabled. + DeleteGeneratedAssetsIfPresent(); + + // Integrity gate: fail the build if a bundled Roslyn DLL was swapped or modified. + VerifyBundledChecksums(); + + var config = RuntimePipelineConfig.Load(); + if (config == null) + { + Debug.LogWarning("Pipeline: No RuntimePipelineConfig asset found (Project Settings > Pipeline > Runtime). Pipeline will be disabled in Player builds."); + return; + } + + try + { + if (!config.enableInBuilds) + { + Debug.LogWarning("Pipeline: RuntimePipelineConfig found, but enableInBuilds = false. Pipeline will be disabled in Player builds."); + return; + } + + // A build that will not contain the Pipeline assemblies cannot read the generated + // Resources assets, so baking them would ship dead, machine-specific data. + if (!PipelineIsInBuild()) + { + Debug.Log("Pipeline: enableInBuilds is on, but this build is neither a Development " + + $"Build nor defines {EnableSymbol}, so the Pipeline assemblies and settings are " + + "excluded from it."); + return; + } + + var validationResult = config.Validate(); + if (!validationResult.IsValid) + { + throw new BuildFailedException($"Pipeline: Runtime configuration validation failed: {validationResult.Message}"); + } + + if (validationResult.Level == "warning") + { + Debug.LogWarning($"Pipeline: Runtime configuration warning: {validationResult.Message}"); + } + + // Push signing: ensure the project key exists and bake its public half into the + // build info, so the player rejects any code-reload push not signed by the matching + // private key. Log the fingerprint so a later key mismatch is diagnosable from the + // build log alone. + var privateKey = PushSigningKey.LoadOrCreate(PushSigningKey.DefaultDirectory, out var keyCreated); + var pushPublicKey = Unity.Pipeline.CodeReload.PushEnvelope.DerivePublicKey(privateKey); + Debug.Log($"Pipeline: push-signing key {(keyCreated ? "GENERATED" : "loaded")} " + + $"(fingerprint {Unity.Pipeline.CodeReload.PushEnvelope.Fingerprint(pushPublicKey)}, " + + $"Library/Pipeline/{PushSigningKey.KeyFileName}). This build only accepts pushes signed with it."); + + WriteConfigAsset(config); + WriteBuildInfoAsset(pushPublicKey); + + Debug.Log("Pipeline: Runtime server ENABLED in build."); + } + finally + { + UnityEngine.Object.DestroyImmediate(config); + } + } + + /// + /// Whether this build will contain the Pipeline runtime assemblies, i.e. whether it satisfies + /// their UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE define + /// constraint. A scripted build (the build command, which calls BuildPlayer with + /// ) is a development build even when the Build + /// Settings toggle is off, so the command's active options count too. + /// + private static bool PipelineIsInBuild() + { + if (EditorUserBuildSettings.development) + return true; + + if (Commands.Build.BuildCommand.ActiveScriptedBuildOptions is { } scriptedOptions + && (scriptedOptions & BuildOptions.Development) != 0) + return true; + + var group = BuildPipeline.GetBuildTargetGroup(EditorUserBuildSettings.activeBuildTarget); + var defines = PlayerSettings.GetScriptingDefineSymbols(NamedBuildTarget.FromBuildTargetGroup(group)); + return defines != null && defines.Split(';').Any(d => d.Trim() == EnableSymbol); + } + + public void OnPostprocessBuild(BuildReport report) + { + DeleteGeneratedAssetsIfPresent(); + } + + /// + /// Absolute roots considered in-scope for runtime code 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). + /// + /// Absolute paths of the project's Assets folder and every loaded package. + 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; + } + + private static void WriteConfigAsset(RuntimePipelineConfig sourceConfig) + { + var folder = Path.GetDirectoryName(ConfigAssetPath).Replace('\\', '/'); + CreateFolderRecursive(folder); + + // No DeleteGeneratedAssetIfPresent() call here: OnPreprocessBuild already purged both + // paths before any of this method's caller ran, so this path is guaranteed clear. + + var baked = ScriptableObject.CreateInstance(); + baked.enableInBuilds = sourceConfig.enableInBuilds; + baked.port = sourceConfig.port; + baked.requestTimeoutMs = sourceConfig.requestTimeoutMs; + baked.enableAuditLogging = sourceConfig.enableAuditLogging; + baked.autoStart = sourceConfig.autoStart; + baked.maxWorkItemsPerFrame = sourceConfig.maxWorkItemsPerFrame; + AssetDatabase.CreateAsset(baked, ConfigAssetPath); + AssetDatabase.SaveAssets(); + } + + private static void WriteBuildInfoAsset(string pushPublicKey) + { + var folder = Path.GetDirectoryName(BuildInfoAssetPath).Replace('\\', '/'); + CreateFolderRecursive(folder); + + // No DeleteGeneratedAssetIfPresent() call here: OnPreprocessBuild already purged both + // paths before any of this method's caller ran, so this path is guaranteed clear. + + var buildInfo = ScriptableObject.CreateInstance(); + buildInfo.allowedReloadRoots = CollectProjectRoots(); + buildInfo.pushPublicKey = pushPublicKey ?? ""; + AssetDatabase.CreateAsset(buildInfo, BuildInfoAssetPath); + AssetDatabase.SaveAssets(); + } + + /// + /// Delete both transient build-only assets if either is present, however they got there — + /// baked by a previous build (normal cleanup) or left behind by one that never reached + /// OnPostprocessBuild (crash, force-quit, cancelled build). + /// + private static void DeleteGeneratedAssetsIfPresent() + { + DeleteGeneratedAssetIfPresent(ConfigAssetPath); + DeleteGeneratedAssetIfPresent(BuildInfoAssetPath); + } + + /// + /// Untyped load + a raw file-existence fallback, deliberately not a typed + /// AssetDatabase.LoadAssetAtPath<T>: a typed load returns null — leaving a stale file + /// untouched on disk, inside Resources, where a Player build would still package it — for + /// any asset whose script reference broke after a package upgrade, or that was only + /// partially written by the very crash that left it behind. Both are exactly the kind of + /// leftover this cleanup exists to catch. + /// + private static void DeleteGeneratedAssetIfPresent(string assetPath) + { + if (AssetDatabase.LoadAssetAtPath(assetPath) != null || File.Exists(assetPath)) + AssetDatabase.DeleteAsset(assetPath); + } + + private static void CreateFolderRecursive(string folder) + { + var parts = folder.Split('/'); + var current = parts[0]; + for (var i = 1; i < parts.Length; i++) + { + var next = current + "/" + parts[i]; + if (!AssetDatabase.IsValidFolder(next)) + AssetDatabase.CreateFolder(current, parts[i]); + current = next; + } + } + + // 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() + { + if (!TryGetBundledCodeAnalysisPaths(out var codeAnalysisDir, out var checksumsPath)) + { + throw new BuildFailedException( + "Pipeline: could not locate the com.unity.pipeline package on disk to verify " + + "bundled Roslyn DLL integrity. Aborting build."); + } + + var error = VerifyChecksums(codeAnalysisDir, checksumsPath); + if (error != null) + { + throw new BuildFailedException($"Pipeline: bundled Roslyn DLL integrity check failed. {error}"); + } + } + + /// + /// Resolve the bundled Roslyn DLL folder and its CHECKSUMS manifest from this package's + /// location on disk. Returns false when the package cannot be located, in which case both + /// out parameters are null. + /// + /// Directory holding the bundled Roslyn DLLs. + /// Path to the CHECKSUMS manifest inside that directory. + /// True when the package was located and both paths were resolved. + public static bool TryGetBundledCodeAnalysisPaths(out string codeAnalysisDir, out string checksumsPath) + { + codeAnalysisDir = null; + checksumsPath = null; + + var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssembly( + typeof(PipelineRuntimeBuildProcessor).Assembly); + + if (packageInfo == null || string.IsNullOrEmpty(packageInfo.resolvedPath)) + return false; + + codeAnalysisDir = Path.Combine(packageInfo.resolvedPath, CodeAnalysisRelDir); + checksumsPath = Path.Combine(codeAnalysisDir, ChecksumsFileName); + return true; + } + + /// + /// 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. + /// + /// Directory containing the bundled Roslyn DLLs. + /// Path to the CHECKSUMS manifest. + /// Null if every DLL matches; otherwise a human-readable description of 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. + /// The file to hash. + /// The lowercase hex-encoded SHA-256 hash. + 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/AIClient/Packages/com.unity.pipeline/Editor/BuildProcessors/PipelineRuntimeBuildProcessor.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/BuildProcessors/PipelineRuntimeBuildProcessor.cs.meta new file mode 100644 index 0000000..acd0329 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/CliEditorProgress.cs b/AIClient/Packages/com.unity.pipeline/Editor/CliEditorProgress.cs new file mode 100644 index 0000000..c5c5b57 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/CliEditorProgress.cs @@ -0,0 +1,51 @@ +using UnityEditor; + +namespace Unity.Pipeline.Editor +{ + /// + /// Drop-in replacements for 's progress-bar calls that ALSO make + /// the progress visible to a connected CLI over GET /api/progress (CLI-488). + /// EditorUtility.DisplayProgressBar has no public getter and cannot be observed from + /// the pipeline server, so existing code keeps its Editor progress dialog but stays invisible + /// to the terminal. Swapping the call site to this class keeps the exact same Editor behavior + /// and adds the CLI reporting — including while the main thread is blocked inside the task, + /// which is when the dialog (and the terminal bar) matter most: + /// + /// CliEditorProgress.DisplayProgressBar("Generating World", $"Processing {i}/{n}", (float)i / n); + /// // … + /// CliEditorProgress.ClearProgressBar(); + /// + /// Code that can take a package dependency but runs headless (batchmode) can call + /// directly instead. + /// + public static class CliEditorProgress + { + /// Same as , plus CLI reporting. + /// Short title for the operation. + /// Detail text (e.g. current step). + /// Progress in [0, 1]. + public static void DisplayProgressBar(string title, string info, float progress) + { + CliProgress.Report(title, info, progress: progress); + EditorUtility.DisplayProgressBar(title, info, progress); + } + + /// Same as , plus CLI reporting. + /// Short title for the operation. + /// Detail text (e.g. current step). + /// Progress in [0, 1]. + /// True if the user clicked Cancel. + public static bool DisplayCancelableProgressBar(string title, string info, float progress) + { + CliProgress.Report(title, info, progress: progress); + return EditorUtility.DisplayCancelableProgressBar(title, info, progress); + } + + /// Same as , plus clearing the CLI report. + public static void ClearProgressBar() + { + CliProgress.Clear(); + EditorUtility.ClearProgressBar(); + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/CliEditorProgress.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/CliEditorProgress.cs.meta new file mode 100644 index 0000000..46cf3e6 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/CliEditorProgress.cs.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: bc6ed9f7b4ea400f8ed4508d6e2b761b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetVersion: 1 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands.meta new file mode 100644 index 0000000..0316bec --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e52bfc361c2ed7846a3c4c69b24828c5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation.meta new file mode 100644 index 0000000..e98352a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 881bcacc155b44229761a2e2b2917fae +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/AnimationClipCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/AnimationClipCommands.cs new file mode 100644 index 0000000..32ede45 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/AnimationClipCommands.cs @@ -0,0 +1,427 @@ +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). + /// + 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, + Tags = new[] { "animation/clip" })] + 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, + Tags = new[] { "animation/clip" })] + 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, + Tags = new[] { "animation/clip" })] + 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, + Tags = new[] { "animation/clip" })] + 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] + 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] + 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] + 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] + 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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/AnimationClipCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/AnimationClipCommands.cs.meta new file mode 100644 index 0000000..c5d610e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/AnimationClipCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 764503eaf79a41668748014af111eaf2 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/AnimatorControllerCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/AnimatorControllerCommands.cs new file mode 100644 index 0000000..b5c1a35 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/AnimatorControllerCommands.cs @@ -0,0 +1,891 @@ +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. + /// + static class AnimatorControllerCommands + { + [CliCommand("create_animator_controller", + "Create an .controller AnimatorController asset (with a default Base Layer) under the authoring root.", + MainThreadRequired = true, + Tags = new[] { "animation/animator" })] + 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, + Tags = new[] { "animation/animator" })] + 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, + Tags = new[] { "animation/animator" })] + 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, + Tags = new[] { "animation/animator" })] + 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, + Tags = new[] { "animation/animator" })] + 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, + Tags = new[] { "animation/animator" })] + 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] + 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] + class ParameterInfo + { + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("type")] + public string ParamType { get; set; } + + [JsonProperty("defaultValue")] + public JToken DefaultValue { get; set; } + } + + [Serializable] + class AddParameterResult : AuthoringResult + { + [JsonProperty("parameter")] + public ParameterInfo Parameter { get; set; } + } + + [Serializable] + class LayerSummary + { + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("index")] + public int Index { get; set; } + + [JsonProperty("weight")] + public float Weight { get; set; } + } + + [Serializable] + class AddLayerResult : AuthoringResult + { + [JsonProperty("layer")] + public LayerSummary Layer { get; set; } + } + + [Serializable] + 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] + class AddStateResult : AuthoringResult + { + [JsonProperty("state")] + public StateSummary State { get; set; } + } + + [Serializable] + class TransitionSummary + { + [JsonProperty("from")] + public string From { get; set; } + + [JsonProperty("to")] + public string To { get; set; } + + [JsonProperty("conditionCount")] + public int ConditionCount { get; set; } + } + + [Serializable] + class AddTransitionResult : AuthoringResult + { + [JsonProperty("transition")] + public TransitionSummary Transition { get; set; } + } + + [Serializable] + 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] + 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] + class StateInfo + { + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("motion")] + public string Motion { get; set; } + + [JsonProperty("isDefault")] + public bool IsDefault { get; set; } + } + + [Serializable] + 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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/AnimatorControllerCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/AnimatorControllerCommands.cs.meta new file mode 100644 index 0000000..403e749 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/AnimatorControllerCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 426c006ffb43461e94ab3dde2caed7be diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/TimelineCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/TimelineCommands.cs new file mode 100644 index 0000000..ba4d9f3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/TimelineCommands.cs @@ -0,0 +1,641 @@ +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. + /// + 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, + Tags = new[] { "animation/timeline" })] + 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, + Tags = new[] { "animation/timeline" })] + 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, + Tags = new[] { "animation/timeline" })] + 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, + Tags = new[] { "animation/timeline" })] + 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] + class TimelineTrackSummary + { + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("type")] + public string TrackType { get; set; } + } + + [Serializable] + class AddTimelineTrackResult : AuthoringResult + { + [JsonProperty("track")] + public TimelineTrackSummary Track { get; set; } + } + + [Serializable] + class TimelineClipSummary + { + [JsonProperty("track")] + public string Track { get; set; } + + [JsonProperty("start")] + public float Start { get; set; } + + [JsonProperty("duration")] + public float Duration { get; set; } + } + + [Serializable] + class AddTimelineClipResult : AuthoringResult + { + [JsonProperty("clip")] + public TimelineClipSummary Clip { get; set; } + } + + [Serializable] + 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] + 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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/TimelineCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/TimelineCommands.cs.meta new file mode 100644 index 0000000..538c4fc --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/TimelineCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dcf5399fb1544ee3a5b069c535bcd002 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/TimelineGuard.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/TimelineGuard.cs new file mode 100644 index 0000000..5a75224 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/TimelineGuard.cs @@ -0,0 +1,58 @@ +using System; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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. + /// +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/TimelineGuard.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/TimelineGuard.cs.meta new file mode 100644 index 0000000..607fca6 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Animation/TimelineGuard.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f3f8120d06814b2c86d46503ac132022 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets.meta new file mode 100644 index 0000000..23d44dc --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 39163f849a3aebd47a3f718fdc0224f9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/AssetCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/AssetCommands.cs new file mode 100644 index 0000000..f5e3fa3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/AssetCommands.cs @@ -0,0 +1,585 @@ +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 PhysicsMaterial = 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. + /// + 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.", Tags = new[] { "assets" })] + 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(PhysicsMaterial).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(PhysicsMaterial).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 PhysicsMaterial + { + 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.", Tags = new[] { "assets/import" })] + 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.", Tags = new[] { "assets" })] + 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.", Tags = new[] { "assets" })] + 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).", Tags = new[] { "assets" })] + 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.", Tags = new[] { "assets" })] + 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.", Tags = new[] { "assets" })] + 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; + + 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(PhysicsMaterial); + } + + 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; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/AssetCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/AssetCommands.cs.meta new file mode 100644 index 0000000..6afcc35 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/AssetCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 47b49bf20465486fb6ffdf6c03775b16 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/AssetImportCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/AssetImportCommands.cs new file mode 100644 index 0000000..ecbb5f5 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/AssetImportCommands.cs @@ -0,0 +1,660 @@ +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; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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. + /// + /// +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + 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.", Tags = new[] { "assets/import" })] + 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.", Tags = new[] { "assets/import" })] + 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] + 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] + 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] + class ErrorResult + { + [Newtonsoft.Json.JsonProperty("code")] + public string Code { get; set; } + + [Newtonsoft.Json.JsonProperty("message")] + public string Message { get; set; } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/AssetImportCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/AssetImportCommands.cs.meta new file mode 100644 index 0000000..f1ef29b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/AssetImportCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cb5b302104dc446d8d94f33252087062 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/FindAssetsResult.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/FindAssetsResult.cs new file mode 100644 index 0000000..bfbd46e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/FindAssetsResult.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/FindAssetsResult.cs.meta new file mode 100644 index 0000000..c916640 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/FindAssetsResult.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7ff032cead554b7f97cd2467a83abd35 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/FolderCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/FolderCommands.cs new file mode 100644 index 0000000..327dc23 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + static class FolderCommands + { + [CliCommand("create_folder", "Create a folder under the authoring root (creates intermediate folders).", Tags = new[] { "assets" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/FolderCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/FolderCommands.cs.meta new file mode 100644 index 0000000..25bacd0 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/TextFileCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/TextFileCommands.cs new file mode 100644 index 0000000..8be2e0a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + static class TextFileCommands + { + [CliCommand("read_text_file", "Read a UTF-8 text file under the authoring root and return its contents.", MainThreadRequired = true, Tags = new[] { "assets/text" })] + 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.", Tags = new[] { "assets/text" })] + 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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/TextFileCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/TextFileCommands.cs.meta new file mode 100644 index 0000000..b8375b8 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Assets/TextFileCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f57a6d794d1e4c40976f65ec3774b8ed diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Authoring.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Authoring.meta new file mode 100644 index 0000000..6184fe6 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Authoring.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7512e3b6952430f4b8fb5c67dd22f13f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Authoring/AuthoringConfigCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Authoring/AuthoringConfigCommands.cs new file mode 100644 index 0000000..f31bb4f --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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). + /// + static class AuthoringConfigCommands + { + [CliCommand("get_authoring_root", "Get the base folder (under Assets/) that bare authoring paths resolve against.", Tags = new[] { "authoring" })] + 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.", Tags = new[] { "authoring" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Authoring/AuthoringConfigCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Authoring/AuthoringConfigCommands.cs.meta new file mode 100644 index 0000000..76c13ef --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/AutoTickCommand.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/AutoTickCommand.cs new file mode 100644 index 0000000..7961582 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/AutoTickCommand.cs @@ -0,0 +1,194 @@ +using System; +using System.Reflection; +using Unity.Pipeline.Commands; +using UnityEditor; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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: the update-loop subscription is static and does not survive a domain reload, but the + /// enabled/interval choice is persisted to SessionState and restored by + /// (called from EditorPipelineStartup on every server + /// (re)start, including after a recompile). Forcing full-rate ticks uses CPU like a focused editor. + /// +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + static class AutoTickCommand + { + // Default interval when nothing else applies (fresh editor session, no prior SetAutoTick call). + internal const int DefaultIntervalMs = 16; + + // SessionState keys: session-scoped editor state, survives domain reload, dies with the editor. + private const string EnabledKey = "Unity.Pipeline.AutoTick.Enabled"; + private const string IntervalKey = "Unity.Pipeline.AutoTick.IntervalMs"; + + 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; + + internal static bool IsEnabled => s_Enabled; + internal static long CurrentIntervalMs => s_IntervalMs; + + [CliCommand("set_autotick", "Keep the editor ticking while unfocused by forcing EditorApplication.SignalTick at a throttled rate", MainThreadRequired = true, Tags = new[] { "editor" })] + 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 = DefaultIntervalMs, + [CliArg("persist", "Persist this choice to SessionState so it survives a domain reload. Set false for a one-off/expensive setting (e.g. interval_ms=0) that should revert to the last persisted choice (or the default) after the next recompile instead of sticking for the rest of the session.")] bool persist = true) + { + var result = ApplyAutoTick(enable, intervalMs); + if (!persist) + return result.StartsWith("Error:") ? result : result + " (session-only, won't survive a domain reload)"; + + if (result.StartsWith("Error:")) + return result; + + SessionState.SetBool(EnabledKey, s_Enabled); + SessionState.SetInt(IntervalKey, (int)s_IntervalMs); + return result; + } + + /// + /// Restore auto-tick to whatever the user last explicitly set via + /// this editor session (surviving the domain reload that just wiped the statics). If nothing + /// was ever explicitly set this session, apply at + /// instead — this is the only case where the watchdog's + /// enabled setting decides auto-tick's state. Call on the main thread (touches SessionState). + /// + internal static void RestoreFromSession(bool defaultEnabled) + { + var storedInterval = SessionState.GetInt(IntervalKey, -1); + if (storedInterval < 0) + { + ApplyAutoTick(defaultEnabled, DefaultIntervalMs); + return; + } + + var storedEnabled = SessionState.GetBool(EnabledKey, defaultEnabled); + ApplyAutoTick(storedEnabled, storedInterval); + } + + /// + /// Test-only: drop the in-memory state (statics + update-loop subscription) but keep + /// SessionState, to simulate a domain reload (statics cleared, SessionState survives). + /// + internal static void ResetForTests() + { + if (s_Pump != null) + { + EditorApplication.update -= s_Pump; + s_Pump = null; + } + s_Stopwatch.Stop(); + s_Enabled = false; + s_IntervalMs = 0; + } + + /// + /// Test-only: erase the persisted SessionState copy (unlike , which + /// deliberately leaves it intact). Use for test isolation, not to simulate a reload. + /// + internal static void EraseSessionForTests() + { + SessionState.EraseInt(IntervalKey); + SessionState.EraseBool(EnabledKey); + } + + /// + /// Test-only: read the persisted SessionState copy without touching the in-memory statics. + /// Returns false if nothing has ever been persisted this session. + /// + internal static bool TryGetPersistedSessionForTests(out bool enabled, out long intervalMs) + { + var storedInterval = SessionState.GetInt(IntervalKey, -1); + if (storedInterval < 0) + { + enabled = false; + intervalMs = 0; + return false; + } + + enabled = SessionState.GetBool(EnabledKey, false); + intervalMs = storedInterval; + return true; + } + + /// + /// Test-only: write the persisted SessionState copy directly, bypassing ApplyAutoTick. + /// + internal static void SetPersistedSessionForTests(bool enabled, long intervalMs) + { + SessionState.SetBool(EnabledKey, enabled); + SessionState.SetInt(IntervalKey, (int)intervalMs); + } + + private static string ApplyAutoTick(bool enable, int intervalMs) + { + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/AutoTickCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/AutoTickCommand.cs.meta new file mode 100644 index 0000000..1b29ec1 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking.meta new file mode 100644 index 0000000..131b032 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 758dc2e598a448e5b80e66443fede0d9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/BakeSceneGuard.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/BakeSceneGuard.cs new file mode 100644 index 0000000..4122776 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/BakeSceneGuard.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/BakeSceneGuard.cs.meta new file mode 100644 index 0000000..40ddf75 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/LightingBakeCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/LightingBakeCommands.cs new file mode 100644 index 0000000..87e6ccd --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/LightingBakeCommands.cs @@ -0,0 +1,646 @@ +using System; +using System.IO; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEngine; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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] +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + 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.", Tags = new[] { "baking/lighting" })] + 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, Tags = new[] { "baking/lighting" })] + 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()).", Tags = new[] { "baking/lighting" })] + 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.", Tags = new[] { "baking/lighting" })] + 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.).", Tags = new[] { "baking/lighting" })] + public static LightingSettingsResult GetLightingSettings() + { + return ReadLightingSettings(); + } + + [CliCommand("set_lighting_settings", "Apply a subset of lighting settings to the active LightingSettings. Returns { applied[], unknown[] }.", Tags = new[] { "baking/lighting" })] + 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] + 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] + 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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/LightingBakeCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/LightingBakeCommands.cs.meta new file mode 100644 index 0000000..e8d6736 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/NavMeshBakeCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/NavMeshBakeCommands.cs new file mode 100644 index 0000000..7fcb0ed --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/NavMeshBakeCommands.cs @@ -0,0 +1,430 @@ +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; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +// 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] +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + 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.", Tags = new[] { "baking/navmesh" })] + 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, Tags = new[] { "baking/navmesh" })] + 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()).", Tags = new[] { "baking/navmesh" })] + 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.", Tags = new[] { "baking/navmesh" })] + 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).", Tags = new[] { "baking/navmesh" })] + 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[] }.", Tags = new[] { "baking/navmesh" })] + 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.", Tags = new[] { "baking/navmesh" })] + 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] + 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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/NavMeshBakeCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/NavMeshBakeCommands.cs.meta new file mode 100644 index 0000000..a3be55d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/OcclusionBakeCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/OcclusionBakeCommands.cs new file mode 100644 index 0000000..19503c4 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/OcclusionBakeCommands.cs @@ -0,0 +1,271 @@ +using System; +using System.IO; +using Newtonsoft.Json; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEngine; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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] +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + 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.", Tags = new[] { "baking/occlusion" })] + 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, Tags = new[] { "baking/occlusion" })] + 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()).", Tags = new[] { "baking/occlusion" })] + 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.", Tags = new[] { "baking/occlusion" })] + 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] + 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] + class OcclusionBakeStats + { + [JsonProperty("umbraDataSizeBytes")] + public long UmbraDataSizeBytes { get; set; } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/OcclusionBakeCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Baking/OcclusionBakeCommands.cs.meta new file mode 100644 index 0000000..eb800ab --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch.meta new file mode 100644 index 0000000..6155104 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2b9ea99052584c228968a32f1c614458 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchCommand.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchCommand.cs new file mode 100644 index 0000000..3f3afa4 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchCommand.cs @@ -0,0 +1,660 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Authoring; +using UnityEditor; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +namespace Unity.Pipeline.Editor.Commands.Batch +{ + /// + /// The batch command (AUTHAPI-27): transactional multi-operation execution with cross-op + /// result references, atomic Undo, and dry-run. + /// + /// WHY it exists: a "create → add components → wire fields" job is otherwise 10–90 sequential + /// calls at ~0.8s each, and eval — the only alternative today — leaves partial state when it + /// throws mid-way, which is unacceptable on user-owned scenes. batch collapses the round + /// trips AND adds what eval structurally lacks: atomicity/rollback, dry-run validation, and + /// cross-step references. + /// + /// Scope guard (epic AUTHAPI-24 design principle): batch is transactional object CRUD with + /// references. It deliberately has NO invoke/loop/conditional op types — that path reinvents + /// eval inside JSON and collapses the auditability story. Arbitrary logic belongs in + /// run_script (code as files on disk), not here. + /// + /// Dispatch reuse: each sub-operation runs through + /// , the exact parameter + /// extraction / required-validation / reflection-invoke path /api/exec uses, so every per-op + /// result is shaped identically to running that command standalone. + /// + /// Atomicity: the whole batch runs inside one , so all ops + /// (each of which registers its own Undo operations) collapse into a single Editor Undo step. When + /// transactional (the default) and an op fails under on_error=abort, every applied op + /// is reverted with — the scene returns to its + /// pre-batch state and there is nothing left on the Undo stack. Rollback is only real for + /// Undo-tracked mutations, which is why commands that mutate state OUTSIDE the Undo system + /// () are rejected in a transactional batch — see that field's + /// doc for the v1 caveat. + /// + /// Timing: the batch executes in a single main-thread turn (no frame-spanning), so the + /// Editor UI is frozen for its duration. time_budget_ms is the cooperative bound on that + /// freeze: the elapsed time is checked before each operation after the first, and when the budget + /// is exhausted the remaining ops are skipped and a transactional batch rolls back. Long batches + /// should be submitted as detached jobs ("job": true) — cancellation is checked between + /// ops, and per-op progress is reported via . + /// +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + static class BatchCommand + { + /// Hard cap on operations per batch (payload bound). + private const int MaxOperations = 200; + + /// Default cooperative time budget for one batch execution (see ). + private const int DefaultTimeBudgetMs = 50_000; + + /// Upper bound on time_budget_ms (1 hour). + private const int MaxTimeBudgetMs = 3_600_000; + + /// + /// Per-op result size (serialized UTF-8 BYTES — what actually goes on the wire, not UTF-16 + /// chars, which undercount CJK/emoji-heavy payloads 3x) above which the result is replaced + /// by a truncation marker so a large sub-result cannot bloat the batch envelope. + /// + private const int MaxPerOpResultBytes = 16 * 1024; + + /// + /// Aggregate budget for ALL per-op results in one batch response (UTF-8 bytes). Bounds the + /// whole envelope: 200 ops each just under the per-op cap would otherwise produce a ~3.2 MB + /// reply. Once exhausted, later results are replaced by truncation markers (the ops still + /// execute normally — only the echoed result payload is elided). + /// + internal const int MaxBatchResultBytes = 256 * 1024; + + /// + /// Operation ids must look like identifiers: they may not be purely numeric (a numeric id + /// would be ambiguous with a 0-based index in a "$<id-or-index>" reference) and + /// may not contain '.' (the reference path separator). + /// + private static readonly Regex m_IdPattern = new Regex("^[A-Za-z_][A-Za-z0-9_-]*$", RegexOptions.Compiled); + + /// + /// Commands excluded from batch (structured not_batchable error). These either escape + /// the "object CRUD with references" scope (build/platform/package/play-mode/recompile), carry + /// arbitrary code that belongs in run_script (eval/eval_file/run_script), + /// can pop modal dialogs that wedge an unattended batch (menu), or would nest a batch + /// inside a batch. (The ticket names eval; eval_file is its file-based twin and + /// is excluded on the same grounds.) + /// + private static readonly HashSet m_ExcludedCommands = new HashSet(StringComparer.Ordinal) + { + "build", "switch_build_target", + "editor_play", "editor_stop", "editor_pause", + "recompile", "eval", "eval_file", "run_script", "menu", "batch", + // A synchronous wait inside a batch cannot make progress: the batch holds the main + // thread for its whole turn, so the editor state the condition watches cannot change — + // the wait just burns its full timeout inside the batch. Use an async wait_for before + // or after the batch instead. (wait_status/wait_cancel are quick reads and stay + // batchable.) + "wait_for" + }; + + /// Excluded command families matched by name prefix (e.g. all package_*). + private static readonly string[] m_ExcludedPrefixes = { "package_" }; + + /// + /// Commands that CLEAR the Editor Undo stack, destroying the batch's ability to revert any + /// prior operation. Always rejected inside a batch (even with transactional=false the + /// single-Undo-step contract would silently break). + /// + /// V1 CAVEAT: like below, this is a curated list — + /// any new command that clears or truncates the Undo stack must be added here, or it will + /// silently break the revert-integrity contract. The durable fix is command-site metadata + /// ([CliCommand(Revertible = ...)]). + /// + private static readonly HashSet m_UndoStackWipingCommands = new HashSet(StringComparer.Ordinal) + { + "open_scene", "create_scene" + }; + + /// + /// Commands whose mutations live OUTSIDE the Editor Undo system — asset/file writes + /// (AssetDatabase / File IO), scene saves, Build Settings, project settings — so + /// cannot roll them back. In a transactional + /// batch they are rejected (not_batchable_transactional): allowing them would make + /// reverted: true a lie. With transactional=false they run, and their per-op + /// result reports revertible: false. + /// + /// V1 CAVEAT: this is a curated list, assembled by auditing each command's + /// implementation (AssetDatabase.SaveAssets / File writes / settings assets). The durable fix + /// is a [CliCommand(Revertible = ...)] metadata flag declared at the command site so + /// new commands can't silently fall through; until then, new non-Undo-tracked commands must + /// be added here. + /// + private static readonly HashSet m_NonRevertibleCommands = new HashSet(StringComparer.Ordinal) + { + // Assets & files (AssetDatabase ops / direct file IO). + "create_asset", "import_asset", "move_asset", "copy_asset", "rename_asset", "delete_asset", + "create_folder", "set_import_settings", "write_text_file", "create_script", + // Prefab ASSET writes (scene-side prefab ops — instantiate/unpack/revert overrides — are + // Undo-tracked and stay batchable). + "create_prefab", "create_prefab_variant", "apply_prefab_overrides", "save_prefab_contents", + // Scene persistence and Build Settings scene list. + "save_scene", "save_all", "add_scene_to_build", "remove_scene_from_build", "set_build_settings", + // Project settings (their own descriptions say "Not undoable via Ctrl+Z"). + "set_audio_settings", "set_graphics_settings", "set_input_settings", "set_lighting_settings", + "set_navmesh_settings", "set_physics_settings", "set_player_settings", "set_quality_settings", + "set_tags_layers", "set_time_settings", "set_authoring_root", + // Animation / Animator / Timeline commands persist their asset with AssetDatabase.SaveAssets. + "create_animation_clip", "set_animation_curve", "remove_animation_curve", + "create_animator_controller", "add_animator_layer", "add_animator_parameter", + "add_animator_state", "add_animator_transition", + "create_timeline", "add_timeline_track", "add_timeline_clip", + // Persists the .mat asset with AssetDatabase.SaveAssetIfDirty (the in-memory change is + // Undo-recorded, but the disk write is not reverted by Undo). + "set_material_properties", + // Baking writes/deletes baked data on disk (GI / NavMesh / occlusion). + "bake_lighting", "clear_baked_lighting", + "bake_navmesh", "bake_navmesh_surfaces", "clear_navmesh", + "bake_occlusion_culling", "clear_occlusion_culling" + }; + + [CliCommand("batch", + "Run multiple registered commands in one transactional request. Later ops can reference earlier " + + "results with \"$.\" (e.g. \"$0.instanceId\"; \"$$\" escapes a literal '$'). " + + "transactional=true (default) groups every op into one Undo step and reverts all applied ops if any " + + "op fails; commands that mutate outside the Undo system (asset/file/settings writes) are rejected " + + "unless transactional=false. on_error=abort|continue (continue forces transactional=false); dry_run " + + "validates without mutating; time_budget_ms (default 50000) bounds execution — when exceeded the " + + "remaining ops are skipped and a transactional batch rolls back (submit long batches with " + + "\"job\": true). Excludes build, switch_build_target, package_*, editor_play/stop/pause, recompile, " + + "eval, eval_file, run_script, menu, open_scene/create_scene, async commands (run_tests/list_tests), " + + "runtime-only commands, and nested batch.", + MainThreadRequired = true, + Tags = new[] { "batch" })] + public static BatchResult Batch( + [CliArg("operations", "Ordered operations to run; each is { id?, command, params }. Max 200.", Required = true)] List operations = null, + [CliArg("transactional", "Group all ops into one Undo step and revert every applied op if any op fails. Default true. Forced false when on_error=continue. Rejects commands whose mutations Undo cannot revert (asset/file/settings writes).")] bool transactional = true, + [CliArg("on_error", "abort (default): stop at the first failing op. continue: run every op, collecting per-op errors (forces transactional=false).")] string onError = "abort", + [CliArg("dry_run", "Validate command names, parameters, reference topology and excluded commands without mutating anything.")] bool dryRun = false, + [CliArg("result_fields", "Optional per-op result projection: map of op id-or-index -> array of result field paths to keep (context economy).")] JObject resultFields = null, + [CliArg("time_budget_ms", "Cooperative time budget for the whole batch in milliseconds (default 50000, max 3600000). Checked before each op after the first; when exhausted the remaining ops are skipped and a transactional batch rolls back. Use \"job\": true for long batches.")] int timeBudgetMs = DefaultTimeBudgetMs) + { + if (operations == null || operations.Count == 0) + throw new ArgumentException("batch 'operations' must contain at least one operation."); + if (operations.Count > MaxOperations) + throw new ArgumentException( + $"batch supports at most {MaxOperations} operations, but {operations.Count} were provided."); + if (timeBudgetMs < 0 || timeBudgetMs > MaxTimeBudgetMs) + throw new ArgumentException( + $"batch 'time_budget_ms' must be between 0 and {MaxTimeBudgetMs}, got {timeBudgetMs}."); + + onError = string.IsNullOrWhiteSpace(onError) ? "abort" : onError.Trim().ToLowerInvariant(); + if (onError != "abort" && onError != "continue") + throw new ArgumentException($"batch 'on_error' must be 'abort' or 'continue', got '{onError}'."); + + // "continue" collects per-op errors and applies the independent ops, so it cannot also + // promise an all-or-nothing rollback — it forces transactional off (per the ticket). + var effectiveTransactional = transactional && onError != "continue"; + + var idToIndex = BuildAndValidateIdIndex(operations); + + if (dryRun) + return DryRun(operations, idToIndex, effectiveTransactional, onError); + + return Execute(operations, idToIndex, effectiveTransactional, onError, resultFields, timeBudgetMs); + } + + /// + /// Validate operation ids (well-formed and unique when supplied) and build the id → index map + /// used to resolve "$id.field" references. + /// + private static Dictionary BuildAndValidateIdIndex(List operations) + { + var idToIndex = new Dictionary(StringComparer.Ordinal); + for (int i = 0; i < operations.Count; i++) + { + var op = operations[i]; + if (op == null) + throw new ArgumentException($"batch operation at index {i} is null."); + if (op.Id == null) + continue; + if (!m_IdPattern.IsMatch(op.Id)) + throw new ArgumentException( + $"batch operation id '{op.Id}' (index {i}) is invalid: ids must match " + + "^[A-Za-z_][A-Za-z0-9_-]*$ (start with a letter or '_'; letters, digits, '_' or '-' " + + "after). Purely numeric ids would be ambiguous with 0-based index selectors in " + + "\"$\" references, and '.' collides with the reference path separator."); + if (idToIndex.ContainsKey(op.Id)) + throw new ArgumentException( + $"batch operation id '{op.Id}' is used more than once; ids must be unique within a batch."); + idToIndex[op.Id] = i; + } + return idToIndex; + } + + /// + /// Execute the batch: run each op (resolving its references against earlier results) inside one + /// Undo group, and on a transactional abort revert every applied op. Between ops, cooperative + /// cancellation and the time budget are checked, and per-op progress is reported. + /// + private static BatchResult Execute(List operations, Dictionary idToIndex, + bool transactional, string onError, JObject resultFields, int timeBudgetMs) + { + var server = BasePipelineServer.CurrentServer; + if (server == null) + throw new InvalidOperationException( + "batch must run inside a pipeline server request (no active server on this thread)."); + + var result = new BatchResult { Transactional = transactional, OnError = onError }; + // One name-indexed lookup per batch call instead of an O(commands) scan per op. + var commandsByName = BuildCommandIndex(); + var resultsByIndex = new JToken[operations.Count]; + var stoppedAt = -1; // index of the op that failed under on_error=abort + var skippedFrom = -1; // first op never attempted because of a batch-level abort (budget/cancel) + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var remainingResultBytes = MaxBatchResultBytes; + + using (var undoScope = new AuthoringUndoScope("batch")) + { + var batchGroup = Undo.GetCurrentGroup(); + result.UndoGroup = batchGroup; + + for (int i = 0; i < operations.Count; i++) + { + // Cooperative cancellation (detached jobs: POST /api/job/cancel) — abort between + // ops and let the transactional rollback below restore the pre-batch state. + if (PipelineCancellation.IsCancellationRequested) + { + result.Error = "batch canceled: cooperative cancellation was requested; " + + (transactional ? "applied operations were reverted and " : "") + + "remaining operations were skipped."; + skippedFrom = i; + break; + } + + // Cooperative time budget: the batch holds the main thread for its whole duration, + // so this is the bound on how long the Editor stays frozen. Op 0 always gets to + // run; every later op requires remaining budget. + if (i > 0 && stopwatch.ElapsedMilliseconds >= timeBudgetMs) + { + result.Error = $"batch time budget exceeded: {stopwatch.ElapsedMilliseconds}ms elapsed " + + $"after {i} of {operations.Count} operations (time_budget_ms={timeBudgetMs}). " + + (transactional ? "Applied operations were reverted and remaining operations were skipped. " + : "Remaining operations were skipped. ") + + "For long batches submit the request with \"job\": true and a larger time_budget_ms."; + skippedFrom = i; + break; + } + + var op = operations[i]; + CliProgress.Report("batch", $"op {i + 1}/{operations.Count}: {op.Command}", + i, operations.Count, i / (double)operations.Count); + + var opResult = new BatchOperationResult { Id = op.Id, Command = op.Command }; + result.Results.Add(opResult); + + try + { + var command = ResolveBatchable(op.Command, transactional, commandsByName); + opResult.Revertible = !m_NonRevertibleCommands.Contains(op.Command); + var resolvedParams = BatchReferenceResolver.Resolve( + op.Params, i, operations, idToIndex, resultsByIndex, result.Results); + + // Dispatched WITHOUT re-checking the server's busy gate (GetBusyReason): + // the batch itself was admitted through it in HandleExecRequest, the settle + // gate is a one-way latch that never re-arms mid-session, and the batch + // holds the main thread for its entire turn — the Editor cannot transition + // into a busy state between two ops of the same batch. + var raw = server.DispatchCommandOnMainThread(command, resolvedParams); + + // Capture the result as a token for later references. A serialization failure + // here must NOT fail an op that already ran — leave the slot null (a later + // reference into it will error clearly) and still report success. + try + { + resultsByIndex[i] = raw == null ? JValue.CreateNull() : JToken.FromObject(raw); + } + catch + { + resultsByIndex[i] = null; + } + + opResult.Success = true; + opResult.Result = ProjectResult(raw, resultsByIndex[i], op, i, resultFields, opResult, ref remainingResultBytes); + result.Applied++; + } + catch (Exception ex) + { + opResult.Success = false; + opResult.Error = ex.Message; + + if (onError == "abort") + { + stoppedAt = i; + break; + } + // on_error=continue: keep going with the remaining independent ops. + } + } + + if ((stoppedAt >= 0 || skippedFrom >= 0) && transactional) + { + // Revert every mutation registered since the batch started — one atomic rollback + // to the pre-batch state (nothing is left on the Undo stack). Deliberately NOT + // conditioned on Applied > 0: a FIRST op that partially mutates and then throws + // has registered exactly the Undo operations this revert must unwind, and + // reverting an empty group is harmless. + Undo.RevertAllDownToGroup(batchGroup); + result.Reverted = true; + // The revert discarded batchGroup from the Undo stack; the scope must not + // collapse a group id that no longer refers to this batch's operations. + undoScope.Cancel(); + } + } + + // Record the operations that were never attempted because the batch aborted (op failure + // under on_error=abort, cancellation, or an exhausted time budget), so the caller can see + // exactly where it stopped. + var firstSkipped = skippedFrom >= 0 ? skippedFrom + : stoppedAt >= 0 ? stoppedAt + 1 + : operations.Count; + for (int j = firstSkipped; j < operations.Count; j++) + { + result.Results.Add(new BatchOperationResult + { + Id = operations[j].Id, + Command = operations[j].Command, + Success = false, + Skipped = true + }); + } + + return result; + } + + /// + /// Validate every operation without executing any of it: command names, that no excluded / + /// non-batchable command is used (including the transactional non-revertible rejection), + /// unknown/missing parameters, and reference topology (unknown/forward refs). Mutates nothing. + /// + private static BatchResult DryRun(List operations, Dictionary idToIndex, + bool transactional, string onError) + { + var result = new BatchResult + { + DryRun = true, + Valid = true, + Transactional = transactional, + OnError = onError + }; + var commandsByName = BuildCommandIndex(); + + for (int i = 0; i < operations.Count; i++) + { + var op = operations[i]; + var opResult = new BatchOperationResult { Id = op.Id, Command = op.Command }; + result.Results.Add(opResult); + + try + { + var command = ResolveBatchable(op.Command, transactional, commandsByName); + opResult.Revertible = !m_NonRevertibleCommands.Contains(op.Command); + ValidateKnownParameters(command, op.Params); + ValidateRequiredParameters(command, op.Params); + BatchReferenceResolver.Validate(op.Params, i, operations, idToIndex); + opResult.Success = true; // would run + } + catch (Exception ex) + { + opResult.Success = false; + opResult.Error = ex.Message; + result.Valid = false; + } + } + + return result; + } + + /// + /// Resolve a command name to its , rejecting non-batchable commands: + /// the excluded list (not_batchable), Undo-stack-wiping scene loads, runtime-only + /// commands, async (Task-returning) commands — which are completed by + /// EditorApplication.update callbacks that can never fire while the batch holds the + /// main thread, so awaiting them inline would freeze the Editor permanently — and, when + /// , commands whose mutations Undo cannot revert + /// (not_batchable_transactional). Unknown commands get an unknown command error. + /// Uses the registry directly (no server-side logging) so dry-run validation of an + /// intentionally bad name stays quiet. + /// + /// + /// Name-indexed snapshot of the registered commands, built once per batch call (the registry + /// list itself is cached; this avoids an O(commands) scan per op). NOTE: this deliberately + /// does not reuse BasePipelineServer.ResolveCommand — that helper serves the exec + /// request path (logging, envelope error shape); unifying the two belongs to a follow-up so + /// the server's hot path is not reshaped from this command. + /// + private static Dictionary BuildCommandIndex() + { + var byName = new Dictionary(StringComparer.Ordinal); + foreach (var c in CommandRegistry.DiscoverCommands()) + if (!byName.ContainsKey(c.Name)) + byName.Add(c.Name, c); + return byName; + } + + private static CommandInfo ResolveBatchable(string name, bool transactional, IReadOnlyDictionary byName) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("operation 'command' is required."); + + if (IsExcluded(name)) + throw new ArgumentException( + $"Command '{name}' is not batchable (not_batchable): commands that build, switch platform, " + + "manage packages, control play mode, recompile, evaluate code, execute Editor menu items " + + "(which can open modal dialogs and wedge an unattended batch), or nest a batch are excluded."); + + if (m_UndoStackWipingCommands.Contains(name)) + throw new ArgumentException( + $"Command '{name}' is not batchable (not_batchable): it clears the Editor Undo stack, which " + + "would make every prior batch operation unrevertible. Call it standalone instead."); + + if (!byName.TryGetValue(name, out var command)) + throw new ArgumentException($"No command named '{name}' is available (unknown command)."); + + if (command.RuntimeOnly) + throw new ArgumentException( + $"Command '{name}' is not batchable (not_batchable): runtime-only command (Player server " + + "surface); it cannot execute on this Editor server."); + + if (typeof(Task).IsAssignableFrom(command.Method.ReturnType)) + throw new ArgumentException( + $"Command '{name}' is not batchable (not_batchable): async commands cannot run inside a " + + "synchronous batch. They complete via EditorApplication.update callbacks, which cannot fire " + + "while the batch holds the main thread — the batch would deadlock the Editor. Call it " + + "standalone instead."); + + if (transactional && m_NonRevertibleCommands.Contains(name)) + throw new ArgumentException( + $"Command '{name}' is not batchable in a transactional batch (not_batchable_transactional): " + + "it mutates state outside the Editor Undo system (asset/file/settings writes), so a rollback " + + "could not revert it. Run the batch with transactional:false, or call the command standalone."); + + return command; + } + + private static bool IsExcluded(string name) + { + if (m_ExcludedCommands.Contains(name)) + return true; + foreach (var prefix in m_ExcludedPrefixes) + { + if (name.StartsWith(prefix, StringComparison.Ordinal)) + return true; + } + return false; + } + + /// Reject parameter keys that are not parameters of the command (dry-run check). + private static void ValidateKnownParameters(CommandInfo command, JObject parameters) + { + if (parameters == null) + return; + var known = new HashSet(command.Parameters.Select(p => p.Name), StringComparer.Ordinal); + foreach (var prop in parameters.Properties()) + { + if (!known.Contains(prop.Name)) + throw new ArgumentException( + $"Command '{command.Name}' has no parameter '{prop.Name}' (unknown parameter). " + + $"Known parameters: [{string.Join(", ", known)}]."); + } + } + + /// + /// Ensure every required parameter is present (dry-run check). A reference string counts as + /// present; an explicit JSON null does not. + /// + private static void ValidateRequiredParameters(CommandInfo command, JObject parameters) + { + foreach (var p in command.Parameters) + { + if (!p.Required) + continue; + var token = parameters?[p.Name]; + if (token == null || token.Type == JTokenType.Null) + throw new ArgumentException( + $"Command '{command.Name}' is missing required parameter '{p.Name}'."); + } + } + + /// + /// Shape an op's result: apply the optional result_fields projection, then replace an + /// oversized result with a truncation marker. With neither in play the raw result object is + /// returned unchanged, so it serializes byte-identically to the standalone command. + /// + internal static object ProjectResult(object raw, JToken rawToken, BatchOperationInput op, int index, + JObject resultFields, BatchOperationResult opResult, ref int remainingResultBytes) + { + object projected = raw; + + var fields = LookupResultFields(resultFields, op, index); + if (fields != null && rawToken != null && rawToken.Type != JTokenType.Null) + { + var projection = new JObject(); + List invalidPaths = null; + foreach (var path in fields) + { + if (string.IsNullOrEmpty(path)) + continue; + JToken value; + try + { + value = rawToken.SelectToken(path); + } + catch (Exception) + { + // A malformed JSONPath must NEVER throw here: by this point the op has + // already run and mutated state, and the outer catch would flip its + // Success to false — triggering a rollback of a succeeded op under + // on_error=abort, and poisoning later $refs into a perfectly valid + // captured result under on_error=continue. Report the bad path in the + // projection instead. + (invalidPaths ?? (invalidPaths = new List())).Add(path); + continue; + } + if (value != null) + projection[path] = value; + } + if (invalidPaths != null) + projection["invalidResultFields"] = new JArray(invalidPaths); + projected = projection; + } + + string json; + try + { + // Measure with the same formatting the wire envelope uses: since the lean envelope + // (AUTHAPI-21) /api/exec serializes compact (Formatting.None) in every mode, so the + // measurement matches what actually goes on the wire. + json = JsonConvert.SerializeObject(projected, Formatting.None); + } + catch (Exception ex) + { + // A result Newtonsoft cannot serialize would fail (or bloat) the whole batch envelope + // later. Replace it with a structured marker — and still flag resultTruncated — rather + // than skipping truncation for exactly the results we know nothing about. + opResult.ResultTruncated = true; + return new JObject + { + ["resultTruncated"] = true, + ["error"] = $"op result could not be serialized: {ex.Message}" + }; + } + + var bytes = System.Text.Encoding.UTF8.GetByteCount(json); + if (bytes > MaxPerOpResultBytes) + { + opResult.ResultTruncated = true; + return new JObject + { + ["resultTruncated"] = true, + ["length"] = bytes, + ["preview"] = json.Substring(0, Math.Min(json.Length, 512)) + }; + } + + if (bytes > remainingResultBytes) + { + // Aggregate budget exhausted: the op executed normally, only its echoed result is + // elided so the whole batch reply stays bounded ($refs still resolve — they read + // the captured token, not this projection). + opResult.ResultTruncated = true; + return new JObject + { + ["resultTruncated"] = true, + ["length"] = bytes, + ["error"] = $"batch aggregate result budget ({MaxBatchResultBytes} bytes) exhausted; " + + "the operation ran and later references to it still resolve, only this echoed result was elided. " + + "Use result_fields to project results down, or split the batch.", + ["preview"] = json.Substring(0, Math.Min(json.Length, 512)) + }; + } + remainingResultBytes -= bytes; + + return projected; + } + + /// + /// Look up the projection field list for an op in result_fields (keyed by op id, then + /// by index). Returns null when no projection is configured for the op. + /// + private static string[] LookupResultFields(JObject resultFields, BatchOperationInput op, int index) + { + if (resultFields == null) + return null; + + JToken entry = null; + if (!string.IsNullOrEmpty(op.Id)) + entry = resultFields[op.Id]; + if (entry == null) + entry = resultFields[index.ToString()]; + if (entry == null) + return null; + + if (entry.Type == JTokenType.Array) + return entry.ToObject(); + if (entry.Type == JTokenType.String) + return new[] { (string)entry }; + return null; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchCommand.cs.meta new file mode 100644 index 0000000..03ff083 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c33c9efe866346aab0db0dc6fad780a5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchInput.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchInput.cs new file mode 100644 index 0000000..fae8edd --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchInput.cs @@ -0,0 +1,43 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Unity.Pipeline.Commands; + +namespace Unity.Pipeline.Editor.Commands.Batch +{ + /// + /// A single operation in a batch request (AUTHAPI-27): the name of a registered command + /// plus the parameters to invoke it with, and an optional so later operations + /// can reference this one's result. + /// + /// String values anywhere in may reference an earlier operation's result + /// with "$<id-or-index>.<jsonPath>" (e.g. "$0.instanceId" or + /// "$createHead.components[0].instanceId"); "$$" escapes a literal '$'. + /// References are backward-only. Implements so the batch + /// command advertises a nested object schema (via GET /api/commands) rather than a string. + /// + sealed class BatchOperationInput : IStructuredCommandInput + { + /// + /// Optional operation id used to reference this operation's result from a later op + /// ("$<id>.<field>"). Must be unique within the batch when supplied, and + /// must match ^[A-Za-z_][A-Za-z0-9_-]*$ — purely numeric ids would be ambiguous with + /// 0-based index selectors, and '.' collides with the reference path separator. + /// + [CliArg("id", "Optional id for referencing this op's result from a later op (\"$.\"). Unique within the batch; must match ^[A-Za-z_][A-Za-z0-9_-]*$ (not purely numeric, no dots).")] + [JsonProperty("id")] + public string Id { get; set; } + + /// Name of the registered command to run (any command except the excluded list). + [CliArg("command", "Name of the registered command to run.", Required = true)] + [JsonProperty("command")] + public string Command { get; set; } + + /// + /// Parameters for the command, same shape as /api/exec parameters. Any string + /// value may reference a prior op's result ("$<id-or-index>.<jsonPath>"). + /// + [CliArg("params", "Parameters for the command. String values may reference a prior op's result: \"$.\".")] + [JsonProperty("params")] + public JObject Params { get; set; } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchInput.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchInput.cs.meta new file mode 100644 index 0000000..8832b23 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchInput.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8f7946d807d348c0a98e13ba7d18f714 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchReferenceResolver.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchReferenceResolver.cs new file mode 100644 index 0000000..0f25be3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchReferenceResolver.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace Unity.Pipeline.Editor.Commands.Batch +{ + /// + /// Resolves cross-operation result references in a batch operation's parameters (AUTHAPI-27). + /// + /// A string value that begins with a single '$' is a reference of the form + /// "$<selector>" or "$<selector>.<jsonPath>", where + /// <selector> names an earlier operation by its id or its 0-based index and + /// <jsonPath> is a Newtonsoft SelectToken path into that op's result + /// (e.g. "$0.instanceId", "$createHead.components[0].instanceId"). A leading + /// "$$" escapes a literal '$'. References are backward-only: a selector must resolve + /// to an operation strictly before the current one. + /// + /// References are resolved as WHOLE values (the entire string must be the reference), and the + /// substituted JSON token preserves its type — so a numeric instanceId stays a number and + /// deserializes into the target parameter (e.g. an ObjectRef) exactly as a literal would. + /// The walk recurses through nested objects and arrays, so a reference can appear anywhere in the + /// parameter tree. + /// + internal static class BatchReferenceResolver + { + /// + /// Validate the reference topology of without substituting any + /// values (used by dry run). Throws on an unknown selector, + /// a forward/self reference, or a malformed reference. + /// + public static void Validate(JToken parameters, int currentIndex, + IReadOnlyList operations, IReadOnlyDictionary idToIndex) + { + if (parameters == null) + return; + // No DeepClone: in validate mode (null resultsByIndex) TransformString always returns the + // original token, so Walk never reassigns a child — the walk is read-only. + Walk(parameters, currentIndex, operations, idToIndex, null, null); + } + + /// + /// Return a copy of with every reference replaced by the + /// referenced value from . Throws + /// on an unknown selector, a forward/self reference, a + /// reference whose path does not resolve, or a reference into an operation that failed, was + /// skipped, or returned no result (per-op outcomes come from ). + /// The input token is not mutated. + /// + public static JObject Resolve(JObject parameters, int currentIndex, + IReadOnlyList operations, IReadOnlyDictionary idToIndex, + JToken[] resultsByIndex, IReadOnlyList outcomes) + { + if (parameters == null) + return null; + var clone = (JObject)parameters.DeepClone(); + return (JObject)Walk(clone, currentIndex, operations, idToIndex, resultsByIndex, outcomes); + } + + /// + /// Recursively transform a token tree. is null in validate + /// mode (topology check only) and non-null in resolve mode (value substitution). + /// + private static JToken Walk(JToken node, int currentIndex, + IReadOnlyList operations, IReadOnlyDictionary idToIndex, + JToken[] resultsByIndex, IReadOnlyList outcomes) + { + switch (node.Type) + { + case JTokenType.Object: + foreach (var prop in ((JObject)node).Properties().ToList()) + { + var replaced = Walk(prop.Value, currentIndex, operations, idToIndex, resultsByIndex, outcomes); + if (!ReferenceEquals(replaced, prop.Value)) + prop.Value = replaced; + } + return node; + case JTokenType.Array: + var arr = (JArray)node; + for (int i = 0; i < arr.Count; i++) + { + var child = arr[i]; + var replaced = Walk(child, currentIndex, operations, idToIndex, resultsByIndex, outcomes); + if (!ReferenceEquals(replaced, child)) + arr[i] = replaced; + } + return node; + case JTokenType.String: + return TransformString((string)node, node, currentIndex, operations, idToIndex, resultsByIndex, outcomes); + default: + return node; + } + } + + private static JToken TransformString(string s, JToken original, int currentIndex, + IReadOnlyList operations, IReadOnlyDictionary idToIndex, + JToken[] resultsByIndex, IReadOnlyList outcomes) + { + if (string.IsNullOrEmpty(s) || s[0] != '$') + return original; // not a reference + + // "$$..." escapes a literal '$'. Only substitute when actually resolving; validation + // has nothing to check for a literal. + if (s.Length >= 2 && s[1] == '$') + return resultsByIndex == null ? original : (JToken)new JValue(s.Substring(1)); + + var rest = s.Substring(1); + var dot = rest.IndexOf('.'); + var selector = dot < 0 ? rest : rest.Substring(0, dot); + var path = dot < 0 ? null : rest.Substring(dot + 1); + + if (string.IsNullOrEmpty(selector)) + throw new ArgumentException( + $"Invalid reference '{s}': missing operation selector after '$'. " + + "Use \"$.\", or \"$$\" for a literal '$'."); + + var targetIndex = ResolveSelector(selector, operations, idToIndex); + if (targetIndex < 0) + throw new ArgumentException( + $"Reference '{s}' points to unknown operation '{selector}'. " + + "A reference must name an earlier operation by its id or 0-based index."); + if (targetIndex >= currentIndex) + throw new ArgumentException( + $"Reference '{s}' is a forward reference to operation '{selector}' (index {targetIndex}); " + + "only backward references to earlier operations are allowed."); + + // Validate mode: topology is fine, and there is no result to substitute yet. + if (resultsByIndex == null) + return original; + + // A failed or skipped operation has no result to reference — substituting null silently + // would feed a bogus value into this op's parameters. Error explicitly instead. In the + // real Execute() loop only Success=false outcomes are observable here (under + // on_error=continue — earlier failures don't stop the batch); Skipped entries are + // appended AFTER the loop, so the "was skipped" wording is defensive, reachable only + // when the resolver is driven directly (unit tests / future callers). + var outcome = outcomes != null && targetIndex < outcomes.Count ? outcomes[targetIndex] : null; + if (outcome != null && !outcome.Success) + { + var reason = outcome.Skipped == true ? "was skipped" : "failed"; + throw new ArgumentException( + $"Reference '{s}' cannot be resolved: operation '{selector}' {reason}; " + + "its result cannot be referenced."); + } + + // Distinct from failure: the op SUCCEEDED but produced nothing to reference (void/null + // result, or a result that could not be captured as a token). + var opResult = resultsByIndex[targetIndex]; + if (opResult == null || opResult.Type == JTokenType.Null) + throw new ArgumentException( + $"Reference '{s}' cannot be resolved: operation '{selector}' returned no result."); + + var picked = string.IsNullOrEmpty(path) ? opResult : opResult.SelectToken(path); + if (picked == null) + throw new ArgumentException( + $"Reference '{s}' did not resolve: operation '{selector}' result has no field '{path}'."); + + return picked.DeepClone(); + } + + /// + /// Resolve a selector to an operation index: an id match takes precedence over a numeric + /// index. Returns -1 when the selector names no operation. + /// + private static int ResolveSelector(string selector, + IReadOnlyList operations, IReadOnlyDictionary idToIndex) + { + if (idToIndex != null && idToIndex.TryGetValue(selector, out var byId)) + return byId; + if (int.TryParse(selector, out var idx) && idx >= 0 && idx < operations.Count) + return idx; + return -1; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchReferenceResolver.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchReferenceResolver.cs.meta new file mode 100644 index 0000000..b3f916d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchReferenceResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf4505eeb7b343d1969a2e1b8a9ab350 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchResult.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchResult.cs new file mode 100644 index 0000000..c76994d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchResult.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Unity.Pipeline.Editor.Commands.Batch +{ + /// + /// Result envelope for the batch command (AUTHAPI-27). Carries a per-operation result in + /// input order, how many ops were applied, whether a transactional rollback occurred, and the + /// Editor Undo group the whole batch collapsed into. + /// + sealed class BatchResult + { + /// Per-operation results, in the same order as the input operations. + [JsonProperty("results")] + public List Results { get; set; } = new List(); + + /// Number of operations that executed successfully (before any rollback). + [JsonProperty("applied")] + public int Applied { get; set; } + + /// True when a transactional rollback undid every applied operation. + [JsonProperty("reverted")] + public bool Reverted { get; set; } + + /// + /// Whether the batch ran transactionally (all ops in one Undo group, revert-on-failure). + /// Forced false when on_error=continue. + /// + [JsonProperty("transactional")] + public bool Transactional { get; set; } + + /// The effective error policy: abort or continue. + [JsonProperty("onError")] + public string OnError { get; set; } + + /// + /// Batch-level abort reason for stops not attributable to a single op's failure: + /// "batch time budget exceeded" (the cooperative time_budget_ms ran out between ops) + /// or "batch canceled" (cooperative job cancellation). Null otherwise. + /// + [JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)] + public string Error { get; set; } + + /// The Editor Undo group index the whole batch collapsed into (execution only). + [JsonProperty("undoGroup", NullValueHandling = NullValueHandling.Ignore)] + public int? UndoGroup { get; set; } + + /// True when this was a dry run (validation only, zero mutation). + [JsonProperty("dryRun", NullValueHandling = NullValueHandling.Ignore)] + public bool? DryRun { get; set; } + + /// Dry run only: whether every operation validated without error. + [JsonProperty("valid", NullValueHandling = NullValueHandling.Ignore)] + public bool? Valid { get; set; } + } + + /// Result of a single operation within a . + sealed class BatchOperationResult + { + /// The operation's id, echoed back when supplied. + [JsonProperty("id", NullValueHandling = NullValueHandling.Ignore)] + public string Id { get; set; } + + /// The command that was (or would be) run. + [JsonProperty("command")] + public string Command { get; set; } + + /// + /// Whether the operation succeeded. On a dry run this is "would run" (validation passed). + /// + [JsonProperty("success")] + public bool Success { get; set; } + + /// + /// The command's result, shaped identically to running it standalone (optionally projected + /// via result_fields, or replaced by a truncation marker for oversized results). + /// + [JsonProperty("result", NullValueHandling = NullValueHandling.Ignore)] + public object Result { get; set; } + + /// Error message when the operation failed (or failed dry-run validation). + [JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)] + public string Error { get; set; } + + /// True for operations not attempted because an earlier op aborted the batch. + [JsonProperty("skipped", NullValueHandling = NullValueHandling.Ignore)] + public bool? Skipped { get; set; } + + /// True when the result was replaced by a truncation marker (payload economy). + [JsonProperty("resultTruncated", NullValueHandling = NullValueHandling.Ignore)] + public bool? ResultTruncated { get; set; } + + /// + /// Whether a transactional rollback can revert this operation's mutations. False for commands + /// that mutate state outside the Editor Undo system (asset/file/settings writes) — those only + /// run with transactional=false. Unset when the command never resolved. + /// + [JsonProperty("revertible", NullValueHandling = NullValueHandling.Ignore)] + public bool? Revertible { get; set; } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchResult.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchResult.cs.meta new file mode 100644 index 0000000..84ed8a5 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Batch/BatchResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f14af31d9bca47ee9536e91221678a86 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build.meta new file mode 100644 index 0000000..aff8815 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bdb8ef5b0513b0d488ac3be1a9a77185 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/BuildCommand.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/BuildCommand.cs new file mode 100644 index 0000000..15cf9be --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/BuildCommand.cs @@ -0,0 +1,596 @@ +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; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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] +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + 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; + + /// + /// BuildOptions of the scripted build currently inside BuildPipeline.BuildPlayer, or null + /// when no `build` command is running. Build processors read it because a scripted build + /// with BuildOptions.Development is a development build even when the Build Settings + /// toggle (EditorUserBuildSettings.development) is off. + /// + internal static BuildOptions? ActiveScriptedBuildOptions { get; private set; } + 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, + Tags = new[] { "build" })] + 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, + Tags = new[] { "build" })] + 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 + }; + + ActiveScriptedBuildOptions = options; + BuildReport report; + try { report = BuildPipeline.BuildPlayer(buildOptions); } + finally { ActiveScriptedBuildOptions = null; } + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/BuildCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/BuildCommand.cs.meta new file mode 100644 index 0000000..39abf76 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/BuildConfigCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/BuildConfigCommands.cs new file mode 100644 index 0000000..d135d20 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/BuildConfigCommands.cs @@ -0,0 +1,347 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEditor.Build; +using UnityEngine; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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). + /// +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + static class BuildConfigCommands + { + [CliCommand("list_build_targets", + "List the known BuildTarget values with their group and whether build support is installed.", + MainThreadRequired = true, + Tags = new[] { "build/targets" })] + 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, + Tags = new[] { "build/settings" })] + 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, + Tags = new[] { "build/settings" })] + 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, + Tags = new[] { "build/settings" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/BuildConfigCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/BuildConfigCommands.cs.meta new file mode 100644 index 0000000..e144578 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/BuildConfigCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e65c0b78842b492d8f9363ec749bc370 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/BuildModels.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/BuildModels.cs new file mode 100644 index 0000000..a1bab61 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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] + 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] + 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] + 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. + /// + 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] + 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] + 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] + 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] + 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] + 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] + 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] + 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] + 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] + 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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/BuildModels.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/BuildModels.cs.meta new file mode 100644 index 0000000..46f8606 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/SwitchBuildTargetCommand.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/SwitchBuildTargetCommand.cs new file mode 100644 index 0000000..a3f293a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/SwitchBuildTargetCommand.cs @@ -0,0 +1,245 @@ +using System; +using System.IO; +using Newtonsoft.Json; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEngine; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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] +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + 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, + Tags = new[] { "build/targets" })] + 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, + Tags = new[] { "build/targets" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/SwitchBuildTargetCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/SwitchBuildTargetCommand.cs.meta new file mode 100644 index 0000000..ccb7ec1 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Build/SwitchBuildTargetCommand.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5b3d3b7a26bf4a5c8f5522cbfdd0574b diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Capture.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Capture.meta new file mode 100644 index 0000000..34a04a3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Capture.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 87f4434ff2c6ed6419e6982345ffe0ef +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Capture/CaptureCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Capture/CaptureCommands.cs new file mode 100644 index 0000000..7c8e85c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Capture/CaptureCommands.cs @@ -0,0 +1,428 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +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, the Scene View, or the composited game view + /// screen to a PNG and return it base64-encoded, so an agent can "see" the editor without a display. + /// The screen source (AUTHAPI-10) also captures Screen Space - Overlay UI (HUDs) that never renders + /// through a camera. 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. + /// + static class CaptureCommands + { + private const int MaxDimension = 4096; + + [CliCommand("capture_game_view", "Render the game view to a PNG. source=camera (default) renders a camera and misses Screen Space - Overlay UI; source=screen captures the composited backbuffer incl. overlay canvases (Play Mode only). Returns it inline as base64, unless save_path is set (path-only result; pass include_inline_image=true to get both).", Tags = new[] { "capture" })] + 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 (source=camera only); 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, + [CliArg("source", "What to capture: 'camera' (default) renders a camera (misses Screen Space - Overlay UI); 'screen' captures the composited game view incl. overlay canvases (HUDs), Play Mode only.")] string source = "camera") + { + var src = (source ?? "camera").Trim().ToLowerInvariant(); + switch (src) + { + case "camera": + { + GuardHasGpu(); + + var cam = ResolveCamera(camera); + if (cam == null) + throw new ArgumentException("No camera found to capture."); + + return Capture((w, h) => EncodeCameraToPng(cam, w, h), $"camera:{cam.name}", + width, height, savePath, includeInlineImage, maxResolution); + } + case "screen": + { + // Overlay UI only composites to the backbuffer at runtime, so screen capture is a + // Play-Mode operation; guard before touching the GPU so the message is deterministic. + if (!Application.isPlaying) + throw new InvalidOperationException( + "capture_game_view with source=\"screen\" requires Play Mode: Screen Space - Overlay UI only composites to the backbuffer at runtime. Use source=\"camera\" in Edit Mode."); + + GuardHasGpu(); + + return Capture(EncodeScreenToPng, "screen", + width, height, savePath, includeInlineImage, maxResolution); + } + default: + throw new ArgumentException($"Unknown source '{source}'. Use 'camera' or 'screen'."); + } + } + + [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).", Tags = new[] { "capture" })] + 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."); + + var cam = sv.camera; + return Capture((w, h) => EncodeCameraToPng(cam, w, h), "sceneView", + width, height, savePath, includeInlineImage, maxResolution); + } + + /// + /// Shared capture core. Renders at the requested (clamped) size via + /// 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. + /// is the source-specific encoder — camera render (source=camera / scene view) or screen + /// backbuffer (source=screen, incl. overlay canvases, AUTHAPI-10). + /// + private static CaptureResult Capture(Func renderPng, string source, + int width, int height, 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 = renderPng(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 = renderPng(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); + } + } + + /// + /// Capture the composited game view — including Screen Space - Overlay canvases and other + /// screen-space UI that never render through a camera (AUTHAPI-10) — and encode it to PNG, + /// scaled to the requested size. + /// + /// The primary source is the game view's own render target (see + /// ): the most recently rendered game frame at its true + /// pixel resolution, independent of window focus and OS display scaling. The old + /// path had two field-reported bugs: + /// it reads a Screen-sized rect in GUI points from a backbuffer sized in physical + /// pixels, cropping the frame on scaled displays (e.g. 150% OS scaling), and its + /// result texture round-trips gamma twice in Linear projects, washing out every capture. + /// + private static byte[] EncodeScreenToPng(int w, int h) + { + var outRt = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32); + var prevActive = RenderTexture.active; + RenderTexture fallbackRt = null; + try + { + var source = GetGameViewTargetTexture(); + if (source == null) + { + // Fallback when the internal PlayModeView API is unavailable: ask ScreenCapture for + // the composited view. Less deterministic — when the editor runs unfocused it can + // capture whichever editor view renders next — but keeps the command functional. + var (screenW, screenH) = GetGameViewTargetSize(); + fallbackRt = RenderTexture.GetTemporary(screenW, screenH, 0, RenderTextureFormat.ARGB32); + + // GetTemporary pools RTs: clear before capturing so a skipped/partial capture can + // never leak a stale frame from an earlier capture. + RenderTexture.active = fallbackRt; + GL.Clear(true, true, Color.black); + + // Unbind before capturing: CaptureScreenshotIntoRenderTexture silently writes + // nothing while its destination is the currently-active RenderTexture (verified + // empirically — the capture comes back all black). + RenderTexture.active = prevActive; + ScreenCapture.CaptureScreenshotIntoRenderTexture(fallbackRt); + source = fallbackRt; + } + + // Scale to the requested output size. Default read/write semantics keep the blit + // conversion-neutral, so the display-ready values reach the PNG unchanged (no double + // gamma). The source holds GPU-UV-oriented content: on APIs where UV(0,0) is the top + // (D3D, Metal, Vulkan) it arrives vertically flipped, so un-flip in the blit. + if (SystemInfo.graphicsUVStartsAtTop) + Graphics.Blit(source, outRt, new Vector2(1f, -1f), new Vector2(0f, 1f)); + else + Graphics.Blit(source, outRt); + RenderTexture.active = outRt; + + 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 + { + RenderTexture.active = prevActive; + RenderTexture.ReleaseTemporary(outRt); + if (fallbackRt != null) + RenderTexture.ReleaseTemporary(fallbackRt); + } + } + + /// + /// The game view's own render target (via the internal PlayModeView API), or null when + /// unavailable. This is the most reliable "what the game view shows" source: the + /// ScreenCapture APIs capture whichever editor view happens to render, which is + /// focus-dependent — an unfocused/background editor (the normal state for an agent-driven + /// editor) can hand back an editor panel (Hierarchy, Inspector) instead of the game. The + /// target texture holds the game view's most recently rendered frame — overlay canvases + /// included — at its true pixel resolution, independent of focus and display scaling. + /// + private static RenderTexture GetGameViewTargetTexture() + { + try + { + var playModeView = Type.GetType("UnityEditor.PlayModeView,UnityEditor"); + var getMain = playModeView?.GetMethod("GetMainPlayModeView", + BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); + var view = getMain?.Invoke(null, null); + if (view == null) + return null; + + var field = playModeView.GetField("m_TargetTexture", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + var rt = field?.GetValue(view) as RenderTexture; + return rt != null && rt.IsCreated() ? rt : null; + } + catch + { + // Internal API changed — callers fall back to the ScreenCapture path. + return null; + } + } + + /// + /// Size of the game view's actual render target, in pixels. Screen.width/height in + /// editor main-thread code report the CURRENT GUI view — which is focus-dependent and can be + /// the main editor window rather than the game view (observed as black bars / truncated + /// captures). The internal PlayModeView API reports the true target size regardless of focus + /// and display scaling; fall back to Screen when the internal API is unavailable. + /// + private static (int w, int h) GetGameViewTargetSize() + { + try + { + var playModeView = Type.GetType("UnityEditor.PlayModeView,UnityEditor"); + var method = playModeView?.GetMethod("GetMainPlayModeViewTargetSize", + BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); + if (method != null) + { + var size = (Vector2)method.Invoke(null, null); + if (size.x >= 1f && size.y >= 1f) + return ((int)size.x, (int)size.y); + } + } + catch + { + // Internal API changed/unavailable — the Screen fallback below still works whenever + // the game view is the current context. + } + + return (Screen.width, Screen.height); + } + + /// + /// 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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Capture/CaptureCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Capture/CaptureCommands.cs.meta new file mode 100644 index 0000000..bb1dd0e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/CaptureEditorElementCommand.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/CaptureEditorElementCommand.cs new file mode 100644 index 0000000..20c3022 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/CaptureEditorElementCommand.cs @@ -0,0 +1,78 @@ +#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. + /// + 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, + Tags = new[] { "capture", "ui" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/CaptureEditorElementCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/CaptureEditorElementCommand.cs.meta new file mode 100644 index 0000000..1b875f3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/EditorStatusCommand.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/EditorStatusCommand.cs new file mode 100644 index 0000000..2035d81 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + static class EditorStatusCommand + { + [CliCommand("editor_status", "Get detailed Unity Editor status and state information", Tags = new[] { "editor" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/EditorStatusCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/EditorStatusCommand.cs.meta new file mode 100644 index 0000000..5699589 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/FocusEditorCommand.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/FocusEditorCommand.cs new file mode 100644 index 0000000..c330464 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/FocusEditorCommand.cs @@ -0,0 +1,124 @@ +using System; +using System.Reflection; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEngine; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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. + /// +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + 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, Tags = new[] { "editor" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/FocusEditorCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/FocusEditorCommand.cs.meta new file mode 100644 index 0000000..77c9f41 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects.meta new file mode 100644 index 0000000..ccc362e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c2281ed89e8a4db9a14ec523905221f9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/ComponentCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/ComponentCommands.cs new file mode 100644 index 0000000..c38bb70 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/ComponentCommands.cs @@ -0,0 +1,214 @@ +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. + /// + 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.", Tags = new[] { "gameobjects/components" })] + 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.", + Tags = new[] { "gameobjects/components" })] + 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.", + Tags = new[] { "gameobjects/components" })] + 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.", + Tags = new[] { "gameobjects/components" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/ComponentCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/ComponentCommands.cs.meta new file mode 100644 index 0000000..37d0ba6 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/ComponentCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1a7470ef26f8497595bcbdb84ceb96dc diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/ComponentPropertiesResult.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/ComponentPropertiesResult.cs new file mode 100644 index 0000000..942ffd8 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/ComponentPropertiesResult.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/ComponentPropertiesResult.cs.meta new file mode 100644 index 0000000..9a9f83c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/ComponentPropertiesResult.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e87b3f2873ff4bac9229a28242917ac0 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/CreateGameObjectsResult.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/CreateGameObjectsResult.cs new file mode 100644 index 0000000..4ebd0bd --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/CreateGameObjectsResult.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/CreateGameObjectsResult.cs.meta new file mode 100644 index 0000000..d78945b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/FindGameObjectsResult.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/FindGameObjectsResult.cs new file mode 100644 index 0000000..eedbd7a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/FindGameObjectsResult.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/FindGameObjectsResult.cs.meta new file mode 100644 index 0000000..23d924c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/FindGameObjectsResult.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1d12bf4a53c14a839c26a91e8ec68292 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/GameObjectCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/GameObjectCommands.cs new file mode 100644 index 0000000..1388faa --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/GameObjectCommands.cs @@ -0,0 +1,538 @@ +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. + /// + 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.", + Tags = new[] { "gameobjects" })] + 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.", + Tags = new[] { "gameobjects" })] + 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.", + Tags = new[] { "gameobjects" })] + 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.", + Tags = new[] { "gameobjects" })] + 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.", + Tags = new[] { "gameobjects" })] + 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).", Tags = new[] { "gameobjects" })] + 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).", Tags = new[] { "gameobjects" })] + 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).", Tags = new[] { "gameobjects" })] + 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.", Tags = new[] { "gameobjects" })] + 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).", Tags = new[] { "gameobjects" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/GameObjectCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/GameObjectCommands.cs.meta new file mode 100644 index 0000000..eb0a58b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/GameObjectCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a1aac051d9234c78b4918a8dd366514a diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/SerializedPropertyConverter.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/SerializedPropertyConverter.cs new file mode 100644 index 0000000..5225659 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/SerializedPropertyConverter.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/SerializedPropertyConverter.cs.meta new file mode 100644 index 0000000..b42d407 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/SerializedPropertyConverter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 85f605c1109e40898fe47ff1e2a8a4d0 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/TypeResolver.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/TypeResolver.cs new file mode 100644 index 0000000..2785d67 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/TypeResolver.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/TypeResolver.cs.meta new file mode 100644 index 0000000..5779266 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/GameObjects/TypeResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f033287d429f4957bfcfd6795f731b48 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials.meta new file mode 100644 index 0000000..64b6857 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4841c84bba2a471eb3fb429f79e3b118 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/MaterialCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/MaterialCommands.cs new file mode 100644 index 0000000..bd17666 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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).", + Tags = new[] { "materials" })] + 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). + // Unity 2022 lacks rawRenderQueue; fall back to the effective renderQueue. +#if UNITY_6000_0_OR_NEWER + RenderQueue = mat.rawRenderQueue, +#else + RenderQueue = mat.renderQueue, +#endif + 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[].", + Tags = new[] { "materials" })] + 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; + } + + /// + /// 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. + /// + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/MaterialCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/MaterialCommands.cs.meta new file mode 100644 index 0000000..b68b4f0 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/MaterialCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a14c4a97ac9e4d61b904de30d1016d92 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/MaterialResults.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/MaterialResults.cs new file mode 100644 index 0000000..4978078 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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] + 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] + 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] + 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] + 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] + 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] + 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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/MaterialResults.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/MaterialResults.cs.meta new file mode 100644 index 0000000..db85ad6 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/MaterialResults.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 52b9d1506e9b4a95982ff554d70179b3 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/MaterialValueConverter.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/MaterialValueConverter.cs new file mode 100644 index 0000000..ae4d5f2 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/MaterialValueConverter.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/MaterialValueConverter.cs.meta new file mode 100644 index 0000000..4445fb6 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/MaterialValueConverter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc8956be3c264ccb8096787d7146f330 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/ShaderCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/ShaderCommands.cs new file mode 100644 index 0000000..bc67cc1 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/ShaderCommands.cs @@ -0,0 +1,188 @@ +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. + /// + 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 }].", + Tags = new[] { "materials/shaders" })] + 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).", + Tags = new[] { "materials/shaders" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/ShaderCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/ShaderCommands.cs.meta new file mode 100644 index 0000000..0c5e23b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Materials/ShaderCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1007e4472d7b4216a6a866f8aaba76c7 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/MenuItemCommand.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/MenuItemCommand.cs new file mode 100644 index 0000000..472a9e8 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/MenuItemCommand.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; +using Unity.Pipeline.Commands; +using UnityEditor; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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. + /// +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + static class MenuItemCommand + { + [CliCommand("menu", "Execute an Editor menu item by path, or list available items when no path is given", MainThreadRequired = true, Tags = new[] { "editor" })] + 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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/MenuItemCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/MenuItemCommand.cs.meta new file mode 100644 index 0000000..92571af --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/MenuItemCommand.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f48719fc11d44809a97c890b8317c0d7 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Navigation.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Navigation.meta new file mode 100644 index 0000000..2dcfd0d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Navigation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 31cd60e0286e77f4cb5baaaa7a3d6377 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Navigation/NavigationCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Navigation/NavigationCommands.cs new file mode 100644 index 0000000..383fb98 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + static class NavigationCommands + { + private const int MaxSearchResults = 200; + + [CliCommand("get_selection", "Read the current Editor selection as structured object identities.", Tags = new[] { "navigation" })] + 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.", Tags = new[] { "navigation" })] + 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.", Tags = new[] { "navigation" })] + 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] + 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] + 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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Navigation/NavigationCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Navigation/NavigationCommands.cs.meta new file mode 100644 index 0000000..353e826 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Observability.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Observability.meta new file mode 100644 index 0000000..e92c7b4 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Observability.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7a7cc3922a14b4741a17d985bb6be540 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Observability/PerformanceCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Observability/PerformanceCommands.cs new file mode 100644 index 0000000..7ea0bf3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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] + 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] + 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] + 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] + 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. + /// + static class PerformanceCommands + { + [CliCommand("get_performance_stats", "Read render, memory, and frame-timing stats (structured, read-only).", Tags = new[] { "observability/performance" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Observability/PerformanceCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Observability/PerformanceCommands.cs.meta new file mode 100644 index 0000000..1a6dec8 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager.meta new file mode 100644 index 0000000..c9a1dc1 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageIdentifier.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageIdentifier.cs new file mode 100644 index 0000000..15c35f9 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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). + 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. + 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. + /// + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageIdentifier.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageIdentifier.cs.meta new file mode 100644 index 0000000..2e8ec86 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageManagerCommand.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageManagerCommand.cs new file mode 100644 index 0000000..6dc8fb9 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageManagerCommand.cs @@ -0,0 +1,656 @@ +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; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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] +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + 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, + Tags = new[] { "packages" })] + 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, + Tags = new[] { "packages" })] + 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, + Tags = new[] { "packages" })] + 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, + Tags = new[] { "packages" })] + 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, + Tags = new[] { "packages" })] + 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, + Tags = new[] { "packages" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageManagerCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageManagerCommand.cs.meta new file mode 100644 index 0000000..d322b20 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageManifest.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageManifest.cs new file mode 100644 index 0000000..27b4aa8 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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 + /// ). + /// + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageManifest.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageManifest.cs.meta new file mode 100644 index 0000000..8608bd4 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageModels.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageModels.cs new file mode 100644 index 0000000..09a9195 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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] + 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] + 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] + 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] + 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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageModels.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/PackageManager/PackageModels.cs.meta new file mode 100644 index 0000000..01d45bd --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/PlayModeCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/PlayModeCommands.cs new file mode 100644 index 0000000..b06d056 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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", Tags = new[] { "editor/playmode" })] + 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", Tags = new[] { "editor/playmode" })] + 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", "Toggle pause state of Unity Editor play mode", Tags = new[] { "editor/playmode" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/PlayModeCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/PlayModeCommands.cs.meta new file mode 100644 index 0000000..2eecf4b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Prefabs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Prefabs.meta new file mode 100644 index 0000000..23a327d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Prefabs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1e6ed0649e884a59b7403a2f28a87644 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Prefabs/PrefabCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Prefabs/PrefabCommands.cs new file mode 100644 index 0000000..6797d7e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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.", Tags = new[] { "prefabs" })] + 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.", Tags = new[] { "prefabs" })] + 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.", Tags = new[] { "prefabs" })] + 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.", Tags = new[] { "prefabs" })] + 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.", Tags = new[] { "prefabs" })] + 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).", Tags = new[] { "prefabs" })] + 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).", Tags = new[] { "prefabs" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Prefabs/PrefabCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Prefabs/PrefabCommands.cs.meta new file mode 100644 index 0000000..e016724 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Prefabs/PrefabCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f7c0d242da0d4deb9c5138d8966c51e2 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectAudit.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectAudit.meta new file mode 100644 index 0000000..1eb046b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectAudit.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a511f17b426c94dbb8b78d0869749d40 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectAudit/ProjectAuditCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectAudit/ProjectAuditCommands.cs new file mode 100644 index 0000000..933f533 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectAudit/ProjectAuditCommands.cs @@ -0,0 +1,604 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using Newtonsoft.Json; +using Unity.Pipeline.Commands; +using UnityEditor; +using UnityEngine; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +namespace Unity.Pipeline.Editor.Commands.ProjectAudit +{ + /// + /// Exposes Unity's Project Auditor static-analysis scan as audit / audit_status: + /// start a scan, poll it, read a CSV of the reported issues. + /// + /// Project Auditor is reached entirely by reflection (as reaches + /// its editor internals): this package must compile and run in editors without Project Auditor, so + /// it takes no asmdef reference and no package dependency. One reflection path covers both Project + /// Auditor deployments — the built-in editor module and the com.unity.project-auditor package — + /// because they share the type name and public surface. They do not share an assembly name, so the + /// lookup scans loaded assemblies rather than naming one; see . + /// + /// AuditAsync is only partly asynchronous: it blocks its caller through assembly + /// compilation and the first analysis phase, then finishes on its own background thread and fires + /// OnCompleted there (or synchronously, when Code analysis is out of scope). So + /// audit only enqueues — an hook runs the scan on a + /// later tick — and audit_status answers off the main thread while compilation holds it. + /// + /// A resolvable Project Auditor is not necessarily a usable one: as a built-in editor module its + /// rules ship in the separate com.unity.project-auditor-rules package, and without them it + /// registers no modules and produces an empty report indistinguishable from a clean project. + /// Hence the registered-modules check, reported as unavailable. + /// + [InitializeOnLoad] +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + static class ProjectAuditCommands + { + const string StatusFile = "Temp/pipeline_audit_status.json"; + const string CsvDirectory = "Temp/pipeline-audit"; + const string RulesPackageName = "com.unity.project-auditor-rules"; + const string AuditorTypeName = "Unity.ProjectAuditor.Editor.ProjectAuditor"; + + static readonly object s_Lock = new object(); + + // Enqueued by audit(); consumed by the OnUpdate pump on the next main-thread tick. + static bool s_Pending; + // True from when the pump starts a scan until OnDone (completion) or a start failure. + static bool s_Scanning; + // Category array for AnalysisParams.Categories (element type varies by deployment), or null = all. + static object s_PendingCategories; + static string s_ScanId; + static string s_CsvPath; + // Last status JSON successfully written; lets audit_status answer while a write is in flight. + static volatile string s_LastStatusJson; + + static ProjectAuditCommands() + { + // A scan cannot survive a domain reload (its background thread and our static state are + // gone), so flip any dangling "scanning" status to "interrupted" before watching again. + ReconcileAfterReload(); + EditorApplication.update += OnUpdate; + } + + [CliCommand("audit", "Run a Project Auditor static-analysis scan. Returns immediately; poll audit_status until status is 'completed', then read the CSV.", MainThreadRequired = false, Tags = new[] { "observability/audit" })] + public static object Audit( + [CliArg("categories", "Comma-separated issue categories to scan (e.g. Code,ProjectSetting,Texture). Default: all categories.")] string categories = "", + [CliArg("output", "CSV output path (absolute or relative to the project root). Defaults to Temp/pipeline-audit/.csv.")] string output = "") + { + if (!TryResolve(out var pa, out var error)) + return new { status = "unavailable", message = error }; + + var requested = ParseCategoryNames(categories); + var validNames = Enum.GetNames(pa.IssueCategoryType); + var validationError = ValidateCategories(requested, validNames); + if (validationError != null) + return new { status = "error", message = validationError }; + + lock (s_Lock) + { + if (s_Pending || s_Scanning) + return new { status = "busy", message = "An audit is already queued or running. Poll audit_status." }; + + s_ScanId = Guid.NewGuid().ToString("N").Substring(0, 8); + s_CsvPath = ResolveCsvPath(output, s_ScanId); + s_PendingCategories = BuildCategoriesArray(pa, requested); + s_Pending = true; + + // Written inside the lock: the pump can take it on the very next frame and write a + // terminal status synchronously (unavailable, or completed when the scan runs inline). + // Writing after the release would clobber that, wedging audit_status on "scanning". + WriteStatus(new AuditStatus { Status = "scanning", ScanId = s_ScanId, CsvPath = s_CsvPath }); + } + + return new { status = "scanning", scanId = s_ScanId, csvPath = s_CsvPath }; + } + + [CliCommand("audit_status", "Get the status of the last audit: idle | scanning | completed | failed | interrupted | unavailable.", MainThreadRequired = false, Tags = new[] { "observability/audit" })] + public static string AuditStatus() + { + if (!TryResolve(out _, out var error)) + return JsonConvert.SerializeObject(new AuditStatus { Status = "unavailable", Message = error }); + + if (!File.Exists(StatusFile)) + return "{\"status\":\"idle\"}"; + + // WriteStatus truncates then rewrites, and its writer is usually another thread (the pump, + // or Project Auditor's background thread) while this read serves an HTTP worker. A read + // landing mid-write either throws (sharing violation) or returns truncated JSON, so retry, + // then fall back to the last status we wrote - the file will match it once the write lands. + for (var attempt = 0; attempt < 3; attempt++) + { + if (attempt > 0) + Thread.Sleep(20); + + try + { + var json = File.ReadAllText(StatusFile); + // WriteStatus writes serialized JSON with nothing after it, so a closing '}' means + // this read caught a whole object rather than a half-written one. + if (json.Length > 1 && json[json.Length - 1] == '}') + return json; + } + catch (IOException) + { + // Mid-write collision; retry. + } + } + + return s_LastStatusJson ?? File.ReadAllText(StatusFile); + } + + /// + /// Main-thread pump: picks up a queued request and runs the audit. Building AnalysisParams and + /// calling AuditAsync must happen on the main thread; AuditAsync then blocks here through + /// compilation and returns once the remaining analysis is handed to its background thread. + /// + static void OnUpdate() + { + object categories; + lock (s_Lock) + { + if (!s_Pending || s_Scanning) + return; + s_Pending = false; + s_Scanning = true; + categories = s_PendingCategories; + } + + try + { + if (!RunAudit(categories)) + { + // RunAudit wrote the terminal status and OnDone will not run: release the guard here. + lock (s_Lock) { s_Scanning = false; } + } + } + catch (Exception ex) + { + // Only up-front and synchronous failures reach here; once AuditAsync has handed off to + // its background thread, OnDone owns both the outcome and s_Scanning. + WriteStatus(new AuditStatus { Status = "failed", ScanId = s_ScanId, Error = ex.Message }); + lock (s_Lock) { s_Scanning = false; } + } + } + + /// + /// Starts the scan. Returns false when no scan was started, in which case the terminal status + /// has already been written and no completion callback will fire. + /// + static bool RunAudit(object categories) + { + if (!TryResolve(out var pa, out var error)) + throw new InvalidOperationException(error); + + // Constructing ProjectAuditor is what initializes its modules, so this is the earliest + // point at which the "has usable rules" precondition can be checked. + var auditor = Activator.CreateInstance(pa.ProjectAuditorType); + if (HasNoModules(pa, auditor)) + { + WriteStatus(new AuditStatus + { + Status = "unavailable", + ScanId = s_ScanId, + Message = "Project Auditor registered no analysis modules, so it cannot analyze this " + + $"project. Install the Project Auditor Rules package ({RulesPackageName})." + }); + return false; + } + + var analysisParams = Activator.CreateInstance(pa.AnalysisParamsType, new object[] { true }); + if (categories != null) + pa.CategoriesField.SetValue(analysisParams, categories); + + // Wire OnCompleted, which is an Action. OnDone takes 'object'; relaxed delegate + // binding permits it because Report derives from object (reference-type contravariance). + var actionType = typeof(Action<>).MakeGenericType(pa.ReportType); + var onDone = Delegate.CreateDelegate(actionType, typeof(ProjectAuditCommands) + .GetMethod(nameof(OnDone), BindingFlags.NonPublic | BindingFlags.Static)); + pa.OnCompletedField.SetValue(analysisParams, onDone); + + // AuditAsync(AnalysisParams, IProgress); a null progress means no progress and no cancel hook. + pa.AuditAsyncMethod.Invoke(auditor, new[] { analysisParams, null }); + return true; + } + + /// No registered modules means the rules package is missing: a scan would complete + /// instantly with an empty report. False when the reflected accessor is absent. + static bool HasNoModules(ResolvedProjectAuditor pa, object auditor) + { + if (pa.GetModulesMethod == null) + return false; + return pa.GetModulesMethod.Invoke(auditor, null) is ICollection modules && modules.Count == 0; + } + + /// + /// Completion callback bound to AnalysisParams.OnCompleted. Fires on Project Auditor's + /// background analysis thread (or synchronously inside AuditAsync when Code analysis is not in + /// scope). Only does thread-safe work: reads the report and writes the CSV + status files. + /// + static void OnDone(object report) + { + try + { + if (!TryResolve(out var pa, out var error)) + throw new InvalidOperationException(error); + + var rows = CollectRows(pa, report); + WriteCsv(s_CsvPath, rows); + WriteStatus(new AuditStatus + { + Status = "completed", + ScanId = s_ScanId, + CsvPath = s_CsvPath, + IssueCount = rows.Count + }); + } + catch (Exception ex) + { + WriteStatus(new AuditStatus { Status = "failed", ScanId = s_ScanId, Error = ex.Message }); + } + finally + { + lock (s_Lock) { s_Scanning = false; } + } + } + + /// + /// Enumerate the report's issues (ReportItems whose descriptor is valid — i.e. diagnostics, not + /// raw-inventory insights) and project each to a CSV row. Reflection only; safe off-thread. + /// + static List CollectRows(ResolvedProjectAuditor pa, object report) + { + var rows = new List(); + var all = pa.GetAllIssuesMethod.Invoke(report, null) as IEnumerable; + if (all == null) + return rows; + + foreach (var item in all) + { + if (item == null || !(bool)pa.IsIssueMethod.Invoke(item, null)) + continue; + + var descriptor = pa.GetDescriptorMethod.Invoke(pa.IdProperty.GetValue(item), null); + var line = (int)pa.LineProperty.GetValue(item); + + rows.Add(new[] + { + pa.CategoryProperty.GetValue(item)?.ToString() ?? string.Empty, + pa.SeverityProperty.GetValue(item)?.ToString() ?? string.Empty, + pa.AreasField.GetValue(descriptor)?.ToString() ?? string.Empty, + pa.DescriptionProperty.GetValue(item) as string ?? string.Empty, + pa.RelativePathProperty.GetValue(item) as string ?? string.Empty, + line > 0 ? line.ToString() : string.Empty, + pa.IdField.GetValue(descriptor) as string ?? string.Empty, + pa.RecommendationField.GetValue(descriptor) as string ?? string.Empty, + }); + } + + return rows; + } + + static readonly string[] k_CsvHeader = + { + "Category", "Severity", "Areas", "Description", "RelativePath", "Line", "DescriptorId", "Recommendation" + }; + + static void WriteCsv(string path, List rows) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + var sb = new StringBuilder(); + AppendCsvRow(sb, k_CsvHeader); + foreach (var row in rows) + AppendCsvRow(sb, row); + + File.WriteAllText(path, sb.ToString()); + } + + static void AppendCsvRow(StringBuilder sb, string[] fields) + { + for (var i = 0; i < fields.Length; i++) + { + if (i > 0) + sb.Append(','); + sb.Append(EscapeCsv(fields[i])); + } + sb.Append("\r\n"); + } + + /// RFC 4180 escaping: quote a field containing a comma, quote or newline; double any embedded quotes. + internal static string EscapeCsv(string field) + { + field = field ?? string.Empty; + if (field.IndexOfAny(new[] { ',', '"', '\n', '\r' }) < 0) + return field; + return "\"" + field.Replace("\"", "\"\"") + "\""; + } + + /// + /// Returns null when every requested category name is valid, otherwise an error message listing + /// the unknown names and the valid values. Pure (valid names injected) so it needs no Project + /// Auditor types to test. + /// + internal static string ValidateCategories(IReadOnlyList requested, IReadOnlyCollection validNames) + { + if (requested == null || requested.Count == 0) + return null; + + var unknown = requested + .Where(r => !validNames.Any(v => string.Equals(v, r, StringComparison.OrdinalIgnoreCase))) + .ToList(); + if (unknown.Count == 0) + return null; + + return $"Unknown categor{(unknown.Count == 1 ? "y" : "ies")}: {string.Join(", ", unknown)}. " + + $"Valid categories: {string.Join(", ", validNames)}."; + } + + static List ParseCategoryNames(string categories) + { + if (string.IsNullOrWhiteSpace(categories)) + return new List(); + return categories.Split(',') + .Select(c => c.Trim()) + .Where(c => c.Length > 0) + .ToList(); + } + + /// Build the array for AnalysisParams.Categories, or null when no filter was requested (= all). + static object BuildCategoriesArray(ResolvedProjectAuditor pa, IReadOnlyList requested) + { + if (requested.Count == 0) + return null; + return BuildCategoryArray(pa.CategoryElementType, pa.CategoryWrapperCtor, pa.IssueCategoryType, requested); + } + + /// + /// Build the typed category array. The element type is a deployment detail — the standalone + /// package stores the bare enum, the built-in module a serialization wrapper — so it and its + /// wrapping constructor are passed in, which also makes this testable without Project Auditor. + /// + internal static Array BuildCategoryArray(Type elementType, ConstructorInfo wrapperCtor, Type enumType, + IReadOnlyList requested) + { + var array = Array.CreateInstance(elementType, requested.Count); + for (var i = 0; i < requested.Count; i++) + { + var category = Enum.Parse(enumType, requested[i], ignoreCase: true); + array.SetValue(wrapperCtor == null ? category : wrapperCtor.Invoke(new[] { category }), i); + } + return array; + } + + static string ResolveCsvPath(string output, string scanId) + { + var projectRoot = Path.GetDirectoryName(Application.dataPath); + if (!string.IsNullOrWhiteSpace(output)) + return Path.IsPathRooted(output) ? output : Path.GetFullPath(Path.Combine(projectRoot, output)); + return Path.Combine(projectRoot, CsvDirectory, scanId + ".csv"); + } + + static void ReconcileAfterReload() + { + if (!File.Exists(StatusFile)) + return; + + AuditStatus prior; + try + { + prior = JsonConvert.DeserializeObject(File.ReadAllText(StatusFile)); + } + catch + { + return; + } + + if (prior != null && prior.Status == "scanning") + WriteStatus(new AuditStatus { Status = "interrupted", ScanId = prior.ScanId }); + } + + static void WriteStatus(AuditStatus status) + { + var json = JsonConvert.SerializeObject(status); + try + { + File.WriteAllText(StatusFile, json); + // Cached only after a successful write, so the audit_status fallback can never report a + // status the file does not hold. + s_LastStatusJson = json; + } + catch (Exception ex) + { + Debug.LogError($"[ProjectAudit] Failed to write status file: {ex.Message}"); + } + } + + #region reflection resolution + + static readonly object s_ResolveLock = new object(); + static ResolvedProjectAuditor s_Resolved; + + /// + /// Resolve the Project Auditor types/members once (thread-safe: audit_status runs off the main + /// thread). Returns false with a clear message when Project Auditor is not installed or an + /// expected member is missing (e.g. an incompatible version). + /// + static bool TryResolve(out ResolvedProjectAuditor pa, out string error) + { + lock (s_ResolveLock) + { + if (s_Resolved != null) + { + pa = s_Resolved; + error = null; + return true; + } + + pa = null; + error = null; + + var auditorType = FindLoadedType(AuditorTypeName); + if (auditorType == null) + { + error = $"Project Auditor is not installed in this Editor ({AuditorTypeName} not found)."; + return false; + } + + var asm = auditorType.Assembly; + try + { + var resolved = new ResolvedProjectAuditor(auditorType, asm); + s_Resolved = resolved; + pa = resolved; + return true; + } + catch (Exception ex) + { + error = $"Project Auditor is present but its API could not be resolved (version mismatch?): {ex.Message}"; + return false; + } + } + } + + /// + /// Find a type by full name in any loaded assembly. The assembly name cannot be used to narrow + /// the search: the same type ships in Unity.ProjectAuditor.Editor as a package but in + /// UnityEditor.ProjectAuditorModule as a built-in editor module (measured on 6000.7), so + /// an assembly-qualified finds only the package deployment. + /// + static Type FindLoadedType(string fullName) + { + foreach (var asm in PipelineUtils.GetLoadedAssemblies()) + { + var type = asm.GetType(fullName, throwOnError: false); + if (type != null) + return type; + } + + return null; + } + + #endregion + } + + /// + /// Cached Project Auditor reflection handles. The constructor throws if any expected member is + /// missing, so can report a clean "unavailable" rather than + /// failing mid-scan. All members come from the one assembly that declares ProjectAuditor. + /// + sealed class ResolvedProjectAuditor + { + public readonly Type ProjectAuditorType; + public readonly Type AnalysisParamsType; + public readonly Type ReportType; + public readonly Type IssueCategoryType; + + public readonly MethodInfo AuditAsyncMethod; + public readonly FieldInfo CategoriesField; + // Element type of AnalysisParams.Categories: the bare IssueCategory in the standalone Project + // Auditor package, a SerializableEnum wrapper in the built-in editor module. + public readonly Type CategoryElementType; + // Constructor wrapping an IssueCategory into that element type; null when it is the bare enum. + public readonly ConstructorInfo CategoryWrapperCtor; + // Optional (internal API): when absent, the registered-modules precondition check is skipped. + public readonly MethodInfo GetModulesMethod; + public readonly FieldInfo OnCompletedField; + + public readonly MethodInfo GetAllIssuesMethod; + public readonly MethodInfo IsIssueMethod; + public readonly PropertyInfo CategoryProperty; + public readonly PropertyInfo SeverityProperty; + public readonly PropertyInfo DescriptionProperty; + public readonly PropertyInfo RelativePathProperty; + public readonly PropertyInfo LineProperty; + public readonly PropertyInfo IdProperty; + + public readonly MethodInfo GetDescriptorMethod; + public readonly FieldInfo IdField; + public readonly FieldInfo AreasField; + public readonly FieldInfo RecommendationField; + + public ResolvedProjectAuditor(Type auditorType, Assembly asm) + { + ProjectAuditorType = auditorType; + AnalysisParamsType = Require(asm, "Unity.ProjectAuditor.Editor.AnalysisParams"); + ReportType = Require(asm, "Unity.ProjectAuditor.Editor.Report"); + IssueCategoryType = Require(asm, "Unity.ProjectAuditor.Editor.IssueCategory"); + var reportItemType = Require(asm, "Unity.ProjectAuditor.Editor.ReportItem"); + var descriptorType = Require(asm, "Unity.ProjectAuditor.Editor.Descriptor"); + + AuditAsyncMethod = Require(ProjectAuditorType.GetMethods() + .FirstOrDefault(m => m.Name == "AuditAsync" && m.GetParameters().Length == 2), "ProjectAuditor.AuditAsync"); + CategoriesField = Require(AnalysisParamsType.GetField("Categories"), "AnalysisParams.Categories"); + CategoryElementType = Require(CategoriesField.FieldType.GetElementType(), "AnalysisParams.Categories element type"); + CategoryWrapperCtor = CategoryElementType == IssueCategoryType + ? null + : Require(CategoryElementType.GetConstructor(new[] { IssueCategoryType }), + $"{CategoryElementType.Name}(IssueCategory) constructor"); + GetModulesMethod = ProjectAuditorType.GetMethod("GetModules", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, Type.EmptyTypes, null); + OnCompletedField = Require(AnalysisParamsType.GetField("OnCompleted"), "AnalysisParams.OnCompleted"); + + GetAllIssuesMethod = Require(ReportType.GetMethod("GetAllIssues", Type.EmptyTypes), "Report.GetAllIssues"); + IsIssueMethod = Require(reportItemType.GetMethod("IsIssue", Type.EmptyTypes), "ReportItem.IsIssue"); + CategoryProperty = Require(reportItemType.GetProperty("Category"), "ReportItem.Category"); + SeverityProperty = Require(reportItemType.GetProperty("Severity"), "ReportItem.Severity"); + DescriptionProperty = Require(reportItemType.GetProperty("Description"), "ReportItem.Description"); + RelativePathProperty = Require(reportItemType.GetProperty("RelativePath"), "ReportItem.RelativePath"); + LineProperty = Require(reportItemType.GetProperty("Line"), "ReportItem.Line"); + IdProperty = Require(reportItemType.GetProperty("Id"), "ReportItem.Id"); + + GetDescriptorMethod = Require(IdProperty.PropertyType.GetMethod("GetDescriptor", Type.EmptyTypes), "DescriptorId.GetDescriptor"); + IdField = Require(descriptorType.GetField("Id"), "Descriptor.Id"); + AreasField = Require(descriptorType.GetField("Areas"), "Descriptor.Areas"); + RecommendationField = Require(descriptorType.GetField("Recommendation"), "Descriptor.Recommendation"); + } + + static Type Require(Assembly asm, string fullName) + { + return Require(asm.GetType(fullName, throwOnError: false), fullName); + } + + static T Require(T member, string name) where T : class + { + if (member == null) + throw new MissingMemberException($"Project Auditor member not found: {name}"); + return member; + } + } + + /// Status/result payload for the audit / audit_status commands. + [Serializable] + class AuditStatus + { + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("scanId", NullValueHandling = NullValueHandling.Ignore)] + public string ScanId { get; set; } + + [JsonProperty("csvPath", NullValueHandling = NullValueHandling.Ignore)] + public string CsvPath { get; set; } + + [JsonProperty("issueCount", NullValueHandling = NullValueHandling.Ignore)] + public int? IssueCount { get; set; } + + [JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)] + public string Error { get; set; } + + [JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)] + public string Message { get; set; } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectAudit/ProjectAuditCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectAudit/ProjectAuditCommands.cs.meta new file mode 100644 index 0000000..213f94c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectAudit/ProjectAuditCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 16350a6f8b1204eeea6649d939abc4b8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings.meta new file mode 100644 index 0000000..6a5e044 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8fe6d0edfccd2e8429890cf65cb7417c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/AudioSettingsCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/AudioSettingsCommands.cs new file mode 100644 index 0000000..c0b0177 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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, Tags = new[] { "settings/audio" })] + 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, Tags = new[] { "settings/audio" })] + 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. + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/AudioSettingsCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/AudioSettingsCommands.cs.meta new file mode 100644 index 0000000..ebc8107 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/GraphicsSettingsCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/GraphicsSettingsCommands.cs new file mode 100644 index 0000000..0828dee --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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). + /// + static class GraphicsSettingsCommands + { + const string Group = "graphics"; + + [CliCommand("get_graphics_settings", "Read GraphicsSettings (default render pipeline).", MainThreadRequired = true, Tags = new[] { "settings/graphics" })] + 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, Tags = new[] { "settings/graphics" })] + 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. + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/GraphicsSettingsCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/GraphicsSettingsCommands.cs.meta new file mode 100644 index 0000000..f14c08d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/InputSettingsCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/InputSettingsCommands.cs new file mode 100644 index 0000000..56d2209 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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, Tags = new[] { "settings/input" })] + 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, Tags = new[] { "settings/input" })] + 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). + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/InputSettingsCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/InputSettingsCommands.cs.meta new file mode 100644 index 0000000..582c4a0 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/PhysicsSettingsCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/PhysicsSettingsCommands.cs new file mode 100644 index 0000000..b0a89c1 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + static class PhysicsSettingsCommands + { + const string Group = "physics"; + + [CliCommand("get_physics_settings", "Read Physics settings (gravity, solver iterations, bounce threshold).", MainThreadRequired = true, Tags = new[] { "settings/physics" })] + 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, Tags = new[] { "settings/physics" })] + 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. + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/PhysicsSettingsCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/PhysicsSettingsCommands.cs.meta new file mode 100644 index 0000000..6ce0c1b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/PlayerSettingsCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/PlayerSettingsCommands.cs new file mode 100644 index 0000000..efb0191 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + static class PlayerSettingsCommands + { + const string Group = "player"; + + [CliCommand("get_player_settings", "Read PlayerSettings (company/product/version, scripting backend, API level).", MainThreadRequired = true, Tags = new[] { "settings/player" })] + 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, Tags = new[] { "settings/player" })] + 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. + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/PlayerSettingsCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/PlayerSettingsCommands.cs.meta new file mode 100644 index 0000000..1ef2ad2 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/ProjectSettingsAsset.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/ProjectSettingsAsset.cs new file mode 100644 index 0000000..0c8bcb5 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/ProjectSettingsAsset.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/ProjectSettingsAsset.cs.meta new file mode 100644 index 0000000..5f9d933 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/ProjectSettingsCommand.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/ProjectSettingsCommand.cs new file mode 100644 index 0000000..b645793 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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). + /// + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/ProjectSettingsCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/ProjectSettingsCommand.cs.meta new file mode 100644 index 0000000..87ad679 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/ProjectSettingsResponse.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/ProjectSettingsResponse.cs new file mode 100644 index 0000000..6131fd9 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/ProjectSettingsResponse.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/ProjectSettingsResponse.cs.meta new file mode 100644 index 0000000..200fa82 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/QualitySettingsCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/QualitySettingsCommands.cs new file mode 100644 index 0000000..6cbeebc --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + static class QualitySettingsCommands + { + const string Group = "quality"; + + [CliCommand("get_quality_settings", "Read QualitySettings (current level, level names, vSync, anti-aliasing).", MainThreadRequired = true, Tags = new[] { "settings/quality" })] + 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, Tags = new[] { "settings/quality" })] + 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. + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/QualitySettingsCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/QualitySettingsCommands.cs.meta new file mode 100644 index 0000000..359020a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/RuntimePipelineSettingsCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/RuntimePipelineSettingsCommands.cs new file mode 100644 index 0000000..62b7b54 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/RuntimePipelineSettingsCommands.cs @@ -0,0 +1,151 @@ +using System.Collections.Generic; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Config; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands.ProjectSettings +{ + /// + /// Get/set the Pipeline Runtime settings (Project Settings > Pipeline > Runtime), i.e. + /// RuntimePipelineConfig: whether the runtime Pipeline server is enabled in builds, its port, + /// timeouts, audit logging, auto-start, and per-frame work item budget. This is the structured + /// equivalent of what a human does through that Project Settings page — the config has no + /// scene GameObject or asset to script against directly (see RuntimePipelineConfig.Load/Save). + /// + static class RuntimePipelineSettingsCommands + { + const string Group = "runtime_pipeline"; + + // Must stay in sync with RuntimePipelineConfig's own enforcement: OnValidate() clamps port + // to this same range, and requestTimeoutMs/maxWorkItemsPerFrame carry matching [Range] + // attributes — but neither is enforced on a direct field set like Apply() below performs, + // so Set() must check them itself. The 7900-7999 "recommended" port range is deliberately + // NOT enforced here: that's Validate()'s job as a build-time warning, not a hard rejection. + const int MinPort = 0, MaxPort = 65535; + const int MinRequestTimeoutMs = 1000, MaxRequestTimeoutMs = 60000; + const int MinMaxWorkItemsPerFrame = 1, MaxMaxWorkItemsPerFrame = 50; + + [CliCommand("get_runtime_pipeline_settings", "Read Pipeline Runtime settings (enableInBuilds, port, requestTimeoutMs, enableAuditLogging, autoStart, maxWorkItemsPerFrame). Reports the settings in effect, falling back to built-in defaults when none have been authored; never creates the settings file.", MainThreadRequired = true, Tags = new[] { "settings/runtime_pipeline" })] + public static ProjectSettingsResponse Get() => ProjectSettingsCommand.Get(Group, Read); + + [CliCommand("set_runtime_pipeline_settings", "Change Pipeline Runtime settings. Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z. Refused while in Play Mode: the running driver already loaded its own settings, so a change would silently not apply until the next Play session or build. port, requestTimeoutMs, and maxWorkItemsPerFrame are rejected outright if outside the same bounds the Project Settings page enforces.", MainThreadRequired = true, Tags = new[] { "settings/runtime_pipeline" })] + public static ProjectSettingsResponse Set( + [CliArg("settings", "Fields to change; omitted fields are left unchanged.")] RuntimePipelineSettingsInput 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 (EditorApplication.isPlaying) + { + return ProjectSettingsResponse.Fail(Group, + "Refused: Pipeline Runtime settings are read-only while in Play Mode — the running " + + "driver already loaded its own settings, so a change here would silently not apply " + + "until the next Play session or build."); + } + + var violations = new List(); + if (settings.Port.HasValue && (settings.Port.Value < MinPort || settings.Port.Value > MaxPort)) + violations.Add($"port {settings.Port.Value} out of range ({MinPort}..{MaxPort})."); + if (settings.RequestTimeoutMs.HasValue && (settings.RequestTimeoutMs.Value < MinRequestTimeoutMs || settings.RequestTimeoutMs.Value > MaxRequestTimeoutMs)) + violations.Add($"requestTimeoutMs {settings.RequestTimeoutMs.Value} out of range ({MinRequestTimeoutMs}..{MaxRequestTimeoutMs})."); + if (settings.MaxWorkItemsPerFrame.HasValue && (settings.MaxWorkItemsPerFrame.Value < MinMaxWorkItemsPerFrame || settings.MaxWorkItemsPerFrame.Value > MaxMaxWorkItemsPerFrame)) + violations.Add($"maxWorkItemsPerFrame {settings.MaxWorkItemsPerFrame.Value} out of range ({MinMaxWorkItemsPerFrame}..{MaxMaxWorkItemsPerFrame})."); + if (violations.Count > 0) + return ProjectSettingsResponse.Fail(Group, string.Join("; ", violations)); + + var config = RuntimePipelineSettingsProvider.LoadOrCreateConfig(); + try + { + var changes = new List(); + + if (settings.EnableInBuilds.HasValue && settings.EnableInBuilds.Value != config.enableInBuilds) + changes.Add($"enableInBuilds {config.enableInBuilds} -> {settings.EnableInBuilds.Value}"); + if (settings.Port.HasValue && settings.Port.Value != config.port) + changes.Add($"port {config.port} -> {settings.Port.Value}"); + if (settings.RequestTimeoutMs.HasValue && settings.RequestTimeoutMs.Value != config.requestTimeoutMs) + changes.Add($"requestTimeoutMs {config.requestTimeoutMs} -> {settings.RequestTimeoutMs.Value}"); + if (settings.EnableAuditLogging.HasValue && settings.EnableAuditLogging.Value != config.enableAuditLogging) + changes.Add($"enableAuditLogging {config.enableAuditLogging} -> {settings.EnableAuditLogging.Value}"); + if (settings.AutoStart.HasValue && settings.AutoStart.Value != config.autoStart) + changes.Add($"autoStart {config.autoStart} -> {settings.AutoStart.Value}"); + if (settings.MaxWorkItemsPerFrame.HasValue && settings.MaxWorkItemsPerFrame.Value != config.maxWorkItemsPerFrame) + changes.Add($"maxWorkItemsPerFrame {config.maxWorkItemsPerFrame} -> {settings.MaxWorkItemsPerFrame.Value}"); + + if (changes.Count == 0) + return NoChanges(); + + var planText = "Set Pipeline Runtime settings: " + string.Join("; ", changes); + + void Apply() + { + if (settings.EnableInBuilds.HasValue) config.enableInBuilds = settings.EnableInBuilds.Value; + if (settings.Port.HasValue) config.port = settings.Port.Value; + if (settings.RequestTimeoutMs.HasValue) config.requestTimeoutMs = settings.RequestTimeoutMs.Value; + if (settings.EnableAuditLogging.HasValue) config.enableAuditLogging = settings.EnableAuditLogging.Value; + if (settings.AutoStart.HasValue) config.autoStart = settings.AutoStart.Value; + if (settings.MaxWorkItemsPerFrame.HasValue) config.maxWorkItemsPerFrame = settings.MaxWorkItemsPerFrame.Value; + config.Save(); + } + + return ProjectSettingsCommand.Apply(Group, confirm, dryRun, settings, () => planText, Apply, Read); + } + finally + { + Object.DestroyImmediate(config); + } + } + + static ProjectSettingsResponse NoChanges() + { + var response = ProjectSettingsCommand.Get(Group, Read); + response.Message = "No changes specified; nothing to apply."; + return response; + } + + static Dictionary Read() + { + var config = RuntimePipelineSettingsProvider.LoadOrCreateConfig(); + try + { + return new Dictionary + { + ["enableInBuilds"] = config.enableInBuilds, + ["port"] = config.port, + ["requestTimeoutMs"] = config.requestTimeoutMs, + ["enableAuditLogging"] = config.enableAuditLogging, + ["autoStart"] = config.autoStart, + ["maxWorkItemsPerFrame"] = config.maxWorkItemsPerFrame + }; + } + finally + { + Object.DestroyImmediate(config); + } + } + } + + /// Pipeline Runtime settings to change. Null/omitted fields are left unchanged. + class RuntimePipelineSettingsInput : IStructuredCommandInput + { + [CliArg("enableInBuilds", "Enable the Pipeline HTTP server in Player builds. SECURITY WARNING: only enable in development/QA builds.")] + public bool? EnableInBuilds { get; set; } + + [CliArg("port", "HTTP port for the Pipeline server. 0 = auto-assign from range 7900-7999.")] + public int? Port { get; set; } + + [CliArg("requestTimeoutMs", "Request timeout in milliseconds.")] + public int? RequestTimeoutMs { get; set; } + + [CliArg("enableAuditLogging", "Log every remote request for security auditing.")] + public bool? EnableAuditLogging { get; set; } + + [CliArg("autoStart", "Start the server automatically when the Player boots (or Play Mode is entered).")] + public bool? AutoStart { get; set; } + + [CliArg("maxWorkItemsPerFrame", "Maximum work items the dispatcher processes per frame.")] + public int? MaxWorkItemsPerFrame { get; set; } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/RuntimePipelineSettingsCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/RuntimePipelineSettingsCommands.cs.meta new file mode 100644 index 0000000..6885fd4 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/RuntimePipelineSettingsCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0bb9e7729ec0e2045a5e1501a5fbfe8c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/TagsLayersCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/TagsLayersCommands.cs new file mode 100644 index 0000000..7226fa7 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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, Tags = new[] { "settings/tags_layers" })] + 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, Tags = new[] { "settings/tags_layers" })] + 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. + 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. + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/TagsLayersCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/TagsLayersCommands.cs.meta new file mode 100644 index 0000000..c68e647 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/TimeSettingsCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/TimeSettingsCommands.cs new file mode 100644 index 0000000..076f806 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + static class TimeSettingsCommands + { + const string Group = "time"; + + [CliCommand("get_time_settings", "Read Time settings (fixedDeltaTime, maximumDeltaTime, timeScale).", MainThreadRequired = true, Tags = new[] { "settings/time" })] + 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, Tags = new[] { "settings/time" })] + 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. + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/TimeSettingsCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ProjectSettings/TimeSettingsCommands.cs.meta new file mode 100644 index 0000000..c467654 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/PushReloadCommand.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/PushReloadCommand.cs new file mode 100644 index 0000000..6202325 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/PushReloadCommand.cs @@ -0,0 +1,509 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Compilation; +using Unity.Pipeline.CodeReload; +using UnityEditor; +using UnityEditor.Networking.PlayerConnection; +using UnityEngine; +using UnityEngine.Networking.PlayerConnection; + +namespace Unity.Pipeline.Editor.Commands +{ + /// + /// Compile a source file's [CodeReload] override(s) in the editor and push the IL to a connected + /// player over PlayerConnection — the Profiler/Console channel (tunnels over USB, no open port or + /// token). The player applies it through the IlInterpreter interpreter (IL2CPP-safe: no Assembly.Load, + /// no Roslyn on device). + /// + /// filename may be a single .cs file or a folder: a folder is walked recursively and every + /// .cs that opts into code reload is compiled and pushed once. For a continuous watch, use the Hot + /// Reload Interpreter Watch section of the Pipeline Settings inspector (). + /// + /// Requires a Development Build connected to the editor (Build Settings → Autoconnect Profiler, + /// or attach via the Profiler/Console) with the runtime Pipeline enabled. The player's + /// ack is asynchronous and logged to the editor console. + /// + static class PushReloadCommand + { + private static bool s_Registered; + + // Pending-ack watchdog. A push that reaches no receiver (e.g. the runtime Pipeline isn't + // enabled in the player) is otherwise indistinguishable from success: the editor logs + // "pushed … (ack async)" and then nothing, forever. One entry per expected ack; entries are + // matched to acks oldest-first (acks carry no correlation id). Editor-session static: a + // domain reload drops pending entries together with the ack handler itself. + private const double AckTimeoutSeconds = 5.0; + private static readonly List<(double deadline, string desc)> s_PendingAcks = new List<(double, string)>(); + private static bool s_AckPumpHooked; + + static PushReloadCommand() + { + AssemblyReloadEvents.beforeAssemblyReload += Unregister; + } + + [CliCommand("reload_file_player_interpreter", + "Compile a file's (or folder's) [CodeReload] method(s) and push the IL to a connected player (IL2CPP-safe) over PlayerConnection. Folders are walked recursively and pushed once.", + MainThreadRequired = true, Tags = new[] { "scripts/codereload" })] + public static object PushReload( + [CliArg("filename", "Source .cs file or a folder containing [CodeReload] methods (e.g. Assets/pong.cs or Assets/Scripts)", Required = true)] string filename, + [CliArg("player", "Target connected player id; -1 = broadcast to all connected players")] int player = -1) + { + if (string.IsNullOrWhiteSpace(filename)) + return new { success = false, error = "filename is required" }; + + if (!TryResolvePath(filename, out var fullPath, out var isFolder)) + return new { success = false, error = $"Could not locate source file or folder: {filename}" }; + + if (isFolder) + return PushFolderOnce(fullPath, player); + + var ok = DoCompileAndSend(fullPath, player, out var summary, out var diagnostics); + return new { success = ok, message = summary, diagnostics }; + } + + /// Compile and push every reloadable .cs under a folder once. Files that don't opt into + /// code reload are skipped (not errors). + private static object PushFolderOnce(string folder, int player) + { + List files; + try + { + files = Directory.EnumerateFiles(folder, "*.cs", SearchOption.AllDirectories) + .Where(FileMentionsCodeReload) + .ToList(); + } + catch (Exception ex) + { + return new { success = false, error = $"Could not enumerate {folder}: {ex.Message}" }; + } + + if (files.Count == 0) + return new { success = false, error = $"No .cs files mentioning [CodeReload] found under {folder}" }; + + var pushed = new List(); + var failed = new List(); + foreach (var f in files) + { + if (DoCompileAndSend(f, player, out var summary, out var diagnostics)) + pushed.Add(Path.GetFileName(f)); + else + failed.Add(new { file = Path.GetFileName(f), error = summary, diagnostics }); + } + + return new + { + success = failed.Count == 0, + message = $"Pushed {pushed.Count}/{files.Count} file(s) from {DisplayName(folder)}" + + (pushed.Count > 0 ? $": {string.Join(", ", pushed)}" : ""), + pushed, + failed + }; + } + + /// Watcher-save push: like for a single file, but diffed + /// against the watch-start — methods still matching it are + /// left running compiled on the device, and a fully up-to-date file skips the compile and + /// send entirely. The explicit CLI command stays unfiltered as the escape hatch when the + /// connected player was NOT built from the watched sources (the baseline can't see a + /// device's build state). + internal static void PushChanged(string fullPath, int player) + { + DoCompileAndSend(fullPath, player, out _, out _, useBaseline: true); + } + + /// Re-push the watched file's/folder's code-reload overrides to one player, compiled + /// from current source. Called by when a player + /// (re)connects while a Player-target watch is active: a restarted player boots the original + /// AOT code, so everything pushed during its previous run is gone until re-pushed. + internal static void RepushAll(string fullPath, bool isFolder, int player) + { + if (!isFolder) + { + DoCompileAndSend(fullPath, player, out _, out _, useBaseline: true); // logs success and failure itself + return; + } + + List files; + try + { + files = Directory.EnumerateFiles(fullPath, "*.cs", SearchOption.AllDirectories) + .Where(FileMentionsCodeReload) + .ToList(); + } + catch (Exception ex) + { + Debug.LogError($"[PushReload] Re-push to player {player} failed — could not enumerate {fullPath}: {ex.Message}"); + return; + } + + int pushed = 0; + foreach (var f in files) + if (DoCompileAndSend(f, player, out _, out _, useBaseline: true)) + pushed++; + Debug.Log($"[PushReload] Re-pushed {pushed}/{files.Count} reloadable file(s) to player {player}."); + } + + /// Preprocessor symbols the target player's copy of this file was compiled with: + /// the defines of the player-variant assembly that owns the file, falling back to any + /// player assembly's defines, then null (= editor defines downstream) if the compilation + /// pipeline can't answer. Cheap relative to the compile that follows. + internal static string[] PlayerDefinesFor(string fullPath) + { + try + { + var assemblies = UnityEditor.Compilation.CompilationPipeline + .GetAssemblies(UnityEditor.Compilation.AssembliesType.Player); + var norm = Path.GetFullPath(fullPath).Replace('\\', '/'); + foreach (var a in assemblies) + foreach (var f in a.sourceFiles) + if (Path.GetFullPath(f).Replace('\\', '/') + .Equals(norm, StringComparison.OrdinalIgnoreCase)) + return a.defines; + return assemblies.Length > 0 ? assemblies[0].defines : null; + } + catch (Exception) + { + return null; + } + } + + /// Compile the file's overrides and send them to the target player(s). Never throws. + /// Failures are logged to the editor console as well as returned: the code-reload watcher + /// (Player target) discards the return value, so the console is the only surface where a + /// failing watch-mode push is visible at all. + private static bool DoCompileAndSend(string fullPath, int player, out string summary, out List diagnostics, + bool useBaseline = false) + { + diagnostics = null; + try + { + EnsureRegistered(); + + // Compile with the PLAYER's preprocessor symbols, not the editor's: the pushed body + // must resolve #if UNITY_EDITOR / platform regions the way the player's own + // assemblies did, or the override diverges from the code it replaces. + var playerDefines = PlayerDefinesFor(fullPath); + // Methods already overridden on a device must reload even when they match the + // baseline again: there is no remote unregister, so the only way to clear a stale + // override is to replace it with one compiled from the (reverted) source. + var mustReload = useBaseline ? GetPushedMethods(fullPath) : null; + + // Up-to-date pre-check BEFORE the pending notice: announcing a compile and then + // sending nothing would leave the device overlay stuck on "compiling…". + if (useBaseline && FileIsUpToDate(fullPath, playerDefines, mustReload)) + { + summary = "up to date — every [CodeReload] method matches the watch-start baseline; nothing pushed"; + Debug.Log($"[PushReload] {Path.GetFileName(fullPath)}: {summary}"); + return true; + } + + // Compilation happens here in the editor — announce it so the player's status overlay + // can show "compiling…" during the gap before the payload (or failure notice) arrives. + TrySendNotice(PipelineCodeReloadConnect.ReloadPendingMsg, Path.GetFileName(fullPath), player); + + var compiled = InPlaceReloadProcessor.CompileOverrideForPush(fullPath, playerDefines, + useBaseline ? mustReload : null, useBaseline); + diagnostics = compiled.Diagnostics; + + // The pre-check should have caught this; a save landing between the two reads can + // still get here. Nothing to push either way. + if (compiled.AllUpToDate) + { + summary = "up to date — every [CodeReload] method matches the watch-start baseline; nothing pushed"; + Debug.Log($"[PushReload] {Path.GetFileName(fullPath)}: {summary}"); + return true; + } + if (!compiled.Success) + { + summary = "compile failed: " + (compiled.Error ?? "unknown"); + var details = diagnostics != null && diagnostics.Count > 0 + ? ":\n- " + string.Join("\n- ", diagnostics) + : ""; + Debug.LogError($"[PushReload] {Path.GetFileName(fullPath)}: {summary}{details}"); + TrySendNotice(PipelineCodeReloadConnect.ReloadFailedMsg, + $"{Path.GetFileName(fullPath)}: {summary} (details in the editor console)", player); + return false; + } + + // Run the same interpreter load pass the player runs on receipt, so binding gaps come + // back in THIS response — the player's async ack carries only a warning count (the + // member list goes to the device log). Never blocks the push: an unbound member is a + // stub that throws only if executed. + var unbound = InterpreterCodeReloadExecutor.ValidateBindingSurface( + compiled.IlBytes, compiled.TypeName, compiled.MethodNames, out var validationNote); + if (unbound.Count > 0) + { + diagnostics ??= new List(); + foreach (var member in unbound) + diagnostics.Add($"binding: host member '{member}' is not registered and will " + + "throw if the reloaded code reaches it"); + Debug.LogWarning($"[PushReload] {Path.GetFileName(fullPath)}: {unbound.Count} host " + + $"member(s) referenced by the override are not in the interpreter binding and will " + + $"throw if reached: {string.Join(", ", unbound)}. Types outside the standard " + + "surface and demand-time auto-bind are not callable from pushed overrides; BCL " + + "members outside the standard surface need an explicit AllowBcl shim."); + } + if (validationNote != null) + Debug.LogWarning($"[PushReload] {Path.GetFileName(fullPath)}: binding validation note: {validationNote}"); + + var players = EditorConnection.instance.ConnectedPlayers; + if (players == null || players.Count == 0) + { + summary = "no player connected (run a Development Build with Autoconnect Profiler)"; + Debug.LogWarning($"[PushReload] {Path.GetFileName(fullPath)}: {summary}"); + return false; + } + + var payload = PipelineCodeReloadConnect.Encode(compiled.TypeName, compiled.MethodNames, compiled.IlBytes); + var desc = $"{compiled.TypeName} ({Path.GetFileName(fullPath)})"; + + // Every player gets its own signed envelope (session nonces differ), so "broadcast" + // is a per-player loop rather than one connection-wide Send. PushSigner tracks the + // expected ack per actual send, so no TrackExpectedAcks call here. + int sent = 0, deferred = 0, failedSends = 0, matched = 0; + foreach (var target in players) + { + if (player >= 0 && target.playerId != player) continue; + matched++; + switch (PushSigner.PushOrDefer(target.playerId, payload, desc)) + { + case PushSendStatus.Sent: sent++; break; + case PushSendStatus.Deferred: deferred++; break; + default: failedSends++; break; + } + } + + if (matched == 0) + { + summary = $"player {player} is not connected"; + Debug.LogWarning($"[PushReload] {Path.GetFileName(fullPath)}: {summary}"); + return false; + } + + // A device can't unregister an override, so record every method sent or deferred — a + // later baseline-filtered push must re-include it or a reverted edit would strand the + // device on the stale override. + RecordPushedMethods(fullPath, compiled.MethodNames); + + summary = $"pushed {compiled.TypeName} ({compiled.MethodNames.Count} method(s), {compiled.IlBytes.Length} B) " + + $"signed to {sent} player(s)" + + (deferred > 0 ? $"; {deferred} deferred awaiting player handshake" : "") + + (failedSends > 0 ? $"; {failedSends} failed (see console)" : "") + + (compiled.UpToDateMethods.Count > 0 ? $"; {compiled.UpToDateMethods.Count} method(s) up to date, left compiled" : "") + + (unbound.Count > 0 ? $"; {unbound.Count} binding warning(s) — see diagnostics" : "") + + " (ack async)"; + Debug.Log($"[PushReload] {summary}"); + return failedSends == 0; + } + catch (Exception ex) + { + summary = $"push error: {ex.Message}"; + Debug.LogError($"[PushReload] {Path.GetFileName(fullPath)}: {summary}"); + TrySendNotice(PipelineCodeReloadConnect.ReloadFailedMsg, + $"{Path.GetFileName(fullPath)}: {summary}", player); + return false; + } + } + + /// Best-effort overlay notice to the target player(s); a notice failing must never + /// fail the push itself. + private static void TrySendNotice(Guid msg, string text, int player) + { + try + { + var payload = PipelineCodeReloadConnect.EncodeText(text); + if (player >= 0) + EditorConnection.instance.Send(msg, payload, player); + else + EditorConnection.instance.Send(msg, payload); + } + catch { /* overlay notices are cosmetic */ } + } + + private static void EnsureRegistered() + { + EditorConnection.instance.Initialize(); + if (s_Registered) return; + EditorConnection.instance.Register(PipelineCodeReloadConnect.ResultMsg, OnResult); + s_Registered = true; + } + + private static void Unregister() + { + s_PendingAcks.Clear(); + if (s_AckPumpHooked) + { + EditorApplication.update -= PumpPendingAcks; + s_AckPumpHooked = false; + } + if (!s_Registered) return; + try { EditorConnection.instance.Unregister(PipelineCodeReloadConnect.ResultMsg, OnResult); } + catch { /* connection may already be gone during teardown */ } + s_Registered = false; + } + + // EditorConnection callbacks are dispatched on the main thread, same as the update pump — + // no locking needed around s_PendingAcks. + private static void OnResult(MessageEventArgs args) + { + if (s_PendingAcks.Count > 0) + s_PendingAcks.RemoveAt(0); + if (PipelineCodeReloadConnect.TryDecodeResult(args.data, out var ok, out var message)) + { + // A pre-signing player build cannot parse a signed envelope and acks it as malformed + // — name the actual fix instead of leaving a mystery. + var hint = !ok && message != null && message.Contains("malformed code-reload push") + ? " (if this player build predates push signing, rebuild it with the current com.unity.pipeline package)" + : ""; + Debug.Log($"[PushReload] Player ack: {(ok ? "OK" : "FAILED")} — {message}{hint}"); + } + else + Debug.LogWarning("[PushReload] Received a player ack that could not be decoded."); + } + + /// One expected-ack slot for a push just sent. + internal static void TrackAck(string desc) + { + EnsureRegistered(); + TrackExpectedAcks(1, desc); + } + + private static void TrackExpectedAcks(int count, string desc) + { + double deadline = EditorApplication.timeSinceStartup + AckTimeoutSeconds; + for (int i = 0; i < count; i++) + s_PendingAcks.Add((deadline, desc)); + if (!s_AckPumpHooked) + { + EditorApplication.update += PumpPendingAcks; + s_AckPumpHooked = true; + } + } + + private static void PumpPendingAcks() + { + double now = EditorApplication.timeSinceStartup; + for (int i = s_PendingAcks.Count - 1; i >= 0; i--) + { + if (now < s_PendingAcks[i].deadline) continue; + Debug.LogWarning($"[PushReload] No player ack for {s_PendingAcks[i].desc} within {AckTimeoutSeconds:0}s — " + + "is the player still connected, and does it have the runtime Pipeline enabled?"); + s_PendingAcks.RemoveAt(i); + } + if (s_PendingAcks.Count == 0) + { + EditorApplication.update -= PumpPendingAcks; + s_AckPumpHooked = false; + } + } + + /// Resolve to an existing folder or .cs file. Folders are checked + /// first (so a bare directory name isn't turned into "<dir>.cs"); files get a .cs suffix if missing. + private static bool TryResolvePath(string input, out string fullPath, out bool isFolder) + { + fullPath = null; + isFolder = false; + + foreach (var d in new[] { input, Path.Combine("Assets", input) }) + { + if (Directory.Exists(d)) + { + fullPath = Path.GetFullPath(d); + isFolder = true; + return true; + } + } + + var file = input.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) ? input : input + ".cs"; + fullPath = ResolveSourceFilePath(file); + return fullPath != null; + } + + private static string ResolveSourceFilePath(string filename) + { + if (Path.IsPathRooted(filename) && File.Exists(filename)) + return filename; + foreach (var p in new[] { Path.Combine("Assets", filename), Path.Combine("Assets", "Scripts", filename), filename }) + if (File.Exists(p)) + return Path.GetFullPath(p); + return null; + } + + /// Every [CodeReload] method in the file still matches the watch-start baseline + /// (player defines) and none of them was ever pushed. Read failure = not up to date. + private static bool FileIsUpToDate(string fullPath, string[] playerDefines, HashSet mustReload) + { + try + { + var source = File.ReadAllText(fullPath); + return CodeReloadBaseline.IsFileUpToDate(fullPath, source, playerDefines, mustReload); + } + catch + { + return false; + } + } + + // Method names pushed to any player this editor session, per file. A device can't + // unregister an override, so once a method is pushed, every later baseline-filtered push + // must include it again — otherwise reverting an edit would strand the device on the stale + // override while the editor reports "up to date". SessionState-backed so the play-mode + // domain reload doesn't forget what connected devices still hold. + private const string SkPushedMethods = "Pipeline.PushReload.PushedMethods"; + + private static HashSet GetPushedMethods(string fullPath) + { + var set = new HashSet(StringComparer.Ordinal); + var raw = SessionState.GetString(SkPushedMethods, ""); + if (string.IsNullOrEmpty(raw)) return set; + var key = Path.GetFullPath(fullPath); + foreach (var line in raw.Split('\n')) + { + int sep = line.IndexOf('\u001f'); + if (sep <= 0 || !string.Equals(line.Substring(0, sep), key, StringComparison.OrdinalIgnoreCase)) + continue; + foreach (var m in line.Substring(sep + 1).Split(';')) + if (m.Length > 0) + set.Add(m); + } + return set; + } + + private static void RecordPushedMethods(string fullPath, List methods) + { + if (methods == null || methods.Count == 0) return; + var set = GetPushedMethods(fullPath); + int before = set.Count; + foreach (var m in methods) + set.Add(m); + if (set.Count == before) return; + + var key = Path.GetFullPath(fullPath); + var lines = new List(); + var raw = SessionState.GetString(SkPushedMethods, ""); + if (!string.IsNullOrEmpty(raw)) + foreach (var line in raw.Split('\n')) + { + int sep = line.IndexOf('\u001f'); + if (sep > 0 && !string.Equals(line.Substring(0, sep), key, StringComparison.OrdinalIgnoreCase)) + lines.Add(line); + } + lines.Add(key + '\u001f' + string.Join(";", set)); + SessionState.SetString(SkPushedMethods, string.Join("\n", lines)); + } + + // Cheap opt-in filter shared with EditorCodeReloadWatcher: a file that never mentions + // "CodeReload" cannot carry the attribute, so folder scans skip it without compiling. + internal static bool FileMentionsCodeReload(string path) + { + try { return File.ReadAllText(path).Contains("CodeReload"); } + catch { return false; } + } + + private static string DisplayName(string path) => + string.IsNullOrEmpty(path) ? "?" : Path.GetFileName(path.TrimEnd('/', '\\')); + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/PushReloadCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/PushReloadCommand.cs.meta new file mode 100644 index 0000000..fe632d6 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/PushReloadCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7c78e53a3bb1c6948b9be86010c9a405 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/RecompileCommand.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/RecompileCommand.cs new file mode 100644 index 0000000..3d2ff4e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/RecompileCommand.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Console; +using UnityEditor; +using UnityEditor.Compilation; +using UnityEngine; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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. + /// + /// The status file is not sufficient on its own: a failed editor compile suppresses the domain + /// reload entirely, and the file lives under Temp/, which Unity deletes at every editor launch. + /// Both commands therefore also consult the compile-failure flag Unity keeps natively. + /// + [InitializeOnLoad] +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + static class RecompileCommand + { + const string StatusFile = "Temp/pipeline_recompile_status.json"; + + // How stale a ground-truth sample may be and still describe the present. Sampling runs on + // the main thread, so it stops while the Editor is blocked and across a domain reload. + const double MaxGroundTruthAgeSeconds = 2.0; + + 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(); + + // Indirected for the same reason: a test cannot make Unity's native compile-failure flag + // true, but it can point this at a stub. + internal static Func s_CompilationFailedProbe = () => EditorUtility.scriptCompilationFailed; + + /// The status file's contents. Shared by the writer and both readers. + [Serializable] + class RecompileStatusPayload + { + [JsonProperty("status")] public string Status; + [JsonProperty("failed")] public bool Failed; + [JsonProperty("errors")] public string[] Errors; + + /// + /// Whether the project currently has editor compile errors, as Unity reports it + /// natively. Filled when the status is read rather than written: it outlives both the + /// status file and any domain reload, so it is the authority when the two disagree. + /// + [JsonProperty("compilationFailed")] public bool CompilationFailed; + } + + 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, Tags = new[] { "scripts/compile" })] + 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(); + + var previous = ReadStatusOrIdle(); + 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 was imported and nothing compiled. That is only "up to date" if the code + // actually builds: a failed editor compile leaves the errors standing and suppresses the + // domain reload, so a repeat call with nothing changed lands here with the failure still + // true. Reporting "up_to_date" would erase it. + if (s_CompilationFailedProbe()) + { + if (previous.Failed) + WriteStatus(previous.Status, true, previous.Errors); + else + WriteStatus("completed", true, Array.Empty()); + + return new { status = "failed", message = "Scripts still have compile errors; nothing was recompiled. Read them with the console command." }; + } + + 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, Tags = new[] { "scripts/compile" })] + public static string RecompileStatus() + { + var status = ReadStatusOrIdle(); + + var truth = ConsoleLogCapture.GroundTruth; + var fresh = truth != null + && (DateTime.UtcNow - truth.SampledUtc).TotalSeconds <= MaxGroundTruthAgeSeconds; + + status.CompilationFailed = fresh && truth.CompilationFailed; + + // Only a status that carries no evidence of its own defers to the native flag. "idle" is + // what the file reads after an editor launch, since Temp/ is deleted then, and + // "up_to_date" means nothing compiled — in both cases errors can be standing with + // nothing in the file to say so. + // + // A "completed" status is not overridden: it was written by the compile that just ran and + // lists exactly what that compile produced. The native flag still reads true for a moment + // after a fixing compile finishes — it clears with the domain reload that follows — so + // trusting it there would report a build that has just been fixed as broken. + var carriesNoEvidence = status.Status == "idle" || status.Status == "up_to_date"; + if (status.CompilationFailed && carriesNoEvidence && !status.Failed) + { + status.Status = "completed"; + status.Failed = true; + } + + return JsonConvert.SerializeObject(status); + } + + 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 RecompileStatusPayload ReadStatusOrIdle() + { + try + { + if (File.Exists(StatusFile)) + { + var payload = JsonConvert.DeserializeObject(File.ReadAllText(StatusFile)); + if (payload != null) + { + payload.Errors = payload.Errors ?? Array.Empty(); + return payload; + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"[Recompile] Failed to read status file: {ex.Message}"); + } + + return new RecompileStatusPayload { Status = "idle", Errors = Array.Empty() }; + } + + static void WriteStatus(string status, bool failed, string[] errors) + { + try + { + var payload = new RecompileStatusPayload + { + Status = status, + Failed = 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/RecompileCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/RecompileCommand.cs.meta new file mode 100644 index 0000000..287a05c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scenes.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scenes.meta new file mode 100644 index 0000000..e7459cb --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scenes.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d7cf2e413ccd43f0b9dfb4db22c6cb29 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scenes/SceneCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scenes/SceneCommands.cs new file mode 100644 index 0000000..1b59b4f --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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.", Tags = new[] { "scenes" })] + 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.", Tags = new[] { "scenes" })] + 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.", Tags = new[] { "scenes" })] + 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.", Tags = new[] { "scenes" })] + 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.", Tags = new[] { "scenes" })] + 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).", Tags = new[] { "scenes" })] + 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.", Tags = new[] { "scenes" })] + 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.", Tags = new[] { "scenes", "build/settings" })] + 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).", Tags = new[] { "scenes", "build/settings" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scenes/SceneCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scenes/SceneCommands.cs.meta new file mode 100644 index 0000000..94e4e99 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scenes/SceneCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2c7b859adf794e05969586b070d0ab79 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scenes/SceneHierarchy.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scenes/SceneHierarchy.cs new file mode 100644 index 0000000..be87333 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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] + 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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scenes/SceneHierarchy.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scenes/SceneHierarchy.cs.meta new file mode 100644 index 0000000..5c3d820 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scenes/SceneHierarchy.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a93c6751c9564baca2addf1deb479da4 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/ScreenshotCommand.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ScreenshotCommand.cs new file mode 100644 index 0000000..8be4637 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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, Tags = new[] { "capture" })] + 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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/ScreenshotCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ScreenshotCommand.cs.meta new file mode 100644 index 0000000..1dcce16 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/ScreenshotCommand.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ee6b23eed8dc4ff096cf01b461985fcc diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts.meta new file mode 100644 index 0000000..5c91cfb --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c4d83f412a414f419f97bac28b65f25b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/AttachScriptCommand.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/AttachScriptCommand.cs new file mode 100644 index 0000000..7980803 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/AttachScriptCommand.cs @@ -0,0 +1,139 @@ +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. + /// + 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.", + Tags = new[] { "scripts" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/AttachScriptCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/AttachScriptCommand.cs.meta new file mode 100644 index 0000000..2c4aca0 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/AttachScriptCommand.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 488a53ecbf5c4984bdc009bf1f2f9a45 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/CreateScriptCommand.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/CreateScriptCommand.cs new file mode 100644 index 0000000..9a1c690 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/CreateScriptCommand.cs @@ -0,0 +1,164 @@ +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. + /// + 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.", + Tags = new[] { "scripts" })] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/CreateScriptCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/CreateScriptCommand.cs.meta new file mode 100644 index 0000000..8884c31 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/CreateScriptCommand.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 34d285de0757475796fcecc889996421 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/RunScriptCommand.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/RunScriptCommand.cs new file mode 100644 index 0000000..6e2407c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/RunScriptCommand.cs @@ -0,0 +1,664 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Compilation; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using Unity.Pipeline.Runtime.Commands; +using UnityEditor; +using UnityEngine; +using Debug = UnityEngine.Debug; +using Object = UnityEngine.Object; + +namespace Unity.Pipeline.Editor.Commands.Scripts +{ + /// + /// The run_script command (AUTHAPI-26): compile a single project .cs file in memory + /// via the shared and execute a NAMED static entry point — + /// with NO domain reload and NO code carried through the protocol. This formalizes the + /// "builder pattern": bulk construction lives in a versioned project script that is written to + /// disk (ideally OUTSIDE Assets/, so writing it triggers no asset import / domain reload) + /// and invoked here by name, instead of shipping escaped C# strings through eval. + /// + /// It composes the two halves the pipeline already had — eval_file (code from disk) and + /// reload_file (single-file in-memory Roslyn compile) — into one first-class command: + /// - ephemeral (default): compile to an in-memory assembly, run the entry point, discard. + /// - hotpatch: delegate to reload_file's in-place [CodeReload] semantics. + /// + /// This is still arbitrary code execution — only the carrier changes (a reviewable file on disk + /// instead of a protocol string). It is grouped under the same scripts/eval tag as + /// eval so a host that disables the eval family disables this too. + /// + static class RunScriptCommand + { + /// Upper bound on a single run's timeout — 24 hours, matching the eval cap (CLI-335). + private const int MaxTimeoutMs = 86_400_000; + + [CliCommand("run_script", + "Compile a project C# file in memory (no domain reload) and execute a named static entry point. " + + "The builder-pattern path for bulk construction. Still arbitrary code execution — shares eval's capability gate.", + MainThreadRequired = true, Tags = new[] { "scripts/eval" })] + public static async Task RunScript( + [CliArg("file", "Path to a .cs file to compile. Relative paths resolve against the PROJECT ROOT (the parent of Assets/), not the process working directory. May live OUTSIDE Assets/ (e.g. AgentScripts/) so writing it never triggers an asset import or domain reload.", Required = true)] string file, + [CliArg("entry", "Entry point as Namespace.Type.Method (or Type.Method, or a bare Method name). Default: the single public static method if unambiguous, else a method named Main. Static methods only in v1. Ephemeral mode only (rejected with mode=hotpatch, which never invokes an entry point).")] string entry = null, + [CliArg("args", "JSON array of arguments passed to the entry point, coerced to its parameter types: primitives, enum names, string[], and a string/object handle (ObjectRef) for UnityEngine.Object parameters. Ephemeral mode only (rejected with mode=hotpatch).")] JArray args = null, + [CliArg("mode", "ephemeral (default): compile to an in-memory assembly, run the entry, discard. hotpatch: apply [CodeReload] method replacements to already-loaded types (reload_file semantics).")] string mode = "ephemeral", + [CliArg("references", "Extra assembly name prefixes to reference beyond the auto-resolved set. In the Editor all loaded assemblies are already referenced, so this is effectively a no-op there. Ephemeral mode only; not applied by hotpatch (which delegates to reload_file's own compile).")] string[] references = null, + [CliArg("defines", "Extra scripting define symbols APPENDED to the project's active editor defines (UNITY_EDITOR etc. are already set) for the compilation's #if directives. Ephemeral mode only; not applied by hotpatch.")] string[] defines = null, + [CliArg("pdb", "Emit a portable PDB mapped to the source file so breakpoints bind and exception stack traces map to file:line. Compiles unoptimized.")] bool pdb = false, + [CliArg("timeout_ms", "Timeout in milliseconds. Bounds the main-thread dispatcher wait AND, for an async entry point, how long its Task is awaited (on expiry the task keeps running detached).")] int timeoutMs = 30000, + [CliArg("dry_run", "Compile only: return diagnostics; the emitted assembly is NOT loaded into the domain and nothing executes. Ephemeral mode only (rejected with mode=hotpatch, which would apply live method replacements).")] bool dryRun = false) + { + var total = Stopwatch.StartNew(); + + if (string.IsNullOrWhiteSpace(file)) + return RunScriptResponse.Fail("Bad Request", "The 'file' parameter is required and cannot be empty.", total.ElapsedMilliseconds); + + if (!file.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + return RunScriptResponse.Fail("Bad Request", "The 'file' must have a .cs extension.", total.ElapsedMilliseconds); + + if (timeoutMs <= 0 || timeoutMs > MaxTimeoutMs) + return RunScriptResponse.Fail("Bad Request", $"'timeout_ms' must be between 1 and {MaxTimeoutMs}.", total.ElapsedMilliseconds); + + var normalizedMode = (mode ?? "ephemeral").Trim().ToLowerInvariant(); + + try + { + switch (normalizedMode) + { + case "ephemeral": + return await RunEphemeral(file, entry, args, references, defines, pdb, dryRun, timeoutMs, total); + case "hotpatch": + { + // hotpatch delegates wholesale to reload_file's [CodeReload] semantics: + // nothing is invoked, so entry/args are meaningless, and there is no + // compile-only path — a "dry run" would still apply live method + // replacements. Reject rather than silently ignore or, worse, apply. + if (dryRun) + { + return RunScriptResponse.Fail("Bad Request", + "'dry_run' is not supported with mode=hotpatch; it would apply live method replacements. " + + "Use mode=ephemeral (the default) for a compile-only check.", + total.ElapsedMilliseconds); + } + + var unsupported = new List(); + if (!string.IsNullOrWhiteSpace(entry)) unsupported.Add("'entry'"); + if (args != null && args.Count > 0) unsupported.Add("'args'"); + if (unsupported.Count > 0) + { + return RunScriptResponse.Fail("Bad Request", + $"{string.Join(" and ", unsupported)} {(unsupported.Count == 1 ? "is" : "are")} not supported with mode=hotpatch: " + + "it applies [CodeReload] method replacements in place and never invokes an entry point. " + + "Use mode=ephemeral to run an entry point.", + total.ElapsedMilliseconds); + } + + return RunHotpatch(file, pdb, timeoutMs, total); + } + default: + return RunScriptResponse.Fail("Bad Request", $"Unknown mode '{mode}'. Expected 'ephemeral' or 'hotpatch'.", total.ElapsedMilliseconds); + } + } + catch (Exception ex) + { + Debug.LogError($"Pipeline: run_script failed: {ex}"); + return RunScriptResponse.Fail("Execution Failed", ex.ToString(), total.ElapsedMilliseconds); + } + } + + /// + /// ephemeral mode: single-file in-memory compile → resolve the entry point → coerce args → + /// invoke → serialize. No AssetDatabase.Refresh and no domain reload occur. Async so + /// a Task-returning entry point can be awaited without blocking the main thread (see the + /// comment at the await); bounds that wait. + /// + private static async Task RunEphemeral( + string file, string entry, JArray args, string[] references, string[] defines, bool pdb, bool dryRun, int timeoutMs, Stopwatch total) + { + // Resolve the path: absolute as-is, else relative to the PROJECT ROOT (the parent of + // Assets/) — NOT the process working directory, which is usually but not guaranteed to + // be the project root. This deliberately accepts files outside Assets/. (hotpatch mode + // intentionally keeps reload_file's own resolution, which probes under Assets/.) + var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Directory.GetCurrentDirectory(); + var fullPath = Path.GetFullPath(Path.IsPathRooted(file) ? file : Path.Combine(projectRoot, file)); + if (!File.Exists(fullPath)) + return RunScriptResponse.Fail("File Not Found", $"Source file not found: {fullPath}", total.ElapsedMilliseconds); + + string source; + try + { + source = File.ReadAllText(fullPath); + } + catch (Exception ex) + { + return RunScriptResponse.Fail("Bad Request", $"Failed to read file: {ex.Message}", total.ElapsedMilliseconds); + } + + if (string.IsNullOrWhiteSpace(source)) + return RunScriptResponse.Fail("Bad Request", $"File is empty: {fullPath}", total.ElapsedMilliseconds); + + var assemblyName = $"PipelineRunScript_{Path.GetFileNameWithoutExtension(fullPath)}_{Guid.NewGuid():N}"; + + // Emit symbols mapped to the source file whenever we are going to execute, so that + // exception stack traces (and, with an attached debugger, breakpoints) map back to + // file:line in the original .cs. dry_run only needs symbols when explicitly requested. + var emitDebug = pdb || !dryRun; + + // Seed the compile with the project's ACTIVE editor defines (UNITY_EDITOR, the + // UNITY_ symbols, platform defines, project scripting symbols, …) so the + // source's #if directives behave like project code. The user's `defines` extend that + // set; they do not replace it. + var activeDefines = EditorUserBuildSettings.activeScriptCompilationDefines; + var effectiveDefines = defines == null || defines.Length == 0 + ? activeDefines + : activeDefines.Concat(defines).Distinct().ToArray(); + + var compileSw = Stopwatch.StartNew(); + var compilation = RoslynCompilationService.Compile(new CompilationRequest + { + SourceCode = source, + AssemblyName = assemblyName, + EmitDebugInformation = emitDebug, + DocumentPath = fullPath, + AdditionalAssemblyPrefixes = references, + PreprocessorSymbols = effectiveDefines, + // dry_run never executes, so don't load the emitted assembly into the domain — + // in-memory assemblies are not unloadable under Mono and would leak per call. + SkipLoad = dryRun + }); + compileSw.Stop(); + + if (!compilation.Success) + { + return RunScriptResponse.Fail( + "Compilation Failed", + "The script failed to compile. See diagnostics for line/column details.", + total.ElapsedMilliseconds, + compilation.Diagnostics, + compileSw.ElapsedMilliseconds, + 0, + assemblyName); + } + + if (dryRun) + { + // Emit-only: no assembly was loaded, so there is no assemblyName to report. + var dr = RunScriptResponse.Ok(null, null, compileSw.ElapsedMilliseconds, 0, total.ElapsedMilliseconds, compilation.Diagnostics); + dr.Message = "Compiled successfully (dry run; nothing was loaded or executed)."; + return dr; + } + + // Resolve the entry point. + var method = ResolveEntryPoint(compilation.Assembly, entry, Path.GetFileName(fullPath), out var entryError); + if (method == null) + { + return RunScriptResponse.Fail("Entry Point Not Found", entryError, total.ElapsedMilliseconds, + compilation.Diagnostics, compileSw.ElapsedMilliseconds, 0, assemblyName); + } + + // Coerce JSON args to the entry point's parameter types. + if (!TryCoerceArguments(method, args, out var callArgs, out var argError)) + { + return RunScriptResponse.Fail("Bad Request", argError, total.ElapsedMilliseconds, + compilation.Diagnostics, compileSw.ElapsedMilliseconds, 0, assemblyName); + } + + // Execute. + var execSw = Stopwatch.StartNew(); + object returnValue = null; + try + { + returnValue = method.Invoke(null, callArgs); + + // A Task/Task-returning entry point must be awaited — otherwise the call + // reports success the moment the first await yields, and any exception in the + // async work is never observed. Awaited ASYNCHRONOUSLY (never a blocking + // GetResult()): this command runs on the main thread, and an entry point's awaits + // resume on the Unity SynchronizationContext — i.e. this same thread — so a + // blocking wait here deadlocks the whole Editor permanently. Awaiting instead + // returns control to the editor loop at this point (the server unwraps the + // command's own Task off the main thread), letting those continuations pump. + // The wait is bounded by what remains of timeout_ms; on expiry the task keeps + // running detached, like an abandoned eval. Task exposes Result via reflection + // (an `async Task` materializes as Task, whose Result carries no + // value — treat it as null). + if (returnValue is Task task) + { + var remainingMs = (int)Math.Max(0, timeoutMs - total.ElapsedMilliseconds); + var completed = await Task.WhenAny(task, Task.Delay(remainingMs)); + if (completed != task) + { + execSw.Stop(); + return RunScriptResponse.Fail( + "Timeout", + $"The entry point's Task did not complete within the {timeoutMs}ms budget " + + $"({remainingMs}ms remained after the synchronous part). The task keeps running detached and " + + "its effects may still land; raise timeout_ms if it legitimately needs longer.", + total.ElapsedMilliseconds, + compilation.Diagnostics, + compileSw.ElapsedMilliseconds, + execSw.ElapsedMilliseconds, + assemblyName); + } + + await task; // a faulted task throws the entry point's own exception here + var resultProperty = task.GetType().GetProperty("Result"); + returnValue = resultProperty != null && resultProperty.PropertyType.FullName != "System.Threading.Tasks.VoidTaskResult" + ? resultProperty.GetValue(task) + : null; + } + } + catch (Exception ex) when (ex is TargetInvocationException || returnValue is Task) + { + execSw.Stop(); + // Reflection wraps a synchronous throw in a TargetInvocationException; a faulted + // task rethrows the entry point's own exception from the await. Normalize both + // to the entry point's exception. With the PDB emitted above and DocumentPath set + // to the source file, the stack trace carries "... in :line ", so the + // failure maps back to the original source. + var inner = (ex as TargetInvocationException)?.InnerException ?? ex; + return RunScriptResponse.Fail( + "Runtime Error", + $"{inner.GetType().Name}: {inner.Message}\n{inner.StackTrace}", + total.ElapsedMilliseconds, + compilation.Diagnostics, + compileSw.ElapsedMilliseconds, + execSw.ElapsedMilliseconds, + assemblyName); + } + execSw.Stop(); + + return RunScriptResponse.Ok( + SerializeResult(returnValue), + assemblyName, + compileSw.ElapsedMilliseconds, + execSw.ElapsedMilliseconds, + total.ElapsedMilliseconds, + compilation.Diagnostics); + } + + /// + /// hotpatch mode: apply in-place [CodeReload] method replacements to already-loaded + /// types, delegating to the existing reload_file command so its behavior/tests are + /// preserved. The result is re-shaped into a . + /// + private static RunScriptResponse RunHotpatch(string file, bool pdb, int timeoutMs, Stopwatch total) + { + var hr = CodeReloadCommands.ReloadFile(file, timeoutMs, null, pdb); + + // reload_file reports a single duration for the whole reload (compile + apply); surface + // it as executeMs rather than mislabeling it compile time. compileMs stays 0 here. + var reloadMs = hr.ExecutionTimeMs ?? 0L; + + if (hr.Success) + { + // reload_file can succeed with advisory diagnostics (e.g. partially-skipped + // overrides) — keep them, as "warning" entries in the same shape the failure + // path uses, instead of dropping them on the floor. + var warnings = (hr.Diagnostics ?? new List()) + .Select(d => new DiagnosticInfo { Severity = "warning", Message = d }) + .ToList(); + var ok = RunScriptResponse.Ok(hr.Items, hr.AssemblyName, 0, reloadMs, total.ElapsedMilliseconds, warnings); + ok.Message = hr.Message; + return ok; + } + + var diagnostics = (hr.Diagnostics ?? new List()) + .Select(d => new DiagnosticInfo { Severity = "error", Message = d }) + .ToList(); + return RunScriptResponse.Fail(hr.Error ?? "Code Reload Failed", hr.ErrorDetails, total.ElapsedMilliseconds, + diagnostics, 0, reloadMs, hr.AssemblyName); + } + + /// + /// Resolve the static entry method from a compiled assembly. + /// - "Namespace.Type.Method" / "Type.Method": split on the last dot; find the type, then the + /// static method by name (public or non-public). Nested types may be written with dots + /// ("Outer.Inner.Method") — the '+' reflection form is retried automatically. + /// - bare "Method": the single static method with that name across all types. + /// - null/empty: the single public static method if unambiguous, else a method named "Main". + /// Generic methods are never candidates (no type arguments to close them with). + /// Returns null and sets when it cannot resolve unambiguously. + /// + private static MethodInfo ResolveEntryPoint(Assembly assembly, string entry, string fileName, out string error) + { + error = null; + + // Skip compiler-generated types (e.g. ). + var types = assembly.GetTypes().Where(t => !t.Name.Contains("<") && !t.Name.Contains(">")).ToArray(); + + const BindingFlags AnyStatic = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly; + const BindingFlags PublicStatic = BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly; + + if (!string.IsNullOrWhiteSpace(entry)) + { + entry = entry.Trim(); + var lastDot = entry.LastIndexOf('.'); + + if (lastDot > 0) + { + var typeName = entry.Substring(0, lastDot); + var methodName = entry.Substring(lastDot + 1); + + var type = assembly.GetType(typeName) + ?? types.FirstOrDefault(t => t.FullName == typeName || t.Name == typeName); + if (type == null && typeName.IndexOf('.') >= 0) + { + // Nested types use '+' in reflection names ("Outer+Inner"), while callers + // naturally write dots ("Outer.Inner.Method"). Retry with the last dot of + // the type part replaced. + var nestedDot = typeName.LastIndexOf('.'); + var nestedName = typeName.Substring(0, nestedDot) + "+" + typeName.Substring(nestedDot + 1); + type = assembly.GetType(nestedName) + ?? types.FirstOrDefault(t => t.FullName == nestedName); + } + if (type == null) + { + error = $"No type '{typeName}' in compiled file '{fileName}'. Types: [{string.Join(", ", types.Select(t => t.FullName))}]."; + return null; + } + + var named = type + .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static) + .Where(m => m.Name == methodName && !m.IsSpecialName) + .ToList(); + var candidates = named.Where(m => !m.ContainsGenericParameters).ToList(); + if (candidates.Count == 1) + return candidates[0]; + if (candidates.Count > 1) + { + error = $"Entry '{entry}' is overloaded ({candidates.Count} static overloads); overloaded entry points are not supported in v1."; + return null; + } + if (named.Count > 0) + { + error = $"Entry '{entry}' only resolves to generic method(s); generic entry points are not supported."; + return null; + } + + var instance = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(m => m.Name == methodName && !m.IsSpecialName); + error = instance != null + ? $"Entry '{entry}' is an instance method; only static methods are supported in v1." + : $"No static method '{methodName}' on type '{type.FullName}'."; + return null; + } + + // Bare method name — search every type for a static method with that name. + var byNameAll = types.SelectMany(t => t.GetMethods(AnyStatic)) + .Where(m => m.Name == entry && !m.IsSpecialName) + .ToList(); + var byName = byNameAll.Where(m => !m.ContainsGenericParameters).ToList(); + if (byName.Count == 1) + return byName[0]; + if (byName.Count == 0) + { + error = byNameAll.Count > 0 + ? $"Static method '{entry}' in '{fileName}' is generic; generic entry points are not supported." + : $"No static method named '{entry}' found in '{fileName}'."; + return null; + } + + error = $"Multiple static methods named '{entry}' found in '{fileName}'; qualify the entry as Namespace.Type.Method."; + return null; + } + + // No entry specified — auto-detect. Generic methods can't be invoked without type + // arguments, so they are never entry-point candidates. + var publicStatic = types.SelectMany(t => t.GetMethods(PublicStatic)) + .Where(m => !m.IsSpecialName && !m.ContainsGenericParameters) + .ToList(); + + if (publicStatic.Count == 1) + return publicStatic[0]; + + var mains = types.SelectMany(t => t.GetMethods(AnyStatic)) + .Where(m => m.Name == "Main" && !m.IsSpecialName && !m.ContainsGenericParameters) + .ToList(); + if (mains.Count == 1) + return mains[0]; + + if (publicStatic.Count == 0) + { + error = $"No public static entry point found in '{fileName}'. Add a public static method or specify 'entry'."; + return null; + } + + error = $"Ambiguous entry point in '{fileName}': {publicStatic.Count} public static methods and no unique 'Main'. " + + $"Specify 'entry' as Namespace.Type.Method. Candidates: [{string.Join(", ", publicStatic.Select(m => (m.DeclaringType?.FullName ?? "?") + "." + m.Name))}]."; + return null; + } + + /// + /// Coerce the JSON array to the entry point's parameter types using + /// the shared converters: primitives / enum names / string[] via Newtonsoft, and a + /// string-or-object handle ( + ) for + /// UnityEngine.Object parameters. Omitted trailing args fall back to C# default values. + /// An explicit JSON null is only valid for reference-type and Nullable<T> parameters — + /// for a non-nullable value type it is a coercion error, never a silent default(T). + /// + private static bool TryCoerceArguments(MethodInfo method, JArray args, out object[] callArgs, out string error) + { + error = null; + var parameters = method.GetParameters(); + callArgs = new object[parameters.Length]; + var provided = args?.Count ?? 0; + + if (provided > parameters.Length) + { + error = $"Entry point '{method.DeclaringType?.FullName}.{method.Name}' expects {parameters.Length} argument(s) but {provided} were provided."; + return false; + } + + for (int i = 0; i < parameters.Length; i++) + { + var p = parameters[i]; + var pt = p.ParameterType; + + if (i >= provided) + { + if (p.HasDefaultValue) + { + callArgs[i] = p.DefaultValue; + continue; + } + + error = $"Missing required argument #{i} for parameter '{p.Name}' ({pt.Name})."; + return false; + } + + var token = args[i]; + + if (typeof(Object).IsAssignableFrom(pt)) + { + if (token == null || token.Type == JTokenType.Null) + { + callArgs[i] = null; + continue; + } + + ObjectRef handle; + try + { + handle = token.ToObject(); + } + catch (Exception ex) + { + error = $"Argument #{i} ('{p.Name}'): not a valid object reference: {ex.Message}"; + return false; + } + + if (handle == null || handle.IsEmpty) + { + error = $"Argument #{i} ('{p.Name}'): empty object reference."; + return false; + } + + if (!ObjectResolver.TryResolve(handle, out var resolved, out var resolveError)) + { + error = $"Argument #{i} ('{p.Name}'): {resolveError}"; + return false; + } + + if (!pt.IsInstanceOfType(resolved)) + { + // Convenience: a GameObject handle can satisfy a Component parameter (and vice versa). + Object coerced = null; + if (resolved is GameObject go && typeof(Component).IsAssignableFrom(pt)) + coerced = go.GetComponent(pt); + else if (resolved is Component c && pt == typeof(GameObject)) + coerced = c.gameObject; + + if (coerced != null && pt.IsInstanceOfType(coerced)) + { + resolved = coerced; + } + else + { + error = $"Argument #{i} ('{p.Name}') resolved to a {resolved.GetType().Name}, which is not assignable to {pt.Name}."; + return false; + } + } + + callArgs[i] = resolved; + continue; + } + + if (token == null || token.Type == JTokenType.Null) + { + // null has no representation in a non-nullable value type — silently passing + // default(T) would hide the caller's mistake. Nullable (and reference + // types) accept null as-is. + if (pt.IsValueType && Nullable.GetUnderlyingType(pt) == null) + { + error = $"Argument #{i} ('{p.Name}'): null cannot be converted to value type {pt.Name}; pass a value, or make the parameter nullable ({pt.Name}?)."; + return false; + } + + callArgs[i] = null; + continue; + } + + try + { + callArgs[i] = token.ToObject(pt); + } + catch (Exception ex) + { + error = $"Argument #{i} ('{p.Name}'): cannot convert to {pt.Name}: {ex.Message}"; + return false; + } + } + + return true; + } + + /// + /// Serialize the entry point's return value for the JSON response. Mirrors + /// EvalCodeCompiler.SerializeResult so run_script results look like eval results: + /// primitives pass through, Unity objects go via JsonUtility, everything else via Newtonsoft, + /// with a ToString() fallback. + /// + private static object SerializeResult(object value) + { + if (value == null) return null; + + if (value is string || value is bool || value is int || value is long || value is float || value is double) + return value; + + if (value is Object unityObj) + { + try + { + return JsonConvert.DeserializeObject(JsonUtility.ToJson(unityObj)); + } + catch + { + return value.ToString(); + } + } + + try + { + return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value)); + } + catch + { + return value.ToString(); + } + } + } + + /// + /// Response for run_script. Result shape: { result, diagnostics, compileMs, executeMs, + /// assemblyName } on top of the standard command envelope. Deliberately carries no echo of the + /// source (path + diagnostics only), so MCP results stay small and reviewable. + /// + [Serializable] + class RunScriptResponse : CommandExecutionResponse + { + /// Compilation diagnostics (errors, warnings) from Roslyn, with line/column. + [JsonProperty("diagnostics")] + public List Diagnostics { get; set; } = new List(); + + /// + /// Milliseconds spent compiling the file to an in-memory assembly. 0 for hotpatch, whose + /// compile is inside reload_file and not reported separately (see ). + /// + [JsonProperty("compileMs")] + public long CompileMs { get; set; } + + /// + /// Milliseconds spent executing the entry point (0 for dry_run). For hotpatch this is the + /// whole reload duration — compile + apply — since reload_file does not split the two. + /// + [JsonProperty("executeMs")] + public long ExecuteMs { get; set; } + + /// Name of the generated in-memory assembly. + [JsonProperty("assemblyName")] + public string AssemblyName { get; set; } + + public static RunScriptResponse Ok(object result, string assemblyName, long compileMs, long executeMs, long executionTimeMs, List diagnostics) + { + return new RunScriptResponse + { + Success = true, + Command = "run_script", + Result = result, + AssemblyName = assemblyName, + CompileMs = compileMs, + ExecuteMs = executeMs, + ExecutionTimeMs = executionTimeMs, + Diagnostics = diagnostics ?? new List(), + ExecutedAt = DateTime.UtcNow + }; + } + + public static RunScriptResponse Fail(string error, string errorDetails, long executionTimeMs, + List diagnostics = null, long compileMs = 0, long executeMs = 0, string assemblyName = null) + { + return new RunScriptResponse + { + Success = false, + Command = "run_script", + Error = error, + ErrorDetails = errorDetails, + AssemblyName = assemblyName, + CompileMs = compileMs, + ExecuteMs = executeMs, + ExecutionTimeMs = executionTimeMs, + Diagnostics = diagnostics ?? new List(), + ExecutedAt = DateTime.UtcNow + }; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/RunScriptCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/RunScriptCommand.cs.meta new file mode 100644 index 0000000..cd149dd --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/RunScriptCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2b6f31ba780d4489823da503b4329157 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/ScriptTypeResolver.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/ScriptTypeResolver.cs new file mode 100644 index 0000000..e0cf76f --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/ScriptTypeResolver.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/ScriptTypeResolver.cs.meta new file mode 100644 index 0000000..79f3338 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/ScriptTypeResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6b1131a6a27648b1829e78c8c0bd69a7 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/SerializedFieldCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/SerializedFieldCommands.cs new file mode 100644 index 0000000..7f6acf5 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/SerializedFieldCommands.cs @@ -0,0 +1,239 @@ +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"). + /// + 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).", + Tags = new[] { "scripts", "gameobjects/components" })] + 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. " + + "Pass format='value' to return just the value(s) — a token-efficient read for scalars.", + Tags = new[] { "scripts", "gameobjects/components" })] + 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, + [CliArg("format", "Output shape. Omit (default) for a full descriptor per field {name,path,propertyType,isArray,arrayLength,value}; 'value' returns just the value(s): the raw value for a single 'field', or a {name: value} map for all fields.")] string format = null) + { + if (target == null || target.IsEmpty) + throw new ArgumentException("get_serialized_fields 'target' is required."); + + // Value projection (AUTHAPI-21): drop the per-field descriptor and return only the value(s) + // so a scalar read costs bytes, not an envelope. Anything other than 'value' is the default + // descriptor form, so existing callers are unaffected. + var valueOnly = string.Equals(format, "value", StringComparison.OrdinalIgnoreCase); + + 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}'."); + + if (valueOnly) + return SerializedPropertyConverter.GetValue(prop); + + return new + { + type = obj.GetType().Name, + fields = new[] { DescribeProperty(prop) } + }; + } + + // Each mode gets only its own container (the other stays null). + var fields = valueOnly ? null : new List(); + var values = valueOnly ? new Dictionary() : null; + 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; + + if (valueOnly) + { + // Degrade per-field rather than failing the whole map read (mirrors + // get_component_properties' per-property guard in ComponentCommands). + try { values[iterator.name] = SerializedPropertyConverter.GetValue(iterator); } + catch (Exception ex) { values[iterator.name] = $""; } + } + else + { + fields.Add(DescribeProperty(iterator)); + } + } + + return valueOnly ? (object)values : 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/SerializedFieldCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/SerializedFieldCommands.cs.meta new file mode 100644 index 0000000..161e82f --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/SerializedFieldCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 974b8c0dcec74d388bbf2061fc3aa3dc diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/SerializedPropertyConverter.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/SerializedPropertyConverter.cs new file mode 100644 index 0000000..9fc1ccb --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/SerializedPropertyConverter.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/SerializedPropertyConverter.cs.meta new file mode 100644 index 0000000..35a6b3d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/Scripts/SerializedPropertyConverter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ce4000d573a64d76a63e42b2b40c60a5 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/TestCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/TestCommands.cs new file mode 100644 index 0000000..8c425b4 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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 + /// + 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, Tags = new[] { "tests" })] + 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, Tags = new[] { "tests" })] + 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, Tags = new[] { "tests" })] + 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, Tags = new[] { "tests" })] + 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] + 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] + 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/AIClient/Packages/com.unity.pipeline/Editor/Commands/TestCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/TestCommands.cs.meta new file mode 100644 index 0000000..e43426e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitConditionEvaluator.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitConditionEvaluator.cs new file mode 100644 index 0000000..139e591 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitConditionEvaluator.cs @@ -0,0 +1,786 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json.Linq; +using Unity.Pipeline; +using Unity.Pipeline.Editor.Authoring; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace Unity.Pipeline.Editor.Commands +{ + /// + /// Thrown when a wait condition cannot be resolved or evaluated (bad member path, unresolved + /// target, unreadable member, un-coercible operand). Distinct from a timeout: the wait reports + /// these as a failed result with this message, never as a spurious timedOut + /// (unless tolerate_missing is set, in which case resolution failures are retried as + /// condition-not-met until the wait's own budget expires) — UNLESS is + /// set, for a failure no amount of retrying can ever resolve (a malformed member path, a + /// findType naming a non- type, a member that does not exist on + /// the resolved type): those fail fast even under tolerate_missing, whose contract is "does not + /// exist YET", not "will never exist". + /// + internal sealed class WaitEvaluationException : Exception + { + internal bool IsPermanent { get; } + + public WaitEvaluationException(string message, bool isPermanent = false) : base(message) + { + IsPermanent = isPermanent; + } + } + + /// + /// Cached resolution state for one wait's condition. The expensive part of member resolution — + /// 's whole-appdomain type scan — is cached here + /// instead of running once per poll: a successful scan never repeats, and a failed one is only + /// retried after the loaded-assembly set changes (relevant to tolerate_missing waits on + /// not-yet-loadable paths). A findType instance binds once and re-scans + /// only when the bound instance is destroyed; an explicit target handle is re-resolved + /// every poll, because it can legitimately change mid-wait. Member lookups are + /// memoized per encountered runtime type, so steady-state polls perform dictionary hits, not + /// reflection walks. + /// + /// Created when the wait is registered (pure reflection state — safe off the main thread); + /// afterwards it is only touched by on the main thread. + /// + internal sealed class WaitConditionPlan + { + internal readonly WaitConditionInput Condition; + internal readonly string[] Segments; + + /// Resolved type of a static member path's leading prefix (cached after the first successful scan). + internal Type StaticType; + + /// Number of leading segments consumed by . + internal int StaticSplit; + + /// Resolved findType (cached after the first successful scan). + internal Type FindTypeResolved; + + /// The live instance a findType wait is bound to; re-resolved only when it + /// has been destroyed (never silently re-bound while alive). + internal Object BoundInstance; + + /// The ambiguity note captured when was bound, + /// re-surfaced on every poll for the wait result. + internal string BoundNote; + + /// Ambiguity note from type resolution (a bare type name matching several loaded + /// types), captured once when the scan resolves and re-surfaced on every poll. + internal string TypeNote; + + /// + /// Signature of the loaded-assembly SET observed by the last FAILED type-resolution scan + /// (combines identity hashes, not just Count — an unload-and-load-of-a-different- + /// assembly can leave the count unchanged while still making a previously-unresolvable name + /// resolvable). A failed scan is only re-attempted once this changes: within a wait's + /// lifetime (waits don't survive domain reloads) the only way an unresolvable name becomes + /// resolvable is a newly loaded assembly (e.g. run_script/eval loading one). + /// Without this, a tolerate_missing wait on a never-resolving path would repeat the + /// whole-appdomain scan every poll. Null means "never scanned" — distinct from any signature + /// value, including a coincidental 0 — so the very first call always rescans. + /// + internal long? FailedScanAssemblySignature; + + /// Non-fatal note from the latest resolution (e.g. an ambiguous findType match), surfaced on the wait result. + internal string ResolutionNote; + + private readonly Dictionary<(Type Lookup, int Segment, bool IsStatic), MemberInfo> m_Members = + new Dictionary<(Type, int, bool), MemberInfo>(); + + internal WaitConditionPlan(WaitConditionInput condition) + { + Condition = condition; + Segments = (condition?.Member ?? string.Empty).Trim().Split('.'); + } + + internal bool TryGetCachedMember(Type lookup, int segment, bool isStatic, out MemberInfo member) + => m_Members.TryGetValue((lookup, segment, isStatic), out member); + + internal void CacheMember(Type lookup, int segment, bool isStatic, MemberInfo member) + => m_Members[(lookup, segment, isStatic)] = member; + } + + /// + /// Reads and compares a member value for the wait_for command (AUTHAPI-25). + /// + /// Member resolution mirrors the rules AUTHAPI-16's invoke/query surface will use (that shared + /// resolver does not exist yet, so this is a self-contained implementation the two can later + /// converge on — see PR notes): + /// - target (an ) resolves via + /// ; the member path then walks instance members from it. + /// - findType resolves a UnityEngine.Object type and walks from a live (non-persistent, + /// unhidden) instance — prefab assets and hidden editor internals are filtered out, scene + /// instances are preferred, and an ambiguous match binds deterministically to the lowest + /// instance id with a note on the result. + /// - Otherwise the member path is a fully-qualified static path (Ns.Type.StaticMember.Nested). + /// + /// Only readable fields and properties are resolved — write-only members and methods are rejected, + /// so the surface is read-only by construction. + /// + internal static class WaitConditionEvaluator + { + // PUBLIC members only, deliberately: wait conditions observe the same surface any C# + // consumer of the API sees. Reading private/internal state by name would let the `wait` + // tag expose internals below eval's capability gate (a host can disable eval but allow + // wait) — anyone who genuinely needs private state has eval/run_script on this server. + private const BindingFlags MemberFlags = BindingFlags.Public; + + /// Convenience overload for one-shot reads (tests): builds a throwaway plan. + internal static object ReadMember(WaitConditionInput condition, out Type memberType) + => ReadMember(new WaitConditionPlan(condition), out memberType); + + /// + /// Resolve the plan's member path and return the current value plus its declared type (used + /// to coerce the comparison operand). Type resolution results are cached on + /// so repeated polls never repeat the appdomain scan; only the live + /// instance (target / findType) is re-resolved per call. Throws + /// on any resolution failure. + /// + internal static object ReadMember(WaitConditionPlan plan, out Type memberType) + { + memberType = typeof(object); + if (plan == null || plan.Condition == null) + throw new WaitEvaluationException("condition is required.", isPermanent: true); + + plan.ResolutionNote = null; + var condition = plan.Condition; + var path = (condition.Member ?? string.Empty).Trim(); + if (path.Length == 0) + throw new WaitEvaluationException("condition.member is required.", isPermanent: true); + + var segments = plan.Segments; + + object current; + Type staticType = null; + int start; + var isFindTypeInstance = false; + + if (condition.Target != null && !condition.Target.IsEmpty) + { + // A malformed globalId is a property of the input STRING itself — no amount of + // retrying ever makes it parseable, unlike every other TryResolve failure below + // (empty/unknown/not-yet-loaded), which can legitimately resolve once the + // referenced object appears — the same "wait for a spawned object" story `target` + // shares with `findType`. Checked locally rather than widening ObjectResolver's + // public TryResolve (20+ call sites) with a permanence flag it has no other use for. + if (!string.IsNullOrEmpty(condition.Target.GlobalId) && + !GlobalObjectId.TryParse(condition.Target.GlobalId, out _)) + throw new WaitEvaluationException( + $"Invalid condition.target.globalId '{condition.Target.GlobalId}'.", isPermanent: true); + + // Re-resolved every poll on purpose: the referenced object can appear/change mid-wait. + if (!ObjectResolver.TryResolve(condition.Target, out var obj, out var err)) + throw new WaitEvaluationException($"Could not resolve condition.target: {err}"); + current = obj; + start = 0; + } + else if (!string.IsNullOrEmpty(condition.FindType)) + { + // The type scan is cached (failures too, until a new assembly loads); the instance + // lookup stays per-poll (it can legitimately change). + var type = plan.FindTypeResolved; + if (type == null && ShouldRescan(plan)) + { + type = plan.FindTypeResolved = ResolveType(condition.FindType, out var typeNote); + if (type != null) + plan.TypeNote = typeNote; + } + if (type == null) + throw new WaitEvaluationException($"Unknown findType '{condition.FindType}'."); + if (plan.BoundInstance is Object boundInstance && boundInstance) + { + // Bound once, reused until destroyed: re-scanning every loaded object on every + // poll (up to ~60/s at the minimum interval) scales with scene size for no + // benefit — the bind is deterministic anyway. Destruction of the bound + // instance triggers a re-scan, so the spawn-wait and replaced-after-destroy + // stories keep working. + current = boundInstance; + } + else + { + current = FindInstance(type, out var note) + ?? throw new WaitEvaluationException($"No live instance of type '{type.FullName}' (persistent assets and hidden objects are excluded)."); + plan.BoundInstance = (Object)current; + plan.BoundNote = note; + } + plan.ResolutionNote = plan.BoundNote; + start = 0; + isFindTypeInstance = true; + } + else + { + // Static path: find the longest leading dotted prefix that resolves to a type, + // leaving at least one trailing member segment. Resolved once, then cached. + if (segments.Length < 2) + // A property of the input STRING, not of anything that could load later — + // permanent regardless of tolerate_missing. + throw new WaitEvaluationException( + $"Static member path '{path}' must be 'Type.Member'; supply 'target' or 'findType' for instance members.", + isPermanent: true); + + if (plan.StaticType == null && ShouldRescan(plan)) + { + for (var k = segments.Length - 1; k >= 1; k--) + { + var prefix = string.Join(".", segments, 0, k); + var t = ResolveType(prefix, out var typeNote); + if (t != null) + { + plan.StaticType = t; + plan.StaticSplit = k; + plan.TypeNote = typeNote; + break; + } + } + } + + if (plan.StaticType == null) + throw new WaitEvaluationException( + $"Could not resolve a type from static path '{path}'. Supply 'target' or 'findType' for instance members."); + + staticType = plan.StaticType; + current = null; + start = plan.StaticSplit; + } + + object value = current; + var firstStatic = current == null; + + for (var i = start; i < segments.Length; i++) + { + var segment = segments[i]; + var isStatic = value == null && firstStatic; + + if (value == null && !firstStatic) + throw new WaitEvaluationException($"Member path '{path}' hit null before segment '{segment}'."); + + // An object-typed `== null` misses UnityEngine.Object's fake-null: a destroyed + // object would pass and the next getter would throw a raw + // MissingReferenceException instead of a structured evaluation failure. + if (value is Object destroyedCheck && !destroyedCheck) + throw new WaitEvaluationException( + $"Member path '{path}' hit a destroyed Unity object before segment '{segment}'."); + + var lookup = value?.GetType() ?? staticType; + if (lookup == null) + throw new WaitEvaluationException($"Could not determine a type to resolve segment '{segment}'.", isPermanent: true); + + // TryReadMember's failures (no such readable member, write-only, indexer) are + // ordinarily properties of the resolved TYPE's shape, which cannot change without + // a domain reload — and a wait does not survive one. Permanent: never worth + // retrying. EXCEPT the very first segment off a findType-bound instance: which + // concrete subtype got bound is a deterministic-but-arbitrary tie-break among + // whichever live candidates exist right now (FindInstance's lowest-instance-id + // pick), not a fixed property of the wait's target the way `target`/static paths + // are — a sibling instance of a different subtype (already alive, or not yet + // spawned) might have this member even though the one we happened to bind doesn't. + // Failing fast here would defeat tolerate_missing's "wait for the right kind of + // spawned object" story; retrying until timeout is the honest answer instead. + var ambiguousCandidate = isFindTypeInstance && i == start; + if (!TryReadMember(plan, i, lookup, segment, isStatic, value, out var next, out var nextType, out var readError)) + throw new WaitEvaluationException(readError, isPermanent: !ambiguousCandidate); + + value = next; + memberType = nextType; + firstStatic = false; + } + + if (plan.TypeNote != null) + plan.ResolutionNote = plan.ResolutionNote == null + ? plan.TypeNote + : plan.TypeNote + " " + plan.ResolutionNote; + + // A destroyed Unity object as the TERMINAL value is observed as null — Unity's own + // == semantics — so a condition can express "wait until destroyed" as + // { op: "equals", value: null }. (Traversal THROUGH a destroyed object still throws + // the structured error above; only the leaf value normalizes.) + if (value is Object terminalUnityObject && !terminalUnityObject) + value = null; + + if (memberType == typeof(object) && value != null) + memberType = value.GetType(); + + return value; + } + + /// + /// Evaluate an operator against a freshly read value. For + /// changed, compares against (the first observed value) + /// and never needs an operand. All other operators coerce the JSON operand to + /// first. Sets (and returns false) when + /// the operator or operand cannot be applied. + /// + internal static bool Evaluate(string op, object current, object operandRaw, Type memberType, + object initialValue, bool haveInitial, out string error) + { + error = null; + // Mirrors WaitForCommands.ValidateCondition's normalization exactly: an empty string is + // as much "not specified" as a null, and the two must agree, or a condition that passed + // validation as a legal equals-wait (op:"") would fail every poll on "Unknown op ''". + op = string.IsNullOrEmpty(op) ? "equals" : op.Trim(); + + if (op == "changed") + return haveInitial && !ValuesEqual(current, initialValue); + + var operandIsNull = operandRaw == null + || (operandRaw is JToken nullToken && nullToken.Type == JTokenType.Null); + if (operandIsNull && (op == "equals" || op == "notEquals")) + { + // A null operand is meaningful for (in)equality — including "wait until + // destroyed", since ReadMember observes a destroyed leaf as null. Bypass coercion + // (there is no type to coerce null to) and compare null-ness directly, + // fake-null-aware for direct evaluator calls. + var isNull = current == null || (current is Object unityCurrent && !unityCurrent); + return op == "equals" ? isNull : !isNull; + } + + if (!TryCoerceOperand(operandRaw, memberType, out var operand, out error)) + return false; + + switch (op) + { + case "equals": + return ValuesEqual(current, operand); + case "notEquals": + return !ValuesEqual(current, operand); + case "greaterThan": + case "lessThan": + if (!TryCompareNumeric(current, operand, out var comparison, out error)) + return false; + return op == "greaterThan" ? comparison > 0 : comparison < 0; + case "contains": + if (!(current is string s)) + { + error = "op 'contains' requires a string member."; + return false; + } + var sub = operand as string ?? operand?.ToString() ?? string.Empty; + return s.IndexOf(sub, StringComparison.Ordinal) >= 0; + default: + error = $"Unknown op '{op}'."; + return false; + } + } + + /// + /// Resolve a type by assembly-qualified name, full name, or simple name across loaded + /// assemblies. This is the expensive whole-appdomain scan — callers cache the result on the + /// wait's so it runs at most once per wait, not per poll. + /// + internal static Type ResolveType(string name) => ResolveType(name, out _); + + internal static Type ResolveType(string name, out string ambiguityNote) + { + ambiguityNote = null; + if (string.IsNullOrEmpty(name)) + return null; + + var direct = Type.GetType(name, throwOnError: false); + if (direct != null) + return direct; + + foreach (var asm in PipelineUtils.GetLoadedAssemblies()) + { + var t = asm.GetType(name, throwOnError: false); + if (t != null) + return t; + } + + // A dotted name can never equal a simple Type.Name (which never contains '.') — skip + // the whole-appdomain scan below for it. This matters: ReadMember's static-path prefix + // loop calls ResolveType once per FAILING dotted prefix (e.g. "Ns.Type.Nested", "Ns.Type" + // for a 4-segment path), and without this guard every one of those burns a full scan that + // can never succeed. + if (name.IndexOf('.') >= 0) + return null; + + // Simple-name fallback: a bare name can match several loaded types (e.g. "Debug" in + // UnityEngine and System.Diagnostics). Bind DETERMINISTICALLY (lowest full name + // ordinally) instead of assembly-enumeration order, and surface the ambiguity as a + // note so the caller learns to fully qualify. + Type chosen = null; + List matches = null; + foreach (var asm in PipelineUtils.GetLoadedAssemblies()) + { + foreach (var candidate in SafeGetTypes(asm)) + { + // No FullName check here: the asm.GetType(name) pass above already covered + // every exact-full-name match, so by this point only simple names can hit. + if (candidate.Name != name) + continue; + if (chosen == null || string.CompareOrdinal(candidate.FullName, chosen.FullName) < 0) + chosen = candidate; + (matches ?? (matches = new List())).Add(candidate.FullName); + } + } + + if (matches != null && matches.Count > 1) + { + matches.Sort(StringComparer.Ordinal); + ambiguityNote = $"Type name '{name}' matched {matches.Count} loaded types " + + $"({string.Join(", ", matches.GetRange(0, Math.Min(matches.Count, 4)))}" + + (matches.Count > 4 ? ", ..." : "") + + $"); bound to '{chosen.FullName}'. Use the fully-qualified name to disambiguate."; + } + + return chosen; + } + + /// + /// Gate for retrying a previously failed type-resolution scan: true only when the set of + /// loaded assemblies has changed since the last attempt (recording a signature of the + /// current set, so the next failure waits for the next actual change). If the scan succeeds + /// the positive cache on the plan short-circuits all later polls and the recorded signature + /// is never consulted again. + /// + private static bool ShouldRescan(WaitConditionPlan plan) + { + var signature = AssemblySetSignature(PipelineUtils.GetLoadedAssemblies()); + if (signature == plan.FailedScanAssemblySignature) + return false; + plan.FailedScanAssemblySignature = signature; + return true; + } + + /// + /// Identity-based signature of a loaded-assembly set: combines each assembly's own + /// (identity) hash code, not just the set's Count. An unload-and-load-of-a-different- + /// assembly (e.g. a hot-reloaded script assembly) can leave the count unchanged while + /// swapping every member of the set — a count-only gate would then never re-attempt a scan + /// that just became resolvable. + /// + private static long AssemblySetSignature(IReadOnlyList assemblies) + { + unchecked + { + long hash = 17; + for (var i = 0; i < assemblies.Count; i++) + hash = hash * 31 + assemblies[i].GetHashCode(); + return hash; + } + } + + private static Type[] SafeGetTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types?.Where(t => t != null).ToArray() ?? Array.Empty(); + } + catch + { + return Array.Empty(); + } + } + + /// + /// Pick the live instance a findType wait binds to. Persistent objects + /// (prefab/asset files on disk — ) and hidden + /// editor internals (hideFlags != None) are excluded so the wait observes the live + /// instance, never the asset. Scene instances are preferred over loose runtime objects; + /// if several candidates still remain, the lowest instance id wins deterministically and + /// the ambiguity is reported via on the wait result. + /// + private static Object FindInstance(Type type, out string note) + { + note = null; + if (!typeof(Object).IsAssignableFrom(type)) + // A property of the resolved TYPE, not of whether an instance exists yet — + // permanent regardless of tolerate_missing. + throw new WaitEvaluationException( + $"findType '{type.FullName}' is not a UnityEngine.Object type; use a static member path or a 'target' handle.", + isPermanent: true); + + var all = Resources.FindObjectsOfTypeAll(type); + if (all == null || all.Length == 0) + return null; + + var candidates = new List(); + foreach (var o in all) + { + if (o == null) + continue; + if (EditorUtility.IsPersistent(o)) + continue; // a prefab or other asset on disk, not the live instance + if (o.hideFlags != HideFlags.None) + continue; // hidden editor internals / preview objects + candidates.Add(o); + } + + if (candidates.Count == 0) + return null; + if (candidates.Count == 1) + return candidates[0]; + + var sceneInstances = candidates.Where(IsSceneInstance).ToList(); + var pool = sceneInstances.Count > 0 ? sceneInstances : candidates; + // Version-stable ids: GetInstanceID() is obsolete-as-error from 6000.4. + var chosen = pool.OrderBy(o => PipelineUtils.GetObjectId(o).RawValue).First(); + if (pool.Count > 1) + note = $"findType '{type.FullName}' matched {pool.Count} live instances; bound to instanceId {PipelineUtils.GetObjectId(chosen).RawValue} (lowest). Use 'target' to select one explicitly."; + return chosen; + } + + private static bool IsSceneInstance(Object o) + { + var go = o as GameObject ?? (o as Component)?.gameObject; + return go != null && go.scene.IsValid(); + } + + /// + /// Read a single named field or property. The member lookup is memoized on the plan per + /// encountered runtime type, so only the first poll pays for reflection resolution. + /// Rejects write-only properties, indexers, and anything that is not a readable field/property. + /// + private static bool TryReadMember(WaitConditionPlan plan, int segmentIndex, Type lookup, string name, + bool isStatic, object instance, out object value, out Type type, out string error) + { + value = null; + type = null; + error = null; + + if (!plan.TryGetCachedMember(lookup, segmentIndex, isStatic, out var member)) + { + if (!TryResolveMember(lookup, name, isStatic, out member, out error)) + return false; + plan.CacheMember(lookup, segmentIndex, isStatic, member); + } + + if (member is PropertyInfo property) + { + value = property.GetValue(isStatic ? null : instance); + type = property.PropertyType; + return true; + } + + var field = (FieldInfo)member; + value = field.GetValue(isStatic ? null : instance); + type = field.FieldType; + return true; + } + + /// Resolve a named field or property, walking base types for inherited PUBLIC + /// members. Public-only is a deliberate security boundary (see ) — + /// the base walk exists because Type.GetField/GetProperty with declared-only semantics + /// would miss inherited members, not to reach private state. + private static bool TryResolveMember(Type lookup, string name, bool isStatic, + out MemberInfo member, out string error) + { + member = null; + error = null; + + var flags = MemberFlags | (isStatic ? BindingFlags.Static : BindingFlags.Instance) | BindingFlags.DeclaredOnly; + + for (var t = lookup; t != null; t = t.BaseType) + { + var property = t.GetProperty(name, flags); + if (property != null) + { + if (!property.CanRead) + { + error = $"Member '{name}' on '{lookup.FullName}' is write-only and cannot be observed."; + return false; + } + if (property.GetIndexParameters().Length > 0) + { + error = $"Member '{name}' on '{lookup.FullName}' is an indexer and cannot be observed."; + return false; + } + + member = property; + return true; + } + + var field = t.GetField(name, flags); + if (field != null) + { + member = field; + return true; + } + } + + error = $"No readable field or property '{name}' on type '{lookup.FullName}' " + + $"({(isStatic ? "static" : "instance")} members only; methods and write-only members are rejected)."; + return false; + } + + /// Coerce a JSON operand (JToken or boxed primitive/string) to the member's runtime type. + private static bool TryCoerceOperand(object raw, Type targetType, out object coerced, out string error) + { + coerced = null; + error = null; + + if (raw == null) + { + error = "condition.value is required for this operator."; + return false; + } + + try + { + var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (underlying.IsEnum) + { + var text = RawToString(raw); + if (!string.IsNullOrEmpty(text) && !LooksNumeric(text)) + { + coerced = Enum.Parse(underlying, text, ignoreCase: true); + return true; + } + var numeric = Convert.ToInt64(RawToScalar(raw)); + coerced = Enum.ToObject(underlying, numeric); + return true; + } + + var token = raw as JToken ?? JToken.FromObject(raw); + coerced = token.ToObject(underlying); + return true; + } + catch (Exception ex) + { + error = $"Could not coerce value '{RawToString(raw)}' to {targetType.Name}: {ex.Message}"; + return false; + } + } + + private static string RawToString(object raw) + { + if (raw is JToken token) + return token.Type == JTokenType.Null ? null : token.ToObject(); + return raw?.ToString(); + } + + private static object RawToScalar(object raw) + { + if (raw is JValue value) + return value.Value; + return raw; + } + + private static bool LooksNumeric(string text) + { + return long.TryParse(text, out _) || double.TryParse(text, out _); + } + + private static bool ValuesEqual(object a, object b) + { + if (a == null && b == null) + return true; + if (a == null || b == null) + return false; + + if (IsNumeric(a) && IsNumeric(b)) + { + // A float member must be compared at float precision: widening float to double + // manufactures noise digits (0.9f -> 0.90000003576...) that never equal the double + // operand parsed from JSON, so 'equals' on a float would never match. + if (a is float || b is float) + return Convert.ToSingle(a).Equals(Convert.ToSingle(b)); + if (a is double || b is double) + return Convert.ToDouble(a).Equals(Convert.ToDouble(b)); + // Both integral/decimal: compare exactly via decimal — a double round-trip + // collapses distinct values above 2^53 (9007199254740993 == 9007199254740992.0d). + return Convert.ToDecimal(a).Equals(Convert.ToDecimal(b)); + } + + if (a.Equals(b)) + return true; + + // Enum-vs-name / cross-type tolerance (e.g. an enum compared to its string name). + return string.Equals(a.ToString(), b.ToString(), StringComparison.Ordinal); + } + + /// + /// Ordering comparison that stays exact for integral/decimal operands (decimal compare — + /// double collapses distinct values above 2^53) and uses double only when a floating-point + /// side or an enum is genuinely involved. + /// + private static bool TryCompareNumeric(object a, object b, out int comparison, out string error) + { + comparison = 0; + if (IsExactNumeric(a) && IsExactNumeric(b)) + { + try + { + comparison = Convert.ToDecimal(a).CompareTo(Convert.ToDecimal(b)); + error = null; + return true; + } + catch (Exception ex) + { + error = $"Values '{a}' and '{b}' are not comparable: {ex.Message}"; + return false; + } + } + + if (!TryToDouble(a, out var da, out error)) + return false; + if (!TryToDouble(b, out var db, out error)) + return false; + comparison = da.CompareTo(db); + return true; + } + + private static bool IsExactNumeric(object value) => + value is sbyte || value is byte || value is short || value is ushort || + value is int || value is uint || value is long || value is ulong || value is decimal; + + private static bool TryToDouble(object value, out double result, out string error) + { + result = 0; + error = null; + + if (value == null) + { + error = "Cannot compare a null value numerically."; + return false; + } + + if (IsNumeric(value) || value is Enum) + { + try + { + result = Convert.ToDouble(value); + return true; + } + catch (Exception ex) + { + error = $"Value '{value}' is not numeric: {ex.Message}"; + return false; + } + } + + error = $"greaterThan/lessThan require a numeric member; got '{value.GetType().Name}'."; + return false; + } + + private static bool IsNumeric(object value) + { + switch (value) + { + case sbyte _: + case byte _: + case short _: + case ushort _: + case int _: + case uint _: + case long _: + case ulong _: + case float _: + case double _: + case decimal _: + return true; + default: + return false; + } + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitConditionEvaluator.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitConditionEvaluator.cs.meta new file mode 100644 index 0000000..82f921a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitConditionEvaluator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 949ea7e65d634f2fa4d4667fa6ec0794 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitForCommands.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitForCommands.cs new file mode 100644 index 0000000..ce2a19e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitForCommands.cs @@ -0,0 +1,515 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Unity.Pipeline; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Commands.Capture; +using Unity.Pipeline.Models; + +namespace Unity.Pipeline.Editor.Commands +{ + /// + /// Server-side condition waiting (AUTHAPI-25): wait_for blocks until a runtime/editor + /// member condition holds — evaluated per editor frame on the main thread — then optionally acts + /// in that same frame (on_met.capture/pause). This replaces the dominant agent + /// anti-pattern of client-side polling loops (read a value via eval, sleep in the client, repeat), + /// which is slow and can miss short-lived states across client round-trips. + /// + /// The wait runs off the main thread (MainThreadRequired = false) and marshals each poll + /// onto the main thread via the server dispatcher between sleeps, so the editor itself never + /// freezes. BUT a synchronous wait holds the exec gate for its whole duration: /api/exec + /// serializes commands, so every other command — including one whose effect the condition is + /// waiting for — queues behind it until it resolves (a self-deadlock that only breaks at the + /// timeout). Synchronous waits are therefore only for SHORT, self-contained conditions; pass + /// async=true for anything else to get a wait_id back immediately and poll + /// wait_status / cancel with wait_cancel while other commands keep executing. + /// + static class WaitForCommands + { + /// Poll interval clamp: below ~a frame is pointless, above 5s risks missing states and starves the timeout check. + internal const double MinPollIntervalMs = 16; + internal const double MaxPollIntervalMs = 5000; + + private static readonly string[] s_ValidOps = + { "equals", "notEquals", "greaterThan", "lessThan", "contains", "changed" }; + + /// + /// The action used for on_met.capture, indirected so tests can observe that it runs in + /// the same frame the condition holds without requiring a GPU (the real capture path needs + /// one). Mirrors RecompileCommand.s_FocusAction. + /// + internal static Func s_CaptureAction = DefaultCapture; + + /// Grace budget for the never-observed final look on a sync timeout (see the loop). + private const int FinalLookGraceMs = 1000; + + [CliCommand("wait_for", + "Wait server-side until a member condition holds, then optionally act in the same frame. Sync waits hold the exec queue (every other command waits behind them, including one the condition may depend on) — keep them short and self-contained; set async=true and poll wait_status for anything else.", + MainThreadRequired = false, Tags = new[] { "wait" })] + public static object WaitFor( + [CliArg("condition", "Condition to wait for: { member, target?, findType?, op, value }. member is a dotted path (static, or instance under target/findType); op is equals|notEquals|greaterThan|lessThan|contains|changed.", Required = true)] + WaitConditionInput condition = null, + [CliArg("timeout_s", "Maximum seconds to wait (default 30, clamped to 0-600; must be finite).")] + double timeoutS = 30, + [CliArg("poll_interval_ms", "How often to re-check the condition, in milliseconds (default 100, clamped to 16-5000; must be finite).")] + double pollIntervalMs = 100, + [CliArg("on_met", "Follow-up run in the same editor frame the condition first holds: { capture: { view, source, save_path, width, height, include_image }, pause }.")] + WaitOnMetInput onMet = null, + [CliArg("return_history", "Include the observed value/timestamp samples (a ring buffer of the last 200, so the tail near the transition is kept).")] + bool returnHistory = false, + [CliArg("tolerate_missing", "Treat resolution failures (type/member/instance not found) as condition-not-met and retry until timeout, instead of failing fast. Enables waiting for something to exist (e.g. a spawned object via findType).")] + bool tolerateMissing = false, + [CliArg("async", "Return a wait_id immediately and evaluate in the background; poll wait_status and cancel with wait_cancel. Use this for long waits and whenever the condition depends on a command you still need to issue — a sync wait blocks it.")] + bool async = false) + { + var validationError = ValidateCondition(condition); + if (validationError != null) + throw new ArgumentException(validationError); + + if (double.IsNaN(timeoutS) || double.IsInfinity(timeoutS)) + throw new ArgumentException("timeout_s must be a finite number of seconds (0-600)."); + if (double.IsNaN(pollIntervalMs) || double.IsInfinity(pollIntervalMs)) + throw new ArgumentException($"poll_interval_ms must be a finite number of milliseconds ({MinPollIntervalMs}-{MaxPollIntervalMs})."); + + var timeoutMs = Clamp(timeoutS, 0, 600) * 1000.0; + var interval = Clamp(pollIntervalMs, MinPollIntervalMs, MaxPollIntervalMs); + + var job = new WaitJob + { + Id = Guid.NewGuid().ToString("N"), + Condition = condition, + Plan = new WaitConditionPlan(condition), + OnMet = onMet, + ReturnHistory = returnHistory, + TolerateMissing = tolerateMissing, + IsAsync = async, + TimeoutMs = timeoutMs, + PollIntervalMs = interval, + StartedAt = DateTime.UtcNow + }; + + if (!WaitRegistry.TryRegister(job, out var registerError)) + throw new InvalidOperationException(registerError); + + if (async) + return job.ToResult(includeWaitId: true); + + try + { + while (true) + { + if (PipelineCancellation.IsCancellationRequested) + { + // A detached ("job": true) sync wait stays cancelable via /api/job/cancel. + job.MarkCanceled("Wait canceled by job cancellation request."); + break; + } + + try + { + // Bound the per-poll marshal by the wait's own remaining budget (min 1 ms, + // capped at the dispatcher default) instead of inheriting the dispatcher's + // 60s default: a stalled main thread must not hold the exec gate for 60s + // when this wait's whole timeout_s is shorter. + var marshalBudgetMs = (int)Math.Max(1, Math.Min(timeoutMs - job.ElapsedMs, 60000)); + RunOnMainThread(job.PollOnce, marshalBudgetMs); + } + catch (TimeoutException) + { + // A long main-thread stall (import, modal bake, ...) timed out the per-poll + // marshal after the dispatcher's default budget. That is a missed poll, not + // a failed wait — keep going until the wait's own budget expires. + } + catch (OperationCanceledException) + { + // The dispatcher is shutting down; never fall back to evaluating Unity + // state on this pool thread. + job.MarkInterrupted("Wait interrupted: the server is stopping."); + break; + } + + if (job.IsTerminal) + break; + + var remainingMs = timeoutMs - job.ElapsedMs; + if (remainingMs <= 0) + { + // Never resolve timedOut having observed NOTHING: if every earlier poll + // missed (main thread busy, tight budget), grant one final look with a + // small grace so the result carries a real value/framesObserved — parity + // with the Tick path's final look. + if (job.FramesObserved == 0) + { + try { RunOnMainThread(job.PollOnce, FinalLookGraceMs); } + catch (TimeoutException) { /* still stalled; report the bare timeout */ } + catch (OperationCanceledException) { job.MarkInterrupted("Wait interrupted: the server is stopping."); break; } + } + if (!job.IsTerminal) + job.MarkTimedOut(); + break; + } + + // Never sleep past the remaining budget (a large interval must not stretch a + // short timeout), and wake early if the surrounding job is canceled. + if (!SleepUnlessCanceled(Math.Min(interval, remainingMs), job)) + { + job.MarkCanceled("Wait canceled by job cancellation request."); + break; + } + } + + return job.ToResult(includeWaitId: false); + } + finally + { + // A synchronous wait is fully reported in its own response; do not retain it. + WaitRegistry.Remove(job.Id); + } + } + + /// + /// Sleep for in short slices, returning false as soon as cooperative + /// job cancellation is requested, and returning early (true) the moment + /// turns terminal — a domain-reload/play-mode interruption landing + /// mid-sleep must not wait out the rest of a poll interval (up to 5s) before the caller + /// notices; the loop re-checks IsTerminal at its top. + /// + private static bool SleepUnlessCanceled(double ms, WaitJob job) + { + var remaining = ms; + while (remaining > 0) + { + if (PipelineCancellation.IsCancellationRequested) + return false; + if (job.IsTerminal) + return true; + // Ceiling, never truncation: a fractional remaining (e.g. timeout_s=32.3 ends + // as ...999.9996 ms) would truncate to a 0 slice that never decrements + // `remaining` — an infinite spin that never releases the exec gate. + var slice = (int)Math.Ceiling(Math.Min(100.0, remaining)); + Thread.Sleep(slice); + remaining -= slice; + } + return !PipelineCancellation.IsCancellationRequested; + } + + [CliCommand("wait_status", + "Get the status/result of an async wait started with wait_for (async=true).", + MainThreadRequired = false, Tags = new[] { "wait" })] + public static object WaitStatus( + [CliArg("wait_id", "The wait id returned by wait_for when async=true.", Required = true)] + string waitId = null) + { + if (string.IsNullOrEmpty(waitId)) + throw new ArgumentException("wait_id is required."); + + if (!WaitRegistry.TryGet(waitId, out var job)) + return new WaitResult + { + WaitId = waitId, + State = "not_found", + Error = "No wait with that id (async waits do not survive domain reloads; completed waits are retained up to a bounded count and pruned oldest-first when a new wait is submitted)." + }; + + return job.ToResult(includeWaitId: true); + } + + [CliCommand("wait_cancel", + "Cancel an async wait started with wait_for (async=true).", + MainThreadRequired = false, Tags = new[] { "wait" })] + public static object WaitCancel( + [CliArg("wait_id", "The wait id to cancel.", Required = true)] + string waitId = null) + { + if (string.IsNullOrEmpty(waitId)) + throw new ArgumentException("wait_id is required."); + + if (!WaitRegistry.Cancel(waitId, out var job)) + return new WaitResult + { + WaitId = waitId, + State = "not_found", + Error = "No wait with that id." + }; + + return job.ToResult(includeWaitId: true); + } + + /// Validate the condition's shape up front so bad parameters fail fast as a 400 (not a wait result). + internal static string ValidateCondition(WaitConditionInput condition) + { + if (condition == null) + return "condition is required."; + if (string.IsNullOrWhiteSpace(condition.Member)) + return "condition.member is required."; + if (condition.Target != null && !condition.Target.IsEmpty && !string.IsNullOrEmpty(condition.FindType)) + return "condition.target and condition.findType are mutually exclusive — set one or the other " + + "(the evaluator would otherwise silently prefer target and observe the wrong object)."; + + var op = string.IsNullOrEmpty(condition.Op) ? "equals" : condition.Op.Trim(); + if (Array.IndexOf(s_ValidOps, op) < 0) + return $"condition.op '{op}' is invalid. Use one of: {string.Join(", ", s_ValidOps)}."; + + // Value is a JToken precisely so an OMITTED key (null reference) is distinguishable + // from an EXPLICIT JSON null (JTokenType.Null): omitting the operand is always an + // error outside op=changed (a forgotten value must not silently become an + // equals-null wait that can resolve immediately), while an explicit null is a + // meaningful equality operand — "wait until destroyed/null". + if (op != "changed" && condition.Value == null) + return $"condition.value is required for op '{op}' (send an explicit null to compare against null)."; + if ((op == "greaterThan" || op == "lessThan" || op == "contains") + && condition.Value != null && condition.Value.Type == JTokenType.Null) + return $"condition.value cannot be null for op '{op}'."; + + return null; + } + + /// + /// Run on the editor main thread via the executing server's + /// dispatcher (or directly when already on it / no server, e.g. a direct unit-test call), + /// waiting at most for the marshal — callers bound it by the + /// wait's own remaining budget so a stalled main thread cannot hold the exec gate past + /// this wait's timeout_s (the resulting is a missed poll). + /// Throws when the dispatcher is shut down instead + /// of falling back to evaluating Unity state on this pool thread — the caller resolves the + /// wait as interrupted ("server stopping"). + /// + private static void RunOnMainThread(Action action, int timeoutMs) + { + var server = BasePipelineServer.CurrentServer; + if (server == null) + { + // Direct in-process invocation (unit tests) on the test's main thread. + action(); + return; + } + + if (server.Dispatcher.IsMainThread()) + { + action(); + return; + } + + if (!server.Dispatcher.IsInitialized) + throw new OperationCanceledException("The server's main-thread dispatcher is shut down (server stopping)."); + + try + { + server.Dispatcher.Invoke(action, timeoutMs); + } + catch (InvalidOperationException) + { + // Shutdown() can flip Dispatcher.IsInitialized to false in the window between the + // check above and this call; Invoke then throws InvalidOperationException instead + // of running. Same outcome as catching it above: report as interrupted, not an + // unhandled fault escaping the command. + throw new OperationCanceledException("The server's main-thread dispatcher is shut down (server stopping)."); + } + } + + /// Reduce an observed value to a small, JSON-friendly form (enum names, primitives, or ToString()). + internal static object Jsonify(object value) + { + if (value == null) + return null; + if (value is Enum e) + return e.ToString(); + if (value is string || value is bool || value is decimal || value.GetType().IsPrimitive) + return value; + if (value is UnityEngine.Object unityObject) + // A cached LastValue/InitialValue can hold a reference that was alive when read + // but has since been destroyed (e.g. a retained terminal job's value re-serialized + // by a later wait_status call) — `!= null` misses Unity's fake-null and .ToString() + // on a destroyed native object throws instead of serializing. + return unityObject ? unityObject.ToString() : null; + return value.ToString(); + } + + internal static WaitCaptureResult RunCapture(WaitCaptureInput capture) => s_CaptureAction(capture); + + /// Test hook: restore the real capture action after a test swaps in a stub. + internal static void ResetCaptureActionForTests() => s_CaptureAction = DefaultCapture; + + private static WaitCaptureResult DefaultCapture(WaitCaptureInput capture) + { + var view = (capture.View ?? "game").Trim().ToLowerInvariant(); + if (view != "game" && view != "scene") + throw new ArgumentException( + $"Unknown view '{capture.View}' for on_met.capture. Use 'game' or 'scene'."); + var width = capture.Width <= 0 ? 1280 : capture.Width; + var height = capture.Height <= 0 ? 720 : capture.Height; + var source = string.IsNullOrWhiteSpace(capture.Source) ? "camera" : capture.Source.Trim().ToLowerInvariant(); + if (source != "camera" && source != "screen") + throw new ArgumentException( + $"Unknown source '{capture.Source}' for on_met.capture. Use 'camera' or 'screen'."); + + CaptureResult result = view == "scene" + ? CaptureCommands.CaptureSceneView(width, height, capture.SavePath, capture.IncludeImage, 0) + : CaptureCommands.CaptureGameView(width, height, null, capture.SavePath, capture.IncludeImage, 0, source); + + return new WaitCaptureResult + { + SavedPath = result.SavedPath, + Base64 = result.Base64, + Bytes = result.Bytes, + Source = result.Source, + Width = result.Width, + Height = result.Height + }; + } + + private static double Clamp(double value, double min, double max) + { + if (value < min) return min; + if (value > max) return max; + return value; + } + } + + /// The condition wait_for polls: which member to read, on which object, and how to compare it. + class WaitConditionInput : IStructuredCommandInput + { + [CliArg("member", "Dotted member path. With no target/findType it is a fully-qualified static path (e.g. \"UnityEditor.EditorApplication.isPlaying\"); with target/findType it walks instance members (e.g. \"State\" or \"Health.CurrentHealth\"). Read-only members only.", Required = true)] + public string Member { get; set; } + + [CliArg("target", "Object handle (instanceId / hierarchyPath / guid / path / globalId) to read instance members from. Omit for a static member path or to use findType.")] + public ObjectRef Target { get; set; } + + [CliArg("findType", "Fully-qualified UnityEngine.Object type name; the member walks from its first loaded instance. Alternative to target.")] + public string FindType { get; set; } + + [CliArg("op", "Comparison: equals (default) | notEquals | greaterThan | lessThan | contains (strings) | changed (met on first change from the initially observed value; needs no value). changed is for value-like members (primitives/enums/strings); content mutations inside a reference-typed member are not detected.")] + public string Op { get; set; } = "equals"; + + [CliArg("value", "Comparison operand, coerced to the member's type (primitive, enum name, or string). Required for every op except 'changed'. equals/notEquals accept an EXPLICIT null (e.g. wait-until-destroyed: a destroyed leaf value observes as null); omitting the key entirely is rejected.")] + public JToken Value { get; set; } + } + + /// Follow-up actions run in the same editor frame the condition first holds. + class WaitOnMetInput : IStructuredCommandInput + { + [CliArg("capture", "Capture the game or scene view the moment the condition holds (reuses the capture command internals).")] + public WaitCaptureInput Capture { get; set; } + + [CliArg("pause", "Pause play mode on the frame the condition holds (no-op outside play mode).")] + public bool Pause { get; set; } + } + + /// Capture options for on_met.capture (mirrors the capture commands' parameters). + class WaitCaptureInput : IStructuredCommandInput + { + [CliArg("view", "Which view to capture: game (default) or scene.")] + public string View { get; set; } = "game"; + + [CliArg("source", "Game view only: 'camera' (default) renders a camera and misses Screen Space - Overlay UI; 'screen' captures the composited game view incl. overlay canvases (HUDs), Play Mode only.")] + public string Source { get; set; } + + [CliArg("save_path", "Project-relative path to write the PNG (e.g. Screenshots/victory.png).")] + public string SavePath { get; set; } + + [CliArg("width", "Output width in px (default 1280).")] + public int Width { get; set; } = 1280; + + [CliArg("height", "Output height in px (default 720).")] + public int Height { get; set; } = 720; + + [CliArg("include_image", "Also return the PNG inline as base64 (default false: path-only when save_path is set).")] + public bool IncludeImage { get; set; } + } + + /// Result of a wait_for (sync response, or a snapshot from wait_status/wait_cancel). + [Serializable] + class WaitResult + { + /// Async wait id (present for async submissions and status/cancel snapshots). + [JsonProperty("waitId", NullValueHandling = NullValueHandling.Ignore)] + public string WaitId { get; set; } + + /// pending | met | timedOut | interrupted | canceled | failed | not_found. + [JsonProperty("state")] + public string State { get; set; } + + [JsonProperty("met")] + public bool Met { get; set; } + + [JsonProperty("timedOut")] + public bool TimedOut { get; set; } + + [JsonProperty("interrupted", NullValueHandling = NullValueHandling.Ignore)] + public bool? Interrupted { get; set; } + + [JsonProperty("member", NullValueHandling = NullValueHandling.Ignore)] + public string Member { get; set; } + + [JsonProperty("op", NullValueHandling = NullValueHandling.Ignore)] + public string Op { get; set; } + + /// The most recently observed value (the value at the moment the wait resolved). + [JsonProperty("value")] + public object Value { get; set; } + + /// The first observed value (baseline for op=changed). + [JsonProperty("initialValue")] + public object InitialValue { get; set; } + + [JsonProperty("elapsedMs")] + public long ElapsedMs { get; set; } + + [JsonProperty("framesObserved")] + public int FramesObserved { get; set; } + + [JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)] + public string Error { get; set; } + + /// Set when the condition HELD but the on_met follow-up (capture/pause) threw — + /// the wait is still met; check this before trusting capture. + [JsonProperty("onMetError", NullValueHandling = NullValueHandling.Ignore)] + public string OnMetError { get; set; } + + /// Non-fatal resolution note (e.g. an ambiguous findType match bound deterministically). + [JsonProperty("note", NullValueHandling = NullValueHandling.Ignore)] + public string Note { get; set; } + + [JsonProperty("capture", NullValueHandling = NullValueHandling.Ignore)] + public WaitCaptureResult Capture { get; set; } + + [JsonProperty("history", NullValueHandling = NullValueHandling.Ignore)] + public List History { get; set; } + } + + /// The PNG written / rendered by an on_met.capture. + [Serializable] + class WaitCaptureResult + { + [JsonProperty("savedPath", NullValueHandling = NullValueHandling.Ignore)] + public string SavedPath { get; set; } + + [JsonProperty("base64", NullValueHandling = NullValueHandling.Ignore)] + public string Base64 { get; set; } + + [JsonProperty("bytes")] + public int Bytes { get; set; } + + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public string Source { get; set; } + + [JsonProperty("width")] + public int Width { get; set; } + + [JsonProperty("height")] + public int Height { get; set; } + } + + /// One observed sample (value + elapsed time) recorded when return_history is set. + [Serializable] + class WaitHistorySample + { + [JsonProperty("value")] + public object Value { get; set; } + + [JsonProperty("elapsedMs")] + public long ElapsedMs { get; set; } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitForCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitForCommands.cs.meta new file mode 100644 index 0000000..2239cd0 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitForCommands.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9d2d80c9117f4850977762fcabcec65d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitRegistry.cs b/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitRegistry.cs new file mode 100644 index 0000000..2be1886 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitRegistry.cs @@ -0,0 +1,596 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.Pipeline; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Commands +{ + /// + /// One active or completed wait tracked by (AUTHAPI-25). + /// + /// The condition is evaluated by , which MUST run on the main thread: the + /// synchronous wait_for path marshals it there via the server dispatcher between sleeps + /// (so the main thread is never held hostage), while an async=true wait is polled by + /// on EditorApplication.update. Any follow-up action + /// (on_met.capture/pause) runs inside the same call that + /// first observes the condition true, so it acts in that same editor frame. + /// + internal sealed class WaitJob + { + internal const int MaxHistorySamples = 200; + + public string Id; + public WaitConditionInput Condition; + public WaitConditionPlan Plan; + public WaitOnMetInput OnMet; + public bool ReturnHistory; + public bool IsAsync; + public bool TolerateMissing; + public double PollIntervalMs; + public double TimeoutMs; + public DateTime StartedAt; + + /// Guards mutable state below; read from the request/background thread, mutated on the main thread. + public readonly object Gate = new object(); + + // --- guarded by Gate --- + /// Single source of truth for the lifecycle. Every public view (the five + /// booleans, the wire string, IsTerminal) derives from it, so a state transition can never + /// be half-applied and the terminal guard lives in exactly one place. + private WaitState m_State = WaitState.Pending; + /// Set while still-Pending but already committed to running the on_met + /// follow-up (its pause/capture side effects are about to happen for real, off the lock). + /// Blocks the same way a non-Pending state does, so a + /// concurrent cancel/interrupt/timeout cannot claim the job mid-follow-up: the follow-up's + /// side effects have already fired and must not be reported as if they never happened. + private bool m_Resolving; + public object InitialValue; + public bool HaveInitial; + public object LastValue; + public string Error; + public string ResolutionNote; + public int FramesObserved; + + /// Error from the on_met follow-up (capture/pause) when the condition held but the + /// follow-up threw — reported as onMetError, distinct from . + public string FollowupError; + public WaitCaptureResult Capture; + public readonly List History = new List(); + public DateTime? LastPollAt; + /// UTC time of the terminal transition; freezes so a + /// completed wait's elapsed time stops growing while it sits retained for wait_status. + private DateTime? m_TerminalAtUtc; + // --- end guarded --- + + /// NOTE: terminal transitions must go through — + /// it also checks (committed-to-on_met, not yet Met); a path + /// testing only m_State != Pending would reopen the cancel-vs-met race. + internal enum WaitState { Pending, Met, TimedOut, Interrupted, Canceled, Failed } + + public bool Met => ReadState() == WaitState.Met; + public bool TimedOut => ReadState() == WaitState.TimedOut; + public bool Interrupted => ReadState() == WaitState.Interrupted; + public bool Canceled => ReadState() == WaitState.Canceled; + public bool Failed => ReadState() == WaitState.Failed; + public bool IsTerminal => ReadState() != WaitState.Pending; + + /// Wire form: "pending" | "met" | "timedOut" | "interrupted" | "canceled" | "failed". + public string State => StateName(ReadState()); + + private WaitState ReadState() + { + lock (Gate) { return m_State; } + } + + private static string StateName(WaitState state) + { + switch (state) + { + case WaitState.Met: return "met"; + case WaitState.TimedOut: return "timedOut"; + case WaitState.Interrupted: return "interrupted"; + case WaitState.Canceled: return "canceled"; + case WaitState.Failed: return "failed"; + default: return "pending"; + } + } + + /// Milliseconds since the wait started, frozen at the terminal transition. + public long ElapsedMs + { + get + { + lock (Gate) + { + var end = m_TerminalAtUtc ?? DateTime.UtcNow; + return (long)(end - StartedAt).TotalMilliseconds; + } + } + } + + /// Record the terminal timestamp. Must be called with held. + private void MarkTerminalLocked() + { + if (m_TerminalAtUtc == null) + m_TerminalAtUtc = DateTime.UtcNow; + } + + /// UTC time this wait became terminal, or null while still pending. Set atomically + /// with the state transition (see ), so any job observed as + /// already has this set — used to prune the OLDEST-COMPLETED jobs + /// first, not the oldest-SUBMITTED ones (a long-running wait can have the earliest + /// in the registry yet be the most recently finished). + public DateTime? TerminalAtUtc + { + get { lock (Gate) { return m_TerminalAtUtc; } } + } + + /// + /// Evaluate the condition once on the main thread. Reads the member, records the observation, + /// and — on the first frame the condition holds — runs the follow-up action and transitions + /// to met. Resolution failures transition to failed (never to timedOut) + /// unless tolerate_missing is set, in which case they are recorded as the last error + /// and retried as condition-not-met (enables "wait until X exists"). No-op once the job is + /// already terminal. + /// + internal void PollOnce() + { + lock (Gate) + { + if (m_State != WaitState.Pending) + return; + LastPollAt = DateTime.UtcNow; + } + + var plan = Plan ?? (Plan = new WaitConditionPlan(Condition)); + + object value; + Type memberType; + try + { + value = WaitConditionEvaluator.ReadMember(plan, out memberType); + } + catch (Exception ex) + { + // WaitEvaluationException is the evaluator's own structured failure; anything else + // is a member getter throwing (reflection wraps those in TargetInvocationException) + // — a property body that throws, a MissingReferenceException, etc. Both must + // resolve the wait (or be tolerated), never escape: in the async Tick path an + // escaped exception is swallowed by Unity's event dispatch and the job would just + // time out with no cause; in the sync path it would propagate out of WaitFor raw. + var inner = (ex as System.Reflection.TargetInvocationException)?.InnerException ?? ex; + var resolutionFailure = inner as WaitEvaluationException; + var isResolutionFailure = resolutionFailure != null; + var message = isResolutionFailure + ? inner.Message + : $"Condition read threw {inner.GetType().Name}: {inner.Message}"; + lock (Gate) + { + if (m_State != WaitState.Pending) + return; // canceled/interrupted while the poll was reading + FramesObserved++; + if (TolerateMissing && isResolutionFailure && !resolutionFailure.IsPermanent) + { + // tolerate_missing is scoped to the evaluator's STRUCTURED resolution + // failures (type/member/instance not found) — its documented contract. + // A member getter that THROWS is a real failure, not "does not exist + // yet": retrying it would leave the wait pending until timeout and + // bury the actual exception. Nor is a PERMANENT resolution failure (a + // malformed path, a findType naming a non-Object type, a member that + // doesn't exist on the resolved type) — no amount of retrying makes those + // resolvable, so tolerating them would just bury a real misconfiguration + // until the timeout instead of failing fast. + Error = message; + return; + } + m_State = WaitState.Failed; + Error = message; + MarkTerminalLocked(); + } + return; + } + + object initialSnapshot; + bool haveInitial; + lock (Gate) + { + if (m_State != WaitState.Pending) + return; // canceled/interrupted while the poll was reading + FramesObserved++; + // A successful read supersedes any tolerated resolution error recorded above. + Error = null; + ResolutionNote = plan.ResolutionNote; + if (!HaveInitial) + { + InitialValue = value; + HaveInitial = true; + } + LastValue = value; + initialSnapshot = InitialValue; + haveInitial = HaveInitial; + + if (ReturnHistory) + { + // Ring buffer keeping the LAST samples: the tail near the transition is what + // diagnoses a wait, not the first 200 identical pre-transition observations. + if (History.Count >= MaxHistorySamples) + History.RemoveAt(0); + History.Add(new WaitHistorySample { Value = WaitForCommands.Jsonify(value), ElapsedMs = ElapsedMs }); + } + } + + bool met; + string evalError; + try + { + met = WaitConditionEvaluator.Evaluate(Condition.Op, value, Condition.Value, memberType, + initialSnapshot, haveInitial, out evalError); + } + catch (Exception ex) + { + met = false; + evalError = ex.Message; + } + + if (!string.IsNullOrEmpty(evalError)) + { + lock (Gate) + { + if (m_State != WaitState.Pending) + return; + if (TolerateMissing) + { + // Same contract as tolerated resolution failures: "cannot compare YET" + // (e.g. greaterThan while a nullable field is still null) retries until + // the budget expires, with the last error surfaced on timeout. The + // permanently-misconfigured variant (contains on a non-string member) + // spins the budget instead of failing fast — the same indistinguishable + // transient-vs-permanent tradeoff as null intermediates, accepted and + // documented. Getter exceptions still fail immediately (handled above). + Error = evalError; + return; + } + m_State = WaitState.Failed; + Error = evalError; + MarkTerminalLocked(); + } + return; + } + + if (!met) + return; + + // wait_cancel/MarkInterrupted/MarkTimedOut run on a different thread and can land any + // time from here on. Claim the job now (still Pending, so a status read in this window + // sees "pending") BEFORE running the on_met follow-up's side effects: this claim, not a + // state check either side of the follow-up, is what must be atomic with "am I about to + // pause/capture for real". Checking-then-running-then-checking-again left a window + // where a cancel could land IN BETWEEN — the pause/screenshot would still fire for + // real, but the job would then commit to canceled, silently discarding all trace that a + // real side effect happened (see review round 5). Once claimed, MarkCanceled / + // MarkInterrupted / MarkTimedOut all no-op (see TryEnterTerminal) for the rest of this + // poll, so the final commit below needs no re-check. + lock (Gate) + { + if (m_State != WaitState.Pending) + return; + m_Resolving = true; + } + + // Same-frame follow-up: this runs on the main thread within the poll that first saw the + // condition true, so a capture reflects that frame and a pause takes effect immediately. + WaitCaptureResult capture = null; + string followupError = null; + if (OnMet != null) + { + try + { + if (OnMet.Pause && Application.isPlaying) + EditorApplication.isPaused = true; + if (OnMet.Capture != null) + capture = WaitForCommands.RunCapture(OnMet.Capture); + } + catch (Exception ex) + { + followupError = $"on_met action failed: {ex.Message}"; + } + } + + lock (Gate) + { + m_State = WaitState.Met; + m_Resolving = false; + Capture = capture; + // The CONDITION held — the wait is met; a failed follow-up (capture/pause) is + // reported on its own field, never in Error: docs define error as the reason a + // wait FAILED, and stashing it there made a met-with-broken-capture reply look + // like a documented failure shape while state said otherwise. + FollowupError = followupError; + MarkTerminalLocked(); + } + } + + /// + /// Claim the terminal transition to , running + /// (e.g. to stamp ) under the same lock before freezing . + /// No-ops once the job is already terminal, OR mid-on_met-follow-up (see + /// ) — a wait that already committed to running real side effects + /// must finish committing to met, never be re-claimed as canceled/interrupted/timedOut. + /// + private void TryEnterTerminal(WaitState state, Action onClaimed = null) + { + lock (Gate) + { + if (m_State != WaitState.Pending || m_Resolving) + return; + m_State = state; + onClaimed?.Invoke(); + MarkTerminalLocked(); + } + } + + internal void MarkTimedOut() => TryEnterTerminal(WaitState.TimedOut); + + internal void MarkCanceled(string reason = null) => TryEnterTerminal(WaitState.Canceled, () => + { + if (!string.IsNullOrEmpty(reason)) + Error = reason; + }); + + internal void MarkInterrupted(string reason = null) => TryEnterTerminal(WaitState.Interrupted, () => + { + // The interruption cause always wins: a stale tolerated-resolution message left by + // an earlier poll must not masquerade as the reason the wait ended. + Error = !string.IsNullOrEmpty(reason) + ? reason + : "Wait interrupted by a domain reload or exiting play mode."; + }); + + internal WaitResult ToResult(bool includeWaitId) + { + lock (Gate) + { + return new WaitResult + { + WaitId = includeWaitId ? Id : null, + State = State, + Met = Met, + TimedOut = TimedOut, + Interrupted = Interrupted ? (bool?)true : null, + Member = Condition?.Member, + Op = string.IsNullOrEmpty(Condition?.Op) ? "equals" : Condition.Op, + Value = HaveInitial ? WaitForCommands.Jsonify(LastValue) : null, + InitialValue = HaveInitial ? WaitForCommands.Jsonify(InitialValue) : null, + ElapsedMs = ElapsedMs, + FramesObserved = FramesObserved, + Error = Error, + OnMetError = FollowupError, + Note = ResolutionNote, + Capture = Capture, + History = (ReturnHistory && History.Count > 0) + ? new List(History) + : null + }; + } + } + } + + /// + /// Process-wide registry of s (AUTHAPI-25). Async waits are polled here on + /// EditorApplication.update; synchronous waits register too (so the concurrency cap and + /// interruption hooks cover them) but are driven by their own command loop. A domain reload or + /// exiting play mode interrupts every active wait so it resolves to a structured + /// interrupted result rather than hanging or leaking. ENTERING play mode does not + /// interrupt: with domain reload disabled a wait survives into play mode; with reload enabled, + /// beforeAssemblyReload interrupts it anyway. + /// + /// PROCESS-WIDE by design — deliberately unlike , which is + /// per-server so one server's command results never cross into another. A wait has no + /// per-server state to isolate: it observes process-global editor state, is polled by the + /// single EditorApplication.update pump, and is interrupted by process-level events + /// (domain reload, play-mode exit). Ids are GUIDs, so waits from different servers cannot + /// collide, and intentionally bounds the shared update pump + /// rather than acting as a per-server quota. If true per-server isolation ever becomes a + /// requirement, scope this like JobRegistry at that point. + /// + /// In-memory: a domain reload discards pending waits the same way it discards every other + /// static — a poller then gets a clear "not found / does not survive domain reloads" result. + /// + internal static class WaitRegistry + { + /// Maximum simultaneously-active (non-terminal) waits; a further submission is rejected. + internal const int MaxConcurrentWaits = 8; + + /// Completed async jobs retained (for wait_status) beyond this count are pruned oldest-first. + internal const int MaxRetainedTerminal = 32; + + private static readonly Dictionary s_Jobs = new Dictionary(); + private static readonly object s_Gate = new object(); + + /// + /// Register a job, unless waits are already active (returns + /// false with a message the caller surfaces as a structured error). + /// + internal static bool TryRegister(WaitJob job, out string error) + { + lock (s_Gate) + { + PruneTerminal(); + + var active = s_Jobs.Values.Count(j => !j.IsTerminal); + if (active >= MaxConcurrentWaits) + { + error = $"Too many concurrent waits: {active} active (max {MaxConcurrentWaits}). Cancel one with wait_cancel or wait for one to complete."; + return false; + } + + s_Jobs[job.Id] = job; + } + + error = null; + return true; + } + + internal static bool TryGet(string id, out WaitJob job) + { + lock (s_Gate) + { + return s_Jobs.TryGetValue(id ?? string.Empty, out job); + } + } + + /// + /// Cancel a tracked wait by id. Returns true whenever the id is known — including when the + /// job had already reached a terminal state before this call: + /// no-ops in that case, so 's state stays whatever it actually resolved + /// to (never forced to "canceled") — see the honest-cancel-result note in wait.md. + /// + internal static bool Cancel(string id, out WaitJob job) + { + if (!TryGet(id, out job)) + return false; + job.MarkCanceled(); + return true; + } + + internal static void Remove(string id) + { + lock (s_Gate) + { + s_Jobs.Remove(id ?? string.Empty); + } + } + + internal static int ActiveCount + { + get + { + lock (s_Gate) + { + return s_Jobs.Values.Count(j => !j.IsTerminal); + } + } + } + + /// + /// Poll all active async waits whose poll interval has elapsed, and time out any that have + /// exceeded their budget. Subscribed to EditorApplication.update; also callable + /// directly from tests to drive async waits deterministically. + /// + internal static void Tick() + { + List pending = null; + lock (s_Gate) + { + foreach (var job in s_Jobs.Values) + { + if (job.IsAsync && !job.IsTerminal) + (pending ?? (pending = new List())).Add(job); + } + } + + if (pending != null) + { + foreach (var job in pending) + { + if (job.ElapsedMs >= job.TimeoutMs) + { + // Final look, unconditionally — full parity with the sync loop, which polls + // before every deadline check. A condition that flipped true between the + // last scheduled poll and the deadline must resolve met, not timedOut (and + // a never-polled job still reports a real value/framesObserved). + job.PollOnce(); + if (!job.IsTerminal) + job.MarkTimedOut(); + continue; + } + + double sincePoll; + lock (job.Gate) + { + sincePoll = job.LastPollAt.HasValue + ? (DateTime.UtcNow - job.LastPollAt.Value).TotalMilliseconds + : double.MaxValue; + } + + if (sincePoll >= job.PollIntervalMs) + job.PollOnce(); + } + } + + } + + /// + /// Subscriptions are installed unconditionally once per domain load, on the main thread — + /// the same idiom as every other EditorApplication.update poller in this package + /// (bake status, build, target switch). and + /// no-op when nothing is registered, so the permanent hooks cost one delegate call per + /// update. This replaces a lazy install/uninstall state machine whose main-thread marshal + /// could fail while the very jobs that needed the pump sat frozen. + /// + [InitializeOnLoadMethod] + private static void HookOnDomainLoad() + { + EditorApplication.update += Tick; + AssemblyReloadEvents.beforeAssemblyReload += InterruptAll; + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + } + + private static void OnPlayModeStateChanged(PlayModeStateChange change) + { + // Interrupt only when LEAVING play mode: the objects a wait observes are torn down + // then. Entering play mode does not interrupt — with domain reload disabled the wait + // survives into play mode (the common wait-for-gameplay-state case); with reload + // enabled, beforeAssemblyReload interrupts it anyway. + if (change == PlayModeStateChange.ExitingPlayMode) + InterruptAll(); + } + + private static void InterruptAll() + { + List active; + lock (s_Gate) + { + active = s_Jobs.Values.Where(j => !j.IsTerminal).ToList(); + } + foreach (var job in active) + job.MarkInterrupted(); + } + + private static void PruneTerminal() + { + // Oldest-COMPLETED first, not oldest-submitted: a long-running wait can have the + // earliest StartedAt of any job in the registry yet be the most recently finished, and + // sorting by StartedAt would evict it the instant it resolves while far-shorter waits + // that finished long ago (but were submitted later) survive. + var terminal = s_Jobs.Values + .Where(j => j.IsTerminal) + .OrderBy(j => j.TerminalAtUtc) + .ToList(); + + var excess = terminal.Count - MaxRetainedTerminal; + for (var i = 0; i < excess; i++) + s_Jobs.Remove(terminal[i].Id); + } + + /// Test hook: forget every tracked wait (main thread). The editor hooks installed + /// by are permanent by design and deliberately NOT removed — + /// do not re-subscribe them from tests. + internal static void ResetForTests() + { + lock (s_Gate) + { + s_Jobs.Clear(); + } + } + + /// Test hook: exercise the domain-reload/play-mode interruption transition without a live reload. + internal static void InterruptAllForTests() => InterruptAll(); + + /// Test hook: exercise the play-mode filter (interrupt on exit only) without entering play mode. + internal static void SimulatePlayModeChangeForTests(PlayModeStateChange change) => OnPlayModeStateChanged(change); + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitRegistry.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitRegistry.cs.meta new file mode 100644 index 0000000..8092d92 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ba4fd5fb7e174b82ba6ca03266b31357 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Console.meta b/AIClient/Packages/com.unity.pipeline/Editor/Console.meta new file mode 100644 index 0000000..6b510e3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Console.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 98aa20a2d767cba16cc6922b9e5bb32b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleCaptureBootstrap.cs b/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleCaptureBootstrap.cs new file mode 100644 index 0000000..75d3819 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleCaptureBootstrap.cs @@ -0,0 +1,76 @@ +using System.Globalization; +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 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. + /// - Re-subscribing on every play-mode transition: Unity clears + /// 's subscribers on play-mode + /// exit even though exiting play mode does not reload the domain, so capture would otherwise go + /// dead until the next recompile. + /// - A session identifier and a sequence floor in , so a client can + /// tell a cursor this editor session issued from one an earlier session did. + /// + /// 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"; + + // SessionState survives domain reloads and is cleared when the Editor restarts, which is + // exactly the lifetime of the Temp file the buffer persists to. Both therefore start over + // together, and the session identifier is what tells a client that happened. + internal const string SessionKey = "pipeline.console.session"; + internal const string SeqFloorKey = "pipeline.console.seq"; + + static EditorConsoleCaptureBootstrap() + { + var session = SessionState.GetString(SessionKey, string.Empty); + if (string.IsNullOrEmpty(session)) + { + session = System.Guid.NewGuid().ToString("N"); + SessionState.SetString(SessionKey, session); + } + + ConsoleLogCapture.Buffer.SetSession(session); + + // Restore before capture starts so restored entries precede any newly captured ones, and + // load before raising the floor: the floor is the same value the snapshot recorded, so + // raising it first would make every snapshot entry look already-issued and drop it. + ConsoleLogCapture.Buffer.Load(PersistencePath); + if (long.TryParse(SessionState.GetString(SeqFloorKey, "0"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var floor)) + ConsoleLogCapture.Buffer.RaiseLastSeq(floor); + + ConsoleLogCapture.EnsureCapturing(); + + AssemblyReloadEvents.beforeAssemblyReload -= Persist; + AssemblyReloadEvents.beforeAssemblyReload += Persist; + + EditorApplication.quitting -= Persist; + EditorApplication.quitting += Persist; + + EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + } + + static void Persist() + { + ConsoleLogCapture.Buffer.Save(PersistencePath); + SessionState.SetString(SeqFloorKey, ConsoleLogCapture.Buffer.LastSeq.ToString(CultureInfo.InvariantCulture)); + } + + static void OnPlayModeStateChanged(PlayModeStateChange state) => ConsoleLogCapture.EnsureCapturing(); + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleCaptureBootstrap.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleCaptureBootstrap.cs.meta new file mode 100644 index 0000000..623e933 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleCaptureBootstrap.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1fb443a79f9bd099f5894c23de206319 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleEntries.cs b/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleEntries.cs new file mode 100644 index 0000000..3a654cb --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleEntries.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Console +{ + /// + /// Reads the Editor console's own entry store through UnityEditor.LogEntries. + /// + /// This is the only way to see entries the package's log-callback capture never received. + /// Editor compile errors are logged with a sticky flag, and the console's Clear skips sticky + /// entries, so they survive the Clear button, Clear on Play, Clear on Build and Clear on + /// Recompile — only the next completed compile removes them. They can therefore stand in the + /// console long after the callback-fed buffer evicted them or was never subscribed. + /// + /// Reflection rather than a direct reference, because LogEntries is internal. Any failure + /// disables this permanently for the session and callers report the console as unseeded. + /// + static class EditorConsoleEntries + { + // ConsoleMode bits from Unity's native EditorMonoConsole (mirrored by the internal + // ConsoleWindow.ConsoleFlags): the three level toggles the console filter checks, and + // Collapse, which folds identical entries into a single row. + const int LevelFlags = (1 << 7) | (1 << 8) | (1 << 9); + const int CollapseFlag = 1 << 0; + + // LogMessageFlags bits, grouped exactly as Unity's own LogModeToLogType groups them. + const int ModeScriptingException = 1 << 17; + const int ModeErrors = (1 << 0) | (1 << 4) | (1 << 8) | (1 << 11) | (1 << 6); + const int ModeAsserts = (1 << 1) | (1 << 21); + const int ModeWarnings = (1 << 9) | (1 << 12) | (1 << 7); + + static readonly MethodInfo k_StartGettingEntries; + static readonly MethodInfo k_EndGettingEntries; + static readonly MethodInfo k_GetEntryInternal; + static readonly MethodInfo k_GetFilteringText; + static readonly MethodInfo k_SetFilteringText; + static readonly MethodInfo k_GetConsoleFlags; + static readonly MethodInfo k_SetConsoleFlags; + static readonly Type k_LogEntryType; + static readonly FieldInfo k_MessageField; + static readonly FieldInfo k_ModeField; + static readonly FieldInfo k_CallstackStartField; + + static bool s_Disabled; + + /// An entry as it stands in the Editor console. + internal struct Entry + { + public LogType Type; + public string Message; + public string StackTrace; + } + + static EditorConsoleEntries() + { + // LogEntries and LogEntry are internal, and LogEntries' members are static and + // non-public, so NonPublic is mandatory in the binding flags — GetMethod without it + // silently returns null. + const BindingFlags statics = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; + + var logEntries = Type.GetType("UnityEditor.LogEntries,UnityEditor"); + k_LogEntryType = Type.GetType("UnityEditor.LogEntry,UnityEditor"); + if (logEntries == null || k_LogEntryType == null) + return; + + k_StartGettingEntries = logEntries.GetMethod("StartGettingEntries", statics); + k_EndGettingEntries = logEntries.GetMethod("EndGettingEntries", statics); + k_GetEntryInternal = logEntries.GetMethod("GetEntryInternal", statics); + k_GetFilteringText = logEntries.GetMethod("GetFilteringText", statics); + k_SetFilteringText = logEntries.GetMethod("SetFilteringText", statics); + + var consoleFlags = logEntries.GetProperty("consoleFlags", statics); + k_GetConsoleFlags = consoleFlags?.GetGetMethod(true); + k_SetConsoleFlags = consoleFlags?.GetSetMethod(true); + + const BindingFlags fields = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + k_MessageField = k_LogEntryType.GetField("message", fields); + k_ModeField = k_LogEntryType.GetField("mode", fields); + k_CallstackStartField = k_LogEntryType.GetField("callstackTextStartUTF16", fields); + } + + /// + /// True when every member this reads was found and no read has failed yet. + /// + internal static bool Available => + !s_Disabled + && k_StartGettingEntries != null && k_EndGettingEntries != null && k_GetEntryInternal != null + && k_GetFilteringText != null && k_SetFilteringText != null + && k_GetConsoleFlags != null && k_SetConsoleFlags != null + && k_MessageField != null && k_ModeField != null && k_CallstackStartField != null; + + /// + /// Read up to most recent console entries, oldest first. Returns + /// false when the internal API is unavailable or the read failed, in which case + /// is left untouched. Main thread only. + /// + /// Maximum number of entries to read, taken from the end of the console. + /// Receives the entries read. + /// True when entries were read. + internal static bool TryReadRecent(int max, List into) + { + if (!Available || max <= 0) + return false; + + var flags = 0; + var filter = string.Empty; + var flagsMutated = false; + var filterMutated = false; + + try + { + flags = (int)k_GetConsoleFlags.Invoke(null, null); + filter = (string)k_GetFilteringText.Invoke(null, null) ?? string.Empty; + + // The console's row list is filtered by the level toggles and the search box, and + // Collapse folds repeats into one row, so a user who has any of them set would + // otherwise hide entries from this read. Open the view only when it is actually + // restricted: in the default state this mutates nothing and avoids two native + // re-filter passes. + // + // Collapse matters because the caller counts occurrences to work out what it missed; + // a collapsed row reports one where the console holds several. + // + // Both must happen outside the StartGettingEntries bracket: setting them rebuilds + // the filtered index, and reordering it under a live snapshot invalidates the row + // indices the read below uses. + var opened = (flags | LevelFlags) & ~CollapseFlag; + if (opened != flags) + { + k_SetConsoleFlags.Invoke(null, new object[] { opened }); + flagsMutated = true; + } + + if (!string.IsNullOrEmpty(filter)) + { + k_SetFilteringText.Invoke(null, new object[] { string.Empty }); + filterMutated = true; + } + + // StartGettingEntries returns the row count and pins it for the duration; GetCount + // would report that same pinned value, so there is no reason to ask twice. + var total = (int)k_StartGettingEntries.Invoke(null, null); + try + { + var entry = Activator.CreateInstance(k_LogEntryType); + var args = new object[2]; + for (var row = Math.Max(0, total - max); row < total; row++) + { + args[0] = row; + args[1] = entry; + if (!(bool)k_GetEntryInternal.Invoke(null, args)) + continue; + + into.Add(ToEntry(entry)); + } + } + finally + { + // Leaving the snapshot open pins the console's row count for every other reader + // and makes Unity warn on the next StartGettingEntries. + k_EndGettingEntries.Invoke(null, null); + } + + return true; + } + catch (Exception ex) + { + s_Disabled = true; + Debug.LogWarning($"[Pipeline] Cannot read the Editor console store; console entries logged before capture started will not be reported: {ex.Message}"); + return false; + } + finally + { + if (filterMutated) + TryRestore(k_SetFilteringText, filter); + if (flagsMutated) + TryRestore(k_SetConsoleFlags, flags); + } + } + + static Entry ToEntry(object logEntry) + { + var message = (string)k_MessageField.GetValue(logEntry) ?? string.Empty; + var mode = (int)k_ModeField.GetValue(logEntry); + + // A console entry stores its message and callstack in one string, split at this index. + var split = Mathf.Clamp((int)k_CallstackStartField.GetValue(logEntry), 0, message.Length); + + return new Entry + { + Type = ToLogType(mode), + Message = message.Substring(0, split), + StackTrace = message.Substring(split) + }; + } + + static LogType ToLogType(int mode) + { + if ((mode & ModeScriptingException) != 0) + return LogType.Exception; + if ((mode & ModeErrors) != 0) + return LogType.Error; + if ((mode & ModeAsserts) != 0) + return LogType.Assert; + if ((mode & ModeWarnings) != 0) + return LogType.Warning; + return LogType.Log; + } + + static void TryRestore(MethodInfo setter, object value) + { + try + { + setter.Invoke(null, new[] { value }); + } + catch (Exception ex) + { + Debug.LogWarning($"[Pipeline] Failed to restore Editor console filtering state: {ex.Message}"); + } + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleEntries.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleEntries.cs.meta new file mode 100644 index 0000000..6c1cf5d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleEntries.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 39378305cb7fde44aaae3a307420ac51 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleGroundTruth.cs b/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleGroundTruth.cs new file mode 100644 index 0000000..8f1c412 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleGroundTruth.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Unity.Pipeline.Console; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor.Console +{ + /// + /// Samples what the Editor knows about the console — the compile-failure flag and the console's + /// own entry counts — and publishes it for the console and recompile_status + /// commands. Those commands are deliberately not main-thread commands, because clients poll + /// them while the Editor is blocked compiling or importing, so they cannot read this state + /// themselves; sampling it here and handing over an immutable snapshot is what lets them report + /// it at all. + /// + /// The same pass does two more jobs that need the same main-thread tick: + /// - Backfills the buffer from the console's own store once per session, so sticky compile + /// errors logged before capture started are visible (see ). + /// - Watches for the console gaining entries while the buffer's cursor stands still, which is + /// the only observable symptom of the log-callback subscription having been dropped, and + /// re-arms capture when it sees it. + /// + /// Sampling runs on rather than + /// , because that event is raised from + /// ConsoleWindow.LogChanged, which returns early when no Console window exists — precisely the + /// case this targets. The event is subscribed only to shorten the wait when a window is open. + /// + [InitializeOnLoad] + static class EditorConsoleGroundTruth + { + const string SeededKey = "pipeline.console.seeded"; + const double SampleIntervalSeconds = 0.25; + const double ReseedCooldownSeconds = 5.0; + + static double s_NextSampleTime; + static double s_NextReseedTime; + static int s_LastConsoleTotal = -1; + static long s_LastBufferSeq = -1; + static bool s_GapPending; + static long s_GapSeq; + static bool s_Seeded; + + static EditorConsoleGroundTruth() + { + s_Seeded = SessionState.GetBool(SeededKey, false); + + EditorApplication.update -= Sample; + EditorApplication.update += Sample; + +#if UNITY_6000_0_OR_NEWER + // consoleLogsChanged is Unity 6+; on older editors sampling via update alone is enough. + ConsoleWindowUtility.consoleLogsChanged -= OnConsoleLogsChanged; + ConsoleWindowUtility.consoleLogsChanged += OnConsoleLogsChanged; +#endif + + AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; + AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; + } + + /// + /// Console entry counts. Unity 6 exposes ConsoleWindowUtility.GetConsoleLogCounts; + /// Unity 2022 falls back to internal LogEntries.GetCountsByType via reflection. + /// + static void GetConsoleLogCounts(out int errors, out int warnings, out int logs) + { +#if UNITY_6000_0_OR_NEWER + ConsoleWindowUtility.GetConsoleLogCounts(out errors, out warnings, out logs); +#else + errors = warnings = logs = 0; + try + { + var logEntries = Type.GetType("UnityEditor.LogEntries,UnityEditor"); + var method = logEntries?.GetMethod( + "GetCountsByType", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + if (method == null) + return; + + object[] args = { 0, 0, 0 }; + method.Invoke(null, args); + errors = (int)args[0]; + warnings = (int)args[1]; + logs = (int)args[2]; + } + catch + { + errors = warnings = logs = 0; + } +#endif + } + + static void Sample() + { + if (EditorApplication.timeSinceStartup < s_NextSampleTime) + return; + s_NextSampleTime = EditorApplication.timeSinceStartup + SampleIntervalSeconds; + + // Seed before publishing, so the first snapshot a client sees already reports whether + // the backfill happened. + if (!s_Seeded) + TrySeed(); + + GetConsoleLogCounts(out var errors, out var warnings, out var logs); + var total = errors + warnings + logs; + var seq = ConsoleLogCapture.Buffer.LastSeq; + + // The console gained entries while the buffer's cursor stood still. Unity unregisters + // the native log callback on domain unload and on play-mode exit, while the managed + // event keeps its subscriber list, so capture can be dead with nothing to test directly. + // + // A gap is confirmed by the cursor still sitting where it was one sample later, not by + // the console count growing again: a dead subscription that missed a single burst — a + // failed compile, say — would otherwise be spotted once and then forgotten. Waiting for + // the second sample keeps a console entry that legitimately never reaches the managed + // callback from re-arming on its own. A Clear lowers the counts, so it can never be + // mistaken for a gap. + if (s_GapPending && seq == s_GapSeq) + { + if (EditorApplication.timeSinceStartup >= s_NextReseedTime) + { + ConsoleLogCapture.EnsureCapturing(); + Reseed(); + seq = ConsoleLogCapture.Buffer.LastSeq; + s_NextReseedTime = EditorApplication.timeSinceStartup + ReseedCooldownSeconds; + s_GapPending = false; + } + } + else if (s_LastConsoleTotal >= 0 && total > s_LastConsoleTotal && seq == s_LastBufferSeq) + { + s_GapSeq = seq; + s_GapPending = true; + } + else + { + // The buffer caught up on its own. + s_GapPending = false; + } + + s_LastConsoleTotal = total; + s_LastBufferSeq = seq; + + ConsoleLogCapture.GroundTruth = new ConsoleGroundTruth + { + SampledUtc = DateTime.UtcNow, + CompilationFailed = EditorUtility.scriptCompilationFailed, + Compiling = EditorApplication.isCompiling, + ConsoleErrors = errors, + ConsoleWarnings = warnings, + ConsoleLogs = logs, + Seeded = s_Seeded + }; + } + + // Only shortens the wait for the next sample; never a correctness dependency, since it is + // not raised while the Console window is closed. +#if UNITY_6000_0_OR_NEWER + static void OnConsoleLogsChanged() => s_NextSampleTime = 0; +#endif + + static void OnAfterAssemblyReload() + { + ConsoleLogCapture.EnsureCapturing(); + + // The snapshot describes the domain that just went away, and on 6000.5+ it survives the + // reload with the rest of the statics. Drop it so nothing reads it as current until the + // next sample replaces it. + ConsoleLogCapture.GroundTruth = null; + + // Let the next sample establish a fresh baseline instead of comparing against counts + // and a cursor from before the reload, which would look like a gap. + s_LastConsoleTotal = -1; + s_LastBufferSeq = -1; + s_GapPending = false; + s_GapSeq = 0; + } + + static void TrySeed() + { + if (!Reseed()) + return; + + s_Seeded = true; + SessionState.SetBool(SeededKey, true); + } + + /// + /// Copy console entries the buffer does not already hold into it. Entries are matched on type + /// and message, the only fields both sides record identically. A log type never contains "|", + /// so the first one always terminates it and the key is unambiguous. + /// + /// Matching counts occurrences rather than presence, because the console holds repeated + /// messages by design. Treating a key as seen-or-not lets a row the buffer already has cancel + /// out an identical row it missed, and that row is then never recovered. + /// + static bool Reseed() + { + var entries = new List(); + if (!EditorConsoleEntries.TryReadRecent(ConsoleLogBuffer.Capacity, entries)) + return false; + + var held = new Dictionary(); + foreach (var entry in ConsoleLogCapture.Buffer.Query(-1, 0, ConsoleLogBuffer.SeverityLog).Entries) + { + var key = Key(entry.LogType, entry.Message); + held.TryGetValue(key, out var count); + held[key] = count + 1; + } + + var now = DateTime.UtcNow; + foreach (var entry in entries) + { + var key = Key(entry.Type.ToString(), entry.Message); + + // Consume one occurrence the buffer already holds; whatever the console has beyond + // that is what capture missed. + if (held.TryGetValue(key, out var remaining) && remaining > 0) + { + held[key] = remaining - 1; + continue; + } + + ConsoleLogCapture.Buffer.Add(entry.Type, entry.Message, entry.StackTrace, now, seeded: true); + } + + return true; + } + + static string Key(string logType, string message) => logType + "|" + message; + + /// Sample immediately, ignoring the throttle. For tests. + internal static void SampleForTests() + { + s_NextSampleTime = 0; + s_NextReseedTime = 0; + Sample(); + } + + /// Forget the baselines and the seeded flag, so the next sample starts over. For tests. + internal static void ResetForTests() + { + s_NextSampleTime = 0; + s_NextReseedTime = 0; + s_LastConsoleTotal = -1; + s_LastBufferSeq = -1; + s_GapPending = false; + s_GapSeq = 0; + s_Seeded = false; + SessionState.EraseBool(SeededKey); + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleGroundTruth.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleGroundTruth.cs.meta new file mode 100644 index 0000000..bcb044e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Console/EditorConsoleGroundTruth.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 132898ab39f8aaa4dbc6c7685e1214d6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/EditorCodeReloadWatcher.cs b/AIClient/Packages/com.unity.pipeline/Editor/EditorCodeReloadWatcher.cs new file mode 100644 index 0000000..4885840 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/EditorCodeReloadWatcher.cs @@ -0,0 +1,694 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Unity.Pipeline.Editor.Commands; +using Unity.Pipeline.CodeReload; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor +{ + /// + /// Watches a file or folder for .cs saves and re-applies the affected [CodeReload] + /// methods. The target is resolved automatically at each save: any connected development player + /// wins (broadcast push via ), + /// otherwise the reload applies in this editor process via . + /// Both paths run on the interpreter backend. Folder mode watches recursively, + /// only touching files that opt into code reload; the settings UI always watches the project's + /// Assets folder (StartWatch itself accepts any file or folder). + /// + /// Change detection is event-driven () — polling a whole tree every + /// editor tick would be prohibitive. The watcher can silently miss saves (macOS kqueue fd + /// exhaustion, atomic rename-based saves), so a low-rate poll of the .cs files' last-write + /// times backstops it; both paths feed the same debounce map, so a change seen by both applies once. + /// + /// Watches also re-push the watched state to any player that connects mid-watch: + /// pushed overrides live only in the player's memory, so a restarted player boots the original AOT + /// code. Standalone owner so the watch survives domain + /// reloads (state persisted in ); the Pipeline Settings inspector only + /// drives Start/Stop. + /// + [InitializeOnLoad] + static class EditorCodeReloadWatcher + { + private const double DebounceSeconds = 0.3; + // Reconciliation poll period; each poll only stats the watched .cs files, so it stays cheap. + private const double ReconcileIntervalSeconds = 2.0; + // PlayerConnection attaches early in player boot but the reload receiver only registers in + // RuntimePipelineDriver.Start(); an immediate push would go unheard. + private const double ReconnectPushDelaySeconds = 2.0; + + // Persisted so an active watch survives editor domain reloads. + private const string PkPath = "Pipeline.CodeReloadWatch.Path"; + private const string PkIsFolder = "Pipeline.CodeReloadWatch.IsFolder"; + // Prior values of the two auto-refresh prefs, persisted while a watch holds them at 0; -1 = we didn't change that key. + private const string PkPrevAutoRefresh = "Pipeline.CodeReloadWatch.PrevAutoRefresh"; + private const string PkPrevAutoRefreshMode = "Pipeline.CodeReloadWatch.PrevAutoRefreshMode"; + // Unity reads kAutoRefreshMode (0=disabled, 1=enabled, 2=enabled outside play mode) since 2021.2; + // kAutoRefresh is the pre-2021.2 bool. Set both — writing only the legacy key is a no-op on Unity 6. + private const string AutoRefreshPref = "kAutoRefresh"; + private const string AutoRefreshModePref = "kAutoRefreshMode"; + + private static bool s_Watching; + private static string s_WatchPath; + private static bool s_IsFolder; + private static int s_PrevAutoRefresh = -1; + private static int s_PrevAutoRefreshMode = -1; + + private static FileSystemWatcher s_Fsw; + // Set from the watcher's background thread when it faults (buffer overflow, backend failure); + // OnEditorUpdate recreates the watcher on the main thread — otherwise it dies silently while + // IsWatching keeps reporting true. + private static volatile bool s_WatcherFaulted; + private static string s_WatcherFaultMessage; + // FileSystemWatcher fires on background threads → enqueue here, drain on the main thread. + private static readonly ConcurrentQueue s_Changed = new ConcurrentQueue(); + // Main-thread-only: path → last-seen editor time, for per-file debounce. + private static readonly Dictionary s_Pending = new Dictionary(); + // Main-thread-only: path → last-write ticks the watch has accounted for. The reconciliation + // poll enqueues any .cs whose mtime differs (a save the FileSystemWatcher missed). Entries for + // deleted files linger until the next Start; bounded by .cs count, harmless. + private static readonly Dictionary s_KnownWriteTimes = new Dictionary(StringComparer.OrdinalIgnoreCase); + private static double s_NextReconcile; + + // Re-pushes due to players that connected mid-watch. Main-thread only; entries are + // (due time, player id). + private static readonly List<(double due, int playerId)> s_PendingReconnectPushes = new List<(double, int)>(); + private static bool s_ConnectHooked; + private static bool s_SuppressConnectCallbacks; + + /// Whether an in-editor watch is currently active. + public static bool IsWatching => s_Watching; + /// The file or folder being watched, or null when not watching. + public static string WatchPath => s_Watching ? s_WatchPath : null; + /// Whether the active watch is a folder (recursive) rather than a single file. + public static bool IsFolder => s_IsFolder; + /// Development players currently connected via PlayerConnection. This decides where a + /// save applies: any connected player wins (broadcast push), otherwise the reload runs in this + /// editor process. Initializes the connection so the count is accurate even before a watch + /// starts (Initialize is idempotent). + public static int ConnectedPlayerCount + { + get + { + try + { + var conn = UnityEditor.Networking.PlayerConnection.EditorConnection.instance; + conn.Initialize(); + return conn.ConnectedPlayers.Count; + } + catch { return 0; } + } + } + + // Liveness telemetry for the settings inspector: saves applying while the event count stays + // flat means the FileSystemWatcher is dead and the reconcile poll is carrying the watch. + // Session-only (reset by domain reloads and Start). + private static int s_FsEventCount; + private static long s_LastFsEventTicks; // DateTime.UtcNow ticks; written on the watcher thread + private static string s_LastApplyFile; // main thread only + private static long s_LastApplyTicks; + + /// Raw FileSystemWatcher events seen since the watch (re)started this session. + public static int FsEventCount => s_FsEventCount; + /// UTC time of the last raw watcher event, or null if none yet. + public static DateTime? LastFsEventUtc => TicksToUtc(System.Threading.Interlocked.Read(ref s_LastFsEventTicks)); + /// File name of the last reload attempt (either detection path), or null. + public static string LastApplyFile => s_LastApplyFile; + /// UTC time of the last reload attempt, or null if none yet. + public static DateTime? LastApplyUtc => TicksToUtc(s_LastApplyTicks); + + private static DateTime? TicksToUtc(long ticks) => + ticks == 0 ? (DateTime?)null : new DateTime(ticks, DateTimeKind.Utc); + + static EditorCodeReloadWatcher() + { + // Editor may not be fully ready at [InitializeOnLoad] time; resume on the next tick. + EditorApplication.delayCall += ResumeWatchIfPersisted; + // The FileSystemWatcher is unmanaged-backed; drop it before a domain reload and recreate on resume. + AssemblyReloadEvents.beforeAssemblyReload += DisposeWatcher; + } + + /// Start watching (a file, or a folder when ). + /// File mode applies once up front; folder mode only reacts to subsequent saves. No-op-with-error if the + /// path doesn't exist. + public static void StartWatch(string path, bool isFolder) + { + if (string.IsNullOrEmpty(path) || (isFolder ? !Directory.Exists(path) : !File.Exists(path))) + { + Debug.LogError($"[CodeReloadWatch] Cannot watch — {(isFolder ? "folder" : "file")} not found: {path}"); + return; + } + + s_WatchPath = Path.GetFullPath(path); + s_IsFolder = isFolder; + s_Watching = true; + s_Pending.Clear(); + while (s_Changed.TryDequeue(out _)) { } + BaselineWriteTimes(); + var baselines = CaptureMethodBaselines(); + ClearAppliedFilesJournal(); + s_FsEventCount = 0; + s_LastFsEventTicks = 0; + s_LastApplyFile = null; + s_LastApplyTicks = 0; + + // Turn auto-refresh off so saving a watched .cs doesn't domain-reload the editor (which would wipe + // the registered overrides and interrupt the watch). Restore only what we changed. + DisableAutoRefresh(); + + CreateWatcher(); + EditorApplication.update -= OnEditorUpdate; + EditorApplication.update += OnEditorUpdate; + HookPlayerConnect(); + + EditorPrefs.SetString(PkPath, s_WatchPath); + EditorPrefs.SetBool(PkIsFolder, isFolder); + EditorPrefs.SetInt(PkPrevAutoRefresh, s_PrevAutoRefresh); + EditorPrefs.SetInt(PkPrevAutoRefreshMode, s_PrevAutoRefreshMode); + + // Single file: apply once up front so its current state is live. Folder: don't bulk-compile the + // tree — only react to saves. + if (!isFolder) + Apply(s_WatchPath); + + Debug.Log($"[CodeReloadWatch] Watching {(isFolder ? "folder " : "")}{s_WatchPath} " + + $"[{DescribeTarget()}]; auto-refresh off" + (baselines != null ? $"; {baselines}" : "") + + ". Stop from Pipeline Settings."); + } + + /// Stop the active watch and restore auto-refresh. + public static void StopWatch() + { + if (!s_Watching) return; + s_Watching = false; + DisposeWatcher(); + UnhookPlayerConnect(); + EditorApplication.update -= OnEditorUpdate; + s_Pending.Clear(); + s_KnownWriteTimes.Clear(); + while (s_Changed.TryDequeue(out _)) { } + ClearAppliedFilesJournal(); + CodeReloadBaseline.Clear(); + SessionState.EraseString(SkMethodBaselines); + + RestoreAutoRefresh(); + + EditorPrefs.DeleteKey(PkPath); + EditorPrefs.DeleteKey(PkIsFolder); + EditorPrefs.DeleteKey(PkPrevAutoRefresh); + EditorPrefs.DeleteKey(PkPrevAutoRefreshMode); + Debug.Log("[CodeReloadWatch] Watch stopped; auto-refresh restored."); + } + + private static void ResumeWatchIfPersisted() + { + if (s_Watching) return; + var path = EditorPrefs.GetString(PkPath, null); + if (string.IsNullOrEmpty(path)) return; + + bool isFolder = EditorPrefs.GetBool(PkIsFolder, false); + bool exists = isFolder ? Directory.Exists(path) : File.Exists(path); + if (!exists) + { + EditorPrefs.DeleteKey(PkPath); + return; + } + + s_WatchPath = path; + s_IsFolder = isFolder; + s_PrevAutoRefresh = EditorPrefs.GetInt(PkPrevAutoRefresh, -1); + s_PrevAutoRefreshMode = EditorPrefs.GetInt(PkPrevAutoRefreshMode, -1); + s_Watching = true; + s_Pending.Clear(); + BaselineWriteTimes(); + + CreateWatcher(); + EditorApplication.update -= OnEditorUpdate; + EditorApplication.update += OnEditorUpdate; + HookPlayerConnect(); + + // Method baselines mirror the re-apply split below: an edit-mode reload means a real + // recompile ran, so disk is the compiled state again — recapture. A play-mode reload + // recompiled nothing (auto-refresh is off) and disk may already carry edits, so + // recapturing would bless those edits as "compiled"; restore the watch-start snapshot. + string baselines = null; + if (EditorApplication.isPlaying) + CodeReloadBaseline.Restore(SessionState.GetString(SkMethodBaselines, "")); + else + baselines = CaptureMethodBaselines(); + + // Edit-mode resume: don't re-apply — the reload came from a real recompile, so the + // compiled code already matches disk. Play-mode resume (entering play, or a mid-play + // reload) is different: the reload wiped CodeReloadRegistry's overrides but recompiled + // nothing (auto-refresh is off), so re-enqueue every file this watch had applied. + // Routing through s_Pending reuses the debounce, by which time the + // RuntimePipelineDriver has re-registered the reload targets (it runs in Awake). + var reapply = ComputeResumeReapplies(EditorApplication.isPlaying, GetJournaledAppliedFiles()); + if (reapply.Count > 0) + { + double now = EditorApplication.timeSinceStartup; + foreach (var f in reapply) + s_Pending[f] = now; + } + // One line per domain reload: what is watched, plus what this resume does about it. + Debug.Log($"[CodeReloadWatch] Resumed watching {path} after domain reload" + + (reapply.Count > 0 ? $"; re-applying {reapply.Count} code-reload override(s) the play-mode reload wiped" : "") + + (baselines != null ? $"; {baselines}" : "") + "."); + } + + /// Set both auto-refresh prefs to 0, remembering each prior value so Stop restores + /// exactly what the user had (the mode key is an enum — it may have been 2, not 1). + private static void DisableAutoRefresh() + { + int prev = EditorPrefs.GetInt(AutoRefreshPref, 1); + s_PrevAutoRefresh = prev == 0 ? -1 : prev; + if (s_PrevAutoRefresh >= 0) + EditorPrefs.SetInt(AutoRefreshPref, 0); + + int prevMode = EditorPrefs.GetInt(AutoRefreshModePref, 1); + s_PrevAutoRefreshMode = prevMode == 0 ? -1 : prevMode; + if (s_PrevAutoRefreshMode >= 0) + EditorPrefs.SetInt(AutoRefreshModePref, 0); + } + + private static void RestoreAutoRefresh() + { + if (s_PrevAutoRefresh >= 0) + EditorPrefs.SetInt(AutoRefreshPref, s_PrevAutoRefresh); + if (s_PrevAutoRefreshMode >= 0) + EditorPrefs.SetInt(AutoRefreshModePref, s_PrevAutoRefreshMode); + s_PrevAutoRefresh = -1; + s_PrevAutoRefreshMode = -1; + } + + private static void CreateWatcher() + { + DisposeWatcher(); + s_WatcherFaulted = false; // before enabling: a fault firing mid-create must not be wiped + try + { + var dir = s_IsFolder ? s_WatchPath : Path.GetDirectoryName(s_WatchPath); + s_Fsw = new FileSystemWatcher(dir) + { + Filter = s_IsFolder ? "*.cs" : Path.GetFileName(s_WatchPath), + IncludeSubdirectories = s_IsFolder, + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName, + InternalBufferSize = 64 * 1024, + }; + s_Fsw.Changed += OnFsEvent; + s_Fsw.Created += OnFsEvent; + s_Fsw.Renamed += OnFsEvent; + s_Fsw.Error += OnFsError; + s_Fsw.EnableRaisingEvents = true; + } + catch (Exception ex) + { + Debug.LogError($"[CodeReloadWatch] Failed to start file watcher on {s_WatchPath}: {ex.Message}"); + } + } + + private static void HookPlayerConnect() + { + if (s_ConnectHooked) return; + try + { + // RegisterConnection replays the callback synchronously for players that are already + // connected; suppress those — folder watches deliberately don't bulk-push on start, + // and an already-running player still has whatever was pushed to it. Only players + // that connect *after* this (i.e. fresh boots with no overrides) need catching up. + s_SuppressConnectCallbacks = true; + UnityEditor.Networking.PlayerConnection.EditorConnection.instance.Initialize(); + UnityEditor.Networking.PlayerConnection.EditorConnection.instance.RegisterConnection(OnPlayerConnected); + s_ConnectHooked = true; + } + catch (Exception ex) + { + Debug.LogWarning($"[CodeReloadWatch] Could not hook player connections (reconnect re-push disabled): {ex.Message}"); + } + finally + { + s_SuppressConnectCallbacks = false; + } + } + + private static void UnhookPlayerConnect() + { + s_PendingReconnectPushes.Clear(); + if (!s_ConnectHooked) return; + try { UnityEditor.Networking.PlayerConnection.EditorConnection.instance.UnregisterConnection(OnPlayerConnected); } + catch { /* connection may be gone during teardown */ } + s_ConnectHooked = false; + } + + // Main thread (EditorConnection dispatches connection events there). + private static void OnPlayerConnected(int playerId) + { + if (s_SuppressConnectCallbacks || !s_Watching) return; + + // Read the setting at event time so an inspector toggle applies to the next connection + // without restarting the watch. No settings asset (CLI-driven watch) = enabled. + if (!(EditorPipelineManager.Load()?.CodeReloadRepushOnConnect ?? true)) + { + // Nothing happens until the next save, so this is first-reload-summary material, + // not a console entry of its own on every connect. + CodeReloadRegistry.NoteForFirstReload($"player {playerId} connected, not re-pushed " + + "(Re-push On Connect is off in Pipeline Settings; it catches up on the next save)"); + return; + } + + s_PendingReconnectPushes.Add((EditorApplication.timeSinceStartup + ReconnectPushDelaySeconds, playerId)); + Debug.Log($"[CodeReloadWatch] Player {playerId} connected — re-pushing watched code-reload state " + + $"in {ReconnectPushDelaySeconds:0}s (a restarted player has none of the previously pushed overrides)."); + } + + private static void OnFsEvent(object sender, FileSystemEventArgs e) + { + System.Threading.Interlocked.Increment(ref s_FsEventCount); + System.Threading.Interlocked.Exchange(ref s_LastFsEventTicks, DateTime.UtcNow.Ticks); + s_Changed.Enqueue(e.FullPath); + } + + // Background thread: only record the fault; the recreate must happen on the main thread. + private static void OnFsError(object sender, ErrorEventArgs e) + { + s_WatcherFaultMessage = e.GetException()?.Message ?? "unknown error"; + s_WatcherFaulted = true; + } + + private static void DisposeWatcher() + { + if (s_Fsw == null) return; + try { s_Fsw.EnableRaisingEvents = false; s_Fsw.Dispose(); } catch { /* best effort */ } + s_Fsw = null; + } + + private static void OnEditorUpdate() + { + if (!s_Watching) return; + + if (s_WatcherFaulted) + { + s_WatcherFaulted = false; + Debug.LogWarning($"[CodeReloadWatch] File watcher faulted ({s_WatcherFaultMessage}); " + + "recreating it. Changes saved in the meantime may have been missed — save again to re-apply."); + CreateWatcher(); + } + + double now = EditorApplication.timeSinceStartup; + + for (int i = s_PendingReconnectPushes.Count - 1; i >= 0; i--) + { + if (now < s_PendingReconnectPushes[i].due) continue; + int playerId = s_PendingReconnectPushes[i].playerId; + s_PendingReconnectPushes.RemoveAt(i); + PushReloadCommand.RepushAll(s_WatchPath, s_IsFolder, playerId); + } + + while (s_Changed.TryDequeue(out var p)) + if (!string.IsNullOrEmpty(p) && p.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + { + s_Pending[p] = now; // (re)start this file's debounce window + // Stamp the accounted-for mtime so the reconcile poll doesn't re-enqueue a + // change the watcher already delivered. + try { s_KnownWriteTimes[p] = File.GetLastWriteTimeUtc(p).Ticks; } catch { /* transient */ } + } + + ReconcilePoll(now); + + if (s_Pending.Count == 0) return; + + List ready = null; + foreach (var kv in s_Pending) + if (now - kv.Value >= DebounceSeconds) + (ready ??= new List()).Add(kv.Key); + if (ready == null) return; + + foreach (var p in ready) + { + s_Pending.Remove(p); + if (File.Exists(p)) + Apply(p); + } + } + + /// Record the current last-write time of every watched .cs so the reconcile poll only + /// reacts to saves made after the watch (re)started — mirrors "don't re-apply on resume". + private static void BaselineWriteTimes() + { + s_KnownWriteTimes.Clear(); + try + { + if (s_IsFolder) + foreach (var f in Directory.EnumerateFiles(s_WatchPath, "*.cs", SearchOption.AllDirectories)) + s_KnownWriteTimes[f] = File.GetLastWriteTimeUtc(f).Ticks; + else + s_KnownWriteTimes[s_WatchPath] = File.GetLastWriteTimeUtc(s_WatchPath).Ticks; + } + catch { /* best effort — the poll fills in files it couldn't stat here */ } + s_NextReconcile = EditorApplication.timeSinceStartup + ReconcileIntervalSeconds; + } + + /// Low-rate safety net for saves the missed: stat the + /// watched .cs files and route any mtime change into the same debounce map the watcher feeds. + private static void ReconcilePoll(double now) + { + if (now < s_NextReconcile) return; + s_NextReconcile = now + ReconcileIntervalSeconds; + try + { + if (s_IsFolder) + { + foreach (var f in Directory.EnumerateFiles(s_WatchPath, "*.cs", SearchOption.AllDirectories)) + CheckWriteTime(f, now); + } + else if (File.Exists(s_WatchPath)) + { + CheckWriteTime(s_WatchPath, now); + } + } + catch { /* transient IO (file mid-write, folder shuffle) — next poll retries */ } + } + + private static void CheckWriteTime(string path, double now) + { + long ticks = File.GetLastWriteTimeUtc(path).Ticks; + if (s_KnownWriteTimes.TryGetValue(path, out var known) && known == ticks) + return; + s_KnownWriteTimes[path] = ticks; + s_Pending[path] = now; + } + + /// Apply one changed file to the active target. Never throws. + private static void Apply(string path) + { + // Folder mode: only compile files that actually opt into code reload — an unrelated .cs save in the + // tree shouldn't recompile or log. (Single-file mode was chosen explicitly, so always apply.) + if (s_IsFolder && !PushReloadCommand.FileMentionsCodeReload(path)) + return; + + s_LastApplyFile = Path.GetFileName(path); + s_LastApplyTicks = DateTime.UtcNow.Ticks; + + try + { + // Resolve the target per save, not per watch: a connected development player wins + // (connecting one is a deliberate act, and the in-editor path is a no-op outside + // play mode anyway), otherwise apply in this editor process. Plugging in or + // disconnecting a device mid-watch reroutes the next save with no restart. + if (ConnectedPlayerCount > 0) + { + // PushChanged logs its own summary; -1 broadcasts to every connected player. + // Baseline-filtered: methods untouched since the watch started stay compiled + // on the device instead of being re-registered as interpreter overrides. + PushReloadCommand.PushChanged(path, player: -1); + return; + } + + var result = InPlaceReloadProcessor.ProcessSourceFileOnMainThread( + path, assemblyDir: null, pdb: false, useInterpreter: true); + if (result.Success) + { + // ProcessSourceFileOnMainThread only binds the overrides; firing [OnCodeReload] + // callbacks on live instances is the caller's job (mirrors CodeReloadCommands.reload_file). + // Reverted methods changed behavior too (their override was removed), so they + // count as reloaded for the callbacks. + CodeReloadRegistry.InvokeReloadCallbacks( + result.RevertedMethods.Count == 0 + ? result.RegisteredMethods + : result.RegisteredMethods.Concat(result.RevertedMethods)); + + if (result.AllUpToDate) + { + Debug.Log($"[CodeReloadWatch] {Path.GetFileName(path)} is up to date — " + + $"{result.UpToDateMethods.Count} method(s) already match compiled code" + + (result.RevertedMethods.Count > 0 + ? $"; removed {result.RevertedMethods.Count} stale override(s)" : "") + "."); + } + // "Compiled but 0 methods bound" looks like success yet applies nothing — surface + // the per-method skip reasons (e.g. the runtime Pipeline isn't running, or the + // interpreter backend rejecting an unsupported construct) instead of a bare count. + else if (result.RegisteredMethods.Count == 0) + { + var reasons = result.CompilationDiagnostics != null && result.CompilationDiagnostics.Count > 0 + ? ":\n- " + string.Join("\n- ", result.CompilationDiagnostics) + : ". Is the runtime Pipeline driver active (in play mode)?"; + Debug.LogWarning($"[CodeReloadWatch] Compiled {Path.GetFileName(path)} " + + $"but bound 0 method(s){reasons}"); + } + else + { + // Journal it so the next play-mode domain reload (which wipes the registry + // while auto-refresh keeps the compiled code stale) re-applies this file. + JournalAppliedFile(path); + Debug.Log($"[CodeReloadWatch] Applied {Path.GetFileName(path)}: " + + $"{result.RegisteredMethods.Count} method(s)" + + (result.UpToDateMethods.Count > 0 + ? $" ({result.UpToDateMethods.Count} up to date, left compiled)" : "") + "."); + } + } + else + { + // Surface the actual compiler diagnostics, not just the generic ErrorMessage — + // otherwise the watcher path swallows the reason the reload_file command already reports. + var details = result.CompilationDiagnostics != null && result.CompilationDiagnostics.Count > 0 + ? ":\n- " + string.Join("\n- ", result.CompilationDiagnostics) + : ""; + Debug.LogError($"[CodeReloadWatch] Reload failed for {Path.GetFileName(path)}: {result.ErrorMessage}{details}"); + } + } + catch (Exception ex) + { + Debug.LogError($"[CodeReloadWatch] Reload error for {Path.GetFileName(path)}: {ex.Message}"); + } + } + + // Per-method baselines (CodeReloadBaseline) captured when the watch starts, i.e. when disk + // still matches the compiled code. Reloads diff each save against them and leave untouched + // methods running compiled instead of re-registering an interpreter override for the whole + // file. SessionState-backed for the same reason as the applied-files journal: the play-mode + // domain reload wipes the store while disk keeps the user's edits. + private const string SkMethodBaselines = "Pipeline.CodeReloadWatch.MethodBaselines"; + + /// Snapshot the compiled method bodies of the watched files. Returns a short clause + /// for the caller's log line (null when nothing was captured), so a watch start or resume + /// stays a single console entry. + private static string CaptureMethodBaselines() + { + CodeReloadBaseline.Clear(); + int captured = 0, stale = 0; + try + { + var files = s_IsFolder + ? Directory.EnumerateFiles(s_WatchPath, "*.cs", SearchOption.AllDirectories) + .Where(PushReloadCommand.FileMentionsCodeReload) + : (IEnumerable)new[] { s_WatchPath }; + + foreach (var f in files) + { + // A source newer than its compiled assembly was edited without a recompile — + // snapshotting it would bless those edits as "compiled" and silently skip them + // on the next save. Leave it baseline-less: every method reloads, like before. + if (!CompiledStateMatchesDisk(f)) + { + stale++; + continue; + } + + if (CodeReloadBaseline.Capture(f)) + captured++; + // Pushes diff under the player's defines; capture that variant too so a device + // save doesn't fall back to reloading everything when #if regions differ. + var playerDefines = PushReloadCommand.PlayerDefinesFor(f); + if (playerDefines != null) + CodeReloadBaseline.Capture(f, playerDefines); + } + } + catch (Exception ex) + { + Debug.LogWarning($"[CodeReloadWatch] Baseline capture failed ({ex.Message}); " + + "saves will reload every [CodeReload] method in a file."); + } + + SessionState.SetString(SkMethodBaselines, CodeReloadBaseline.Serialize()); + if (captured == 0 && stale == 0) + return null; + return $"method baselines captured for {captured} file(s)" + + (stale > 0 ? $" ({stale} newer than their compiled assembly, unfiltered)" : "") + + ", so saves only reload methods that differ"; + } + + /// Whether the compiled assembly owning is at least as new + /// as the file — i.e. the source on disk is what the editor is running. Unknown = false. + private static bool CompiledStateMatchesDisk(string path) + { + try + { + var projectRoot = Path.GetFullPath(Path.GetDirectoryName(Application.dataPath)); + var full = Path.GetFullPath(path); + if (!full.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase)) + return false; + var relative = full.Substring(projectRoot.Length).TrimStart('/', '\\').Replace('\\', '/'); + var asmName = UnityEditor.Compilation.CompilationPipeline.GetAssemblyNameFromScriptPath(relative); + if (string.IsNullOrEmpty(asmName)) + return false; + if (!asmName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + asmName += ".dll"; + var dll = Path.Combine(projectRoot, "Library", "ScriptAssemblies", asmName); + return File.Exists(dll) && File.GetLastWriteTimeUtc(dll) >= File.GetLastWriteTimeUtc(full); + } + catch + { + return false; + } + } + + // Applied-files journal: full paths of files whose overrides this watch applied in-editor. + // SessionState-backed because the overrides it mirrors live only in CodeReloadRegistry's + // statics — the domain reload triggered by entering play mode wipes them while the watched + // sources stay un-recompiled (auto-refresh is off), so without this record the next play + // session silently runs the stale compiled bodies. Session-scoped, like the overrides. + private const string SkAppliedFiles = "Pipeline.CodeReloadWatch.AppliedFiles"; + + /// Record a file whose overrides were successfully applied in this editor process. + internal static void JournalAppliedFile(string path) + { + if (string.IsNullOrEmpty(path)) return; + var files = GetJournaledAppliedFiles(); + foreach (var f in files) + if (string.Equals(f, path, StringComparison.OrdinalIgnoreCase)) + return; + var joined = files.Length == 0 ? path : string.Join("\n", files) + "\n" + path; + SessionState.SetString(SkAppliedFiles, joined); + } + + /// All journaled applied files (empty when none). + internal static string[] GetJournaledAppliedFiles() + { + var raw = SessionState.GetString(SkAppliedFiles, ""); + return string.IsNullOrEmpty(raw) ? Array.Empty() : raw.Split('\n'); + } + + /// Forget all journaled applies (watch started/stopped: overrides no longer ours to restore). + internal static void ClearAppliedFilesJournal() => SessionState.EraseString(SkAppliedFiles); + + /// Files to re-apply when a watch resumes after a domain reload. Only in play mode: + /// there the reload wiped the registry without recompiling the watched sources, so disk and + /// compiled code have diverged. An edit-mode resume follows a real recompile — current + /// compiled state already matches disk, so nothing needs re-applying. + internal static List ComputeResumeReapplies(bool isPlaying, string[] journaledFiles) + { + var result = new List(); + if (!isPlaying || journaledFiles == null) return result; + foreach (var f in journaledFiles) + if (!string.IsNullOrEmpty(f) && File.Exists(f)) + result.Add(f); + return result; + } + + private static string DescribeTarget() => + "target: connected players when present, else in-editor (always interpreter)"; + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/EditorCodeReloadWatcher.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/EditorCodeReloadWatcher.cs.meta new file mode 100644 index 0000000..a7324fe --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/EditorCodeReloadWatcher.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5ef2773459c98e743988655aff847d46 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/EditorDialogStateMirror.cs b/AIClient/Packages/com.unity.pipeline/Editor/EditorDialogStateMirror.cs new file mode 100644 index 0000000..afbcf78 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/EditorDialogStateMirror.cs @@ -0,0 +1,52 @@ +#if UNITY_6000_7_OR_NEWER +using System; +using System.Linq; +using UnityEditor; + +namespace Unity.Pipeline.Editor +{ + /// + /// Mirrors UnityEditor.EditorDialogEvents into the live pipeline server's DialogStateTracker, + /// so GET /api/dialog, dialogsDuringExecution, and the dialog busy-gate all observe modal + /// dialogs with no per-command instrumentation (see the trunk-side design doc). Ambient + /// dialogs are a live-editor-wide concept, not per-test-server — targets the live server + /// explicitly, the same way EditorProgressMirror does, so a test server's DialogStateTracker + /// never receives them. + /// + [InitializeOnLoad] + internal static class EditorDialogStateMirror + { + static EditorDialogStateMirror() + { + EditorDialogEvents.dialogWillShow -= OnDialogWillShow; + EditorDialogEvents.dialogWillShow += OnDialogWillShow; + EditorDialogEvents.dialogDismissed -= OnDialogDismissed; + EditorDialogEvents.dialogDismissed += OnDialogDismissed; + } + + static void OnDialogWillShow(DialogEventInfo info) + { + var server = PipelineServerStartup.Server; + if (server == null) + return; + + // Still on the main thread here — dialogWillShow fires before the dialog actually + // blocks it — so this is the last chance to read live Editor state for the dialog + // busy-gate's editor_status fallback (see SnapshotStatusForDialogGate's doc). + server.SnapshotStatusForDialogGate(); + + server.Dialogs.OnShown(info.Id, info.Source.ToString(), info.Title, info.Message, + info.ButtonLabels?.ToArray(), info.Level?.ToString(), info.OpenedAtUtc); + } + + static void OnDialogDismissed(DialogEventInfo info) + { + var server = PipelineServerStartup.Server; + if (server == null) + return; + + server.Dialogs.OnDismissed(info.Id, DateTime.UtcNow); + } + } +} +#endif diff --git a/AIClient/Packages/com.unity.pipeline/Editor/EditorDialogStateMirror.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/EditorDialogStateMirror.cs.meta new file mode 100644 index 0000000..2aaf5a0 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/EditorDialogStateMirror.cs.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: eebd91c94b7a4f5494390874c6beb9e9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetVersion: 1 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineManager.cs b/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineManager.cs new file mode 100644 index 0000000..5cd2903 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineManager.cs @@ -0,0 +1,105 @@ +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. "Window/Pipeline/Settings" + /// creates it on demand. + /// + 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; + + [Tooltip("Record local eval-usage telemetry (fingerprints + shape, no raw source) to " + + "/Library/Pipeline/eval-usage.jsonl. Read it back with the 'report_evals' " + + "command. Local-only; no data leaves the machine. Applies live.")] + [SerializeField] private bool m_EvalTelemetryEnabled = true; + + [Tooltip("Also store the raw eval source in each eval-usage telemetry record. Off by default " + + "(privacy-first) — enable only for local debugging. Applies live.")] + [SerializeField] private bool m_StoreEvalSource = false; + + [Tooltip("Accept requests from a client running in a sandboxed browser frame (Origin: null), " + + "such as a plugin hosted inside a web application. An ordinary web page is refused " + + "either way, and a sandboxed one still needs the bearer token. Applies on next start.")] + [SerializeField] private bool m_AllowBrowserClients = false; + + // Code Reload Watch config, drawn by a dedicated inspector section (not DrawDefaultInspector). + [HideInInspector, SerializeField, UnityEngine.Serialization.FormerlySerializedAs("m_HotReloadRepushOnConnect")] + private bool m_CodeReloadRepushOnConnect = true; + + /// HTTP port for the editor server. 0 = auto-assign. Applies on next start. + public int Port => m_Port; + /// Start the server automatically when the editor loads. + public bool AutoStart => m_AutoStart; + /// Self-heal: re-open the listener on a timer if it dies without a Stop(). + public bool WatchdogEnabled => m_WatchdogEnabled; + /// How often the watchdog checks the listener, in seconds. + public int WatchdogIntervalSeconds => m_WatchdogIntervalSeconds; + /// Log every command request/response to Logs/pipeline.log. + public bool LogRequestsResponses => m_LogRequestsResponses; + public bool EvalTelemetryEnabled => m_EvalTelemetryEnabled; + public bool StoreEvalSource => m_StoreEvalSource; + public bool AllowBrowserClients => m_AllowBrowserClients; + + /// Player-target watches: re-push the watched code-reload state to any player that + /// connects mid-watch, so a restarted player catches up without waiting for the next save. + internal bool CodeReloadRepushOnConnect => m_CodeReloadRepushOnConnect; + + /// 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. + /// + /// The settings asset, or null if none exists. + 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/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineManager.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineManager.cs.meta new file mode 100644 index 0000000..1196d74 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineManagerEditor.cs b/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineManagerEditor.cs new file mode 100644 index 0000000..a13d2f1 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineManagerEditor.cs @@ -0,0 +1,196 @@ +using System.IO; +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))] + class EditorPipelineManagerEditor : UnityEditor.Editor + { + // Repaint() from inside OnInspectorGUI re-triggers OnInspectorGUI on the next editor tick, so + // a running server redrew this IMGUI-heavy inspector every frame (several ms) while merely + // visible. Repaint from EditorApplication.update at a low rate instead. + private const double StatusRepaintInterval = 0.25; + private double m_NextStatusRepaint; + + private void OnEnable() + { + EditorApplication.update += ThrottledStatusRepaint; + } + + private void OnDisable() + { + EditorApplication.update -= ThrottledStatusRepaint; + } + + private void ThrottledStatusRepaint() + { + var mgr = target as EditorPipelineManager; + if (mgr == null || !(mgr.IsServerRunning || EditorCodeReloadWatcher.IsWatching)) + return; + + double now = EditorApplication.timeSinceStartup; + if (now < m_NextStatusRepaint) + return; + m_NextStatusRepaint = now + StatusRepaintInterval; + Repaint(); + } + + /// Draw the default inspector plus live server status and Start/Stop/Restart controls. + 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; + } + + // Eval-usage telemetry config lives on static fields (recording happens in the + // Runtime command layer, not the server), so push edits there too — applies live. + // Goes through the same ApplyEvalTelemetrySettings the startup/server-start paths + // use, so there is exactly one place that knows the settings->statics mapping. + PipelineServerStartup.ApplyEvalTelemetrySettings(mgr); + } + + 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(); + } + + DrawCodeReloadWatchSection(); + // Live status is kept current by ThrottledStatusRepaint — never Repaint() from here. + } + + /// + /// Watch the project's Assets folder and re-apply affected [CodeReload] methods on every save, + /// driving . Neither scope, target, nor backend is a + /// choice: the whole Assets tree is watched, each save goes to the connected player(s) if any, + /// else applies in-editor, and both paths run on the interpreter; a read-only row shows the + /// current target resolution. Config is locked while a watch is active. + /// + private void DrawCodeReloadWatchSection() + { + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Code Reload Interpreter Watch", EditorStyles.boldLabel); + + bool watching = EditorCodeReloadWatcher.IsWatching; + + serializedObject.Update(); + + // Not a picker: each save is pushed to the connected player(s) if any, else applied + // in-editor — resolved per save, so plugging in a device mid-watch reroutes the next save. + int connected = EditorCodeReloadWatcher.ConnectedPlayerCount; + using (new EditorGUI.DisabledScope(true)) + EditorGUILayout.LabelField( + new GUIContent("Target", "Resolved automatically at each file change: development player(s) if connected, otherwise the reload applies in this editor process. Both run on the interpreter."), + new GUIContent(connected > 0 + ? $"Player — {connected} connected" + : "In Editor — no player connected")); + + // Outside the watching-locked scope: read at each player-connect event, so toggling it + // takes effect for the next connection even while a watch is active. + EditorGUILayout.PropertyField( + serializedObject.FindProperty("m_CodeReloadRepushOnConnect"), + new GUIContent("Re-push On Connect", + "Re-push the watched code-reload state to any player that connects while the watch " + + "is active. A restarted player boots the original build without previously pushed " + + "overrides; this catches it up immediately instead of waiting for the next save.")); + + serializedObject.ApplyModifiedProperties(); + + using (new EditorGUILayout.HorizontalScope()) + { + using (new EditorGUI.DisabledScope(watching)) + { + // The UI always watches the whole Assets tree; StartWatch itself accepts any + // file or folder for CLI/API callers. + if (GUILayout.Button("Start Watching")) + EditorCodeReloadWatcher.StartWatch(Path.GetFullPath(Application.dataPath), + isFolder: true); + } + using (new EditorGUI.DisabledScope(!watching)) + { + if (GUILayout.Button("Stop Watching")) + EditorCodeReloadWatcher.StopWatch(); + } + } + + if (watching) + EditorGUILayout.HelpBox( + "Asset auto-refresh is disabled while watching, so saved scripts code-reload instead of " + + "triggering a domain reload. New or changed assets won't import until you refresh manually " + + "(Cmd/Ctrl+R); Stop Watching restores your auto-refresh setting.", + MessageType.Warning); + + using (new EditorGUI.DisabledScope(true)) + { + // Read WatchPath once: it goes null the instant the watch stops (domain reload / StopWatch), + // which can race the `watching` captured at the top of this method — guard against null here. + var watchPath = EditorCodeReloadWatcher.WatchPath; + // The live Target row above shows where saves currently go; this line covers what is watched. + string status = watching && !string.IsNullOrEmpty(watchPath) + ? $"{(EditorCodeReloadWatcher.IsFolder ? "folder" : "file")} · {Path.GetFileName(watchPath.TrimEnd('/', '\\'))}" + : "idle"; + EditorGUILayout.LabelField("Watching", status); + + if (watching) + { + // Liveness: saves applying while the event count stays flat means the OS file + // watcher is dead and the reconcile poll is carrying the watch. + EditorGUILayout.LabelField("Watcher Events", + $"{EditorCodeReloadWatcher.FsEventCount}{Ago(EditorCodeReloadWatcher.LastFsEventUtc)}"); + EditorGUILayout.LabelField("Last Apply", + EditorCodeReloadWatcher.LastApplyFile is string f + ? $"{f}{Ago(EditorCodeReloadWatcher.LastApplyUtc)}" + : "none yet"); + } + } + } + + private static string Ago(System.DateTime? utc) + { + if (utc == null) return ""; + var s = (System.DateTime.UtcNow - utc.Value).TotalSeconds; + return s < 1 ? " · just now" + : s < 120 ? $" · {s:0}s ago" + : s < 7200 ? $" · {s / 60:0}m ago" + : $" · {s / 3600:0}h ago"; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineManagerEditor.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineManagerEditor.cs.meta new file mode 100644 index 0000000..9d89150 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineServer.cs b/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineServer.cs new file mode 100644 index 0000000..3cddf42 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineServer.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Commands; +using Unity.Pipeline.Models; +using Unity.Pipeline.Security; +using UnityEditor; +using UnityEngine; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +namespace Unity.Pipeline.Editor +{ +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + class EditorPipelineServer : BasePipelineServer + { + private InstanceDescriptor m_InstanceDescriptor; + + /// + /// Guards mutate-then-write of m_InstanceDescriptor: /api/status and /api/editor_status + /// both reach UpdateHeartBeat concurrently now, and InstanceDescriptor.WriteToProjectRoot's + /// own lock only covers the write, not the field mutations feeding it. + /// + private readonly object m_HeartbeatLock = new object(); + + /// + /// One-way settle latch (AUTHAPI-35). On a cold project import the server starts (and + /// writes its descriptor) while the Editor is still importing assets / compiling scripts, + /// so early main-thread commands land in a not-quite-ready Editor and fail opaquely. The + /// latch stays false until the Editor is first seen idle after startup, and is scoped to + /// the EDITOR SESSION via : once any server instance has + /// seen the Editor idle, every later instance (domain-reload restart, test server) starts + /// settled — a server coming up mid-session while a compile/import happens to be in + /// flight (script-creating test fixtures, recompile loops) must NOT re-arm the gate. + /// Volatile: set on the main thread, read from HTTP request threads. + /// + private volatile bool m_Settled; + private bool m_SettleTickSubscribed; + + /// + /// SessionState marker: the Editor has been seen idle at least once this session, i.e. + /// the post-startup settle window is over for the whole session. SessionState survives + /// domain reloads and dies with the Editor process — exactly the settle window's scope. + /// Read/written on the main thread only (Start and the update tick); request threads + /// only read the volatile snapshot. + /// + private const string SettledSessionKey = "Unity.Pipeline.EditorSessionSettled"; + + /// + /// Commands that must stay servable while the Editor is settling: editor_status is the + /// status surface callers poll to observe the busy/settling state itself. + /// Background (MainThreadRequired = false) commands are always exempt and don't need + /// listing here. + /// + private static readonly HashSet CommandsServiceableWhileSettling = new HashSet { "editor_status" }; + + /// + /// editor_status's fields as of the instant a dialog was about to be shown — see + /// . Once the dialog actually blocks the main + /// thread, editor_status can no longer run for real (unlike the settle case above, where + /// the thread is merely busy, not stuck inside a native message loop), so the dialog gate + /// serves this snapshot instead. Nothing it describes (compiling/domain reload/play mode) + /// can change while the thread that would update it is stuck, so it stays correct for the + /// whole blocked window. Null until the first dialog of the session is about to be shown. + /// + private StatusResponse m_LastStatusBeforeDialog; + + /// When true, every command transaction is written to the project transaction log. + public bool LogRequestsResponses { get; set; } + + /// + /// Whether /api/status's ordinary (non-settling, non-dialog-blocked) case should report + /// "ready" rather than "error". Defaults to whether the instance descriptor was actually + /// created; a -false test double that is genuinely running + /// can override this instead of faking a descriptor just to exercise that branch. + /// + protected virtual bool HasInstanceDescriptor => m_InstanceDescriptor != null; + + /// UTC time this server instance started listening. + public override DateTime StartedAt => m_InstanceDescriptor == null ? new DateTime() : m_InstanceDescriptor.StartedAt; + + /// + /// Whether the Editor has been seen idle (not importing, not compiling) at least once + /// this editor session. Until then the server is "settling": main-thread commands are + /// rejected with a retryable busy signal instead of executing into a half-ready Editor. + /// Virtual so tests can pin the unsettled state. + /// + protected virtual bool IsSettled => m_Settled; + + /// + /// Whether this Editor session has already completed its settle window (the SessionState + /// marker any settling server sets on first idle). Main thread only. Exposed protected so + /// tests can assert it as a precondition before probing the latch's immediate-settle path. + /// + protected static bool IsSessionSettled => SessionState.GetBool(SettledSessionKey, false); + + /// Arm the post-startup settle latch. + protected override void ServerStarted() + { + InitializeSettleLatch(); + } + + /// + /// Whether the Editor was launched with -automated. A Unity console warning for this fact + /// is unactionable (nobody watches the console in an agent-driven workflow) and, worse, was + /// logged once per constructed server instance — the isolated test servers built per test + /// (see TestEditorPipelineServer) spammed it dozens of times per suite run. The fact is + /// instead surfaced as structured data on the instance descriptor (see + /// ) — the one thing every client reads before it can + /// connect at all (UUM-149977). + /// + internal static bool DetectAutomatedMode(string[] commandLineArgs) + { + foreach (var arg in commandLineArgs) + { + if (arg == "-automated") + return true; + } + return false; + } + + /// Unsubscribe the settle-tick handler. + protected override void ServerStopped() + { + UnsubscribeSettleTick(); + } + + /// + /// Arm or immediately release the settle latch. Start() runs on the main thread, so the + /// Editor state and SessionState are readable directly. A server starts settled when the + /// session has already settled once (mid-session restarts after domain reloads, test + /// servers started while a test's own compile/import is in flight) or when the Editor is + /// idle right now — zero behavior change in both cases. Only a server started before the + /// session's first idle moment (cold import: [InitializeOnLoad] runs inside the startup + /// AssetDatabase refresh) arms the gate, and settles on the first update tick that sees + /// the Editor idle. + /// + private void InitializeSettleLatch() + { + if (SessionState.GetBool(SettledSessionKey, false) + || (!EditorApplication.isCompiling && !EditorApplication.isUpdating)) + { + MarkSettled(); + return; + } + + m_SettleTickSubscribed = true; + EditorApplication.update += SettleTick; + } + + private void SettleTick() + { + if (EditorApplication.isCompiling || EditorApplication.isUpdating) + return; + + MarkSettled(); + UnsubscribeSettleTick(); + } + + /// + /// Release this instance's latch and mark the whole session settled (main thread only). + /// + private void MarkSettled() + { + m_Settled = true; + SessionState.SetBool(SettledSessionKey, true); + } + + private void UnsubscribeSettleTick() + { + if (!m_SettleTickSubscribed) + return; + + m_SettleTickSubscribed = false; + EditorApplication.update -= SettleTick; + } + + /// + /// Busy gate (see ): while settling, reject + /// main-thread commands with a retryable busy signal — during the post-start import/compile + /// window they would otherwise execute into a half-ready Editor and fail with an opaque + /// null-data envelope (AUTHAPI-35). Background commands (status polling: recompile_status, + /// package_status, console, ...) and editor_status stay servable so callers can observe + /// progress. Reads only the volatile latch — safe on the request thread. + /// + /// The command about to be dispatched. + /// A busy reason while settling, or null once the Editor has been seen idle. + protected override string GetBusyReason(CommandInfo command) + { + if (IsSettled) + return null; + + if (!command.MainThreadRequired || CommandsServiceableWhileSettling.Contains(command.Name)) + return null; + + return "The Editor is still settling after startup (importing assets / compiling scripts), " + + "so main-thread commands are not serviceable yet. Retry shortly, or poll /api/status until it reports 'ready'."; + } + + /// + /// Called by EditorDialogStateMirror right before a dialog blocks the main thread (while + /// it's still free to read live Editor state) — captures editor_status's fields for + /// to serve while the dialog is open. + /// + internal void SnapshotStatusForDialogGate() + { + m_LastStatusBeforeDialog = EditorStatusCommand.GetEditorStatus(); + } + + /// + /// editor_status is the one MainThreadRequired command the dialog gate doesn't hard-fail: + /// it can't run for real (see 's doc), but its answer + /// is a snapshot taken the instant before the block started, which stays accurate for the + /// whole window. No snapshot exists only if a dialog opened before this server ever saw one + /// coming (should not happen in practice — EditorDialogStateMirror snapshots on every + /// dialogWillShow), in which case this falls back to the normal 503. + /// + internal override object TryGetDialogBlockedFallback(CommandInfo command, DialogInfo blockingDialog) + { + if (command.Name != "editor_status" || m_LastStatusBeforeDialog == null) + return null; + + return new StatusResponse + { + Status = "blocked_by_dialog", + Compiling = m_LastStatusBeforeDialog.Compiling, + DomainReloadInProgress = m_LastStatusBeforeDialog.DomainReloadInProgress, + PlayMode = m_LastStatusBeforeDialog.PlayMode, + LastHeartbeat = DateTime.UtcNow, + ProjectPath = m_LastStatusBeforeDialog.ProjectPath, + UnityVersion = m_LastStatusBeforeDialog.UnityVersion, + Dialog = BuildDialogPayload(blockingDialog) + }; + } + + /// + /// Post-command work, on the HTTP thread. + /// + /// The transaction log runs here directly rather than crossing to the main thread with the + /// analytics: its append is thread-safe file I/O by design (see + /// ), and driving it from the editor update loop would + /// put a read-modify-write of the log on every frame that served a command. An info with no + /// request JSON is a detached job reporting its real result, which was never part of a + /// transaction. + /// + protected override void OnCommandDone(in CommandExecutionInfo info) + { + if (LogRequestsResponses && info.RequestJson != null) + PipelineTransactionLog.Append(info.RequestJson, info.ResponseJson); + + // Requests rejected before anything ran carry no command, and nothing on the main-thread + // side has anything to say about them. + if (info.Command == null) + return; + + // Copied out of the `in` parameter because a lambda cannot capture one. + var posted = info; + Dispatcher.Post(() => OnCommandDoneMainThread(posted)); + } + + /// + /// Post-command work that needs the main thread, run on the next dispatcher pump. Analytics + /// lives here, and so does anything else added later that has to touch Unity APIs once a + /// command is done. Virtual so a test server can opt out wholesale. + /// + protected virtual void OnCommandDoneMainThread(in CommandExecutionInfo info) + { + PipelineAnalytics.RecordCommandExecuted(info); + } + + // Runtime-only commands (eval, codereload_status, …) are part of the Player surface; don't + // advertise them when a client is connected to the Editor. + /// False: Editor clients don't see runtime-only commands (they belong to the Player surface). + protected override bool IncludeRuntimeOnlyCommands => false; + /// Write the shared instance descriptor file so clients can discover this server. + protected override void CreateInstanceDescriptor() + { + // Create and write instance descriptor for CLI discovery + var automated = DetectAutomatedMode(System.Environment.GetCommandLineArgs()); + m_InstanceDescriptor = InstanceDescriptor.CreateCurrent(Port, automated); + InstanceDescriptor.WriteToProjectRoot(m_InstanceDescriptor); + } + + /// Remove the shared instance descriptor file on shutdown. + protected override void DeleteInstanceDescriptor() + { + // Clean up instance descriptor file + if (m_InstanceDescriptor != null) + { + InstanceDescriptor.RemoveFromProjectRoot(m_InstanceDescriptor.ProjectPath); + } + m_InstanceDescriptor = null; + } + + /// Refresh the descriptor's heartbeat timestamp and current token. + protected override void UpdateHeartBeat() + { + // Update heartbeat in instance descriptor + if (m_InstanceDescriptor == null) + return; + + lock (m_HeartbeatLock) + { + // 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}"); + } + } + } + + /// Build the Editor-specific status/heartbeat payload for /api/status, including the settle state. + /// The status payload. + protected override object GetServerStatus() + { + UpdateHeartBeat(); + // "blocked_by_dialog" is checked first: a modal dialog blocks the main thread + // regardless of settle state, and is the more actionable fact for a caller — settling + // resolves on its own, a dialog needs a human. Read off DialogStateTracker's lock, same + // as /api/dialog, so this stays fast and needs no main-thread access. "settling" is + // checked next: on a cold import the settle window precedes normal operation, and + // clients waiting for "ready" (instead of firing commands blindly) land after the + // Editor is actually serviceable (AUTHAPI-35). + var openDialogs = Dialogs.CurrentlyOpen; + string status = openDialogs.Count > 0 ? "blocked_by_dialog" + : !IsSettled ? "settling" + : (HasInstanceDescriptor ? "ready" : "error"); + + // Keep the common case's shape unchanged (no "dialog" key at all) rather than + // always emitting "dialog": null — this is a widely-polled endpoint, not worth + // changing its byte shape for every request just to cover the rare blocked case. + // Gated on status itself, not openDialogs.Count, so a dialog that opens during the + // settle window doesn't attach a "dialog" key to a "settling" response (StatusResponse's + // dialog field is documented as present only when status is "blocked_by_dialog"). + if (status == "blocked_by_dialog") + { + return new + { + status, + lastHeartbeat = m_InstanceDescriptor?.LastHeartbeat, + capabilities = Capabilities, + dialog = BasePipelineServer.BuildDialogPayload(openDialogs[0]) + }; + } + return new + { + status, + lastHeartbeat = m_InstanceDescriptor?.LastHeartbeat, + capabilities = Capabilities + }; + } + + /// The bearer token clients must present to authenticate requests. + /// The current live token. + 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/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineServer.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineServer.cs.meta new file mode 100644 index 0000000..8fb37eb --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineStartup.cs b/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineStartup.cs new file mode 100644 index 0000000..924e18c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineStartup.cs @@ -0,0 +1,345 @@ +using UnityEditor; +using UnityEngine; +using Unity.Pipeline.Models; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Telemetry; +using Unity.Pipeline.Threading; +using UnityEditor.MPE; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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] +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + 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() + { + // Seed the compile service's define snapshot while we're guaranteed on the main + // thread — a background compile fired before any main-thread parse would otherwise + // run with an empty define set (wrong #if branches). Before the worker-process + // early-out: costs one editor API call and holds everywhere. + Compilation.RoslynCompilationService.SnapshotProjectDefines(); + + // 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(); + + var cfg = EditorPipelineManager.Load(); + + // Apply the local eval-usage telemetry config on EVERY domain reload, not only when + // the server starts (AUTHAPI-29): recording hooks eval itself, not the server + // lifecycle, so an AutoStart=false session must still honor the configured opt-outs + // instead of falling back to the hardcoded defaults. + ApplyEvalTelemetrySettings(cfg); + + // Start the pipeline server (respecting the settings asset's autoStart if one exists) + if (cfg?.AutoStart ?? true) + StartServer(); + + // Handle domain reloads and editor shutdown + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + EditorApplication.quitting += OnEditorQuitting; + EditorApplication.wantsToQuit += OnEditorWantsToQuit; + + // Handle domain reload detection + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; + AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; + } + + /// Start the server if it isn't already running. + 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("Window/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("Window/Pipeline/Start Server", true)] + private static bool MenuStartServerValidate() => m_Server == null || !m_Server.IsRunning; + + [MenuItem("Window/Pipeline/Stop Server")] + private static void MenuStopServer() + { + StopServer(); + } + + [MenuItem("Window/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("Window/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; + } + + /// + /// Apply the local eval-usage telemetry config (AUTHAPI-29). Local-only, no transmission; + /// disabling it stops even local recording. StoreSource is the explicit opt-in for + /// persisting raw eval source (off by default). Resolving the directory primes the + /// dataPath-derived default on the main thread, so off-main-thread readers (report_evals) + /// and the background record task never touch Unity APIs. + /// + /// Internal (not private): this is the ONE place that knows how to turn the settings asset + /// into the static telemetry config, so every push site — the domain-reload static ctor, + /// server start, and 's live inspector push — calls + /// through here rather than re-deriving the two-field mapping independently, which would + /// silently drift the moment a fourth push site (or a third telemetry field) showed up. + /// + internal static void ApplyEvalTelemetrySettings(EditorPipelineManager cfg) + { + EvalUsageTelemetry.Enabled = cfg?.EvalTelemetryEnabled ?? true; + EvalUsageTelemetry.StoreSource = cfg?.StoreEvalSource ?? false; + EvalUsageTelemetry.ResolveDirectory(); + } + + /// + /// 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, + AllowSandboxedBrowserClients = cfg?.AllowBrowserClients ?? false, + MaxRequestBodyBytes = 32L * 1024 * 1024 + }; + + // 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(); + + // Re-applied on every server start so inspector edits made this session stick + // even without a domain reload (the static ctor covers the reload path). + ApplyEvalTelemetrySettings(cfg); + + m_Server.Start(cfg?.Port ?? 0); // 0 auto-assigns from the 7800-7849 range. + + // Restore whatever auto-tick state the user last set this session (survives the + // domain reload that just wiped AutoTickCommand's statics). Only a session with no + // prior explicit set_autotick call falls back to a default, and that default is "on" + // when the watchdog is enabled: 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. Keeping auto-tick on by default + // keeps the update loop spinning regardless of focus, which keeps both the watchdog + // AND the dispatcher message pump alive. + Commands.AutoTickCommand.RestoreFromSession(defaultEnabled: m_Server.WatchdogEnabled); + } + 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 + + // RuntimePipelineDriver.Awake() switches CommandRegistry to reflection-based discovery + // on every Play Mode entry (a Player has no TypeCache). With Fast Enter Play Mode + // (domain reload disabled), nothing else runs this static constructor to switch it back + // on the way out — pre-FEPM, re-entering Edit mode via a domain reload did this for + // free. Restore TypeCache discovery explicitly here instead of relying on that reload. + if (state == PlayModeStateChange.EnteredEditMode) + CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery()); + + // Dynamic command registrations (CommandRegistry.RegisterCommand) are static state with + // domain-reload lifetime, and a script registering from Awake/OnEnable re-registers on + // every Play Mode entry. With domain reload disabled nothing resets the registry between + // sessions, so the second entry would throw "already registered dynamically" and a + // surviving entry would hold a delegate bound to the previous session's destroyed + // object. Bracket the session here to clear what it registered; registrations made in + // Edit Mode are kept, since nothing re-runs to restore them. + if (state == PlayModeStateChange.ExitingEditMode) + CommandRegistry.PlayModeSessionStarted(); + else if (state == PlayModeStateChange.EnteredEditMode) + CommandRegistry.PlayModeSessionEnded(); + } + + /// + /// Handle Editor shutdown. + /// + private static void OnEditorQuitting() + { + StopServer(); // Stop() shuts down the server's own dispatcher. + } + + private static bool OnEditorWantsToQuit() + { + // WARNING: we need to fire SessionStop in WantsToQuit. Firing in Quits won't actually send the event. + PipelineAnalytics.SendSessionStoppedIfStarted(); + return true; + } + + /// + /// 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/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineStartup.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/EditorPipelineStartup.cs.meta new file mode 100644 index 0000000..ef3bd7b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/EditorProgressMirror.cs b/AIClient/Packages/com.unity.pipeline/Editor/EditorProgressMirror.cs new file mode 100644 index 0000000..269605b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/EditorProgressMirror.cs @@ -0,0 +1,67 @@ +using UnityEditor; + +namespace Unity.Pipeline.Editor +{ + /// + /// Mirrors running items into so + /// background Editor tasks (async imports, bakes, anything instrumented with + /// Progress.Report) surface on GET /api/progress with no explicit + /// calls (CLI-488). + /// + /// The Progress events fire on the main thread, so this mirror only advances while the main + /// thread is pumping. A long SYNCHRONOUS main-thread task never yields — code inside such a + /// task should call (or the + /// wrappers) directly; that path records from within the + /// blocked call and is what the HTTP listener thread serves. + /// + [InitializeOnLoad] + internal static class EditorProgressMirror + { + static EditorProgressMirror() + { + Progress.added += OnProgressChanged; + Progress.updated += OnProgressChanged; + Progress.removed += OnProgressChanged; + } + + static void OnProgressChanged(Progress.Item[] items) + { + Refresh(); + } + + static void Refresh() + { + // Ambient UnityEditor.Progress is a live-editor-wide concept, not per-test-server — + // target the live server explicitly so a test server's CliProgress never receives it + // (and vice versa). No-op while the live server isn't running. + var server = PipelineServerStartup.Server; + if (server == null) + return; + + // Mirror the most recently ADDED running item (enumeration is registration order, + // so the last running one wins) — for nested/sequential operations that is the one + // a user watching the Editor would consider "current". + Progress.Item best = null; + foreach (var item in Progress.EnumerateItems()) + { + if (item.running) + { + best = item; + } + } + + if (best == null) + { + server.Progress.ClearAmbient(); + return; + } + + server.Progress.ReportAmbient( + best.name, + best.description, + best.currentStep > 0 ? best.currentStep : (long?)null, + best.totalSteps > 0 ? best.totalSteps : (long?)null, + !best.indefinite && best.progress >= 0f ? (double?)best.progress : null); + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/EditorProgressMirror.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/EditorProgressMirror.cs.meta new file mode 100644 index 0000000..a81c32d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/EditorProgressMirror.cs.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: 9af021bfb7d14a23bdd4aa333b5d9105 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetVersion: 1 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/PipelineAnalytics.cs b/AIClient/Packages/com.unity.pipeline/Editor/PipelineAnalytics.cs new file mode 100644 index 0000000..1e7272e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/PipelineAnalytics.cs @@ -0,0 +1,17 @@ +// Unity 2022 compatibility shim: IAnalytic / AnalyticInfo / EditorAnalytics.SendAnalytic are Unity 6+. +// Keep call sites compiling with no-op analytics on this editor version. +#if !UNITY_6000_0_OR_NEWER +using Unity.Pipeline.Telemetry; + +namespace Unity.Pipeline.Editor +{ + internal static class PipelineAnalytics + { + internal static void RecordCommandExecuted(in CommandExecutionInfo info) { } + + internal static void SendSessionStoppedIfStarted() { } + } +} +#else +#error PipelineAnalytics.cs was replaced with a Unity 2022 shim. Restore the original package file on Unity 6+. +#endif diff --git a/AIClient/Packages/com.unity.pipeline/Editor/PipelineAnalytics.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/PipelineAnalytics.cs.meta new file mode 100644 index 0000000..ec48b13 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/PipelineAnalytics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d5ea5eba3cbb88e43881d93395472416 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/PipelineTransactionLog.cs b/AIClient/Packages/com.unity.pipeline/Editor/PipelineTransactionLog.cs new file mode 100644 index 0000000..87b1ffd --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/PipelineTransactionLog.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +[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. + /// +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + 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); + var oldPath = Path.Combine(logsDir, OldLogFileName); + RotatingFileBackup.RotateToBackup(logPath, oldPath); + } + + /// + /// Serializes the log's read-modify-write. Requests are processed concurrently since the + /// /api/progress work (an exec response can be written while the next exec runs), so two + /// appends may race; without this lock they would drop or corrupt entries. + /// + private static readonly object m_AppendGate = new object(); + + /// + /// 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) + { + lock (m_AppendGate) + { + AppendLocked(logsDir, requestJson, responseJson); + } + } + + private static void AppendLocked(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/AIClient/Packages/com.unity.pipeline/Editor/PipelineTransactionLog.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/PipelineTransactionLog.cs.meta new file mode 100644 index 0000000..9062216 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/PipelineTransactionLog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7e3c9a4b21d54f8d8b6e0c1f2a3d4e5b diff --git a/AIClient/Packages/com.unity.pipeline/Editor/PushSigner.cs b/AIClient/Packages/com.unity.pipeline/Editor/PushSigner.cs new file mode 100644 index 0000000..824097f --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/PushSigner.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using Unity.Pipeline.CodeReload; +using UnityEditor; +using UnityEditor.Networking.PlayerConnection; +using UnityEngine; +using UnityEngine.Networking.PlayerConnection; + +namespace Unity.Pipeline.Editor +{ + /// + /// Editor side of code-reload push signing. Owns the per-player session table (nonce announced by + /// each player's HandshakeMsg) and produces signed s with the project key + /// (Library/Pipeline/signing.key, created at build time — never here: a key no build has + /// baked would sign pushes nothing accepts, so its absence should say "build first"). + /// + /// The session table is editor-memory only, so every domain reload empties it. Recovery is the + /// handshake protocol: on load we broadcast RequestHandshake; a push whose target has no session yet is + /// deferred a couple of seconds while a directed RequestHandshake round-trips (see + /// ) — watch-mode saves right after a script recompile keep working. + /// + /// Counters only need to be strictly increasing per player session and are bound to the signed + /// nonce, so they are seeded from UTC ticks: a fresh editor session (or another machine holding a + /// copy of the key) continues above whatever a previous editor session used, without the player + /// having to report where the count stands. + /// + [InitializeOnLoad] + internal static class PushSigner + { + private sealed class Session + { + public byte[] Nonce; + public long Counter; + public string Fingerprint; // key fingerprint the player expects ("" = no key baked) + public int ProtocolVersion; + } + + // How long a push waits for the player's handshake before it is dropped. An idle IL2CPP + // player on the same machine took longer than 2 s to answer the first request after the + // editor's domain reload (verified live), which silently lost the first push. + private const double DeferSeconds = 10.0; + + private static readonly Dictionary s_Sessions = new Dictionary(); + private static readonly List<(double deadline, int playerId, byte[] payload, string desc)> s_Deferred = + new List<(double, int, byte[], string)>(); + private static bool s_DeferPumpHooked; + private static bool s_Registered; + + static PushSigner() + { + // EditorConnection is not reliably initializable during InitializeOnLoad itself. + EditorApplication.delayCall += RegisterAndRequestHandshakes; + AssemblyReloadEvents.beforeAssemblyReload += Unregister; + } + + private static void RegisterAndRequestHandshakes() + { + try + { + EditorConnection.instance.Initialize(); + EditorConnection.instance.Register(PipelineCodeReloadConnect.HandshakeMsg, OnHandshake); + s_Registered = true; + + // A domain reload just wiped the session table; ask every connected player to + // re-announce so it is warm again before anyone pushes. + var players = EditorConnection.instance.ConnectedPlayers; + if (players != null && players.Count > 0) + EditorConnection.instance.Send(PipelineCodeReloadConnect.RequestHandshakeMsg, RequestPayload()); + } + catch (Exception ex) + { + Debug.LogWarning($"[PushReload] Could not register the push-signing handshake handler: {ex.Message}"); + } + } + + private static void Unregister() + { + s_Deferred.Clear(); + if (s_DeferPumpHooked) + { + EditorApplication.update -= PumpDeferred; + s_DeferPumpHooked = false; + } + if (!s_Registered) return; + try { EditorConnection.instance.Unregister(PipelineCodeReloadConnect.HandshakeMsg, OnHandshake); } + catch { /* connection may already be gone during teardown */ } + s_Registered = false; + } + + // Non-empty payload: some transport paths drop zero-length messages. + private static byte[] RequestPayload() => new byte[] { PushEnvelope.ProtocolVersion }; + + // Main thread (EditorConnection dispatches there, same as the ack handler in PushReloadCommand). + private static void OnHandshake(MessageEventArgs args) + { + if (!PipelineCodeReloadConnect.TryDecodeHandshake(args.data, out var version, out var fingerprint, out var nonce)) + { + Debug.LogWarning($"[PushReload] Undecodable handshake from player {args.playerId} — its pushes will fail."); + return; + } + if (nonce == null || nonce.Length != PushEnvelope.NonceSize) + { + Debug.LogWarning($"[PushReload] Handshake from player {args.playerId} carries a bad session nonce — its pushes will fail."); + return; + } + + bool isNew = !s_Sessions.TryGetValue(args.playerId, out var existing) + || !NoncesEqual(existing.Nonce, nonce); + s_Sessions[args.playerId] = new Session + { + Nonce = nonce, + Counter = s_Sessions.TryGetValue(args.playerId, out var prev) ? prev.Counter : 0, + Fingerprint = fingerprint ?? "", + ProtocolVersion = version, + }; + + if (isNew) + { + var local = LocalFingerprint(); + var expected = string.IsNullOrEmpty(fingerprint) ? "none (no key baked!)" : fingerprint; + var session = $"player {args.playerId} session: protocol v{version}, expects key {expected}"; + if (local != null && !string.IsNullOrEmpty(fingerprint) && local != fingerprint) + // Actionable right now — every push to this player would be rejected — so it stays + // a warning of its own rather than waiting for the first reload. + Debug.LogWarning($"[PushReload] {session} — MISMATCH with this machine's key {local}: rebuild the player, " + + $"or copy Library/Pipeline/{PushSigningKey.KeyFileName} from the machine that built it."); + else + // A player connecting is only interesting once something is pushed to it; a + // connected dev player with the watcher off used to log this on every connect. + CodeReloadRegistry.NoteForFirstReload(session); + } + + PumpDeferred(); // a deferred push may have been waiting exactly for this handshake + } + + private static bool NoncesEqual(byte[] a, byte[] b) + { + if (a == null || b == null || a.Length != b.Length) return false; + for (int i = 0; i < a.Length; i++) + if (a[i] != b[i]) return false; + return true; + } + + internal static bool HasSession(int playerId) => s_Sessions.ContainsKey(playerId); + + internal static string LocalFingerprint() => + PushSigningKey.TryLoad(PushSigningKey.DefaultDirectory, out var key) + ? PushEnvelope.Fingerprint(key) + : null; + + /// + /// Sign for one player and send it, or — if that player's handshake + /// has not arrived (typical right after an editor domain reload) — request one and defer the + /// send for up to a couple of seconds. Returns a short status for the caller's summary line; + /// deferred/failed details are also logged here. + /// + internal static PushSendStatus PushOrDefer(int playerId, byte[] payload, string desc) + { + if (s_Sessions.ContainsKey(playerId)) + return SignAndSend(playerId, payload, desc) ? PushSendStatus.Sent : PushSendStatus.Failed; + + try + { + EditorConnection.instance.Send(PipelineCodeReloadConnect.RequestHandshakeMsg, RequestPayload(), playerId); + } + catch (Exception ex) + { + Debug.LogWarning($"[PushReload] Could not request a handshake from player {playerId}: {ex.Message}"); + } + + s_Deferred.Add((EditorApplication.timeSinceStartup + DeferSeconds, playerId, payload, desc)); + if (!s_DeferPumpHooked) + { + EditorApplication.update += PumpDeferred; + s_DeferPumpHooked = true; + } + return PushSendStatus.Deferred; + } + + private static bool SignAndSend(int playerId, byte[] payload, string desc) + { + var session = s_Sessions[playerId]; + + if (!PushSigningKey.TryLoad(PushSigningKey.DefaultDirectory, out var privateKey)) + { + Debug.LogError($"[PushReload] {desc}: no push-signing key at Library/Pipeline/{PushSigningKey.KeyFileName}. " + + "It is created at build time — build the player once on this machine, or copy the key file from the machine that built it."); + return false; + } + + var local = PushEnvelope.Fingerprint(privateKey); + if (string.IsNullOrEmpty(session.Fingerprint)) + { + Debug.LogError($"[PushReload] {desc}: player {playerId} has no baked push key and rejects all pushes. " + + "Rebuild with the Pipeline build processor enabled (it bakes the key into RuntimePipelineBuildInfo)."); + return false; + } + if (session.Fingerprint != local) + { + Debug.LogError($"[PushReload] {desc}: player {playerId} expects key {session.Fingerprint}, this machine has {local}. " + + $"Rebuild the player, or copy Library/Pipeline/{PushSigningKey.KeyFileName} from the machine that built it."); + return false; + } + + // Strictly increasing per session; UTC-tick seeding keeps a fresh editor domain (counter + // state lost) above anything a previous editor session already used. + session.Counter = Math.Max(session.Counter + 1, DateTime.UtcNow.Ticks); + + try + { + var envelope = PushEnvelope.Sign(privateKey, session.Nonce, session.Counter, payload); + EditorConnection.instance.Send(PipelineCodeReloadConnect.ApplyReloadMsg, envelope, playerId); + Commands.PushReloadCommand.TrackAck(desc + $" → player {playerId}"); + return true; + } + catch (Exception ex) + { + Debug.LogError($"[PushReload] {desc}: signing/sending to player {playerId} failed: {ex.Message}"); + return false; + } + } + + private static void PumpDeferred() + { + if (s_Deferred.Count == 0) + { + if (s_DeferPumpHooked) + { + EditorApplication.update -= PumpDeferred; + s_DeferPumpHooked = false; + } + return; + } + + double now = EditorApplication.timeSinceStartup; + // Drain in insertion (FIFO) order. The counter is assigned at send time, so sending the + // oldest ready push first keeps the newest push on the highest counter; draining + // newest-first would sign a later push with a lower counter and the player would then + // apply the stale earlier override last. Rebuild the queue with the entries that were + // neither sent nor expired, preserving their order. + var remaining = new List<(double deadline, int playerId, byte[] payload, string desc)>(s_Deferred.Count); + foreach (var entry in s_Deferred) + { + var (deadline, playerId, payload, desc) = entry; + + if (s_Sessions.ContainsKey(playerId)) + { + if (SignAndSend(playerId, payload, desc)) + Debug.Log($"[PushReload] {desc}: deferred push sent to player {playerId} (handshake arrived)."); + continue; + } + + if (now >= deadline) + { + Debug.LogWarning($"[PushReload] {desc}: no handshake from player {playerId} within {DeferSeconds:0}s — push dropped. " + + "Is the player build made with a signing-aware com.unity.pipeline package? Reconnect or restart the player and retry."); + continue; + } + + remaining.Add(entry); + } + s_Deferred.Clear(); + s_Deferred.AddRange(remaining); + } + } + + internal enum PushSendStatus + { + Sent, + Deferred, + Failed, + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/PushSigner.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/PushSigner.cs.meta new file mode 100644 index 0000000..4fc3135 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/PushSigner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 92b04efc75a841dd83c6f1e29a07d5b3 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/PushSigningKey.cs b/AIClient/Packages/com.unity.pipeline/Editor/PushSigningKey.cs new file mode 100644 index 0000000..47a243c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/PushSigningKey.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using Unity.Pipeline.CodeReload; +using UnityEngine; + +namespace Unity.Pipeline.Editor +{ + /// + /// The per-project code-reload push-signing keypair: Library/Pipeline/signing.key. + /// + /// Created lazily at build time (see PipelineRuntimeBuildProcessor) and loaded at push + /// time (see PushSigner). Living under Library/ makes committing it structurally + /// impossible and scopes trust to "the machine that built the player can push to it" — a + /// teammate either copies the file or rebuilds. Deleting Library/ orphans existing + /// device builds until the next rebuild; the key fingerprint in build and push logs is how + /// that mismatch names itself. + /// + internal static class PushSigningKey + { + internal const string KeyFileName = "signing.key"; + internal const string PublicInfoFileName = "signing.key.pub"; + + /// The project's Library/Pipeline directory (also home of the port file). + internal static string DefaultDirectory => + Path.Combine(Path.GetDirectoryName(Path.GetFullPath(Application.dataPath)), "Library", "Pipeline"); + + /// Load the private key if the file exists and fully parses. + internal static bool TryLoad(string directory, out string privateKey) + { + privateKey = null; + var path = Path.Combine(directory, KeyFileName); + try + { + if (!File.Exists(path)) return false; + var text = File.ReadAllText(path).Trim(); + if (!PushEnvelope.IsPrivateKey(text)) return false; + // The prefix alone doesn't prove the body parses. Fully parse the RSA parameters now: + // a truncated or corrupt "upp1-rsa:..." passes the prefix check, then throws later in + // DerivePublicKey and fails the build. A parse failure here falls to the catch and is + // treated as unreadable, so LoadOrCreate regenerates the key — the documented recovery. + PushEnvelope.DerivePublicKey(text); + privateKey = text; + return true; + } + catch (Exception ex) + { + Debug.LogWarning($"Pipeline: could not read push-signing key at {path} ({ex.Message}) — a fresh key will be generated on the next build."); + return false; + } + } + + /// + /// Load the project key, generating and persisting a fresh one if missing or unreadable. + /// Also writes a human-readable signing.key.pub (public half + fingerprint) beside it + /// for diagnostics and for handing to a teammate's editor. + /// + internal static string LoadOrCreate(string directory, out bool created) + { + created = false; + if (TryLoad(directory, out var existing)) + return existing; + + var privateKey = PushEnvelope.CreatePrivateKey(); + var publicKey = PushEnvelope.DerivePublicKey(privateKey); + var fingerprint = PushEnvelope.Fingerprint(publicKey); + + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, KeyFileName), privateKey); + File.WriteAllText(Path.Combine(directory, PublicInfoFileName), + $"# com.unity.pipeline push-signing public key (fingerprint {fingerprint})\n" + + "# The private half is signing.key next to this file — never commit or share it.\n" + + publicKey + "\n"); + + created = true; + return privateKey; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/PushSigningKey.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/PushSigningKey.cs.meta new file mode 100644 index 0000000..f90346e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/PushSigningKey.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c2e94d1a6b34f08b5d90a3c81f7e644 diff --git a/AIClient/Packages/com.unity.pipeline/Editor/RuntimePipelineConfigEditor.cs b/AIClient/Packages/com.unity.pipeline/Editor/RuntimePipelineConfigEditor.cs new file mode 100644 index 0000000..cc5c73e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/RuntimePipelineConfigEditor.cs @@ -0,0 +1,116 @@ +using Unity.Pipeline.Config; +using UnityEditor; + +namespace Unity.Pipeline.Editor +{ + /// + /// Draws a status HelpBox (feature description, or a security Warning/Error whenever + /// enableInBuilds is on) above RuntimePipelineConfig's fields, grouped "Server" / "Runtime + /// Behavior" via [Header] (enableInBuilds sits ungrouped above both, with its own note — it is + /// checked both at build time and at runtime, unlike every other field which only matters once + /// the driver is already running). Every field except enableInBuilds itself is disabled + /// whenever enableInBuilds is off — there is nothing to configure for a server that will not + /// run — and, separately, every field except maxWorkItemsPerFrame is also disabled during Play + /// Mode: RuntimePipelineBootstrap already loaded its own (separate) config instance by the time + /// this page could be edited, so changes here would silently do nothing until the next Play + /// session or build — same as a built Player, whose config is frozen at build time. + /// maxWorkItemsPerFrame is the one exception to the Play Mode rule — RuntimePipelineDriver.Update() + /// polls it live (see RuntimePipelineConfig.GetLiveMaxWorkItemsPerFrame), so it stays editable + /// and takes effect immediately, matching the pre-redesign component's behavior for this field; + /// it is still disabled while enableInBuilds is off, since there is no driver polling it then. + /// Never reached by clicking an asset in the Project window (the config is never a saved asset) + /// — hosted directly by RuntimePipelineSettingsProvider's Project Settings page instead, via + /// Editor.CreateEditor, which works fine on an in-memory instance. + /// + [CustomEditor(typeof(RuntimePipelineConfig))] + class RuntimePipelineConfigEditor : UnityEditor.Editor + { + public override void OnInspectorGUI() + { + serializedObject.Update(); + + // Default (150px) clips "Max Work Items Per Frame"; wide enough for every label here. + var previousLabelWidth = EditorGUIUtility.labelWidth; + EditorGUIUtility.labelWidth = 200f; + + var config = (RuntimePipelineConfig)target; + var status = GetStatusMessage(config.enableInBuilds, EditorUserBuildSettings.development); + EditorGUILayout.HelpBox(status.Message, status.Type); + EditorGUILayout.Space(); + + using (new EditorGUI.DisabledScope(EditorApplication.isPlaying)) + { + EditorGUILayout.PropertyField(serializedObject.FindProperty("enableInBuilds")); + EditorGUILayout.HelpBox( + "Checked in two places: at build time it decides whether the Pipeline server is " + + "baked into the Player at all; at runtime it decides whether the driver actually " + + "starts it — live from this file in the Editor (including Play Mode), but frozen " + + "to whatever it was at build time in a Player.", + MessageType.None); + } + + // Nothing else here matters until Pipeline is actually enabled — grey it all out + // rather than let you tune settings for a server that will not run. + using (new EditorGUI.DisabledScope(EditorApplication.isPlaying || !config.enableInBuilds)) + { + EditorGUILayout.PropertyField(serializedObject.FindProperty("port")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("requestTimeoutMs")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("enableAuditLogging")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("autoStart")); + } + + // Polled live every frame by RuntimePipelineDriver.Update(), so it stays editable (and + // useful to tune) even while playing, unlike everything else on this page — but only + // when there's actually a driver that could be running to tune. + using (new EditorGUI.DisabledScope(!config.enableInBuilds)) + { + EditorGUILayout.PropertyField(serializedObject.FindProperty("maxWorkItemsPerFrame")); + } + + if (EditorApplication.isPlaying) + { + EditorGUILayout.HelpBox( + "Read-only while in Play Mode, except Max Work Items Per Frame: the running " + + "driver already loaded its own settings, so edits to the other fields would only " + + "take effect on the next Play session or build. Max Work Items Per Frame is " + + "re-read live every frame, so it can be tuned while playing.", + MessageType.Info); + } + + serializedObject.ApplyModifiedProperties(); + + EditorGUIUtility.labelWidth = previousLabelWidth; + } + + /// + /// Core status-message logic, factored out so it's unit-testable without rendering IMGUI. + /// Always returns a message: an informative description of the feature when there is + /// nothing to warn about, a Warning when enabled in a development build (the server, and + /// with it remote code execution, will be in that build), or an Info note when enabled + /// without Development Build, where the setting has no effect. + /// + public static (string Message, MessageType Type) GetStatusMessage(bool enableInBuilds, bool developmentBuildCurrentlyOn) + { + if (!enableInBuilds) + { + return ("Runtime Pipeline lets a Player build be remotely controlled over HTTP — " + + "code-reload code, run commands, inspect state — without a domain reload. Enable " + + "'Enable In Builds' below to include it in Player builds.", + MessageType.Info); + } + + if (!developmentBuildCurrentlyOn) + { + return ("'Enable In Builds' is on, but Development Build is currently off — the " + + "Pipeline assemblies are excluded from non-development Player builds, so this " + + "setting has no effect on the next build. Enable Development Build to include " + + "the Pipeline server.", + MessageType.Info); + } + + return ("Pipeline server will be enabled in builds. Only ship this in development/QA " + + "builds — it exposes code execution and code reload over a local HTTP server.", + MessageType.Warning); + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/RuntimePipelineConfigEditor.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/RuntimePipelineConfigEditor.cs.meta new file mode 100644 index 0000000..263b62e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/RuntimePipelineConfigEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 563bc7467faa618478075fbf3ab74884 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/RuntimePipelineSettingsProvider.cs b/AIClient/Packages/com.unity.pipeline/Editor/RuntimePipelineSettingsProvider.cs new file mode 100644 index 0000000..08fa956 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/RuntimePipelineSettingsProvider.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using Unity.Pipeline.Config; +using UnityEditor; +using UnityEngine; + +namespace Unity.Pipeline.Editor +{ + /// + /// Project Settings UI for the runtime Pipeline configuration (Project Settings > Pipeline > + /// Runtime). This is the only editing surface for RuntimePipelineConfig — the config is never + /// a saved asset, so there's nothing to click on in the Project window. This is the primary, + /// discoverable UI for what used to require manually adding a RuntimePipelineManager + /// component to a scene. + /// + static class RuntimePipelineSettingsProvider + { + /// + /// Load the current settings, or a fresh in-memory default instance if none have been + /// authored yet. Deliberately does NOT persist that default: merely viewing this page, or + /// reading via get_runtime_pipeline_settings, must never have the side effect of writing to + /// disk. The caller owns the returned instance and must DestroyImmediate it when done, same + /// as RuntimePipelineConfig.Load(). + /// + public static RuntimePipelineConfig LoadOrCreateConfig() => + RuntimePipelineConfig.Load() ?? ScriptableObject.CreateInstance(); + + [SettingsProvider] + public static SettingsProvider CreateSettingsProvider() + { + RuntimePipelineConfig config = null; + UnityEditor.Editor editor = null; + DateTime loadedWriteTimeUtc = default; + + void Reload() + { + if (editor != null) + UnityEngine.Object.DestroyImmediate(editor); + if (config != null) + UnityEngine.Object.DestroyImmediate(config); + config = LoadOrCreateConfig(); + editor = UnityEditor.Editor.CreateEditor(config, typeof(RuntimePipelineConfigEditor)); + // DateTime.MinValue (no settings file yet) is a stable value here, not a moving + // target: GetSettingsFileWriteTimeUtc() keeps returning MinValue on every repaint + // until an actual edit below calls config.Save(), so LoadOrCreateConfig() no longer + // persisting a default does not cause a reload loop. + loadedWriteTimeUtc = RuntimePipelineConfig.GetSettingsFileWriteTimeUtc(); + } + + return new SettingsProvider("Project/Pipeline/Runtime", SettingsScope.Project) + { + label = "Runtime", + activateHandler = (_, __) => Reload(), + deactivateHandler = () => + { + if (editor != null) + UnityEngine.Object.DestroyImmediate(editor); + if (config != null) + UnityEngine.Object.DestroyImmediate(config); + editor = null; + config = null; + }, + guiHandler = _ => + { + // Reload whenever our cached state can no longer be trusted: + // - config == null: entering/exiting Play Mode destroyed it out from under the + // cached Editor without deactivateHandler running first (Unity purges loose, + // non-persisted ScriptableObjects around the Play Mode transition). Unity's + // Object == overload treats a destroyed native object as null even though the + // C# reference is not. + // - the file's write-time moved since we last loaded/saved it: something else + // wrote to it directly — a build's "Disable Pipeline" security-dialog choice, + // or a set_runtime_pipeline_settings CLI call — so our in-memory copy is stale. + if (config == null || RuntimePipelineConfig.GetSettingsFileWriteTimeUtc() != loadedWriteTimeUtc) + Reload(); + + EditorGUI.BeginChangeCheck(); + editor.OnInspectorGUI(); + if (EditorGUI.EndChangeCheck()) + { + config.Save(); + loadedWriteTimeUtc = RuntimePipelineConfig.GetSettingsFileWriteTimeUtc(); + } + }, + keywords = new HashSet(new[] { "Pipeline", "Runtime", "Code Reload", "Server", "CLI" }) + }; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/RuntimePipelineSettingsProvider.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/RuntimePipelineSettingsProvider.cs.meta new file mode 100644 index 0000000..0ebc8ee --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/RuntimePipelineSettingsProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d885965fd2bb57e499a29613a6cbf44f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Testing.meta b/AIClient/Packages/com.unity.pipeline/Editor/Testing.meta new file mode 100644 index 0000000..4f35249 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Testing.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 487c1aaaab92fe74884c8b7b0442128a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Testing/PipelineTestRunner.cs b/AIClient/Packages/com.unity.pipeline/Editor/Testing/PipelineTestRunner.cs new file mode 100644 index 0000000..3a2f333 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Testing/PipelineTestRunner.cs @@ -0,0 +1,836 @@ +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; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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. + /// +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + 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 + /// + /// "editor"/"editmode", "playmode"/"play", or "all". + /// Filter value to match against, per . + /// "testname", "assembly", or "category". + /// Whether to include tests/fixtures marked [Explicit]. + /// Run asynchronously, returning immediately with a status path to poll. + /// Timeout for synchronous execution. + /// The test execution response. + 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.", + ExecutedAt = DateTime.UtcNow + }; + } + 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, + ExecutedAt = DateTime.UtcNow + }; + + // 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}"); + } + finally + { + UnregisterCollector(api, collector); + } + } + } + + /// + /// 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.", + ExecutedAt = DateTime.UtcNow + }); + } + 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) + { + TestRunnerApi api = null; + TestResultCollector collector = null; + try + { + api = ScriptableObject.CreateInstance(); + 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(); + UnregisterCollector(api, collector); + }; + + 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(); + UnregisterCollector(api, collector); + 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(); + UnregisterCollector(api, collector); + } + }); + } + catch (Exception ex) + { + Debug.LogError($"[PipelineTestRunner] Async execution error: {ex.Message}"); + WriteStatusFile(new { status = "error", message = ex.Message }); + CleanupRequestFile(); + UnregisterCollector(api, collector); + } + } + + /// + /// 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() + { + TestRunnerApi api = null; + TestResultCollector collector = null; + try + { + api = ScriptableObject.CreateInstance(); + collector = new TestResultCollector(); + + m_ActiveApi = api; + m_ActiveCollector = collector; + + collector.OnRunFinished = () => + { + WriteCompletedResults(collector); + RestoreLiveServerIfDisrupted(); + CleanupRequestFile(); + UnregisterCollector(api, collector); + }; + + api.RegisterCallbacks(collector); + } + catch (Exception ex) + { + Debug.LogError($"[PipelineTestRunner] Failed to reattach result collector: {ex.Message}"); + WriteStatusFile(new { status = "error", message = ex.Message }); + CleanupRequestFile(); + UnregisterCollector(api, collector); + } + } + + /// + /// Called after domain reload to resume pending tests + /// + [InitializeOnLoad] + public static class TestReloader + { + static TestReloader() + { + EditorApplication.delayCall += CheckForPendingTests; + } + } + + /// Resume any test run that was in flight before a domain reload dropped it. + 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(); + UnregisterCollector(m_ActiveApi, m_ActiveCollector); + Debug.Log("[PipelineTestRunner] Invalidated previous test run"); + } + } + + /// + /// Unregisters a collector from Unity's TestRunnerApi callback registry (process-wide, not + /// per-instance) and clears the active-run fields if they still point at it. + /// + private static void UnregisterCollector(TestRunnerApi api, TestResultCollector collector) + { + if (api != null && collector != null) + { + try { api.UnregisterCallbacks(collector); } + catch (Exception ex) + { + Debug.LogWarning($"[PipelineTestRunner] Could not unregister collector: {ex.Message}"); + } + } + + if (ReferenceEquals(m_ActiveCollector, collector)) + { + 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(), + ExecutedAt = DateTime.UtcNow + }; + } + + 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) + /// + /// The status file's JSON contents, "running" if a request is pending, or null if there's no run. + 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 + /// + /// A status payload confirming cancellation (or that there was nothing to cancel). + 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/AIClient/Packages/com.unity.pipeline/Editor/Testing/PipelineTestRunner.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Testing/PipelineTestRunner.cs.meta new file mode 100644 index 0000000..2992d00 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Testing/TestResultCollector.cs b/AIClient/Packages/com.unity.pipeline/Editor/Testing/TestResultCollector.cs new file mode 100644 index 0000000..32114ed --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Testing/TestResultCollector.cs @@ -0,0 +1,182 @@ +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. + /// + class TestResultCollector : ICallbacks + { + /// True once has processed a (non-duplicate, non-cancelled) run. + public bool IsComplete { get; private set; } + /// Set by ; subsequent callbacks are ignored once true. + public bool IsCancelled { get; set; } + /// Per-test results collected so far. + public List Results { get; } = new List(); + /// The completed run's root result tree, set by . + public ITestResultAdaptor RootResult { get; private set; } + + // For async mode + /// Invoked when processes a completed run (async mode). + public Action OnRunFinished { get; set; } + + // For sync mode + private TaskCompletionSource m_CompletionSource; + + /// Create a collector ready to receive callbacks for a new run. + public TestResultCollector() + { + m_CompletionSource = new TaskCompletionSource(); + } + + /// + /// Wait for test completion (sync mode) + /// + /// The completed run's root result tree. + public Task WaitForCompletionAsync() + { + return m_CompletionSource.Task; + } + + /// ICallbacks hook: a run has started. + /// The tests about to run. + public void RunStarted(ITestAdaptor testsToRun) + { + if (IsCancelled) return; + + IsComplete = false; + Results.Clear(); + Debug.Log($"[TestResultCollector] Run started: {testsToRun.TestCaseCount} test(s)"); + } + + /// ICallbacks hook: a single test has started. No-op. + /// The test about to run. + public void TestStarted(ITestAdaptor test) + { + // No action needed for test start + } + + /// ICallbacks hook: a single (non-suite) test has finished. + /// The finished test's result. + public void TestFinished(ITestResultAdaptor result) + { + if (IsCancelled) return; + if (result.Test.IsSuite) return; + + Results.Add(BuildTestResult(result)); + } + + /// ICallbacks hook: the run has finished. + /// The run's root result tree. + public void RunFinished(ITestResultAdaptor result) + { + if (IsCancelled) + { + Debug.Log("[TestResultCollector] Ignoring RunFinished from cancelled run"); + return; + } + + // A stale collector can receive a later run's RunFinished (see PipelineTestRunner); + // re-completing OnRunFinished/m_CompletionSource would throw. IsComplete can't detect + // this — RunStarted resets it on every non-cancelled collector, stale ones included. + if (m_CompletionSource.Task.IsCompleted) + { + Debug.Log("[TestResultCollector] Ignoring duplicate RunFinished (already completed)"); + 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 (guard against double-complete if the task was already + // completed by SetError/Cancel, mirroring the guards in those methods) + m_CompletionSource.TrySetResult(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 + /// + /// The error to complete the sync waiter's task with. + 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/AIClient/Packages/com.unity.pipeline/Editor/Testing/TestResultCollector.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/Testing/TestResultCollector.cs.meta new file mode 100644 index 0000000..97f68e1 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/TypeCacheCommandDiscovery.cs b/AIClient/Packages/com.unity.pipeline/Editor/TypeCacheCommandDiscovery.cs new file mode 100644 index 0000000..9174418 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/TypeCacheCommandDiscovery.cs @@ -0,0 +1,25 @@ +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. + /// + class TypeCacheCommandDiscovery : ICommandDiscovery + { + /// + /// Find all methods marked with specified attribute using TypeCache. + /// + /// The attribute type to search for. + /// The matching methods. + public IEnumerable GetMethodsWithAttribute() where T : Attribute + { + return TypeCache.GetMethodsWithAttribute(); + } + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Editor/TypeCacheCommandDiscovery.cs.meta b/AIClient/Packages/com.unity.pipeline/Editor/TypeCacheCommandDiscovery.cs.meta new file mode 100644 index 0000000..15b703c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Editor/Unity.Pipeline.Editor.asmdef b/AIClient/Packages/com.unity.pipeline/Editor/Unity.Pipeline.Editor.asmdef new file mode 100644 index 0000000..c2e7ed4 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Unity.Pipeline.Editor.asmdef @@ -0,0 +1,21 @@ +{ + "name": "Unity.Pipeline.Editor", + "rootNamespace": "Unity.Pipeline.Editor", + "references": [ + "Unity.Pipeline", + "Unity.Pipeline.Attributes", + "Unity.Pipeline.IlInterpreter", + "Unity.Nuget.Newtonsoft-Json" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/AIClient/Packages/com.unity.pipeline/Editor/Unity.Pipeline.Editor.asmdef.meta b/AIClient/Packages/com.unity.pipeline/Editor/Unity.Pipeline.Editor.asmdef.meta new file mode 100644 index 0000000..0b423f3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Editor/Unity.Pipeline.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a3ca9b2d97442e0499ea28b681f751d9 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/LICENSE.md b/AIClient/Packages/com.unity.pipeline/LICENSE.md new file mode 100644 index 0000000..d786c29 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/LICENSE.md.meta b/AIClient/Packages/com.unity.pipeline/LICENSE.md.meta new file mode 100644 index 0000000..6b4c589 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/LICENSE.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c07017e56bf04bf4992412964a20140c +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/README.md b/AIClient/Packages/com.unity.pipeline/README.md new file mode 100644 index 0000000..00295ab --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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 code-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/AIClient/Packages/com.unity.pipeline/README.md.meta b/AIClient/Packages/com.unity.pipeline/README.md.meta new file mode 100644 index 0000000..786d10f --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d1a2488a30b969941afb4af17c063ad3 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime.meta b/AIClient/Packages/com.unity.pipeline/Runtime.meta new file mode 100644 index 0000000..4b3402e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a9bf7426ce9ccf4448467e47c849c567 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Analyzers.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Analyzers.meta new file mode 100644 index 0000000..501f850 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Analyzers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a83ffb4c25f355a419ba91b8fc12ca48 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Analyzers/IlInterpreterAnalyzer.dll b/AIClient/Packages/com.unity.pipeline/Runtime/Analyzers/IlInterpreterAnalyzer.dll new file mode 100644 index 0000000..82197e4 Binary files /dev/null and b/AIClient/Packages/com.unity.pipeline/Runtime/Analyzers/IlInterpreterAnalyzer.dll differ diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Analyzers/IlInterpreterAnalyzer.dll.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Analyzers/IlInterpreterAnalyzer.dll.meta new file mode 100644 index 0000000..85a1566 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Analyzers/IlInterpreterAnalyzer.dll.meta @@ -0,0 +1,39 @@ +fileFormatVersion: 2 +guid: 33fa77d2af5e469ca652745160e4c35d +labels: +- RoslynAnalyzer +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/AssemblyInfo.cs b/AIClient/Packages/com.unity.pipeline/Runtime/AssemblyInfo.cs new file mode 100644 index 0000000..1f7bd9b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/AssemblyInfo.cs @@ -0,0 +1,10 @@ +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")] +// Lets the Editor assembly feed CliProgress's internal ambient mirror (EditorProgressMirror) +// and consume internal interpreter glue (e.g. IlInterpreterHostBindings, which the code-reload +// link.xml generator derives its preservation set from). +[assembly: InternalsVisibleTo("Unity.Pipeline.Editor")] diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/AssemblyInfo.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/AssemblyInfo.cs.meta new file mode 100644 index 0000000..0045189 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Attributes.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Attributes.meta new file mode 100644 index 0000000..585d915 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Attributes.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3da432e2a5ea462089d41e21aa49f59b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Attributes/CodeReloadAttributes.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Attributes/CodeReloadAttributes.cs new file mode 100644 index 0000000..0d1082d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Attributes/CodeReloadAttributes.cs @@ -0,0 +1,71 @@ +using System; + +namespace Unity.Pipeline.CodeReload +{ + /// + /// Marks a method as reloadable: edit its body directly in the original source file and + /// apply the file with the reload_file command (or the interpreter-backed variants). The + /// running instance picks up the new body without a domain reload. Bodies may touch members of + /// any accessibility, including private/protected/internal ones of the target type. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class CodeReloadAttribute : Attribute + { + /// + /// Optional identifier for this 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 parameterless instance method to be invoked after a code reload is applied to its + /// declaring component. Fires once per reload_file that binds at least one override on + /// the type, on every live instance — a place to re-initialize or refresh state when the code is + /// swapped (analogous to a domain-reload-free OnEnable). The method runs on the main thread; + /// exceptions are logged, not propagated. Only UnityEngine.Object-derived types are supported + /// (instances are discovered via FindObjectsByType). + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + [JetBrains.Annotations.MeansImplicitUse] + public class OnCodeReloadAttribute : Attribute + { + } + + /// + /// Marks a compiled static method as the code reload override for a specific [CodeReload] + /// method. Emitted by the reload pipeline on the code it generates from an edited source file + /// (see SourceCodeTransformer); user code does not write it by hand. + /// + /// Override signature: the original method's signature plus the target instance as the first + /// parameter. Example: void Update() becomes static void Update(TargetType instance). + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class CodeReloadOverrideMethodAttribute : Attribute + { + /// + /// Target method identifier in format "TypeName.MethodName" + /// Must match a method marked with [CodeReload]. + /// + public string TargetMethodId { get; } + + /// + /// Optional description of what this code reload does. + /// Useful for debugging and code reload management. + /// + public string Description { get; set; } + + /// Mark a method as the code-reload override for the given target method. + /// Target method identifier, "TypeName.MethodName". + public CodeReloadOverrideMethodAttribute(string targetMethodId) + { + TargetMethodId = targetMethodId ?? throw new ArgumentNullException(nameof(targetMethodId)); + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Attributes/CodeReloadAttributes.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Attributes/CodeReloadAttributes.cs.meta new file mode 100644 index 0000000..2d20e06 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Attributes/CodeReloadAttributes.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/AIClient/Packages/com.unity.pipeline/Runtime/Attributes/Unity.Pipeline.Attributes.asmdef b/AIClient/Packages/com.unity.pipeline/Runtime/Attributes/Unity.Pipeline.Attributes.asmdef new file mode 100644 index 0000000..8cc2ba3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Attributes/Unity.Pipeline.Attributes.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Unity.Pipeline.Attributes", + "rootNamespace": "Unity.Pipeline.CodeReload", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Attributes/Unity.Pipeline.Attributes.asmdef.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Attributes/Unity.Pipeline.Attributes.asmdef.meta new file mode 100644 index 0000000..799fa2c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Attributes/Unity.Pipeline.Attributes.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b522baaf20314499ba2deea8dd838496 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload.meta b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload.meta new file mode 100644 index 0000000..5fa8328 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c29607a31a43d7149b8296de99b81d8e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/AccessibilityValidator.cs b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/AccessibilityValidator.cs new file mode 100644 index 0000000..7df5a39 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/AccessibilityValidator.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityPipeline.Microsoft.CodeAnalysis; +using UnityPipeline.Microsoft.CodeAnalysis.CSharp; +using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax; +using UnityEngine; + +namespace Unity.Pipeline.CodeReload +{ + /// + /// Accessibility gate for [CodeReload] method bodies on the compiled (Assembly.Load) backend. + /// + /// The compiled backend cannot use non-public members of the target type: even if the override + /// compiled with Roslyn's access checks relaxed, Unity's Mono enforces accessibility when + /// JIT-ing the loaded override IL and does not honor [assembly: IgnoresAccessChecksTo] + /// (verified on Mono 6.13) — the access would throw (e.g. FieldAccessException) on first + /// dispatch and silently fall back to the original body. Compiled-backend overrides therefore + /// compile with standard access checks (see + /// RoslynCompilationService.AllowNonPublicMemberAccess, interpreter-only), and this + /// validator runs up front — only when the compiled backend will execute the override — to turn + /// the eventual raw CS0122 into a clear reload error. The interpreter backend reaches + /// non-public members via reflection and must not be gated. + /// + static class AccessibilityValidator + { + /// + /// Check that every [CodeReload] method body only accesses public instance members of its + /// declaring type. Runs over an already-built semantic model + class declaration, so the + /// in-place reload path shares one compilation between validation and transformation + /// instead of building two. + /// + /// The semantic model built over the source containing the declaring type. + /// The declaring type's class declaration within . + /// Map of method name to body source, for the methods being validated. + /// Name of the declaring type to validate against. + /// The validation result, including any violations found. + public static AccessibilityValidationResult ValidatePublicAccess( + SemanticModel model, + ClassDeclarationSyntax classDecl, + Dictionary methodBodies, + string originalTypeName) + { + var result = new AccessibilityValidationResult + { + IsValid = true, + Violations = new List() + }; + + try + { + 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 [CodeReload] method '{methodName}'", + Suggestion = $"Make '{symbol.Name}' public in {originalTypeName}, or reload " + + "through the interpreter backend (reload_file_editor_interpreter), which " + + "reaches non-public members. The compiled backend loads the override " + + "as a separate assembly and Mono enforces accessibility at dispatch." + }); + } + } + } + + result.IsValid = result.Violations.Count == 0; + return result; + } + catch (Exception ex) + { + Debug.LogError($"CodeReload: 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 code reload methods. + /// + class AccessibilityValidationResult + { + /// True when no accessibility violations were found. + public bool IsValid { get; set; } + /// Violations found, if any. + public List Violations { get; set; } = new List(); + /// Set when validation itself could not run (e.g. a parse failure), rather than a violation being found. + public string ValidationError { get; set; } + + /// Human-readable summary of or all . + public string GetFormattedErrorMessage() + { + if (!string.IsNullOrEmpty(ValidationError)) + return $"CodeReload Validation Error: {ValidationError}"; + + if (Violations.Count == 0) + return "All member access is valid for code reload."; + + var errorMessage = $"CodeReload 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 code reload code. + /// + class AccessibilityViolation + { + /// Name of the non-public member that was accessed. + public string MemberName { get; set; } + /// Name of the [CodeReload] method containing the access. + public string MethodName { get; set; } + /// The member's actual accessibility. + public Accessibility AccessLevel { get; set; } + /// Category of violation. + public AccessibilityViolationType ViolationType { get; set; } + /// Human-readable description of the violation. + public string ErrorMessage { get; set; } + /// Suggested fix. + public string Suggestion { get; set; } + } + + /// + /// Types of accessibility violations that can occur in code reload methods. + /// + enum AccessibilityViolationType + { + /// A private member was accessed. + PrivateAccess, + /// An internal member was accessed. + InternalAccess, + /// A protected member was accessed. + ProtectedAccess, + /// The source could not be parsed to determine accessibility. + ParseError + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/AccessibilityValidator.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/AccessibilityValidator.cs.meta new file mode 100644 index 0000000..3958537 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/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/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadActivity.cs b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadActivity.cs new file mode 100644 index 0000000..2edcc0b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadActivity.cs @@ -0,0 +1,67 @@ +using System; + +namespace Unity.Pipeline.CodeReload +{ + /// + /// Thread-safe snapshot of the most recent code-reload activity, for on-screen display by + /// . Fed from two sides: the PlayerConnection receiver + /// (compile-started / compile-failed notices arrive on a background thread) and the apply path + /// in RuntimePipelineDriver (main thread). Timestamps use + /// because Unity's Time API is main-thread-only and reporters run off it. + /// + static class CodeReloadActivity + { + public enum Phase + { + /// No reload in flight; nothing to show beyond the override count. + Idle = 0, + /// The editor announced a compile for this player (ReloadPendingMsg). + Compiling = 1, + /// A pushed override was applied; message is the apply summary. + Applied = 2, + /// Editor compile failed or on-device apply failed; message says why. + Failed = 3, + } + + private static readonly object s_Lock = new object(); + private static Phase s_Phase = Phase.Idle; + private static string s_Message = ""; + private static int s_PhaseTick = Environment.TickCount; + + public static void ReportCompileStarted(string name) => Set(Phase.Compiling, name); + public static void ReportApplied(string summary) => Set(Phase.Applied, summary); + public static void ReportFailed(string summary) => Set(Phase.Failed, summary); + + private static void Set(Phase phase, string message) + { + lock (s_Lock) + { + s_Phase = phase; + s_Message = message ?? ""; + s_PhaseTick = Environment.TickCount; + } + } + + /// Current phase, its message, and milliseconds since the phase changed. + public static Phase Snapshot(out string message, out int ageMs) + { + lock (s_Lock) + { + message = s_Message; + ageMs = Environment.TickCount - s_PhaseTick; + return s_Phase; + } + } + + private static long s_OverrideCalls; + + /// Called by the dispatch path once per overridden-method invocation. The running + /// total lets the overlay show a call rate — "N overrides · 0 calls/s" exposes the + /// bound-but-inert case (override registered but the woven prologue never dispatching), + /// which no log line catches. + public static void CountOverrideCall() => System.Threading.Interlocked.Increment(ref s_OverrideCalls); + + /// Total overridden-method invocations this session. + public static long OverrideCallCount => System.Threading.Interlocked.Read(ref s_OverrideCalls); + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadActivity.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadActivity.cs.meta new file mode 100644 index 0000000..21640ef --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadActivity.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 689f041b295d86a4b8ffd6249abf5a7a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadBaseline.cs b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadBaseline.cs new file mode 100644 index 0000000..ddeadd6 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadBaseline.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using UnityPipeline.Microsoft.CodeAnalysis; +using UnityPipeline.Microsoft.CodeAnalysis.CSharp; +using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax; +using UnityPipeline.Microsoft.CodeAnalysis.Text; +using Unity.Pipeline.Compilation; + +namespace Unity.Pipeline.CodeReload +{ + /// + /// Per-method snapshot of a reloadable file's last-compiled source, so a reload can skip + /// methods whose bodies still match the code the target already runs compiled. Without this, + /// every save re-registers an interpreter override for every [CodeReload] method in the file, + /// and methods the user never touched pay the interpreter's per-call cost. + /// + /// A baseline is captured per (file, preprocessor symbols) pair — the same file diffs + /// differently under editor and player defines. For each [CodeReload] method it stores a hash + /// of the declaration's token stream (trivia excluded, so whitespace and comment edits don't + /// count as changes), plus one context hash over the rest of the file with all executable + /// bodies removed. A context change (fields, consts, signatures, usings) conservatively marks + /// every method changed: an override compiles against the current file, so same-file + /// declaration edits can shift an untouched body's meaning. + /// + /// Owned by whoever knows when disk matches the compiled state (the editor watch); with no + /// baseline captured, classification returns null and reloads behave as before. The store is + /// static, so the editor persists/restores it across domain reloads itself. + /// + static class CodeReloadBaseline + { + private sealed class Entry + { + public ulong ContextHash; + public readonly Dictionary MethodHashes = new Dictionary(); + } + + // Key: normalized full path + '\u001f' + define key. + private static readonly Dictionary s_Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// Number of captured (file, defines) baselines. + public static int Count => s_Entries.Count; + + /// + /// Snapshot as the compiled reference state for the given + /// preprocessor symbols (null = project defaults). Call only when the file on disk is known + /// to match what the reload target runs compiled. Returns false (and drops any stale entry) + /// when the file can't be read or has no [CodeReload] method. + /// + public static bool Capture(string filePath, string[] preprocessorSymbols = null) + { + string source; + try { source = File.ReadAllText(filePath); } + catch { source = null; } + return CaptureFromSource(filePath, source, preprocessorSymbols); + } + + /// See ; source text supplied by the caller (tests). + public static bool CaptureFromSource(string filePath, string sourceCode, string[] preprocessorSymbols = null) + { + var key = KeyFor(filePath, preprocessorSymbols); + var entry = sourceCode == null ? null : ComputeEntry(sourceCode, preprocessorSymbols); + if (entry == null) + { + s_Entries.Remove(key); + return false; + } + s_Entries[key] = entry; + return true; + } + + /// + /// Diff against the captured baseline for + /// (, ). Returns the + /// [CodeReload] method names whose declarations still match it — safe to leave running + /// compiled. Null when no baseline was captured for that pair (treat everything as + /// changed); empty when the file's context changed, so nothing is skippable. + /// + public static HashSet GetUnchangedMethods( + string filePath, string sourceCode, string[] preprocessorSymbols = null) + { + if (!s_Entries.TryGetValue(KeyFor(filePath, preprocessorSymbols), out var baseline)) + return null; + + var unchanged = new HashSet(StringComparer.Ordinal); + var current = sourceCode == null ? null : ComputeEntry(sourceCode, preprocessorSymbols); + if (current == null || current.ContextHash != baseline.ContextHash) + return unchanged; + + foreach (var kv in current.MethodHashes) + if (baseline.MethodHashes.TryGetValue(kv.Key, out var h) && h == kv.Value) + unchanged.Add(kv.Key); + return unchanged; + } + + /// + /// True when every [CodeReload] method in matches the baseline + /// and none of them is in (methods that must reload anyway — + /// e.g. already overridden on a device that can't unregister). Callers use this to skip a + /// whole compile-and-push. False when no baseline was captured. + /// + public static bool IsFileUpToDate( + string filePath, string sourceCode, string[] preprocessorSymbols = null, + ICollection mustReload = null) + { + if (!s_Entries.TryGetValue(KeyFor(filePath, preprocessorSymbols), out var baseline)) + return false; + var current = sourceCode == null ? null : ComputeEntry(sourceCode, preprocessorSymbols); + if (current == null || current.ContextHash != baseline.ContextHash) + return false; + + foreach (var kv in current.MethodHashes) + { + if (!baseline.MethodHashes.TryGetValue(kv.Key, out var h) || h != kv.Value) + return false; + if (mustReload != null && mustReload.Contains(kv.Key)) + return false; + } + return true; + } + + /// Drop every captured baseline (watch stopped, or about to recapture). + public static void Clear() => s_Entries.Clear(); + + /// + /// Flatten the store to one string so the editor can stash it in SessionState across the + /// play-mode domain reload — the reload wipes these statics while disk keeps the user's + /// edits, so the baseline can't be recaptured from disk at that point. + /// + public static string Serialize() + { + var sb = new StringBuilder(); + foreach (var kv in s_Entries) + { + sb.Append(kv.Key).Append('\u001e').Append(kv.Value.ContextHash.ToString("x", CultureInfo.InvariantCulture)); + foreach (var m in kv.Value.MethodHashes) + sb.Append('\u001e').Append(m.Key).Append('=').Append(m.Value.ToString("x", CultureInfo.InvariantCulture)); + sb.Append('\n'); + } + return sb.ToString(); + } + + /// Replace the store with a snapshot. Unparseable lines are dropped. + public static void Restore(string serialized) + { + s_Entries.Clear(); + if (string.IsNullOrEmpty(serialized)) return; + + foreach (var line in serialized.Split('\n')) + { + if (line.Length == 0) continue; + var fields = line.Split('\u001e'); + if (fields.Length < 2) continue; + if (!ulong.TryParse(fields[1], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var contextHash)) + continue; + + var entry = new Entry { ContextHash = contextHash }; + bool ok = true; + for (int i = 2; i < fields.Length; i++) + { + int eq = fields[i].LastIndexOf('='); + if (eq <= 0 || !ulong.TryParse(fields[i].Substring(eq + 1), + NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var h)) + { + ok = false; + break; + } + entry.MethodHashes[fields[i].Substring(0, eq)] = h; + } + if (ok) + s_Entries[fields[0]] = entry; + } + } + + private static string KeyFor(string filePath, string[] preprocessorSymbols) + { + string path; + try { path = Path.GetFullPath(filePath); } + catch { path = filePath ?? ""; } + var defines = preprocessorSymbols == null ? "" : string.Join(";", preprocessorSymbols); + return path + '\u001f' + defines; + } + + /// + /// Hash the file: one hash per [CodeReload] method name (overload hashes fold together — + /// method ids are name-keyed everywhere else too), and a context hash over every token + /// outside an executable body. Null when no class carries a [CodeReload] method. + /// + private static Entry ComputeEntry(string sourceCode, string[] preprocessorSymbols) + { + SyntaxNode root; + try + { + root = CSharpSyntaxTree.ParseText(sourceCode, + RoslynCompilationService.ProjectParseOptions(preprocessorSymbols)).GetRoot(); + } + catch + { + return null; + } + + var entry = new Entry(); + foreach (var node in root.DescendantNodes()) + { + if (node is not MethodDeclarationSyntax method || + !InPlaceReloadProcessor.HasCodeReloadAttribute(method)) + continue; + var name = method.Identifier.ValueText; + var declHash = HashTokens(method, null); + entry.MethodHashes[name] = entry.MethodHashes.TryGetValue(name, out var prior) + ? MixHash(prior, declHash) + : declHash; + } + if (entry.MethodHashes.Count == 0) + return null; + + entry.ContextHash = HashTokens(root, CollectBodySpans(root)); + return entry; + } + + /// + /// Spans of every executable body in the file: method/ctor/operator bodies, accessor + /// bodies, and expression bodies (incl. expression-bodied properties/indexers). Reloadable + /// bodies are covered per-method; the others bind to compiled code from an override either + /// way, so their edits can't change what an override does and must not poison the context. + /// + private static List CollectBodySpans(SyntaxNode root) + { + var spans = new List(); + foreach (var node in root.DescendantNodes()) + { + switch (node) + { + case BaseMethodDeclarationSyntax m: + if (m.Body != null) spans.Add(m.Body.Span); + if (m.ExpressionBody != null) spans.Add(m.ExpressionBody.Span); + break; + case AccessorDeclarationSyntax a: + if (a.Body != null) spans.Add(a.Body.Span); + if (a.ExpressionBody != null) spans.Add(a.ExpressionBody.Span); + break; + case PropertyDeclarationSyntax p when p.ExpressionBody != null: + spans.Add(p.ExpressionBody.Span); + break; + case IndexerDeclarationSyntax ix when ix.ExpressionBody != null: + spans.Add(ix.ExpressionBody.Span); + break; + } + } + spans.Sort((a, b) => a.Start.CompareTo(b.Start)); + return spans; + } + + // FNV-1a 64 over the token stream. Trivia never enters the hash, so formatting and + // comments can't mark a method changed. A separator byte between tokens keeps ("ab","c") + // and ("a","bc") distinct even though C# tokenization already makes that unlikely. + private const ulong FnvOffset = 14695981039346656037UL; + private const ulong FnvPrime = 1099511628211UL; + + private static ulong HashTokens(SyntaxNode node, List excludedSpans) + { + ulong h = FnvOffset; + int next = 0; // excludedSpans is sorted; tokens come in document order + foreach (var token in node.DescendantTokens()) + { + if (excludedSpans != null) + { + while (next < excludedSpans.Count && excludedSpans[next].End <= token.Span.Start) + next++; + if (next < excludedSpans.Count && excludedSpans[next].Contains(token.Span)) + continue; + } + + h = (h ^ 0xFF) * FnvPrime; + var text = token.Text; + for (int i = 0; i < text.Length; i++) + { + h = (h ^ (byte)text[i]) * FnvPrime; + h = (h ^ (byte)(text[i] >> 8)) * FnvPrime; + } + } + return h; + } + + private static ulong MixHash(ulong a, ulong b) + { + for (int i = 0; i < 8; i++) + a = (a ^ (byte)(b >> (i * 8))) * FnvPrime; + return a; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadBaseline.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadBaseline.cs.meta new file mode 100644 index 0000000..6f4df9c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadBaseline.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9844e60b4d0144ffa632f3433a159737 diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadRegistry.cs b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadRegistry.cs new file mode 100644 index 0000000..176dd93 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadRegistry.cs @@ -0,0 +1,673 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.ExceptionServices; +using Unity.Pipeline.Threading; +using UnityEngine; + +namespace Unity.Pipeline.CodeReload +{ + /// + /// Central registry for managing code reload method and component overrides. + /// Handles runtime method resolution, override registration, and fallback logic. + /// Thread-safe for runtime code reload operations. + /// + /// + /// Deliberately NOT [NoAutoStaticsCleanup]: RegisterReloadableMethod appends without deduplicating, + /// and RuntimePipelineDriver.Awake re-registers on every Play Mode entry, so opting out would + /// accumulate duplicates across FEPM sessions. Let AutoStaticsCleanup reset these statics instead. + /// + public static class CodeReloadRegistry + { + /// + /// Dispatcher used to marshal main-thread-required code-reload overrides to the main thread. + /// Injected by RuntimePipelineDriver (= 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 code reload source files from (Assets folder + /// + loaded package locations). Baked into RuntimePipelineBuildInfo 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; } + + // Facts established at domain-reload / Play Mode entry that only matter once the user + // actually reloads (target count, receiver state, connected players). Collected here and + // printed as ONE console line on the first override registration ATTEMPT of the session — + // attempt, not success, so a reload against an empty registry still gets the summary next + // to its skip reasons. Statics reset with the domain, so each session collects afresh. + private static readonly List s_FirstReloadNotes = new(); + private static bool s_FirstReloadNotesFlushed; + + /// + /// Record a startup fact for the "first reload this session" summary line. Once that line + /// has been printed, a late note (e.g. a player connecting mid-session) is logged directly. + /// + /// Short clause, no trailing period, e.g. "3 reloadable method(s) registered". + public static void NoteForFirstReload(string note) + { + if (string.IsNullOrEmpty(note)) return; + lock (s_FirstReloadNotes) + { + if (s_FirstReloadNotesFlushed) + { + Debug.Log($"CodeReload: {note}."); + return; + } + s_FirstReloadNotes.Add(note); + } + } + + private static void FlushFirstReloadNotes() + { + string line = null; + lock (s_FirstReloadNotes) + { + if (s_FirstReloadNotesFlushed) return; + s_FirstReloadNotesFlushed = true; + if (s_FirstReloadNotes.Count > 0) + line = "CodeReload: first reload this session — " + string.Join("; ", s_FirstReloadNotes) + "."; + s_FirstReloadNotes.Clear(); + } + if (line != null) + Debug.Log(line); + } + + // Thread-safe collections for runtime code reload switching + private static readonly ConcurrentDictionary m_MethodOverrides = new(); + private static readonly ConcurrentDictionary> m_ReloadableMethods = new(); + private static readonly ConcurrentDictionary m_LoadedCodeReloadTypes = new(); + + /// + /// Raised after a method override is registered or removed (once per method — a + /// multi-method reload fires it several times back to back, so debounce reactions). + /// Lets UI that reflects reloadable code (e.g. a debug panel overlay) rebuild when a + /// compiled method's body is swapped. + /// + /// INTERNAL for now — deliberately held back from the first public API surface; make it + /// public when the introspection surface ships (external consumers poll + /// GetStats().ActiveOverrideCount meanwhile). The raise sites stay: register, unregister + /// (revert), and ClearAllOverrides — the transitions a subscriber needs. + /// + internal static event Action OverridesChanged; + + private static void RaiseOverridesChanged() + { + try + { + OverridesChanged?.Invoke(); + } + catch (Exception ex) + { + Debug.LogError($"CodeReload: OverridesChanged handler threw: {ex}"); + } + } + + /// + /// Register a method as reloadable. Called during discovery phase. + /// + /// The method marked with . + /// The attribute instance (supplies the override id and main-thread requirement). + public static void RegisterReloadableMethod(MethodInfo method, CodeReloadAttribute attribute) + { + var methodId = GetMethodId(method, attribute.Id); + + if (!m_ReloadableMethods.ContainsKey(methodId)) + { + m_ReloadableMethods[methodId] = new List(); + } + + m_ReloadableMethods[methodId].Add(method); + } + + /// + /// Register a code reload method override from compiled code reload assembly. + /// Returns true if the override was registered, false if it was skipped. + /// + /// The override method marked with . + /// The attribute instance (identifies the target method). + /// The type the override method was compiled into. + /// True if the override was registered. + public static bool RegisterMethodOverride(MethodInfo overrideMethod, CodeReloadOverrideMethodAttribute attribute, Type sourceType) + { + return RegisterMethodOverride(overrideMethod, attribute, sourceType, out _); + } + + /// + /// Register a code reload method override from compiled code 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. + /// + /// The override method marked with . + /// The attribute instance (identifies the target method). + /// The type the override method was compiled into. + /// A user-facing reason when registration is skipped, or null on success. + /// True if the override was registered. + public static bool RegisterMethodOverride(MethodInfo overrideMethod, CodeReloadOverrideMethodAttribute attribute, Type sourceType, out string skipReason) + { + FlushFirstReloadNotes(); + skipReason = null; + var targetMethodId = attribute.TargetMethodId; + + // Validate that target method exists and is reloadable + if (!m_ReloadableMethods.ContainsKey(targetMethodId)) + { + Debug.LogWarning($"CodeReload: Target method '{targetMethodId}' not found or not marked [CodeReload]"); + skipReason = $"target '{targetMethodId}' is not registered as a reloadable [CodeReload] method. " + + "Ensure the component is in the scene and in play mode (RuntimePipelineDriver auto-discovers it)."; + return false; + } + + var originalMethods = m_ReloadableMethods[targetMethodId]; + if (!originalMethods.Any()) + { + Debug.LogWarning($"CodeReload: 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($"CodeReload: 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, ...)'."; + return false; + } + + var methodOverride = new MethodOverride + { + TargetMethodId = targetMethodId, + OverrideMethod = overrideMethod, + SourceType = sourceType, + RequireMainThread = GetMainThreadRequirement(originalMethod), + Description = attribute.Description + }; + + m_MethodOverrides[targetMethodId] = methodOverride; + RaiseOverridesChanged(); + return true; + } + + /// + /// Register an IL2CPP-safe override that dispatches through + /// (a IlInterpreter interpreter invoke) instead of a loaded . The target + /// must already be registered reloadable (same precondition as ). + /// + internal static bool RegisterInterpreterMethodOverride( + string targetMethodId, Func interpreterInvoke, out string skipReason) + { + FlushFirstReloadNotes(); + skipReason = null; + + if (!m_ReloadableMethods.TryGetValue(targetMethodId, out var originalMethods) || originalMethods.Count == 0) + { + skipReason = $"target '{targetMethodId}' is not registered as a reloadable [CodeReload] method. " + + "Ensure the component is in the scene and in play mode (RuntimePipelineDriver auto-discovers it)."; + return false; + } + + m_MethodOverrides[targetMethodId] = new MethodOverride + { + TargetMethodId = targetMethodId, + InterpreterInvoke = interpreterInvoke, + RequireMainThread = GetMainThreadRequirement(originalMethods.First()), + }; + + Debug.Log($"CodeReload: Registered interpreter override for '{targetMethodId}'"); + RaiseOverridesChanged(); + return true; + } + + /// + /// Remove one active override so the woven prologue falls through to the original compiled + /// body again. Used when a reloaded file's method matches the compiled baseline again (the + /// edit was reverted) — keeping the override would leave a correct but slower interpreter + /// dispatch in place. Returns false when no override was registered for the id. + /// + internal static bool UnregisterMethodOverride(string targetMethodId) + { + if (!m_MethodOverrides.TryRemove(targetMethodId, out _)) + return false; + Debug.Log($"CodeReload: Removed override for '{targetMethodId}' — original compiled body is active again"); + RaiseOverridesChanged(); + return true; + } + + /// + /// After a reload binds, invoke every [OnCodeReload] method on all live instances of the + /// types that own the given reloaded methods. Lets a component re-init state when its code is + /// swapped. Parameterless instance methods only; exceptions are logged, not propagated. + /// + internal static void InvokeReloadCallbacks(IEnumerable reloadedTargetMethodIds) + { + if (reloadedTargetMethodIds == null) return; + + var types = new HashSet(); + foreach (var id in reloadedTargetMethodIds) + { + if (m_ReloadableMethods.TryGetValue(id, out var methods) && methods.Count > 0 + && methods[0].DeclaringType != null) + { + types.Add(methods[0].DeclaringType); + } + } + + foreach (var type in types) + InvokeReloadCallbacksOnType(type); + } + + private static void InvokeReloadCallbacksOnType(Type type) + { + var callbacks = type + .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Where(m => m.GetParameters().Length == 0 && m.GetCustomAttribute() != null) + .ToList(); + if (callbacks.Count == 0) return; + + if (!typeof(UnityEngine.Object).IsAssignableFrom(type)) + { + Debug.LogWarning($"CodeReload: [OnCodeReload] on '{type.Name}' ignored — only " + + "UnityEngine.Object-derived types are supported (instances are found via FindObjectsByType)."); + return; + } + +#if UNITY_6000_5_OR_NEWER + var instances = UnityEngine.Object.FindObjectsByType(type); +#else + var instances = UnityEngine.Object.FindObjectsByType(type, FindObjectsSortMode.None); +#endif + foreach (var instance in instances) + { + foreach (var cb in callbacks) + { + try + { + cb.Invoke(instance, null); + } + catch (Exception ex) + { + Debug.LogError($"CodeReload: [OnCodeReload] {type.Name}.{cb.Name} threw: {ex.Message}"); + } + } + } + } + + /// True when at least one [CodeReload] target has been registered this session. + internal static bool HasReloadableMethods => m_ReloadableMethods.Count > 0; + + /// + /// Resolve the runtime that declares a reloadable target method, so an + /// interpreter host binding can be built for it. Returns false if not registered. + /// + internal static bool TryGetReloadableDeclaringType(string targetMethodId, out Type declaringType) + { + declaringType = null; + if (m_ReloadableMethods.TryGetValue(targetMethodId, out var methods) && methods.Count > 0) + { + declaringType = methods[0].DeclaringType; + return declaringType != null; + } + return false; + } + + /// + /// Attempt to invoke code reload override if available, otherwise invoke original method. + /// Returns true if code reload override was invoked, false if original method should be called. + /// + /// The instance's type. + /// Method identifier, "TypeName.MethodName". + /// The instance to invoke the override on. + /// The original call's arguments, boxed. + /// True if a code reload override was invoked. + public static bool TryInvokeCodeReload(string methodId, T instance, object[] parameters = null) + { + return TryInvokeCodeReload(methodId, (object)instance, parameters); + } + + /// + /// Cheap, allocation-free check for an active override. The woven prologue calls this + /// before building the boxed argument array, so a woven method with no active override + /// allocates nothing per call. + /// + /// Method identifier, "TypeName.MethodName". + /// True if an override is currently registered for . + public static bool HasOverride(string methodId) => m_MethodOverrides.ContainsKey(methodId); + + /// + /// Non-generic dispatch entry point. This is the method woven into [CodeReload] + /// methods at compile time (a non-generic signature keeps the injected IL simple). + /// Returns true if a code reload override was invoked, false if the original body should run. + /// + /// Method identifier, "TypeName.MethodName". + /// The instance to invoke the override on. + /// The original call's arguments, boxed. + /// True if a code reload override was invoked. + public static bool TryInvokeCodeReload(string methodId, object instance, object[] parameters = null) + => TryInvokeCodeReloadResult(methodId, instance, parameters, out _); + + /// + /// Called by the woven prologue when an override returned null for a method whose return + /// type is a value type — unboxing null would throw an opaque NullReferenceException at + /// the call site. Public only for woven code; not meant to be called by hand. + /// + /// Method identifier, "TypeName.MethodName", named in the thrown message. + public static void ThrowOverrideReturnedNull(string methodId) + { + throw new InvalidOperationException( + $"CodeReload: the override for '{methodId}' returned null, but the method returns " + + "a value type, so there is no value to unbox. Fix the override to return a value, " + + "or revert the reload."); + } + + /// + /// Value-returning dispatch: woven into value-returning [CodeReload] methods. Like + /// but also hands back the + /// override's (boxed) return value in . Returns true if an override + /// ran (and the woven prologue should return ), false if the + /// original body should run. + /// + /// Method identifier, "TypeName.MethodName". + /// The instance to invoke the override on. + /// The original call's arguments, boxed. + /// The override's (boxed) return value, if one ran. + /// True if a code reload override was invoked. + public static bool TryInvokeCodeReloadResult(string methodId, object instance, object[] parameters, out object result) + { + result = null; + if (!m_MethodOverrides.TryGetValue(methodId, out var methodOverride)) + { + return false; // No code reload override available + } + + CodeReloadActivity.CountOverrideCall(); // feeds the overlay's calls/s rate + + try + { + result = InvokeOverrideMarshalled(methodOverride, instance, parameters); + return true; + } + catch (TargetInvocationException ex) when (ex.InnerException != null) + { + // Exception from mono backend - rethrow for cases like imgui ExitGui + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; // unreachable + } + catch (IlInterpreter.ScriptRuntimeException ex) when (ex.InnerException != null) + { + // Exception from IlInterpreter backend - rethrow for cases like imgui ExitGui. + // The inner exception alone has no script location; log the interpreter's + // message (which carries "at line N (file)") first so the failure is traceable. + if (!(ex.InnerException is ExitGUIException)) + Debug.LogError($"CodeReload: override '{methodId}' threw {ex.Message}"); + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; // unreachable + } + catch (Exception ex) + { + // Invocation infrastructure failed (interpreter-detected error, reflection arg + // marshaling): log and fall back to the original body. + Debug.LogError($"CodeReload: Error invoking override for '{methodId}': " + + $"{ex.GetType().Name}: {ex.Message}"); + Debug.LogError($"CodeReload: Stack trace: {ex.StackTrace}"); + result = null; + return false; // Fall back to original method + } + } + + /// + /// Run the override, marshalling to the main thread when required, and return its (boxed) + /// result. The common case is already on the main thread: invoke directly with no per-call + /// closure allocation; only the rare cross-thread marshal pays for a closure. + /// + private static object InvokeOverrideMarshalled(MethodOverride methodOverride, object instance, object[] parameters) + { + if (methodOverride.RequireMainThread && Dispatcher != null && !Dispatcher.IsMainThread()) + { + object marshalled = null; + var mo = methodOverride; + Dispatcher.Invoke(() => marshalled = InvokeOverride(mo, instance, parameters)); + return marshalled; + } + return InvokeOverride(methodOverride, instance, parameters); + } + + /// + /// Invoke the override: through the IlInterpreter interpreter (IL2CPP-safe; the delegate marshals + /// args itself, so no array is built here) or via reflection on the loaded MethodInfo (which + /// needs an instance-prepended argument array). Returns the (boxed) result, null for void. + /// + private static object InvokeOverride(MethodOverride methodOverride, object instance, object[] parameters) + { + if (methodOverride.InterpreterInvoke != null) + return methodOverride.InterpreterInvoke(instance, parameters); + + var invokeParams = new object[(parameters?.Length ?? 0) + 1]; + invokeParams[0] = instance; + if (parameters != null && parameters.Length > 0) + Array.Copy(parameters, 0, invokeParams, 1, parameters.Length); + return methodOverride.OverrideMethod.Invoke(null, invokeParams); + } + + /// + /// Register all methods in a type that have the [CodeReload] attribute. + /// Uses reflection to discover and register them as reload targets. + /// + /// The type to scan for [CodeReload] methods. + public static void RegisterReloadableType(System.Type type) + { + if (type == null) + { + Debug.LogWarning("CodeReload: 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 codeReloadAttr = method.GetCustomAttribute(); + if (codeReloadAttr != null) + { + RegisterReloadableMethod(method, codeReloadAttr); + 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 codeReloadAttr = method.GetCustomAttribute(); + if (codeReloadAttr != null) + { + RegisterReloadableMethod(method, codeReloadAttr); + registeredCount++; + } + } + + if (registeredCount > 0) + { + Debug.Log($"CodeReload: Registered {registeredCount} reloadable methods from type {type.Name}"); + } + else + { + Debug.Log($"CodeReload: No methods with [CodeReload] attribute found in type {type.Name}"); + } + } + + /// + /// Register a type from loaded code reload assembly for discovery scanning. + /// + /// The loaded code-reload assembly's type. + /// Identifier of the compiled code-reload assembly the type came from. + public static void RegisterCodeReloadType(Type type, string assemblyId) + { + var typeKey = $"{assemblyId}:{type.FullName}"; + m_LoadedCodeReloadTypes[typeKey] = type; + } + + /// + /// Clear all code reload overrides. Used by cleanup_codereload command. + /// + public static void ClearAllOverrides() + { + var overrideCount = m_MethodOverrides.Count; + var typeCount = m_LoadedCodeReloadTypes.Count; + + m_MethodOverrides.Clear(); + m_LoadedCodeReloadTypes.Clear(); + if (overrideCount > 0) + RaiseOverridesChanged(); + } + + /// + /// 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_LoadedCodeReloadTypes.Count; + var reloadableCount = m_ReloadableMethods.Sum(kvp => kvp.Value.Count); + + m_MethodOverrides.Clear(); + m_LoadedCodeReloadTypes.Clear(); + m_ReloadableMethods.Clear(); + } + + /// + /// Get statistics about current code reload state. + /// + /// Current reloadable/override/loaded-type counts. + public static CodeReloadStats GetStats() + { + return new CodeReloadStats + { + ReloadableMethodCount = m_ReloadableMethods.Sum(kvp => kvp.Value.Count), + ActiveOverrideCount = m_MethodOverrides.Count, + LoadedTypeCount = m_LoadedCodeReloadTypes.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 code reload method signature is compatible with original method. + /// Code 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(); + + // Code 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) + { + // Honour the [CodeReload] attribute's main thread requirement + var codeReloadAttr = originalMethod.GetCustomAttribute(); + if (codeReloadAttr != null) + { + return codeReloadAttr.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; } + + /// + /// IL2CPP-safe dispatch: when set, the override runs through this delegate (a IlInterpreter + /// interpreter invoke) instead of reflection. Receives the + /// target instance and the original call arguments; returns the method's (boxed) result, + /// or null for a void method. + /// + public Func InterpreterInvoke { get; set; } + + public Type SourceType { get; set; } + public bool RequireMainThread { get; set; } + public string Description { get; set; } + } + } + + /// + /// Statistics about current code reload registry state. + /// + public class CodeReloadStats + { + /// Number of methods registered as reloadable. + public int ReloadableMethodCount { get; set; } + /// Number of active method overrides. + public int ActiveOverrideCount { get; set; } + /// Number of code-reload assembly types currently loaded. + public int LoadedTypeCount { get; set; } + /// Method ids registered as reloadable. + public List ReloadableMethodIds { get; set; } = new(); + /// Method ids with an active override. + public List ActiveOverrideIds { get; set; } = new(); + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadRegistry.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadRegistry.cs.meta new file mode 100644 index 0000000..bcabbee --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/CodeReloadRegistry.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/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/InPlaceReloadProcessor.cs b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/InPlaceReloadProcessor.cs new file mode 100644 index 0000000..275ad5e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/InPlaceReloadProcessor.cs @@ -0,0 +1,703 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using UnityPipeline.Microsoft.CodeAnalysis; +using UnityPipeline.Microsoft.CodeAnalysis.CSharp; +using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax; +using Unity.Pipeline.Compilation; +using UnityEngine; + +namespace Unity.Pipeline.CodeReload +{ + /// + /// Main orchestrator for in-place code reload processing. + /// Handles the complete pipeline: source parsing -> transformation -> compilation. + /// + static class InPlaceReloadProcessor + { + /// + /// Process a source file for in-place code reload. + /// Extracts [CodeReload] methods, transforms to static overrides, and compiles. + /// DEADLOCK FIX: Detects main thread and uses synchronous processing when needed. + /// + /// Path to source file containing [CodeReload] 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, bool useInterpreter = false) + { + // Runs synchronously on the calling (main) thread; returns a completed Task. + return Task.FromResult(ProcessSourceFileOnMainThread(sourceFilePath, assemblyDir, pdb, useInterpreter)); + } + + /// + /// Process source file on main thread synchronously to avoid deadlocks. + /// + /// Path to source file containing [CodeReload] methods. + /// Optional directory to save compiled assembly. + /// Emit debug symbols mapped to the original source so breakpoints bind. + /// Route the override through the interpreter backend instead of Assembly.Load. + /// Compilation result with success/failure and diagnostic information. + public static InPlaceReloadResult ProcessSourceFileOnMainThread(string sourceFilePath, string assemblyDir = null, bool pdb = false, bool useInterpreter = false) + { + var result = new InPlaceReloadResult + { + SourceFilePath = sourceFilePath, + Success = false + }; + + try + { + Debug.Log($"CodeReload: Processing source file synchronously on main thread: {sourceFilePath}"); + + // The interpreter backend needs #line directives when source-line errors are + // enabled: its runtime errors resolve locations from the embedded PDB, which are + // only meaningful mapped to the user's file. + var lineInfo = useInterpreter && CodeReloadCompiler.EmitSourceLineInfo; + var prep = PrepareOverride(sourceFilePath, pdb || lineInfo, enforcePublicAccess: !useInterpreter); + if (!prep.Success) + { + result.ErrorMessage = prep.Error; + result.CompilationDiagnostics = prep.Diagnostics; + return result; + } + + result.OriginalTypeName = prep.TypeName; + result.ExtractedMethods = prep.MethodNames; + result.UpToDateMethods = prep.UpToDateMethods; + result.TransformedCode = prep.TransformedCode; + + // A method that matches the baseline again (the edit was undone) may still carry an + // override from an earlier reload — drop it so the compiled body runs at full speed. + foreach (var name in prep.UpToDateMethods) + { + var id = $"{prep.TypeName}.{name}"; + if (CodeReloadRegistry.UnregisterMethodOverride(id)) + result.RevertedMethods.Add(id); + } + + if (prep.MethodNames.Count == 0) + { + result.Success = true; + result.CompilationDiagnostics = prep.Diagnostics; + Debug.Log($"CodeReload: {sourceFilePath} is up to date — all " + + $"{prep.UpToDateMethods.Count} [CodeReload] method(s) match the compiled baseline" + + (result.RevertedMethods.Count > 0 + ? $"; removed {result.RevertedMethods.Count} stale override(s)" : "") + "."); + return result; + } + + var compilationResult = CompileTransformedCode(prep, assemblyDir, pdb, sourceFilePath, useInterpreter); + + result.Success = compilationResult.IsSuccess; + result.AssemblyName = compilationResult.AssemblyName; + result.RegisteredMethods = compilationResult.RegisteredMethods; + result.CompilationDiagnostics = prep.Diagnostics.Concat(compilationResult.Diagnostics).ToList(); + + if (result.Success) + { + Debug.Log($"CodeReload: In-place reload successful for {sourceFilePath} - {result.RegisteredMethods.Count} methods registered" + + (result.UpToDateMethods.Count > 0 ? $" ({result.UpToDateMethods.Count} up to date, left compiled)" : "")); + } + else + { + result.ErrorMessage = compilationResult.ErrorDetails ?? "Compilation failed"; + Debug.LogError($"CodeReload: In-place reload compilation failed: {result.ErrorMessage}"); + } + + return result; + } + catch (Exception ex) + { + Debug.LogError($"CodeReload: Error processing source file {sourceFilePath}: {ex.Message}\n{ex.StackTrace}"); + + result.ErrorMessage = $"Processing error: {ex.Message}"; + return result; + } + } + + /// + /// What hands back: the extracted type and surviving methods, + /// the transformed source (null when everything was up to date), and the diagnostics + /// gathered on the way. false means a hard failure with + /// set. + /// + private sealed class OverridePreparation + { + public bool Success; + public string Error; + public string TypeName; + public List MethodNames; + public List UpToDateMethods = new List(); + public string TransformedCode; + public List Diagnostics = new List(); + } + + /// + /// Shared prefix for in-place reload and push-to-player: read the file, extract [CodeReload] + /// methods, drop the ones still matching the captured (they + /// run correct AND faster compiled — + /// reports them, and keeps named ones in anyway), reject + /// non-public member access when the compiled backend will run the override + /// (), and transform bodies into a static override + /// class. When every method is up to date, succeeds with an empty + /// and a null + /// — nothing to compile. + /// + private static OverridePreparation PrepareOverride( + string sourceFilePath, bool pdb, bool enforcePublicAccess, + string[] preprocessorSymbols = null, ICollection forceInclude = null, + bool useBaseline = true) + { + var prep = new OverridePreparation(); + + var sourceCode = ReadSourceFile(sourceFilePath); + if (string.IsNullOrEmpty(sourceCode)) + { + prep.Error = $"Could not read source file: {sourceFilePath}"; + return prep; + } + + var extraction = ExtractCodeReloadableMethods(sourceCode, preprocessorSymbols); + // Each skip entry carries its own reason (return-type gate, nested type, …). + foreach (var skipped in extraction.SkippedMethods) + prep.Diagnostics.Add($"[CodeReload] {skipped} was skipped."); + + if (!extraction.HasMethods) + { + prep.Error = extraction.SkippedMethods.Count > 0 + ? $"No reloadable [CodeReload] methods in {sourceFilePath}. Skipped: " + + $"{string.Join(", ", extraction.SkippedMethods)}." + : $"No [CodeReload] methods found in {sourceFilePath}"; + return prep; + } + + // Skip methods whose declarations still match the captured baseline: their compiled + // bodies are already what the source says, and the compiled body is strictly faster + // than any override (the interpreter backend pays per call). No baseline captured + // (returns null) = no filtering, the pre-baseline behavior. + var unchanged = useBaseline + ? CodeReloadBaseline.GetUnchangedMethods(sourceFilePath, sourceCode, preprocessorSymbols) + : null; + if (unchanged != null && unchanged.Count > 0) + { + foreach (var name in extraction.Methods.Keys.ToList()) + { + if (!unchanged.Contains(name)) continue; + if (forceInclude != null && forceInclude.Contains(name)) continue; + extraction.Methods.Remove(name); + extraction.MethodSignatures.Remove(name); + prep.UpToDateMethods.Add(name); + } + + if (extraction.Methods.Count == 0) + { + prep.TypeName = extraction.TypeName; + prep.MethodNames = new List(); + prep.Success = true; + return prep; + } + } + + // Build one compilation + semantic model for the transform, so Roslyn's metadata bind + // is paid once per reload (shared with the string TransformMethodBodies overload). + var model = SourceCodeTransformer.BuildSemanticModel( + sourceCode, extraction.TypeName, out var classDecl, preprocessorSymbols); + if (classDecl == null) + { + prep.Error = $"Could not find class '{extraction.TypeName}' in source."; + return prep; + } + + // Only the interpreter backend compiles with relaxed accessibility (see + // RoslynCompilationService.AllowNonPublicMemberAccess) and reaches non-public members at + // runtime, via reflection. The Assembly.Load backend keeps standard access checks — a + // non-public access would only surface later as a raw CS0122 — so reject it up front + // with an actionable message. + if (enforcePublicAccess) + { + var validation = AccessibilityValidator.ValidatePublicAccess( + model, classDecl, extraction.Methods, extraction.TypeName); + if (!validation.IsValid) + { + prep.Error = validation.GetFormattedErrorMessage(); + return prep; + } + } + + // enforcePublicAccess doubles as the backend discriminator (true == Assembly.Load, see + // the callers): only the interpreter needs interpolation lowered to string.Format — + // the compiled backend keeps interpolations native. + prep.TransformedCode = SourceCodeTransformer.TransformMethodBodies( + model, classDecl, extraction.Methods, extraction.TypeName, extraction.MethodSignatures, + emitLineDirectives: pdb, originalFilePath: sourceFilePath, + rewriteInterpolations: !enforcePublicAccess); + + prep.TypeName = extraction.TypeName; + prep.MethodNames = extraction.Methods.Keys.ToList(); + prep.Success = true; + return prep; + } + + /// + /// Compile a source file's [CodeReload] override(s) to raw assembly IL bytes without + /// registering them locally — for pushing to a player that runs them through the + /// interpreter. Editor-side only (needs Roslyn). false + /// bypasses the filter — the caller can't vouch that the + /// target player was built from the baselined sources. + /// + internal static PushCompileResult CompileOverrideForPush(string sourceFilePath, string[] preprocessorSymbols = null, + ICollection forceInclude = null, bool useBaseline = true) + { + var r = new PushCompileResult(); + try + { + // When source-line errors are enabled, #line directives map each body to the + // original file so the embedded PDB's sequence points — used by the player-side + // interpreter for runtime error messages — carry the user's real line numbers. + // enforcePublicAccess: false — pushed overrides run through the on-device + // interpreter, which reaches non-public members by reflection. + var prep = PrepareOverride(sourceFilePath, pdb: CodeReloadCompiler.EmitSourceLineInfo, + enforcePublicAccess: false, preprocessorSymbols, forceInclude, useBaseline); + if (!prep.Success) + { + r.Error = prep.Error; + r.Diagnostics = prep.Diagnostics; + return r; + } + + r.UpToDateMethods = prep.UpToDateMethods; + if (prep.MethodNames.Count == 0) + { + // Everything matches the baseline — a push would only replace compiled bodies + // with slower interpreted ones on the device. + r.Success = true; + r.TypeName = prep.TypeName; + r.Diagnostics = prep.Diagnostics; + return r; + } + + var tempName = $"PushReload_{prep.TypeName}_{DateTime.Now:yyyyMMdd_HHmmss}"; + if (!CodeReloadCompiler.TryCompileOverrideBytes(prep.TransformedCode, tempName, out var il, out var compileDiagnostics, documentPath: sourceFilePath, preprocessorSymbols: preprocessorSymbols)) + { + r.Error = "Compilation failed"; + r.Diagnostics = prep.Diagnostics.Concat(compileDiagnostics).ToList(); + return r; + } + + r.Success = true; + r.TypeName = prep.TypeName; + r.MethodNames = prep.MethodNames; + r.IlBytes = il; + r.Diagnostics = prep.Diagnostics.Concat(compileDiagnostics).ToList(); + return r; + } + catch (Exception ex) + { + r.Error = $"Push compile error: {ex.Message}"; + return r; + } + } + + /// + /// Check if a source file contains [CodeReload] methods. + /// Simple synchronous check to avoid async deadlocks in tests. + /// + /// Path to source file to check + /// True if file contains [CodeReload] methods + public static Task ContainsCodeReloadableMethodsAsync(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 the [CodeReload] attribute. Prefix match (no closing bracket) so + // combined attribute lists like [CodeReload, DebugPanel("x")] still pass. + var result = sourceCode.Contains("[CodeReload"); + return Task.FromResult(result); + } + catch (Exception ex) + { + Debug.LogError($"CodeReload: Error checking for [CodeReload] 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($"CodeReload: Source file not found: {filePath}"); + return null; + } + + return File.ReadAllText(filePath); + } + catch (Exception ex) + { + Debug.LogError($"CodeReload: Error reading source file {filePath}: {ex.Message}"); + return null; + } + } + + /// + /// Extract [CodeReload] methods from source code. + /// + private static CodeReloadableExtractionResult ExtractCodeReloadableMethods( + string sourceCode, string[] preprocessorSymbols = null) + { + var result = new CodeReloadableExtractionResult(); + + try + { + var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode, + RoslynCompilationService.ProjectParseOptions(preprocessorSymbols)); + var root = syntaxTree.GetRoot(); + + // Find the class containing [CodeReload] methods. + var classDeclaration = root.DescendantNodes() + .OfType() + .FirstOrDefault(c => c.DescendantNodes().OfType().Any(HasCodeReloadAttribute)); + + if (classDeclaration == null) + { + return result; + } + + result.TypeName = classDeclaration.Identifier.ValueText; + + // The transform lifts bodies into ONE override class for the top-level type chosen + // above. Tagged methods anywhere else in the file — in a NESTED type (which the + // weaver still weaves: Cecil's GetTypes() is recursive) or in a sibling top-level + // type — would otherwise be dropped without a word, leaving a woven prologue that + // never finds an override. Surface each one loudly; when nothing else is + // reloadable, these become the reload's error. + foreach (var method in root.DescendantNodes() + .OfType().Where(HasCodeReloadAttribute)) + { + var owner = method.Ancestors().OfType().FirstOrDefault(); + if (owner == null || owner == classDeclaration) + continue; + var ownerChain = string.Join(".", method.Ancestors() + .OfType().Reverse().Select(t => t.Identifier.ValueText)); + result.SkippedMethods.Add(owner.Parent is TypeDeclarationSyntax + ? $"'{method.Identifier.ValueText}' (declared in nested type '{ownerChain}' — " + + "code reload only supports methods declared directly in a top-level class)" + : $"'{method.Identifier.ValueText}' (declared in '{ownerChain}' — only the first " + + $"type with [CodeReload] methods, '{result.TypeName}', is reloaded per file; split the file)"); + } + + var codeReloadableMethods = classDeclaration.Members + .OfType() + .Where(HasCodeReloadAttribute); + + foreach (var method in codeReloadableMethods) + { + var methodName = method.Identifier.ValueText; + + // Tagged methods must pass the same gate the weaver applies (void or + // IEnumerator returns; instance or static). The weaver never weaves a + // dispatch prologue into the others, so their override would register and + // then never run — reload would report success while silently no-opping. + if (!IsMethodLevelSupported(method, out var skipReason)) + { + result.SkippedMethods.Add($"'{methodName}' ({skipReason})"); + continue; + } + var methodBody = ExtractMethodBody(method); + var signature = ExtractMethodSignature(method); + + if (methodBody != null) + { + result.Methods[methodName] = methodBody; + result.MethodSignatures[methodName] = signature; + } + } + + Debug.Log($"CodeReload: Extracted {result.Methods.Count} [CodeReload] methods from class {result.TypeName}"); + return result; + } + catch (Exception ex) + { + Debug.LogError($"CodeReload: Error extracting [CodeReload] methods: {ex.Message}"); + return result; + } + } + + /// + /// Check if method has [CodeReload] attribute. The unqualified name must match exactly: a + /// suffix match also catches [OnCodeReload] — the post-reload callback, not a reload target — + /// and pushing that name makes the player-side registry lookup fail for the whole file. + /// Internal so hashes exactly the methods extraction picks. + /// + internal static bool HasCodeReloadAttribute(MethodDeclarationSyntax method) + { + return method.AttributeLists + .SelectMany(al => al.Attributes) + .Any(a => UnqualifiedAttributeName(a) == "CodeReload"); + } + + /// + /// Attribute name as the source wrote it, minus any namespace qualifier and the optional + /// "Attribute" suffix — [Foo.BarAttribute] and [Bar] both yield "Bar". + /// + private static string UnqualifiedAttributeName(AttributeSyntax attr) + { + var name = attr.Name.ToString(); + var lastDot = name.LastIndexOf('.'); + if (lastDot >= 0) + name = name.Substring(lastDot + 1); + if (name.EndsWith("Attribute")) + name = name.Substring(0, name.Length - "Attribute".Length); + return name; + } + + /// + /// Gate for explicitly-tagged [CodeReload] methods, mirroring the skips the weaver applies + /// (CodeReloadInPlaceILPostProcessor.Process): unsupported return types get no dispatch + /// prologue, so extracting them would produce an override that never runs. Instance and + /// STATIC methods both weave (a static dispatches with a null instance). Coroutines + /// (System.Collections.IEnumerator) are supported: the weaver routes them through the + /// result dispatch, and the override's iterator — compiled or interpreted — is what + /// StartCoroutine drives. + /// + private static bool IsMethodLevelSupported(MethodDeclarationSyntax method, out string reason) + { + var returnType = method.ReturnType.ToString(); + if (returnType != "void" && + returnType is not ("IEnumerator" or "System.Collections.IEnumerator")) + { + reason = $"returns {returnType} — void methods and IEnumerator coroutines only"; + return false; + } + + reason = null; + return true; + } + + /// + /// Extract method body content (excluding braces). + /// null when the method has no block body at all (expression-bodied, abstract). + /// + private static string ExtractMethodBody(MethodDeclarationSyntax method) + { + if (method.Body == null) + return 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; + } + + /// + /// 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 CodeReloadCompileResult CompileTransformedCode( + OverridePreparation prep, + string assemblyDir, + bool emitPdb, + string documentPath, + bool useInterpreter) + { + try + { + // Generate a temporary file name for the transformed code + var tempFileName = $"InPlace_{prep.TypeName}_{DateTime.Now:yyyyMMdd_HHmmss}"; + + return CodeReloadCompiler.CompileSourceCodeOnMainThread( + prep.TransformedCode, + tempFileName, + assemblyDir, + emitPdb, + documentPath, + useInterpreter + ? new InterpreterOverrideSet + { + TypeName = prep.TypeName, + MethodNames = prep.MethodNames + } + : null); + } + catch (Exception ex) + { + Debug.LogError($"CodeReload: Error compiling transformed code for {prep.TypeName}: {ex.Message}"); + + return CodeReloadCompileResult.Failure( + "Compilation Error", + ex.Message, + 0, + new List { ex.ToString() }); + } + } + + /// + /// Result of extracting [CodeReload] methods from source code. + /// + private class CodeReloadableExtractionResult + { + /// + /// Name of the class containing [CodeReload] 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 [CodeReload] methods were found. + /// + public bool HasMethods => Methods.Count > 0; + + /// + /// Explicitly-tagged methods that were rejected with the reason ("'Total' (returns + /// int — void methods and IEnumerator coroutines only)"), so the caller can report + /// them instead of failing opaquely. + /// + public List SkippedMethods { get; } = new List(); + } + } + + /// + /// Result of in-place code reload processing. + /// + 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 [CodeReload] 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 code 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(); + + /// + /// [CodeReload] method names skipped because they still match the compiled baseline — + /// their compiled bodies keep running (no interpreter cost). + /// + internal List UpToDateMethods { get; set; } = new List(); + + /// + /// Method ids whose stale override was removed because the source matches the compiled + /// baseline again (the edit was reverted). + /// + internal List RevertedMethods { get; set; } = new List(); + + /// Every extracted method matched the baseline; nothing was compiled or registered. + internal bool AllUpToDate => Success && ExtractedMethods.Count == 0 && UpToDateMethods.Count > 0; + + /// + /// Error message if processing failed. + /// + public string ErrorMessage { get; set; } + + /// + /// Compilation diagnostics (warnings, errors). + /// + public List CompilationDiagnostics { get; set; } = new List(); + + /// + /// Execution time in milliseconds. + /// + public long ExecutionTimeMs { get; set; } + } + + /// + /// Result of compiling an override for pushing to a player (no local registration). + /// + internal class PushCompileResult + { + public bool Success { get; set; } + public string TypeName { get; set; } + public List MethodNames { get; set; } = new List(); + public byte[] IlBytes { get; set; } + public string Error { get; set; } + public List Diagnostics { get; set; } = new List(); + + /// [CodeReload] method names skipped because they still match the compiled baseline. + public List UpToDateMethods { get; set; } = new List(); + + /// Every extracted method matched the baseline; there is nothing to push. + public bool AllUpToDate => Success && MethodNames.Count == 0 && UpToDateMethods.Count > 0; + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/InPlaceReloadProcessor.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/InPlaceReloadProcessor.cs.meta new file mode 100644 index 0000000..a8da6aa --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/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/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/PipelineCodeReloadConnect.cs b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/PipelineCodeReloadConnect.cs new file mode 100644 index 0000000..b987dcf --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/PipelineCodeReloadConnect.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Unity.Pipeline.CodeReload +{ + /// + /// Shared contract for pushing a compiled in-place code-reload override from the editor to a + /// running player over Unity's PlayerConnection — the same editor↔player message bus the Profiler + /// and Console use (tunnels over USB for mobile, no IP or open port required). Lives in Runtime so + /// both the player (PlayerConnection.Register) and the editor push command + /// (EditorConnection.Send) reference the same GUIDs and wire format. + /// + /// The editor compiles the edited method(s) to override-assembly IL bytes (Roslyn, editor-only) + /// and ships {typeName, methodNames, il}; the player feeds them to + /// InterpreterCodeReloadExecutor.Register and runs them through the IlInterpreter interpreter — + /// no Assembly.Load, so it works under IL2CPP. + /// + /// This type touches no Unity API, so it compiles unconditionally on both editor and player. + /// + static class PipelineCodeReloadConnect + { + /// editor → player: apply a compiled override (typeName + method names + IL). + public static readonly Guid ApplyReloadMsg = new Guid("b6f1e0d24c7a4e13a9f2c8d5017e3b44"); + + /// player → editor: an ack (ok + human-readable summary) for a received push. + public static readonly Guid ResultMsg = new Guid("c2a7d4e916b84f0db3e5a1c6072f8d99"); + + /// editor → player: a save is being compiled for a push (payload = utf8 file name). + /// Compilation happens editor-side, so this notice is the only way an on-device overlay can + /// show "compiling…" progress. + public static readonly Guid ReloadPendingMsg = new Guid("d4c9f2a1385e46b7a0d1e8c4529b6f77"); + + /// editor → player: the compile for a pending reload failed (payload = utf8 summary). + /// Without it the on-device overlay would sit on "compiling…" forever after a syntax error. + public static readonly Guid ReloadFailedMsg = new Guid("e7b3a8d05c214f9e8b6d2f0a1c435588"); + + /// player → editor: session announcement for push signing — protocol version, the + /// key fingerprint the player's baked public key expects, and the random session nonce every + /// signed push must carry. Sent on connect and in answer to . + public static readonly Guid HandshakeMsg = new Guid("f19c5b8a2d7e40c6b8a3f0d612c94e55"); + + /// editor → player: re-announce your handshake (empty payload). Sent when the editor's + /// nonce table is empty for a target — typically right after an editor domain reload. + public static readonly Guid RequestHandshakeMsg = new Guid("a8d2c7f4691e4b0f9c5d3a80e17b6c22"); + + /// + /// Wire format: [typeNameLen:int32-LE][utf8 typeName][methodCount:int32-LE] + /// (per method: [len:int32-LE][utf8 name]) [il bytes…]. + /// + public static byte[] Encode(string typeName, IReadOnlyList methodNames, byte[] il) + { + var methods = methodNames ?? Array.Empty(); + + var nameBytes = Encoding.UTF8.GetBytes(typeName ?? ""); + var methodBytes = new byte[methods.Count][]; + int methodsSize = 4; // count + for (int i = 0; i < methods.Count; i++) + { + methodBytes[i] = Encoding.UTF8.GetBytes(methods[i] ?? ""); + methodsSize += 4 + methodBytes[i].Length; + } + + int ilLen = il?.Length ?? 0; + var buf = new byte[4 + nameBytes.Length + methodsSize + ilLen]; + int o = 0; + o = WriteInt32(buf, o, nameBytes.Length); + o = WriteBytes(buf, o, nameBytes); + o = WriteInt32(buf, o, methods.Count); + for (int i = 0; i < methods.Count; i++) + { + o = WriteInt32(buf, o, methodBytes[i].Length); + o = WriteBytes(buf, o, methodBytes[i]); + } + if (ilLen > 0) Buffer.BlockCopy(il, 0, buf, o, ilLen); + return buf; + } + + public static bool TryDecode(byte[] data, out string typeName, out string[] methodNames, out byte[] il) + { + typeName = null; methodNames = null; il = null; + if (data == null || data.Length < 4) return false; + try + { + int o = 0; + int nameLen = ReadInt32(data, ref o); + if (nameLen < 0 || o + nameLen > data.Length) return false; + typeName = Encoding.UTF8.GetString(data, o, nameLen); o += nameLen; + + if (o + 4 > data.Length) return false; + int methodCount = ReadInt32(data, ref o); + if (methodCount < 0) return false; + methodNames = new string[methodCount]; + for (int i = 0; i < methodCount; i++) + { + if (o + 4 > data.Length) return false; + int len = ReadInt32(data, ref o); + if (len < 0 || o + len > data.Length) return false; + methodNames[i] = Encoding.UTF8.GetString(data, o, len); o += len; + } + + int ilLen = data.Length - o; + il = new byte[ilLen]; + if (ilLen > 0) Buffer.BlockCopy(data, o, il, 0, ilLen); + return true; + } + catch + { + return false; + } + } + + /// Handshake wire format: [protocolVersion:int32-LE][fpLen:int32-LE][utf8 fingerprint][nonce…]. + public static byte[] EncodeHandshake(int protocolVersion, string keyFingerprint, byte[] sessionNonce) + { + var fp = Encoding.UTF8.GetBytes(keyFingerprint ?? ""); + var nonce = sessionNonce ?? Array.Empty(); + var buf = new byte[4 + 4 + fp.Length + nonce.Length]; + int o = 0; + o = WriteInt32(buf, o, protocolVersion); + o = WriteInt32(buf, o, fp.Length); + o = WriteBytes(buf, o, fp); + if (nonce.Length > 0) Buffer.BlockCopy(nonce, 0, buf, o, nonce.Length); + return buf; + } + + public static bool TryDecodeHandshake(byte[] data, out int protocolVersion, out string keyFingerprint, out byte[] sessionNonce) + { + protocolVersion = 0; keyFingerprint = null; sessionNonce = null; + if (data == null || data.Length < 8) return false; + try + { + int o = 0; + protocolVersion = ReadInt32(data, ref o); + int fpLen = ReadInt32(data, ref o); + if (fpLen < 0 || fpLen > 256 || o + fpLen > data.Length) return false; + keyFingerprint = Encoding.UTF8.GetString(data, o, fpLen); o += fpLen; + sessionNonce = new byte[data.Length - o]; + if (sessionNonce.Length > 0) Buffer.BlockCopy(data, o, sessionNonce, 0, sessionNonce.Length); + return true; + } + catch + { + return false; + } + } + + /// Payload for the plain-text notices (, ). + public static byte[] EncodeText(string text) => Encoding.UTF8.GetBytes(text ?? ""); + + public static string DecodeText(byte[] data) => + data == null || data.Length == 0 ? "" : Encoding.UTF8.GetString(data); + + /// Result wire format: [ok:1 byte][utf8 message…]. + public static byte[] EncodeResult(bool ok, string message) + { + var msgBytes = Encoding.UTF8.GetBytes(message ?? ""); + var buf = new byte[1 + msgBytes.Length]; + buf[0] = (byte)(ok ? 1 : 0); + Buffer.BlockCopy(msgBytes, 0, buf, 1, msgBytes.Length); + return buf; + } + + public static bool TryDecodeResult(byte[] data, out bool ok, out string message) + { + ok = false; message = null; + if (data == null || data.Length < 1) return false; + ok = data[0] != 0; + message = data.Length > 1 ? Encoding.UTF8.GetString(data, 1, data.Length - 1) : ""; + return true; + } + + private static int WriteInt32(byte[] buf, int off, int v) + { + buf[off] = (byte)v; + buf[off + 1] = (byte)(v >> 8); + buf[off + 2] = (byte)(v >> 16); + buf[off + 3] = (byte)(v >> 24); + return off + 4; + } + + private static int WriteBytes(byte[] buf, int off, byte[] src) + { + if (src.Length > 0) Buffer.BlockCopy(src, 0, buf, off, src.Length); + return off + src.Length; + } + + private static int ReadInt32(byte[] buf, ref int off) + { + int v = buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24); + off += 4; + return v; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/PipelineCodeReloadConnect.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/PipelineCodeReloadConnect.cs.meta new file mode 100644 index 0000000..17ffc14 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/PipelineCodeReloadConnect.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0ec832e9c0db7b84db213582f2ebc203 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/PushEnvelope.cs b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/PushEnvelope.cs new file mode 100644 index 0000000..e9a2603 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/PushEnvelope.cs @@ -0,0 +1,336 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Unity.Pipeline.CodeReload +{ + /// Outcome of opening a signed push envelope. Everything except is a + /// rejection; the receiver reports them generically on the wire and specifically in its own log. + enum PushVerifyStatus + { + Ok, + /// The receiver has no baked public key — every push is rejected (strict policy). + NoKey, + /// Structurally not a v1 envelope (bad magic, truncated, absurd lengths). + Malformed, + /// Envelope parses but the RSA signature does not verify under the baked key. + BadSignature, + /// Signature is valid but was made for a different session nonce (replay across + /// sessions/devices, or the editor's nonce table is stale). + NonceMismatch, + /// Signature and nonce are valid but the counter did not increase (replay within + /// the session, or out-of-order delivery). + CounterNotIncreasing, + /// The platform's RSA implementation threw — crypto is unusable here. Surfaced by + /// the boot self-test; should never be the steady state. + CryptoUnavailable, + } + + /// + /// Authenticated envelope for editor → player code-reload pushes, plus the project signing-key + /// primitives. Pure BCL (System.Security.Cryptography, no Unity API) so it compiles on both + /// sides and is unit-testable without a connection. + /// + /// Wire format (little-endian): + /// [magic "UPP1" 4B][session nonce 16B][counter int64][payloadLen int32][payload][RSA signature] + /// The signature is RSA-2048 / SHA-256 / PKCS#1 v1.5 over UTF8() followed + /// by every envelope byte before the signature, so a signature can never be repurposed by a + /// future feature signing other content with the same project key. + /// + /// Keys are serialized in a deliberately trivial private format (base64 RSAParameters fields) + /// rather than XML/PKCS#8: RSA.To/FromXmlString and ImportPkcs8PrivateKey both have per-platform + /// availability gaps across Unity scripting backends; base64ing the raw parameters has none. + /// + static class PushEnvelope + { + public const string DomainTag = "unity-pipeline/push-reload/v1"; + public const int ProtocolVersion = 1; + public const int NonceSize = 16; + public const int RsaKeySizeBits = 2048; + + private const string PrivatePrefix = "upp1-rsa:"; + private const string PublicPrefix = "upp1-rsa-pub:"; + private const int HeaderSize = 4 + NonceSize + 8 + 4; // magic + nonce + counter + payloadLen + private const int MaxPayload = 64 * 1024 * 1024; // sanity bound, far above any real push + + private static readonly byte[] Magic = { (byte)'U', (byte)'P', (byte)'P', (byte)'1' }; + + // ---- keys ---- + + /// Generate a fresh RSA-2048 private key (contains the public half). + public static string CreatePrivateKey() + { + using (var rsa = RSA.Create()) + { + rsa.KeySize = RsaKeySizeBits; + var p = rsa.ExportParameters(true); + return PrivatePrefix + string.Join(".", + B64(p.Modulus), B64(p.Exponent), B64(p.D), B64(p.P), B64(p.Q), + B64(p.DP), B64(p.DQ), B64(p.InverseQ)); + } + } + + /// Extract the shippable public half of a private key. + public static string DerivePublicKey(string privateKey) + { + var p = ParsePrivate(privateKey); + return PublicPrefix + B64(p.Modulus) + "." + B64(p.Exponent); + } + + public static bool IsPublicKey(string key) => + !string.IsNullOrEmpty(key) && key.StartsWith(PublicPrefix, StringComparison.Ordinal); + + public static bool IsPrivateKey(string key) => + !string.IsNullOrEmpty(key) && key.StartsWith(PrivatePrefix, StringComparison.Ordinal); + + /// Short stable id of a keypair (SHA-256 over modulus+exponent, first 8 bytes hex). + /// Accepts the public or the private serialization and gives the same answer. + public static string Fingerprint(string key) + { + RSAParameters p = IsPublicKey(key) ? ParsePublic(key) : ParsePrivate(key); + using (var sha = SHA256.Create()) + { + var data = new byte[p.Modulus.Length + p.Exponent.Length]; + Buffer.BlockCopy(p.Modulus, 0, data, 0, p.Modulus.Length); + Buffer.BlockCopy(p.Exponent, 0, data, p.Modulus.Length, p.Exponent.Length); + var hash = sha.ComputeHash(data); + var sb = new StringBuilder(16); + for (int i = 0; i < 8; i++) sb.Append(hash[i].ToString("x2")); + return sb.ToString(); + } + } + + /// Cryptographically random session nonce. + public static byte[] CreateNonce() + { + var nonce = new byte[NonceSize]; + using (var rng = RandomNumberGenerator.Create()) + rng.GetBytes(nonce); + return nonce; + } + + // ---- signing (editor side) ---- + + public static byte[] Sign(string privateKey, byte[] sessionNonce, long counter, byte[] payload) => + Sign(privateKey, sessionNonce, counter, payload, DomainTag); + + /// Domain-tag overload: exists so tests can prove a signature made under a different + /// tag is rejected. Production code always signs with . + public static byte[] Sign(string privateKey, byte[] sessionNonce, long counter, byte[] payload, string domainTag) + { + if (sessionNonce == null || sessionNonce.Length != NonceSize) + throw new ArgumentException($"session nonce must be {NonceSize} bytes", nameof(sessionNonce)); + payload = payload ?? Array.Empty(); + + var preamble = new byte[HeaderSize + payload.Length]; + Buffer.BlockCopy(Magic, 0, preamble, 0, 4); + Buffer.BlockCopy(sessionNonce, 0, preamble, 4, NonceSize); + WriteInt64(preamble, 4 + NonceSize, counter); + WriteInt32(preamble, 4 + NonceSize + 8, payload.Length); + Buffer.BlockCopy(payload, 0, preamble, HeaderSize, payload.Length); + + byte[] sig; + using (var rsa = RSA.Create()) + { + rsa.ImportParameters(ParsePrivate(privateKey)); + sig = rsa.SignData(Tagged(domainTag, preamble), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + + var envelope = new byte[preamble.Length + sig.Length]; + Buffer.BlockCopy(preamble, 0, envelope, 0, preamble.Length); + Buffer.BlockCopy(sig, 0, envelope, preamble.Length, sig.Length); + return envelope; + } + + // ---- opening (player side) ---- + + /// Cheap structural check: does this look like a v1 envelope (as opposed to a + /// legacy raw payload from a pre-signing editor)? + public static bool LooksLikeEnvelope(byte[] data) => + data != null && data.Length >= HeaderSize && + data[0] == Magic[0] && data[1] == Magic[1] && data[2] == Magic[2] && data[3] == Magic[3]; + + /// Parse without any crypto. Editor-play-mode strip only — never a security gate. + public static bool TryParse(byte[] envelope, out byte[] nonce, out long counter, out byte[] payload) + { + nonce = null; counter = 0; payload = null; + if (!LooksLikeEnvelope(envelope)) return false; + + nonce = new byte[NonceSize]; + Buffer.BlockCopy(envelope, 4, nonce, 0, NonceSize); + counter = ReadInt64(envelope, 4 + NonceSize); + int payloadLen = ReadInt32(envelope, 4 + NonceSize + 8); + if (payloadLen < 0 || payloadLen > MaxPayload || HeaderSize + payloadLen > envelope.Length) + return false; + + payload = new byte[payloadLen]; + Buffer.BlockCopy(envelope, HeaderSize, payload, 0, payloadLen); + return true; + } + + /// + /// Full verification: structure, signature under , session nonce + /// equality, strictly increasing counter. On , + /// and are set. Stateless — callers own + /// the last-counter bookkeeping (see ). + /// + public static PushVerifyStatus TryOpen(string publicKey, byte[] envelope, byte[] expectedNonce, + long lastCounter, out long counter, out byte[] payload) + { + counter = 0; payload = null; + if (string.IsNullOrEmpty(publicKey)) return PushVerifyStatus.NoKey; + if (!LooksLikeEnvelope(envelope)) return PushVerifyStatus.Malformed; + + int payloadLen = ReadInt32(envelope, 4 + NonceSize + 8); + if (payloadLen < 0 || payloadLen > MaxPayload) return PushVerifyStatus.Malformed; + int preambleLen = HeaderSize + payloadLen; + if (preambleLen >= envelope.Length) return PushVerifyStatus.Malformed; // signature must be non-empty + + // Signature first: nothing later is meaningful on unauthenticated bytes. + try + { + var preamble = new byte[preambleLen]; + Buffer.BlockCopy(envelope, 0, preamble, 0, preambleLen); + var sig = new byte[envelope.Length - preambleLen]; + Buffer.BlockCopy(envelope, preambleLen, sig, 0, sig.Length); + + using (var rsa = RSA.Create()) + { + rsa.ImportParameters(ParsePublic(publicKey)); + if (!rsa.VerifyData(Tagged(DomainTag, preamble), sig, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)) + return PushVerifyStatus.BadSignature; + } + } + catch (FormatException) { return PushVerifyStatus.Malformed; } // corrupt key string + catch (Exception) { return PushVerifyStatus.CryptoUnavailable; } + + if (expectedNonce == null || expectedNonce.Length != NonceSize) + return PushVerifyStatus.NonceMismatch; + // Constant-time not required (the nonce is not secret; it is a liveness binding), but + // the full compare is trivial anyway. + int diff = 0; + for (int i = 0; i < NonceSize; i++) diff |= envelope[4 + i] ^ expectedNonce[i]; + if (diff != 0) return PushVerifyStatus.NonceMismatch; + + long c = ReadInt64(envelope, 4 + NonceSize); + if (c <= lastCounter) return PushVerifyStatus.CounterNotIncreasing; + + counter = c; + payload = new byte[payloadLen]; + Buffer.BlockCopy(envelope, HeaderSize, payload, 0, payloadLen); + return PushVerifyStatus.Ok; + } + + /// + /// Boot-time probe that the platform can run the verify path at all (RSA import + VerifyData + /// on a garbage signature must return false, not throw). Catches an IL2CPP/platform crypto + /// gap on day one instead of as mysterious per-push rejects. + /// + public static bool VerifySelfTest(string publicKey, out string error) + { + error = null; + try + { + using (var rsa = RSA.Create()) + { + rsa.ImportParameters(ParsePublic(publicKey)); + var garbageSig = new byte[RsaKeySizeBits / 8]; + if (rsa.VerifyData(new byte[] { 1, 2, 3 }, garbageSig, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)) + { + error = "garbage signature verified — RSA implementation is broken here"; + return false; + } + } + return true; + } + catch (Exception ex) + { + error = ex.GetType().Name + ": " + ex.Message; + return false; + } + } + + // ---- internals ---- + + private static byte[] Tagged(string domainTag, byte[] preamble) + { + var tag = Encoding.UTF8.GetBytes(domainTag ?? ""); + var signed = new byte[tag.Length + preamble.Length]; + Buffer.BlockCopy(tag, 0, signed, 0, tag.Length); + Buffer.BlockCopy(preamble, 0, signed, tag.Length, preamble.Length); + return signed; + } + + private static RSAParameters ParsePrivate(string key) + { + if (!IsPrivateKey(key)) throw new FormatException("not a " + PrivatePrefix + " private key"); + var f = key.Substring(PrivatePrefix.Length).Split('.'); + if (f.Length != 8) throw new FormatException("private key must have 8 fields"); + return new RSAParameters + { + Modulus = D64(f[0]), Exponent = D64(f[1]), D = D64(f[2]), P = D64(f[3]), + Q = D64(f[4]), DP = D64(f[5]), DQ = D64(f[6]), InverseQ = D64(f[7]), + }; + } + + private static RSAParameters ParsePublic(string key) + { + if (!IsPublicKey(key)) throw new FormatException("not a " + PublicPrefix + " public key"); + var f = key.Substring(PublicPrefix.Length).Split('.'); + if (f.Length != 2) throw new FormatException("public key must have 2 fields"); + return new RSAParameters { Modulus = D64(f[0]), Exponent = D64(f[1]) }; + } + + private static string B64(byte[] b) => Convert.ToBase64String(b ?? Array.Empty()); + private static byte[] D64(string s) => Convert.FromBase64String(s); + + private static void WriteInt32(byte[] buf, int off, int v) + { + buf[off] = (byte)v; buf[off + 1] = (byte)(v >> 8); + buf[off + 2] = (byte)(v >> 16); buf[off + 3] = (byte)(v >> 24); + } + + private static void WriteInt64(byte[] buf, int off, long v) + { + for (int i = 0; i < 8; i++) buf[off + i] = (byte)(v >> (8 * i)); + } + + private static int ReadInt32(byte[] buf, int off) => + buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24); + + private static long ReadInt64(byte[] buf, int off) + { + long v = 0; + for (int i = 0; i < 8; i++) v |= (long)buf[off + i] << (8 * i); + return v; + } + } + + /// + /// The player-side gate: one per receiver session. Owns the session nonce and the last-accepted + /// counter, so is the single call the receiver makes per push. + /// + sealed class PushVerifier + { + private readonly string m_PublicKey; + private readonly byte[] m_SessionNonce; + private long m_LastCounter; + + public PushVerifier(string publicKey, byte[] sessionNonce) + { + m_PublicKey = publicKey; + m_SessionNonce = sessionNonce; + } + + public byte[] SessionNonce => m_SessionNonce; + + public PushVerifyStatus Verify(byte[] envelope, out byte[] payload) + { + var status = PushEnvelope.TryOpen(m_PublicKey, envelope, m_SessionNonce, m_LastCounter, + out var counter, out payload); + if (status == PushVerifyStatus.Ok) + m_LastCounter = counter; + return status; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/PushEnvelope.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/PushEnvelope.cs.meta new file mode 100644 index 0000000..d6b4c54 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/PushEnvelope.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3f7a1c92be504d8fa2617e0c4b9d5a21 diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/SourceCodeTransformer.cs b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/SourceCodeTransformer.cs new file mode 100644 index 0000000..3a8cbc3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/SourceCodeTransformer.cs @@ -0,0 +1,590 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +// The package bundles Roslyn renamed with a "UnityPipeline." prefix (Runtime/Plugins/CodeAnalysis); +// the dotnet test projects reference the same DLLs (IlInterpreter/BundledRoslyn.props). +using UnityPipeline.Microsoft.CodeAnalysis; +using UnityPipeline.Microsoft.CodeAnalysis.CSharp; +using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax; +using Unity.Pipeline.Compilation; +using UnityEngine; + +namespace Unity.Pipeline.CodeReload +{ + /// + /// Transforms [CodeReload] method bodies (edited in place on a MonoBehaviour) into static + /// code reload override methods that the registry can dispatch to. + /// + /// The transform is driven by a Roslyn . Because the body is moved into + /// a standalone static class (no this, no base type, not nested in the original type), every + /// reference that relied on the declaring type's scope is re-qualified so it still binds: + /// + /// instance members of the type hierarchy (e.g. transform on Component) -> instance.<member> + /// static members inherited or declared on the type hierarchy (e.g. FindObjectsByType on + /// Object) -> <ContainingType>.<member> + /// nested types (enum/class) of the type hierarchy (e.g. a private enum State) -> <ContainingType>.<NestedType> + /// + /// Locals, parameters, and types/members reachable via the file's using directives are left + /// untouched. The output is a static override class the registry can bind: + /// + /// [CodeReloadOverrideMethod("Type.Method")] + /// public static <ret> Method(Type instance, <params>) { ...rewritten body... } + /// + static class SourceCodeTransformer + { + /// + /// Transform the [CodeReload] 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. + /// Map of method name to edited body source. + /// Name of the declaring type. + /// Map of method name to its original signature. + /// The full original source file, needed to build the semantic model. + /// + /// Rewrite string interpolation to string.Format(...). Required by the interpreter + /// backend, which cannot run the compiler's DefaultInterpolatedStringHandler + /// lowering; pass false on the Assembly.Load backend so interpolations compile natively + /// (e.g. a FormattableString-targeted interpolation survives). + /// + /// Source for a static override class matching the shape the registry dispatches to. + internal static string TransformMethodBodies( + Dictionary methodBodies, + string originalTypeName, + Dictionary originalMethodSignatures, + string originalTypeDefinition = null, + bool emitLineDirectives = false, + string originalFilePath = null, + bool rewriteInterpolations = true) + { + if (string.IsNullOrEmpty(originalTypeDefinition)) + { + throw new InvalidOperationException( + "In-place transformation requires the full original source to build a semantic model."); + } + + var model = BuildSemanticModel(originalTypeDefinition, originalTypeName, out var classDecl); + if (classDecl == null) + throw new InvalidOperationException($"Could not find class '{originalTypeName}' in source."); + + return TransformMethodBodies(model, classDecl, methodBodies, originalTypeName, + originalMethodSignatures, emitLineDirectives, originalFilePath, rewriteInterpolations); + } + + /// + /// Parse and build the one compilation + semantic model the + /// transform (and its callers' validation) runs against, plus the declaration of + /// — null when the source has no such class. Imports ALL + /// metadata members, not the Public default: the transform's new-method check diffs the + /// source against the compiled type, and with public-only import every PRIVATE compiled + /// method would look new and get wrongly co-emitted. + /// (the project's, or a push target's) makes #if regions match the assembly being patched. + /// + internal static SemanticModel BuildSemanticModel( + string source, string typeName, out ClassDeclarationSyntax classDecl, + string[] preprocessorSymbols = null) + { + var tree = CSharpSyntaxTree.ParseText(source, + RoslynCompilationService.ProjectParseOptions(preprocessorSymbols)); + var compilation = CSharpCompilation.Create( + "CodeReloadInPlaceTransform", + new[] { tree }, + RoslynCompilationService.GetMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + .WithMetadataImportOptions(MetadataImportOptions.All)); + var model = compilation.GetSemanticModel(tree); + classDecl = tree.GetRoot().DescendantNodes() + .OfType() + .FirstOrDefault(c => c.Identifier.ValueText == typeName); + return model; + } + + /// + /// Core transform over an already-built semantic model + class declaration, so the in-place + /// reload path can share one compilation between validation and transformation instead of + /// building two. + /// + internal static string TransformMethodBodies( + SemanticModel model, + ClassDeclarationSyntax classDecl, + Dictionary methodBodies, + string originalTypeName, + Dictionary originalMethodSignatures, + bool emitLineDirectives = false, + string originalFilePath = null, + bool rewriteInterpolations = true) + { + try + { + var root = classDecl.SyntaxTree.GetRoot(); + var classSymbol = model.GetDeclaredSymbol(classDecl); + var compiledTwin = FindCompiledType(model.Compilation, classSymbol); + // A top-level type with no compiled twin is entirely NEW (the file was added since + // the build): every method is co-emittable, and the receiver parameter can't name + // the type — it doesn't exist in any reference — so it degrades to object (the + // dispatch passes null for statics anyway). Instance-member access still can't + // compile in that state; new-type reloads are for self-contained static methods. + var wholeTypeIsNew = compiledTwin == null && classSymbol?.ContainingType == null; + var newMethods = ComputeNewMethodNames(classDecl, compiledTwin, wholeTypeIsNew); + // A static type can't appear as a parameter (CS0721), so its receiver slot — always + // dispatched null anyway — degrades to object. Check the compiled twin too: the + // source may have dropped the static keyword while the running build still has it. + var typeIsStatic = classDecl.Modifiers.Any(SyntaxKind.StaticKeyword) + || (compiledTwin?.IsStatic ?? false); + var instanceTypeName = wholeTypeIsNew || typeIsStatic ? "object" : originalTypeName; + var rewriter = new ImplicitScopeQualifier(model, classSymbol, newMethods, + new HashSet(methodBodies.Keys)); + + var sb = new StringBuilder(); + var emittedUsings = new HashSet(); + foreach (var u in root.DescendantNodes().OfType()) + { + if (emittedUsings.Add(u.Name?.ToString() ?? u.ToString())) + sb.AppendLine(u.ToString()); + } + if (emittedUsings.Add("Unity.Pipeline.CodeReload")) + sb.AppendLine("using Unity.Pipeline.CodeReload;"); + + var namespaceName = GetNamespace(classDecl); + if (!string.IsNullOrEmpty(namespaceName) && emittedUsings.Add(namespaceName)) + sb.AppendLine($"using {namespaceName};"); + sb.AppendLine(); + + sb.AppendLine($"public static class {originalTypeName}CodeReloadOverrides"); + sb.AppendLine("{"); + + foreach (var method in classDecl.Members.OfType()) + { + var methodName = method.Identifier.ValueText; + var isOverride = methodBodies.ContainsKey(methodName) && method.Body != null; + // A method the compiled type doesn't declare is NEW — nothing compiled can + // dispatch to it, but a reloaded body can: co-emit it into the override class + // so it becomes an interpreted method the rewritten call sites reach directly. + var isNewMethod = !isOverride && newMethods.Contains(methodName) + && (method.Body != null || method.ExpressionBody != null); + if (!isOverride && !isNewMethod) + continue; + + // Qualify instance members first: the semantic model is bound to the original + // syntax tree, so this must run before any rewrite that produces new nodes. + // Interpolation-to-string.Format is purely syntactic — no semantic model — so + // it can safely run on the qualified (synthesized) tree. It runs only for the + // interpreter backend (see the rewriteInterpolations doc). + // New methods may be expression-bodied; emitted text is `=> expr;` then. + SyntaxNode rewrittenBody = method.Body != null + ? rewriter.Visit(method.Body) + : rewriter.Visit(method.ExpressionBody); + if (rewriteInterpolations) + rewrittenBody = new InterpolationToFormatRewriter().Visit(rewrittenBody); + var bodySuffix = method.Body != null ? "" : ";"; + var returnType = method.ReturnType.ToString(); + + // Overrides always take the receiver first (the dispatch passes null for + // statics); a new STATIC method has no receiver at all — call sites stay bare. + var parameters = new List(); + if (isOverride || !method.Modifiers.Any(SyntaxKind.StaticKeyword)) + parameters.Add($"{instanceTypeName} instance"); + parameters.AddRange(isOverride + ? method.ParameterList.Parameters.Select(p => $"{p.Type} {p.Identifier.ValueText}") + // New methods keep their parameter list verbatim (modifiers, defaults). + : method.ParameterList.Parameters.Select(p => p.ToString())); + + if (isOverride) + sb.AppendLine($" [CodeReloadOverrideMethod(\"{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 bodyNode = (SyntaxNode)method.Body ?? method.ExpressionBody; + var bodyLine = bodyNode.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + var escapedPath = originalFilePath.Replace("\\", "\\\\"); + sb.AppendLine($"#line {bodyLine} \"{escapedPath}\""); + sb.AppendLine(rewrittenBody.ToString() + bodySuffix); + sb.AppendLine("#line hidden"); + } + else + { + sb.AppendLine(" " + rewrittenBody.NormalizeWhitespace().ToString() + bodySuffix); + } + sb.AppendLine(); + } + + sb.AppendLine("}"); + + var result = sb.ToString(); + Debug.Log($"CodeReload: Transformed {methodBodies.Count} in-place method(s) for {originalTypeName}"); + return result; + } + catch (Exception ex) + { + Debug.LogError($"CodeReload: 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; + } + + /// + /// Names of methods the edited source declares but the COMPILED type doesn't — methods the + /// user added since the last compile. Their call sites can't bind to the host object, so + /// they are co-emitted into the override class and called directly. Generic methods are + /// excluded (outside the interpreter subset). When the whole type is new + /// () every method qualifies; when only the compiled twin + /// lookup failed (a nested type) the set is empty — every call site then binds to the + /// host, the pre-existing behavior. + /// + private static HashSet ComputeNewMethodNames( + ClassDeclarationSyntax classDecl, INamedTypeSymbol compiledType, bool wholeTypeIsNew) + { + var result = new HashSet(); + if (compiledType == null && !wholeTypeIsNew) + return result; + + foreach (var method in classDecl.Members.OfType()) + { + if (method.Body == null && method.ExpressionBody == null) + continue; + if (method.TypeParameterList != null) + continue; + var name = method.Identifier.ValueText; + // Declared-members check only: a name that exists compiled (any overload, or on a + // base type but redeclared here) stays host-bound. A source method hiding a base + // member counts as new — the co-emitted copy preserves the source's binding. + if (wholeTypeIsNew || compiledType.GetMembers(name).Length == 0) + result.Add(name); + } + return result; + } + + /// + /// The compiled (metadata) symbol for , resolved from the + /// compilation's references — the source declaration shadows it inside the compilation, so + /// the reference assemblies are probed directly. Null when no compiled twin exists. + /// + private static INamedTypeSymbol FindCompiledType( + UnityPipeline.Microsoft.CodeAnalysis.Compilation compilation, INamedTypeSymbol classSymbol) + { + if (classSymbol == null || classSymbol.ContainingType != null) + return null; + var metadataName = classSymbol.ContainingNamespace is { IsGlobalNamespace: false } ns + ? $"{ns.ToDisplayString()}.{classSymbol.MetadataName}" + : classSymbol.MetadataName; + foreach (var reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol asm + && asm.GetTypeByMetadataName(metadataName) is { } compiled) + return compiled; + } + return null; + } + + /// + /// Rewrites bare references that depended on the original type's scope so they still bind after + /// the body is moved into a standalone static class, using the semantic model: + /// instance members -> instance.x, static members of the type hierarchy -> + /// ContainingType.x, and nested types of the type hierarchy -> ContainingType.Nested. + /// Locals, parameters, and anything reachable via using are left untouched. + /// + private class ImplicitScopeQualifier : CSharpSyntaxRewriter + { + private readonly SemanticModel m_Model; + private readonly INamedTypeSymbol m_Type; + private readonly HashSet m_NewMethods; + private readonly HashSet m_ReloadedMethods; + + public ImplicitScopeQualifier(SemanticModel model, INamedTypeSymbol type, + HashSet newMethods = null, HashSet reloadedMethods = null) + { + m_Model = model; + m_Type = type; + m_NewMethods = newMethods ?? new HashSet(); + m_ReloadedMethods = reloadedMethods ?? new HashSet(); + } + + /// + /// Calls to NEW methods (declared in the edited source, absent from the compiled type) + /// can't bind to the host object — the callee only exists as a co-emitted static in the + /// override class. Rebuild the call as a direct static invocation, threading the + /// receiver (the implicit instance, or an explicit one like other.Helper()) + /// as the first argument to match the co-emitted signature. + /// + public override SyntaxNode VisitInvocationExpression(InvocationExpressionSyntax node) + { + // Resolve on the original node — the model is bound to the original tree. + var target = m_NewMethods.Count > 0 + ? m_Model.GetSymbolInfo(node.Expression).Symbol as IMethodSymbol + : null; + var visited = (InvocationExpressionSyntax)base.VisitInvocationExpression(node); + if (target == null || !m_NewMethods.Contains(target.Name) + || !SymbolEqualityComparer.Default.Equals(target.ContainingType, m_Type)) + return visited; + // Conditional calls (`x?.NewHelper()`) have no receiver expression to thread as an + // argument; leave them — the compile fails with a clear CS1061 instead of silently + // running the helper on the wrong instance. + if (visited.Expression is MemberBindingExpressionSyntax) + return visited; + + var arguments = visited.ArgumentList; + // A new method that is itself reloaded is emitted in the override shape — leading + // instance parameter even when static (the dispatch it gets after the next compile + // passes null there) — so its call sites must pass a receiver either way. + if (!target.IsStatic || m_ReloadedMethods.Contains(target.Name)) + { + // The visited expression is either `receiver.Helper` (this -> instance already + // applied by the base visit) or a bare/qualified name; a member access carries + // the receiver to thread through, anything else means the implicit `instance`. + // A STATIC call has no receiver — its qualifier is a type name — so the + // caller's own `instance` is what the override-shaped callee gets. + var receiver = !target.IsStatic && visited.Expression is MemberAccessExpressionSyntax ma + ? ma.Expression + : (ExpressionSyntax)SyntaxFactory.IdentifierName("instance"); + arguments = arguments.WithArguments( + arguments.Arguments.Insert(0, SyntaxFactory.Argument(receiver))); + } + return SyntaxFactory.InvocationExpression( + SyntaxFactory.IdentifierName(target.Name), arguments) + .WithTriviaFrom(visited); + } + + 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); + + // Symbol is resolved from the original node (the semantic model is bound to the original + // tree); the same node is what gets wrapped since an identifier has no children to rewrite. + return TryQualify(node, node) ?? base.VisitIdentifierName(node); + } + + public override SyntaxNode VisitGenericName(GenericNameSyntax node) + { + // e.g. GetComponent() or FindObjectsByType() — visit type arguments first + // (a type arg may itself be a nested type needing qualification), then wrap the result. + var visited = (GenericNameSyntax)base.VisitGenericName(node); + + if (ShouldSkip(node)) + return visited; + + return TryQualify(node, visited) ?? 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; + } + + /// + /// Returns the qualified replacement for if + /// binds to something that needs the original type's scope, otherwise null (leave as-is). + /// + private SyntaxNode TryQualify(SimpleNameSyntax original, SimpleNameSyntax toWrap) + { + var symbol = m_Model.GetSymbolInfo(original).Symbol; + if (symbol == null) + return null; + + // `var` (and a `using Alias = Outer.Nested;` alias) binds to a type spelled + // differently from the identifier. Qualifying the identifier would emit + // `Outer.var` / `Outer.Alias`, which does not exist. Roslyn re-infers `var` on + // the rewritten initializer, so leave the spelling alone. + if (symbol is ITypeSymbol && symbol.Name != original.Identifier.ValueText) + return null; + + // Nested type of the type hierarchy (e.g. a private `enum State`) -> ContainingType.Nested. + if (symbol is INamedTypeSymbol nested && nested.ContainingType != null + && InTypeHierarchy(nested.ContainingType)) + return QualifyWithType(toWrap, nested.ContainingType, original); + + if (!IsMemberSymbol(symbol)) + return null; + + if (!InTypeHierarchy(symbol.ContainingType)) + return null; + + // A method GROUP over a new method (delegate argument, event `+=`): the member + // doesn't exist on the compiled type, so `instance.M` can't bind. Wrap it as a + // lambda over the co-emitted static instead. + if (symbol is IMethodSymbol group && m_NewMethods.Contains(group.Name) + && !(original.Parent is InvocationExpressionSyntax inv && inv.Expression == original) + && MethodGroupLambda(group, original, receiver: null) is { } lambda) + return lambda.WithTriviaFrom(toWrap); + + return symbol.IsStatic + ? QualifyWithType(toWrap, symbol.ContainingType, original) + : QualifyWith(toWrap, SyntaxFactory.IdentifierName("instance")); + } + + /// + /// `x.Click` where Click is NEW and not invoked — the member-access form of a method + /// group over a new method. Same lambda rewrite as the bare-identifier form, but the + /// (visited) receiver expression is threaded as the co-emitted static's first argument. + /// + public override SyntaxNode VisitMemberAccessExpression(MemberAccessExpressionSyntax node) + { + if (m_NewMethods.Count > 0 + && !(node.Parent is InvocationExpressionSyntax inv && inv.Expression == node) + && m_Model.GetSymbolInfo(node).Symbol is IMethodSymbol group + && m_NewMethods.Contains(group.Name) + && SymbolEqualityComparer.Default.Equals(group.ContainingType, m_Type)) + { + var visited = (MemberAccessExpressionSyntax)base.VisitMemberAccessExpression(node); + if (MethodGroupLambda(group, node, visited.Expression) is { } lambda) + return lambda.WithTriviaFrom(node); + return visited; + } + return base.VisitMemberAccessExpression(node); + } + + /// + /// Builds ((a0, a1) => M(receiver, a0, a1)) for a method group over a new + /// method, shaped by the delegate type the group converts to (the semantic model's + /// ConvertedType). Null when the conversion target isn't a delegate — the caller + /// falls through, and the compile reports the site. One semantic difference from a + /// real method-group conversion: the receiver expression is evaluated per invocation, + /// not captured at conversion time. + /// + private ExpressionSyntax MethodGroupLambda( + IMethodSymbol group, ExpressionSyntax original, ExpressionSyntax receiver) + { + if (m_Model.GetTypeInfo(original).ConvertedType is not INamedTypeSymbol delegateType + || delegateType.DelegateInvokeMethod is not { } invoke) + return null; + + var argList = new List(); + // Matches the co-emitted signature: instance methods and reloaded (override-shaped) + // methods take the receiver first; new plain statics don't. + if (!group.IsStatic || m_ReloadedMethods.Contains(group.Name)) + argList.Add(SyntaxFactory.Argument(receiver ?? SyntaxFactory.IdentifierName("instance"))); + var parameters = new List(); + for (int i = 0; i < invoke.Parameters.Length; i++) + { + var name = "__mg" + i; + parameters.Add(SyntaxFactory.Parameter(SyntaxFactory.Identifier(name))); + argList.Add(SyntaxFactory.Argument(SyntaxFactory.IdentifierName(name))); + } + var call = SyntaxFactory.InvocationExpression( + SyntaxFactory.IdentifierName(group.Name), + SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(argList))); + return SyntaxFactory.ParenthesizedExpression( + SyntaxFactory.ParenthesizedLambdaExpression( + SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(parameters)), + call)); + } + + private static bool IsMemberSymbol(ISymbol symbol) + { + switch (symbol.Kind) + { + case SymbolKind.Field: + case SymbolKind.Property: + case SymbolKind.Method: + case SymbolKind.Event: + return true; + 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; + } + + /// Qualify with a fully-qualified type name (e.g. global::UnityEngine.Object.X). + private static SyntaxNode QualifyWithType(SimpleNameSyntax node, INamedTypeSymbol type, SimpleNameSyntax original) + { + var typeName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + // In a type position (local/foreach declaration, cast, array type, generic argument, + // typeof) the parent's slot holds a TypeSyntax, so the replacement must be a + // QualifiedName — a MemberAccessExpression there makes the rewriter's typed rebuild + // throw InvalidCastException. Expression positions keep MemberAccess; both print the + // same text. + if (SyntaxFacts.IsInNamespaceOrTypeContext(original)) + return SyntaxFactory.QualifiedName( + SyntaxFactory.ParseName(typeName), + node.WithoutTrivia()) + .WithTriviaFrom(node); + + return QualifyWith(node, SyntaxFactory.ParseExpression(typeName)); + } + + private static SyntaxNode QualifyWith(SimpleNameSyntax node, ExpressionSyntax qualifier) + { + return SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + qualifier, + node.WithoutTrivia()) + .WithTriviaFrom(node); + } + } + } + + /// + /// Information about a method's signature for transformation purposes. + /// + class MethodSignatureInfo + { + /// The method's return type name, or "void". + public string ReturnType { get; set; } + /// The method's parameters. + public List Parameters { get; set; } = new List(); + /// True unless is "void". + public bool ReturnsValue => !string.IsNullOrEmpty(ReturnType) && ReturnType != "void"; + } + + /// + /// Information about a method parameter. + /// + class ParameterInfo + { + /// The parameter's type name. + public string Type { get; set; } + /// The parameter's name. + public string Name { get; set; } + /// Whether the parameter has a default value. + public bool HasDefaultValue { get; set; } + /// The parameter's default value expression, if any. + public string DefaultValue { get; set; } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/SourceCodeTransformer.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/SourceCodeTransformer.cs.meta new file mode 100644 index 0000000..75ef1fb --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/CodeReload/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/AIClient/Packages/com.unity.pipeline/Runtime/Commands.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Commands.meta new file mode 100644 index 0000000..ed4d613 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Commands.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 67704748e63416c4c959bd6de99b4c4f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Commands/CaptureRuntimeElementCommand.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/CaptureRuntimeElementCommand.cs new file mode 100644 index 0000000..bea5223 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/CaptureRuntimeElementCommand.cs @@ -0,0 +1,144 @@ +#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. + /// + 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, + Tags = new[] { "capture", "ui" })] + 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/AIClient/Packages/com.unity.pipeline/Runtime/Commands/CaptureRuntimeElementCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/CaptureRuntimeElementCommand.cs.meta new file mode 100644 index 0000000..4437241 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Commands/CodeEvalCommand.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/CodeEvalCommand.cs new file mode 100644 index 0000000..34f1cf9 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/CodeEvalCommand.cs @@ -0,0 +1,163 @@ +using System; +using Unity.Pipeline.Models; +using Unity.Pipeline.Compilation; +using Unity.Pipeline.Telemetry; +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. + /// + static class CodeEvalCommand + { + [CliCommand("eval", "Evaluate C# code dynamically using Roslyn compiler", MainThreadRequired = true, Tags = new[] { "scripts/eval" })] + 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 RejectedEval("eval", code, "Code parameter is required and cannot be empty"); + + return EvaluateSource(code, timeout, "eval"); + } + + [CliCommand("eval_file", "Evaluate C# code read from a .cs file on disk", MainThreadRequired = true, Tags = new[] { "scripts/eval" })] + 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 RejectedEval("eval_file", null, "File parameter is required and cannot be empty"); + + if (!file.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + return RejectedEval("eval_file", null, "File must have a .cs extension"); + + if (!System.IO.File.Exists(file)) + return RejectedEval("eval_file", null, $"File not found: {file}"); + + string code; + try + { + code = System.IO.File.ReadAllText(file); + } + catch (Exception ex) + { + return RejectedEval("eval_file", null, $"Failed to read file: {ex.Message}"); + } + + if (string.IsNullOrWhiteSpace(code)) + return RejectedEval("eval_file", code, $"File is empty: {file}"); + + return EvaluateSource(code, timeout, "eval_file"); + } + + /// + /// Reject an invocation at validation time AND record it: a rejected eval is still an eval + /// invocation, and skipping telemetry here would undercount report_evals' errorCount / + /// errorRate for exactly the mistakes an agent makes most (empty code, wrong path). + /// + private static EvalResponse RejectedEval(string command, string code, string errorDetails) + { + // Guarded like the RecordInBackground call in EvaluateSource: telemetry must never + // affect eval behavior, so a failure in its synchronous prelude (ResolveDirectory, + // the write-chain lock) can't turn a clean Bad Request into an unhandled exception. + try + { + EvalUsageTelemetry.RecordInBackground(command, code ?? string.Empty, success: false, + error: "Bad Request", executionTimeMs: 0); + } + catch + { + // Telemetry must never affect eval behavior. + } + + return EvalResponse.EvalFailure("Bad Request", errorDetails); + } + + /// Upper bound on a single eval's timeout — 24 hours (CLI-335; was 30s). + private const int MaxTimeoutMs = 86_400_000; + + /// + /// 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. Each invocation is recorded to local eval-usage telemetry + /// (AUTHAPI-29) on a best-effort basis before returning. + /// + private static EvalResponse EvaluateSource(string code, int timeout, string commandName) + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + EvalResponse response; + + try + { + // Validate timeout + // Cap raised from 30000ms (CLI-335): long evals are now legitimate — clients + // set custom timeouts or run detached jobs and reattach. 24h bounds abuse. + // (Supersedes the interim 300000ms raise from UUM-148641.) + if (timeout <= 0 || timeout > MaxTimeoutMs) + { + stopwatch.Stop(); + response = EvalResponse.EvalFailure( + "Bad Request", + $"Timeout must be between 1ms and {MaxTimeoutMs}ms", + stopwatch.ElapsedMilliseconds); + } + else + { + // 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; + } + + response = 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}"); + + response = EvalResponse.EvalFailure( + "Execution Failed", + ex.ToString(), + stopwatch.ElapsedMilliseconds); + } + + // Best-effort local telemetry (editor-only; a no-op in players): fingerprint + shape only + // by default (no raw source unless the explicit opt-in is set). The eval primitives and + // the current settings are snapshotted here on the main thread, then the Roslyn parse and + // file append run on a background task — fire-and-forget, zero latency added to the + // response. RecordInBackground swallows its own errors, but guard here too so an + // unexpected failure can never turn a good eval into a failed command. + try + { + EvalUsageTelemetry.RecordInBackground( + commandName, + code, + response.Success, + response.Error, + response.ExecutionTimeMs ?? stopwatch.ElapsedMilliseconds); + } + catch + { + // Telemetry must never affect eval behavior. + } + + return response; + } + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Commands/CodeEvalCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/CodeEvalCommand.cs.meta new file mode 100644 index 0000000..2d9d584 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Commands/CodeReloadCommands.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/CodeReloadCommands.cs new file mode 100644 index 0000000..06772e5 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/CodeReloadCommands.cs @@ -0,0 +1,455 @@ +using System; +using System.IO; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Compilation; +using Unity.Pipeline.CodeReload; +using Unity.Pipeline.Models; +using UnityEngine; + +namespace Unity.Pipeline.Runtime.Commands +{ + /// + /// CLI commands for code reload operations: apply [CodeReload] edits from a source file, inspect + /// the registry, and clean up saved reload assemblies. + /// + static class CodeReloadCommands + { + [CliCommand("reload_file", "Compile and apply in-place [CodeReload] edits from a source file", MainThreadRequired = true, Tags = new[] { "scripts/codereload" })] + public static CodeReloadResponse ReloadFile( + [CliArg("filename", "Source file containing [CodeReload] 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) + { + return ReloadFileCore("reload_file", filename, timeout, assemblyDir, pdb, useInterpreter: false); + } + + [CliCommand("reload_file_editor_interpreter", "Compile in-place [CodeReload] edits and run them through the IlInterpreter VM in this process instead of Assembly.Load (IL2CPP-safe; only a minimal host API + the target type are available)", MainThreadRequired = true, Tags = new[] { "scripts/codereload" })] + public static CodeReloadResponse ReloadFileEditorInterpreter( + [CliArg("filename", "Source file containing [CodeReload] 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) + { + return ReloadFileCore("reload_file_editor_interpreter", filename, timeout, assemblyDir, pdb, useInterpreter: true); + } + + /// Shared body of reload_file and reload_file_editor_interpreter: + /// the two commands differ only in the backend the reloaded methods run on. + private static CodeReloadResponse ReloadFileCore(string commandName, string filename, int timeout, string assemblyDir, bool pdb, bool useInterpreter) + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + try + { + if (string.IsNullOrWhiteSpace(filename)) + { + stopwatch.Stop(); + return CodeReloadResponse.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 CodeReloadResponse.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, CodeReloadRegistry.AllowedReloadRoots); + if (!scopeCheck.IsValid) + { + stopwatch.Stop(); + return CodeReloadResponse.CmdFailure( + scopeCheck.Error, + scopeCheck.ErrorDetails, + stopwatch.ElapsedMilliseconds); + } + } + + Debug.Log($"CodeReload: Executing {commandName} command: {fullPath}, timeout: {timeout}ms, assemblyDir: {assemblyDir ?? "in-memory"}, pdb: {pdb}, interpreter: {useInterpreter}"); + + var task = InPlaceReloadProcessor.ProcessSourceFileAsync(fullPath, assemblyDir, pdb, useInterpreter); + task.Wait(); + var result = task.Result; + + stopwatch.Stop(); + + if (result.Success) + { + // Everything matches the compiled baseline: an honest no-op success, not + // "reload successful with 0 methods" against a null assembly name. Reverting + // stale overrides (the edit was undone) is part of the outcome — say so + // instead of unregistering silently. + if (result.AllUpToDate) + { + var upToDate = CodeReloadResponse.CmdSuccess( + string.Empty, + $"Up to date: all {result.UpToDateMethods.Count} [CodeReload] method(s) match the compiled baseline — nothing reloaded" + + (result.RevertedMethods.Count > 0 + ? $"; reverted {result.RevertedMethods.Count} stale override(s): {string.Join(", ", result.RevertedMethods)}" + : "") + ".", + result.RegisteredMethods, + stopwatch.ElapsedMilliseconds); + upToDate.Diagnostics = result.CompilationDiagnostics; + return upToDate; + } + + // "Compiled OK but bound nothing" is a failed reload in practice: the compile + // succeeded but every method was skipped downstream (e.g. an interpreter-unsupported + // construct, or no RuntimePipelineDriver registered the target). Surface the + // reasons from CompilationDiagnostics as a failure instead of a silent success. + if (result.RegisteredMethods.Count == 0 && result.ExtractedMethods.Count > 0) + { + var details = result.CompilationDiagnostics != null && result.CompilationDiagnostics.Count > 0 + ? "Compiled, but no methods were applied:\n- " + string.Join("\n- ", result.CompilationDiagnostics) + : "Compiled, but no methods were applied. Is the target component in the scene " + + "and in play mode (with the runtime Pipeline driver active)?"; + return CodeReloadResponse.CmdFailure( + "No Methods Applied", + details, + stopwatch.ElapsedMilliseconds, + result.CompilationDiagnostics); + } + + CodeReloadRegistry.InvokeReloadCallbacks(result.RegisteredMethods); + + var response = CodeReloadResponse.CmdSuccess( + result.AssemblyName, + $"In-place code reload successful: {result.AssemblyName} with {result.RegisteredMethods.Count} methods" + + (result.RevertedMethods.Count > 0 + ? $"; reverted {result.RevertedMethods.Count} stale override(s): {string.Join(", ", result.RevertedMethods)}" + : ""), + result.RegisteredMethods, + stopwatch.ElapsedMilliseconds); + // Surface partial-skip reasons (some methods bound, others skipped) on success too. + response.Diagnostics = result.CompilationDiagnostics; + return response; + } + + return CodeReloadResponse.CmdFailure( + "In-Place Reload Failed", + result.ErrorMessage ?? "In-place code reload processing failed", + stopwatch.ElapsedMilliseconds, + result.CompilationDiagnostics); + } + catch (Exception ex) + { + stopwatch.Stop(); + Debug.LogError($"CodeReload: {commandName} command failed: {ex.Message}"); + Debug.LogError($"CodeReload: Stack trace: {ex.StackTrace}"); + + return CodeReloadResponse.CmdFailure( + "Execution Failed", + ex.ToString(), + stopwatch.ElapsedMilliseconds); + } + } + + [CliCommand("cleanup_codereload", "Remove old code reload DLL versions and clear registry", MainThreadRequired = true, RuntimeOnly = true, Tags = new[] { "scripts/codereload" })] + public static CodeReloadResponse CleanupCodeReload( + [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($"CodeReload: Executing cleanup_codereload command for directory: {assemblyDir}"); + + // Execute cleanup + var cleanupResult = CodeReloadCompiler.CleanupCodeReloadDlls(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("CodeReload: Requesting Unity domain reload to clean memory state"); + UnityEditor.EditorUtility.RequestScriptReload(); + message += " Unity domain reload requested."; +#endif + } + + Debug.Log($"CodeReload: cleanup_codereload completed successfully in {stopwatch.ElapsedMilliseconds}ms"); + + return CodeReloadResponse.CmdSuccess( + "cleanup_completed", + message, + cleanupResult.DeletedFiles, + stopwatch.ElapsedMilliseconds); + } + else + { + return CodeReloadResponse.CmdFailure( + "Cleanup Failed", + cleanupResult.Message, + stopwatch.ElapsedMilliseconds); + } + } + catch (Exception ex) + { + stopwatch.Stop(); + Debug.LogError($"CodeReload: cleanup_codereload command failed: {ex.Message}"); + + return CodeReloadResponse.CmdFailure( + "Execution Failed", + ex.ToString(), + stopwatch.ElapsedMilliseconds); + } + } + + [CliCommand("codereload_status", "Show current code reload registry status and statistics", MainThreadRequired = true, RuntimeOnly = true, Tags = new[] { "scripts/codereload" })] + public static CodeReloadResponse CodeReloadStatus() + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + try + { + // Get current registry stats + var stats = CodeReloadRegistry.GetStats(); + + stopwatch.Stop(); + + var statusMessage = $"Code Reload Status - " + + $"Reloadable Methods: {stats.ReloadableMethodCount}, " + + $"Active Overrides: {stats.ActiveOverrideCount}, " + + $"Loaded Types: {stats.LoadedTypeCount}"; + + Debug.Log($"CodeReload: {statusMessage}"); + + return CodeReloadResponse.CmdSuccess( + "status_retrieved", + statusMessage, + stats.ActiveOverrideIds, + stopwatch.ElapsedMilliseconds); + } + catch (Exception ex) + { + stopwatch.Stop(); + Debug.LogError($"CodeReload: codereload_status command failed: {ex.Message}"); + + return CodeReloadResponse.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", + $"Code 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. + /// + 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; + } + + } + + /// + /// Response model for code reload CLI commands. + /// Provides consistent response format for all code reload operations. + /// + class CodeReloadResponse : 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 code reload response. + /// + public static CodeReloadResponse CmdSuccess(string assemblyName, string message, System.Collections.Generic.List items, long executionTimeMs) + { + return new CodeReloadResponse + { + Success = true, + AssemblyName = assemblyName, + Message = message, + Items = items ?? new System.Collections.Generic.List(), + ExecutionTimeMs = executionTimeMs, + ExecutedAt = DateTime.UtcNow + }; + } + + /// + /// Create a failed code reload response. + /// + public static CodeReloadResponse CmdFailure(string error, string errorDetails, long executionTimeMs, System.Collections.Generic.List diagnostics = null) + { + return new CodeReloadResponse + { + Success = false, + Error = error, + ErrorDetails = errorDetails, + ExecutionTimeMs = executionTimeMs, + Diagnostics = diagnostics ?? new System.Collections.Generic.List(), + ExecutedAt = DateTime.UtcNow + }; + } + } + + /// + /// Result of validating a code reload source path. is a short category and + /// the human-readable explanation, matching the shape consumed by + /// . + /// + 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/AIClient/Packages/com.unity.pipeline/Runtime/Commands/CodeReloadCommands.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/CodeReloadCommands.cs.meta new file mode 100644 index 0000000..74077bc --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/CodeReloadCommands.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/AIClient/Packages/com.unity.pipeline/Runtime/Commands/ConsoleCommand.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/ConsoleCommand.cs new file mode 100644 index 0000000..1ef3856 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/ConsoleCommand.cs @@ -0,0 +1,118 @@ +using System; +using System.Reflection; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Console; +using Unity.Pipeline.Models; + +namespace Unity.Pipeline.Runtime.Commands +{ + /// + /// The console commands: console to read captured output, console_status for its + /// counters alone, and clear_console to empty it. console backs the CLI's + /// console [--tail N] [--level log|warn|error] [--follow]. + /// + /// All three live in the runtime assembly, so they are 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 and since_session = previous session + /// + /// Reads only the in-memory buffer and the last ground-truth sample (no Unity main-thread + /// APIs), so it is marked MainThreadRequired = false and keeps answering while the + /// Editor is blocked compiling or importing — which is when a client most needs it. + /// + 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, Tags = new[] { "observability/console" })] + 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, + [CliArg("since_session", "Session the 'since' cursor came from: pass the 'session' from a previous response. A cursor from another session is refused and the tail returned with reset=true.")] string since_session = null) + { + // 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); + var response = ConsoleLogCapture.Buffer.Query(since, tail, minSeverity, since_session); + response.GroundTruth = ReadGroundTruth(); + return response; + } + + [CliCommand("console_status", "Console ground truth and buffer counters without pulling entries: compile-failure flag, Editor console counts, and the buffer's retained counts and cursor.", MainThreadRequired = false, Tags = new[] { "observability/console" })] + public static ConsoleLogResponse GetConsoleStatus() + { + // Same shape as `console` with no entries, so a client polling during a long compile + // gets the counters and the compile-failure flag without paying for the payload. + var response = ConsoleLogCapture.Buffer.Stats(); + response.GroundTruth = ReadGroundTruth(); + return response; + } + + [CliCommand("clear_console", "Clear the captured log buffer and the Unity Editor console.", Tags = new[] { "observability/console" })] + public static object ClearConsole() + { + // Clearing keeps the sequence counter, so a client following the console sees dropped=true + // on its next poll rather than silently skipping the window that was cleared. + ConsoleLogCapture.Buffer.Clear(); + + // Best-effort: also clear Unity's own console window. UnityEditor.LogEntries is internal, + // so reach it by name and swallow any failure (the API is not part of the public contract). + // Resolving the type by string is also what keeps this command in the runtime assembly: + // there is no compile-time UnityEditor dependency, and in a player GetType returns null so + // the chain no-ops and only the captured buffer is cleared. + // 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. + // Note it leaves editor compile errors standing: those are logged as sticky entries, which + // Unity's Clear skips, and only a completed compile removes them. + 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 }; + } + + /// + /// A copy of the last ground-truth sample with its age filled in. Copied rather than + /// returned directly so the published snapshot stays immutable and shared between callers. + /// + static ConsoleGroundTruth ReadGroundTruth() + { + var sample = ConsoleLogCapture.GroundTruth; + if (sample == null) + return null; + + var ageMs = (long)(DateTime.UtcNow - sample.SampledUtc).TotalMilliseconds; + return new ConsoleGroundTruth + { + SampledUtc = sample.SampledUtc, + AgeMs = ageMs < 0 ? 0 : ageMs, + CompilationFailed = sample.CompilationFailed, + Compiling = sample.Compiling, + ConsoleErrors = sample.ConsoleErrors, + ConsoleWarnings = sample.ConsoleWarnings, + ConsoleLogs = sample.ConsoleLogs, + Seeded = sample.Seeded + }; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Commands/ConsoleCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/ConsoleCommand.cs.meta new file mode 100644 index 0000000..597d4a8 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/ConsoleCommand.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 92be7cfe398a263a7b06a4a8a00a802c diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Commands/EvalTelemetryCommand.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/EvalTelemetryCommand.cs new file mode 100644 index 0000000..2b96be2 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/EvalTelemetryCommand.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Linq; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Telemetry; + +namespace Unity.Pipeline.Runtime.Commands +{ + /// + /// Read-back command for the local eval-usage telemetry (AUTHAPI-29). Aggregates + /// Library/Pipeline/eval-usage.jsonl — merged with the rotated eval-usage.old.jsonl, + /// so the report sees the full retained history (~2× the size cap) — into a ranked report: API + /// fingerprint frequency, one-liner vs multi-statement split, error rate. It cross-references the + /// fingerprints against the live command catalog to flag patterns already coverable by an existing + /// command and the top uncovered patterns. This is the artifact that steers eval-displacement + /// backlog triage (AUTHAPI-24). Read-only and off the main thread, so it stays servable even while + /// the Editor is settling. The ranked lists are capped by top; the report's + /// distinctFingerprints total makes any truncation visible. + /// + static class EvalTelemetryCommand + { + [CliCommand("report_evals", + "Aggregate local eval-usage telemetry into a ranked report: API fingerprint frequency, one-liner percentage, error rate, and command-coverage suggestions.", + MainThreadRequired = false, + Tags = new[] { "observability/eval_usage" })] + public static EvalUsageReport ReportEvals( + [CliArg("top", "Maximum entries in each ranked list (API fingerprints, and uncovered patterns suggested as commands). Accepted range: 0 or greater; negative values fall back to the default (50).")] int top = 50) + { + // Clamp, don't reject: a negative cap has no meaning, so fall back to the default rather + // than fail the command or return an unbounded report. + if (top < 0) + top = EvalUsageAggregator.DefaultFingerprintLimit; + + var records = EvalUsageTelemetry.ReadRecords(EvalUsageTelemetry.ResolveDirectory()); + + // Cross-reference against the live catalog. Discovery is already warm (the server queried + // it to route this very request), so this reads the cache and touches no main-thread API. + var availableCommands = new HashSet( + CommandRegistry.DiscoverCommands().Select(c => c.Name)); + + return EvalUsageAggregator.Build(records, availableCommands, topUncoveredLimit: top, fingerprintLimit: top); + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Commands/EvalTelemetryCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/EvalTelemetryCommand.cs.meta new file mode 100644 index 0000000..529634b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/EvalTelemetryCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7ecb5f9bf7384f0186d1e26cf4a355a3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeApplicationCommand.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeApplicationCommand.cs new file mode 100644 index 0000000..3fc4aeb --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + static class RuntimeApplicationCommand + { + [CliCommand("quit", "Gracefully quit the Unity application", MainThreadRequired = true, RuntimeOnly = true, Tags = new[] { "runtime/application" })] + 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, Tags = new[] { "runtime/application" })] + 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, Tags = new[] { "runtime/application" })] + 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/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeApplicationCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeApplicationCommand.cs.meta new file mode 100644 index 0000000..d952319 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeInputCommand.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeInputCommand.cs new file mode 100644 index 0000000..a4d1375 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeInputCommand.cs @@ -0,0 +1,182 @@ +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. + /// + static class RuntimeInputCommand + { + [CliCommand("simulate_key", "Simulate a keyboard key event (Input System). Drives the running app.", + MainThreadRequired = true, RuntimeOnly = true, + Tags = new[] { "runtime/input" })] + 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, + Tags = new[] { "runtime/input" })] + 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] + 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/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeInputCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeInputCommand.cs.meta new file mode 100644 index 0000000..788ed9d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeLogCommand.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeLogCommand.cs new file mode 100644 index 0000000..72cd1b9 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + static class RuntimeLogCommand + { + [CliCommand("log", "Write a message to Unity console", MainThreadRequired = true, RuntimeOnly = true, Tags = new[] { "observability/console" })] + 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/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeLogCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeLogCommand.cs.meta new file mode 100644 index 0000000..876ab94 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeStatusCommand.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeStatusCommand.cs new file mode 100644 index 0000000..64aa259 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeStatusCommand.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using Unity.Pipeline; +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. + /// + static class RuntimeStatusCommand + { + [CliCommand("runtime_status", "Get comprehensive runtime application status", MainThreadRequired = true, RuntimeOnly = true, Tags = new[] { "runtime" })] + 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(), + + // The driver's actual state and in-effect config — not just what Project + // Settings currently says, which can differ (live JSON in the Editor vs. the + // snapshot frozen into the Player at build time). + PipelineDriver = BuildDriverStatus(), + + // Timestamp + Timestamp = DateTime.UtcNow + }; + } + catch (Exception ex) + { + Debug.LogError($"Pipeline: Runtime status command failed: {ex.Message}"); + throw; + } + } + + /// + /// Null when no driver was ever bootstrapped (e.g. enableInBuilds was false), otherwise the + /// driver's actual running state and the config values presently governing it. + /// maxWorkItemsPerFrame is read via CurrentMaxWorkItemsPerFrame rather than the config + /// directly, since that field alone can be tuned live after the driver started. + /// + private static RuntimePipelineDriverStatus BuildDriverStatus() + { + var driver = RuntimePipelineBootstrap.Instance; + if (driver == null) + return null; + + var config = driver.Config; + return new RuntimePipelineDriverStatus + { + IsServerRunning = driver.IsServerRunning, + ActualPort = driver.ActualPort, + EnableInBuilds = config != null && config.enableInBuilds, + RequestTimeoutMs = config != null ? config.requestTimeoutMs : 0, + EnableAuditLogging = config != null && config.enableAuditLogging, + AutoStart = config != null && config.autoStart, + MaxWorkItemsPerFrame = driver.CurrentMaxWorkItemsPerFrame + }; + } + + /// + /// 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] + 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; } + + // The runtime Pipeline driver's actual state, or null if none was ever bootstrapped. + public RuntimePipelineDriverStatus PipelineDriver { get; set; } + + // Metadata + public DateTime Timestamp { get; set; } + } + + /// + /// The runtime Pipeline driver's actual state: whether its server is running, the port it's + /// actually on, and the config values presently governing it. Distinct from what Project + /// Settings > Pipeline > Runtime currently shows — that's live-editable in the Editor, while a + /// running driver is working from whatever it was initialized with (a Player's is additionally + /// frozen at build time). This is the authoritative "what's actually happening right now" view. + /// + [Serializable] + class RuntimePipelineDriverStatus + { + public bool IsServerRunning { get; set; } + public int ActualPort { get; set; } + public bool EnableInBuilds { get; set; } + public int RequestTimeoutMs { get; set; } + public bool EnableAuditLogging { get; set; } + public bool AutoStart { get; set; } + + /// The value actually governing the dispatcher right now — see RuntimePipelineDriver.CurrentMaxWorkItemsPerFrame. + public int MaxWorkItemsPerFrame { 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] + class RuntimePerformanceTelemetry + { + // Frame timing — sampled over a rolling window by the RuntimePipelineDriver. + /// 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/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeStatusCommand.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/RuntimeStatusCommand.cs.meta new file mode 100644 index 0000000..3ae7365 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Commands/VisualElementCaptureSupport.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/VisualElementCaptureSupport.cs new file mode 100644 index 0000000..e44fda3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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>). + /// + 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] + 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/AIClient/Packages/com.unity.pipeline/Runtime/Commands/VisualElementCaptureSupport.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Commands/VisualElementCaptureSupport.cs.meta new file mode 100644 index 0000000..4c2af27 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common.meta new file mode 100644 index 0000000..614ed4a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c9f4a11d82a4dd54d93200f6c3aa4678 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/BasePipelineServer.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/BasePipelineServer.cs new file mode 100644 index 0000000..ee4edb7 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/BasePipelineServer.cs @@ -0,0 +1,2713 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +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 + { + private const long DefaultMaxRequestBodyBytes = 1 * 1024 * 1024; + + /// + /// Maximum accepted request body size. Larger requests are rejected with 413, bounding the memory + /// one remote request can force the server to buffer. Settable so a server whose clients + /// legitimately post larger documents can raise it without every other server paying for it. + /// + public long MaxRequestBodyBytes { get; set; } = DefaultMaxRequestBodyBytes; + + /// + /// 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; + + /// + /// Maximum accepted body size for /api/job/cancel (4 KiB). The body is just an + /// {"id": "..."} object, so it needs far less headroom than . + /// + private const long MaxCancelBodyBytes = 4 * 1024; + + /// + /// Dispatcher wait for a detached job's main-thread execution. Nothing is synchronously + /// waiting on this thread (the job's HTTP response was already sent), so this only needs + /// to be larger than any command's own timeout (e.g. eval's 24h cap) rather than bounded + /// by an HTTP-facing default. + /// + private const int UnboundedJobDispatcherTimeoutMs = int.MaxValue; + + private bool m_IsRunning; + private int m_Port; + + /// + /// Serializes /api/exec command execution now that requests are processed + /// concurrently: execs queue exactly as they did when the accept loop was serial, + /// while read-only endpoints (/api/status, /api/progress, …) answer immediately. + /// + private readonly SemaphoreSlim m_ExecGate = new SemaphoreSlim(1, 1); + private HttpListener m_HttpListener; + private readonly Dispatcher m_Dispatcher = new Dispatcher(); + + /// This server's own progress-reporting state (see CliProgress). + internal CliProgressState Progress { get; } = new CliProgressState(); + + /// This server's own modal-dialog state (see EditorDialogStateMirror, Task 7). + internal DialogStateTracker Dialogs { get; } = new DialogStateTracker(); + + /// This server's own detached-job registry (see PipelineCancellation, /api/job). + internal PipelineJobRegistry JobRegistry { get; } = new PipelineJobRegistry(); + + /// + /// The server instance currently executing a command on this thread, if any. Set only + /// around the actual command invocation (see ) so + /// CliProgress.Report/PipelineCancellation — called from arbitrary command code with no + /// reference to "which server is running me" — resolve to the right instance. Correct + /// regardless of which physical thread ends up running the command, since the push/read/pop + /// all happen within one synchronous call frame; a command that awaits and resumes on a + /// different thread before calling either API would not see it (no command does today). + /// + [ThreadStatic] private static BasePipelineServer m_CurrentServer; + internal static BasePipelineServer CurrentServer => m_CurrentServer; + + 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 RuntimePipelineDriver.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; + + /// UTC time this server instance started listening. + 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; + + /// + /// Whether requests from a sandboxed browser frame (Origin: null) are accepted. Off by default; + /// an ordinary web page is refused either way, and a sandboxed one still needs the bearer token, + /// which lives in a file no browser can read. + /// + public bool AllowSandboxedBrowserClients + { + get => m_AllowSandboxedBrowserClients; + set => m_AllowSandboxedBrowserClients = value; + } + + // Read on the listener thread, so volatile: a value set on the main thread must be seen there. + private volatile bool m_AllowSandboxedBrowserClients; + + /// The only Origin a sandboxed frame reports; a real page sends its own origin. + private const string SandboxedOrigin = "null"; + + /// Write the shared instance descriptor file so clients can discover this server. + protected abstract void CreateInstanceDescriptor(); + /// Remove the shared instance descriptor file on shutdown. + protected abstract void DeleteInstanceDescriptor(); + /// Refresh the descriptor's heartbeat timestamp so discovery can detect a live server. + protected abstract void UpdateHeartBeat(); + /// Build the host-specific payload for /api/status (Editor vs. Player fields differ). + /// The status payload. + protected abstract object GetServerStatus(); + /// The bearer token clients must present to authenticate requests. + /// The current token. + protected abstract string GetToken(); + + /// + /// Optional wire features this server understands, advertised on /api/status and in the + /// port descriptor. + /// + /// One array feeds both carriers so they cannot drift: a server that claims a capability + /// it lacks is worse than one that claims nothing, because a client's fallback is keyed on + /// the claim. Absence of the key entirely means the server is too old to have it, and + /// clients must then assume no raw command-line support. + /// + internal static readonly string[] Capabilities = { "exec.argv", "exec.commandLine" }; + + /// Hook invoked once the listener is accepting requests. No-op by default. + protected virtual void ServerStarted() + { + + } + + /// + /// Busy probe for the /api/exec gate: return a non-null, human-readable reason when the + /// host cannot service right now (e.g. the Editor is still + /// importing/compiling while settling after a cold start), or null to let it run. A busy + /// command is rejected with HTTP 503 and a structured, retryable envelope — before sync + /// execution and before a detached job is created — instead of executing into a + /// half-ready host and failing opaquely (AUTHAPI-35). Called on the request thread, so + /// implementations must only read thread-safe state. Default: never busy. + /// + /// The command about to be dispatched. + /// A human-readable busy reason, or null to let the command run. + protected virtual string GetBusyReason(CommandInfo command) => null; + + /// + /// For a command otherwise blocked by the dialog gate, an optional cached-but-still-valid + /// result to serve as a normal 200 success instead of the 503 (e.g. editor_status: nothing + /// about compilation/play-mode/domain-reload can change while the main thread is stuck + /// inside the dialog's native message loop, so a snapshot taken just before it blocked + /// stays correct for the whole window — see EditorPipelineServer's override). Called on the + /// request thread, so implementations must only read thread-safe state. Default: no + /// fallback exists, so every MainThreadRequired command stays hard-gated. + /// + /// Internal, not protected: DialogInfo is internal, and a protected member can't expose a + /// less-accessible type in its signature — a subclass outside this assembly (without + /// InternalsVisibleTo) couldn't reference DialogInfo to override it. EditorPipelineServer + /// overrides this from Unity.Pipeline.Editor, which already has that grant (same as + /// Dialogs and BuildDialogPayload above). + /// + internal virtual object TryGetDialogBlockedFallback(CommandInfo command, DialogInfo blockingDialog) => null; + + /// Hook invoked once the listener has stopped accepting requests. No-op by default. + 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(); + + /// + /// Whether was picked from this server's range rather than requested by + /// the caller. Recorded at bind time, so it still describes the running listener after the + /// settings asset is edited. + /// + internal bool PortAutoAssigned { get; private set; } + + /// + /// 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; + PortAutoAssigned = port == 0; + + 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(); + + 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 RuntimePipelineDriver.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 RuntimePipelineDriver.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(); + // Process detached so the accept loop keeps serving while a long command + // holds its /api/exec connection open. Without this, ANY in-flight exec + // blocked every other request — including /api/status probes and the + // /api/progress polls that exist precisely for that situation (CLI-488; + // the "editor is unresponsive until the command finishes" report in + // CLI-335). Command execution itself stays strictly serialized via + // m_ExecGate below, so exec ordering semantics are unchanged. + _ = ProcessRequestDetached(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; + } + + /// + /// Run ProcessRequest without the accept loop awaiting it; a per-request failure is + /// logged and must never tear down the listener loop. + /// + private async Task ProcessRequestDetached(HttpListenerContext context) + { + try + { + await ProcessRequest(context); + } + catch (Exception ex) + { + Debug.LogError(ex); + } + } + + /// + /// Process individual HTTP request and route to appropriate handler. + /// + /// The incoming HTTP request/response context. + /// A task that completes once the response has been written. + 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). A server that opts into + // AllowSandboxedBrowserClients additionally admits Origin: null. + var origin = request.Headers["Origin"]; + if (!string.IsNullOrEmpty(origin)) + { + if (!AllowSandboxedBrowserClients || origin != SandboxedOrigin) + { + await SendStatusResponse(response, 403, "Forbidden", "Cross-origin requests are not allowed"); + return; + } + + // "*" rather than echoing "null": the gate above already decides who is served, no + // credentials are involved, and it is better supported for an opaque origin. + response.AddHeader("Access-Control-Allow-Origin", "*"); + response.AddHeader("Vary", "Origin"); + } + + // Preflight is answered before auth by necessity: the browser sends OPTIONS with no + // Authorization header, so requiring the token here would fail every real request. + if (request.HttpMethod == "OPTIONS") + { + if (string.IsNullOrEmpty(origin)) + { + await SendStatusResponse(response, 405, "Method Not Allowed", "OPTIONS is only used for CORS preflight"); + return; + } + + response.AddHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + response.AddHeader("Access-Control-Allow-Headers", "Authorization, Content-Type"); + response.AddHeader("Access-Control-Max-Age", "600"); + // Chrome's Private Network Access: a page on the public internet reaching a loopback + // address must be granted it explicitly, or the preflight fails. + if (!string.IsNullOrEmpty(request.Headers["Access-Control-Request-Private-Network"])) + response.AddHeader("Access-Control-Allow-Private-Network", "true"); + response.StatusCode = 204; + response.ContentLength64 = 0; + response.OutputStream.Close(); + 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(request, response); + break; + case "/api/exec": + if (request.HttpMethod == "POST") + await HandleExecRequest(request, response); + else + await HandleMethodNotAllowed(response, "POST"); + break; + case "/api/test-status": + await HandleTestStatusRequest(response); + break; + case "/api/progress": + await HandleProgressRequest(request, response); + break; + case "/api/dialog": + await HandleDialogRequest(response); + break; + case "/api/job": + if (request.HttpMethod == "GET") + await HandleJobStatusRequest(request, response); + else + await HandleMethodNotAllowed(response, "GET"); + break; + case "/api/job/cancel": + if (request.HttpMethod == "POST") + await HandleJobCancelRequest(request, response); + else + await HandleMethodNotAllowed(response, "POST"); + break; + default: + await HandleNotFound(response); + break; + } + } + catch (Exception e) + { + // A silent 500 leaves the client with nothing to go on; name the request and the fault. + Debug.LogError($"Pipeline: unhandled error serving {request.HttpMethod} " + + $"{request.Url?.AbsolutePath}: {e}"); + + // 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 { } + } + } + + /// + /// Parse the omit_nulls query parameter (snake_case like the other query + /// parameters, e.g. group_by) — the GET-endpoint equivalent of exec's omitNulls + /// JSON body flag, for /api/job, /api/job/cancel, and /api/progress. Accepted values: + /// "true"/"false", case-insensitive; absent means false. Any other value (e.g. + /// omit_nulls=1) is NOT silently coerced to false: it yields false plus a + /// the caller surfaces in the response's "warnings" array, so + /// an agent learns the accepted spelling instead of guessing why nulls are still present. + /// (Exec body requests don't need this: JSON deserialization coerces their boolean.) + /// + private static bool ParseOmitNulls(HttpListenerRequest request, out string warning) + { + warning = null; + var raw = request.QueryString["omit_nulls"]; + if (raw == null) + { + // A bare "?omit_nulls" (no '=') is not the same as absent: .NET files valueless + // query tokens under the null key, so look there to warn instead of silently + // treating an intended opt-in as false. + var valueless = request.QueryString.GetValues(null); + if (valueless != null && Array.IndexOf(valueless, "omit_nulls") >= 0) + warning = "omit_nulls given without a value ignored; use omit_nulls=true"; + return false; + } + if (string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase)) + return true; + if (string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase)) + return false; + + warning = $"omit_nulls='{raw}' ignored; expected 'true' or 'false'"; + return false; + } + + /// + /// Serialize and send it as the JSON body. Null fields are + /// INCLUDED by default — on /api/job and /api/progress every field is payload (there is + /// no envelope/payload split like /api/exec's), and an absent key is indistinguishable + /// from a nonexistent or misspelled one. Callers pass from + /// the endpoint's omit_nulls query parameter (see ) + /// to opt out (AUTHAPI-21). + /// Shares 's write guard: a failure while writing + /// (e.g. the client disconnected mid-response) is swallowed here instead of propagating + /// into the caller's own catch block, which would otherwise attempt a second response on + /// the now-dead connection. + /// + private async Task SendJsonResponse(HttpListenerResponse response, int statusCode, object payload, bool omitNulls = false) + { + try + { + var json = JsonConvert.SerializeObject(payload, + new JsonSerializerSettings + { + NullValueHandling = omitNulls ? NullValueHandling.Ignore : NullValueHandling.Include + }); + + response.StatusCode = statusCode; + 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 + { + 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?? + + // Queue behind the same gate as /api/exec so this doesn't run concurrently with + // an in-flight exec (or another editor_status/test_status call): it dispatches + // onto the main thread just like exec does, so left ungated it would race rather + // than queue the way it did under the old serial accept loop. + object result; + await m_ExecGate.WaitAsync(); + try + { + result = await ExecuteCommandByName("editor_status", new JObject()); + } + finally + { + m_ExecGate.Release(); + } + + // 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 the available CLI commands. + /// + /// Optional query parameters (filters combine with AND): + /// - detail: 'full' (default) includes parameters and the generated JSON schema; + /// 'compact' returns a lightweight index (name, description, tags, package) so a + /// client can cheaply browse, then fetch full detail only for the commands it + /// intends to invoke. + /// - query: case-insensitive substring match on name, description, or any tag. + /// - tag: scope to a tag subtree via segment-aware prefix match ('assets' matches + /// 'assets' and 'assets/import' but not 'assetsx'). + /// - group_by: 'flat' (default) returns a 'commands' array; 'package' and 'tag' + /// return a 'groups' array instead ('tag' is a nested tree mirroring tag/subtag; + /// untagged commands land in a node with an empty tag). + /// - sort: 'name' (default) or 'package' (originating package, name as tiebreak); + /// order: 'asc' (default) or 'desc'. Sorting applies to the flat list before + /// pagination and grouping. + /// - offset / limit: paginate the filtered, sorted flat list (applied before + /// grouping, so pages are deterministic); 'total' reports the match count before + /// pagination while 'count' is the number of commands actually returned. + /// + private async Task HandleCommandsRequest(HttpListenerRequest request, HttpListenerResponse response) + { + try + { + var queryString = request.QueryString; + + var detail = queryString["detail"] ?? "full"; + bool fullDetail; + if (string.Equals(detail, "full", StringComparison.OrdinalIgnoreCase)) + fullDetail = true; + else if (string.Equals(detail, "compact", StringComparison.OrdinalIgnoreCase)) + fullDetail = false; + else + { + await SendStatusResponse(response, 400, "Invalid Request", + $"Unknown detail value '{detail}'. Accepted values: compact, full"); + return; + } + + var groupBy = queryString["group_by"] ?? "flat"; + var groupByPackage = string.Equals(groupBy, "package", StringComparison.OrdinalIgnoreCase); + var groupByTag = string.Equals(groupBy, "tag", StringComparison.OrdinalIgnoreCase); + if (!groupByPackage && !groupByTag && !string.Equals(groupBy, "flat", StringComparison.OrdinalIgnoreCase)) + { + await SendStatusResponse(response, 400, "Invalid Request", + $"Unknown group_by value '{groupBy}'. Accepted values: flat, package, tag"); + return; + } + + var sort = queryString["sort"] ?? "name"; + var sortByPackage = string.Equals(sort, "package", StringComparison.OrdinalIgnoreCase); + if (!sortByPackage && !string.Equals(sort, "name", StringComparison.OrdinalIgnoreCase)) + { + await SendStatusResponse(response, 400, "Invalid Request", + $"Unknown sort value '{sort}'. Accepted values: name, package"); + return; + } + + var order = queryString["order"] ?? "asc"; + var descending = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase); + if (!descending && !string.Equals(order, "asc", StringComparison.OrdinalIgnoreCase)) + { + await SendStatusResponse(response, 400, "Invalid Request", + $"Unknown order value '{order}'. Accepted values: asc, desc"); + return; + } + + var offset = 0; + if (queryString["offset"] != null + && (!int.TryParse(queryString["offset"], out offset) || offset < 0)) + { + await SendStatusResponse(response, 400, "Invalid Request", + $"Invalid offset value '{queryString["offset"]}'. Expected a non-negative integer"); + return; + } + + int? limit = null; + if (queryString["limit"] != null) + { + if (!int.TryParse(queryString["limit"], out var parsedLimit) || parsedLimit < 0) + { + await SendStatusResponse(response, 400, "Invalid Request", + $"Invalid limit value '{queryString["limit"]}'. Expected a non-negative integer"); + return; + } + limit = parsedLimit; + } + + var query = queryString["query"]; + var tag = queryString["tag"]; + + var filtered = CommandRegistry.DiscoverCommands() + .Where(c => IncludeRuntimeOnlyCommands || !c.RuntimeOnly) + .Where(c => MatchesQuery(c, query) && MatchesTagSubtree(c, tag)); + var matching = SortCommands(filtered, sortByPackage, descending).ToList(); + + // Paginate the flat, sorted list before any grouping so pages are deterministic. + IEnumerable window = matching.Skip(offset); + if (limit.HasValue) + window = window.Take(limit.Value); + var page = window.ToList(); + + Func project = c => + fullDetail ? BuildFullCommandResponse(c) : BuildCompactCommandResponse(c); + + var responseData = new Dictionary(); + if (groupByPackage) + responseData["groups"] = BuildPackageGroups(page, project); + else if (groupByTag) + responseData["groups"] = BuildTagTree(page, project); + else + responseData["commands"] = page.Select(project).ToList(); + responseData["count"] = page.Count; + responseData["total"] = matching.Count; + responseData["offset"] = offset; + responseData["limit"] = limit; + responseData["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 the lightweight index entry for a single command (detail=compact). + /// + private static object BuildCompactCommandResponse(CommandInfo command) + { + return new + { + name = command.Name, + description = command.Description, + tags = command.Tags, + package = command.Package + }; + } + + /// + /// Build the complete JSON response object for a single command (detail=full). + /// + private object BuildFullCommandResponse(CommandInfo command) + { + var entry = BuildCommandEntry(command); + entry["schema"] = JsonSchemaGenerator.GenerateCommandSchema(command); + return entry; + } + + /// + /// One command's catalog entry: everything /api/commands serves for it except the + /// generated JSON schema. + /// + /// Shared by the catalog and by an argument-error envelope, so a client's entry renderer + /// consumes both unchanged and the two cannot drift. The error path takes this directly + /// rather than building the full response and dropping the schema, because generating and + /// serializing a schema nobody reads is pure waste on the interactive retry path. + /// + private JObject BuildCommandEntry(CommandInfo command) + { + return JObject.FromObject(new + { + name = command.Name, + description = command.Description, + tags = command.Tags, + package = command.Package, + 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() + }); + } + + /// + /// Attaches the bound-parameter echo for raw-form requests, and only for those, leaving + /// the structured path's envelope unchanged. + /// + /// The value is exactly what the binder stored, so the echo cannot disagree with what the + /// command executes with. A client that does not bind locally has no other way to report + /// what the command received. + /// + private static CommandExecutionResponse WithBoundParameters( + CommandExecutionResponse response, CommandExecutionRequest request) + { + if (request.IsRawForm) + response.BoundParameters = request.Parameters ?? new JObject(); + return response; + } + + /// + /// Renders argument problems as English prose for errorDetails. + /// + /// This is the fallback, not the primary channel: a client that understands + /// argProblems renders and localizes them itself. This text serves curl, MCP, and + /// any client that meets a kind it does not recognize, so it must stay useful on + /// its own. + /// + private static string DescribeArgProblems(CommandInfo command, List problems) + { + if (problems == null || problems.Count == 0) + return "Invalid arguments"; + + var parts = new List(problems.Count); + foreach (var problem in problems) + { + switch (problem.Kind) + { + case ArgProblemKind.EmptyName: + parts.Add($"'{problem.Token}' is not a valid flag: the name is empty."); + break; + case ArgProblemKind.EmptyValue: + parts.Add($"--{problem.Name} needs a value."); + break; + case ArgProblemKind.SingleDash: + parts.Add($"'{problem.Token}' is not a valid flag: single-dash flags are not supported. Use a double dash, or pass it after -- to send it as a value."); + break; + case ArgProblemKind.Duplicate: + parts.Add($"--{problem.Name} was given more than once."); + break; + case ArgProblemKind.UnknownName: + parts.Add(problem.Suggestion != null + ? $"{command.Name} has no parameter --{problem.Name}. Did you mean --{problem.Suggestion}?" + : $"{command.Name} has no parameter --{problem.Name}. Valid parameters: {DescribeParameterNames(command)}."); + break; + case ArgProblemKind.BareAssignment: + parts.Add($"'{problem.Token}' looks like a flag written without dashes. Use --name value instead."); + break; + case ArgProblemKind.ExcessPositional: + parts.Add($"{command.Name} takes {problem.Capacity} argument(s) but {problem.Given} were given (starting at '{problem.Token}')."); + break; + case ArgProblemKind.PositionalConflict: + parts.Add($"--{problem.Name} is already set by an explicit flag, so there is no slot for '{problem.Token}'."); + break; + case ArgProblemKind.TypeMismatch: + parts.Add($"--{problem.Name} expects {problem.ExpectedType}, but got '{problem.Token}'.{DescribeValidValues(command, problem.Name)}"); + break; + default: + parts.Add($"{problem.Kind}: {problem.Token ?? problem.Name}"); + break; + } + } + + return string.Join(" ", parts); + } + + /// Every declared parameter of the command, flag-spelled, or "(none)". + private static string DescribeParameterNames(CommandInfo command) + { + if (command.Parameters.Count == 0) + return "(none)"; + return string.Join(", ", command.Parameters.Select(p => "--" + p.Name)); + } + + /// + /// For an enum parameter, the legal names, following the same enumerate-the-valid-set + /// convention used for unknown command names and query values. Empty for every other type. + /// + private static string DescribeValidValues(CommandInfo command, string parameterName) + { + foreach (var parameter in command.Parameters) + { + if (!string.Equals(parameter.Name, parameterName, StringComparison.OrdinalIgnoreCase)) + continue; + var type = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType; + if (type.IsEnum) + return " Valid values: " + string.Join(", ", Enum.GetNames(type)) + "."; + break; + } + return string.Empty; + } + + /// + /// Order the filtered command list: by name, or by originating package with name as + /// tiebreak. Both keys follow the requested direction. + /// + private static IEnumerable SortCommands(IEnumerable commands, bool byPackage, bool descending) + { + if (byPackage) + { + var byPkg = descending + ? commands.OrderByDescending(c => c.Package ?? string.Empty, StringComparer.Ordinal) + : commands.OrderBy(c => c.Package ?? string.Empty, StringComparer.Ordinal); + return descending + ? byPkg.ThenByDescending(c => c.Name, StringComparer.Ordinal) + : byPkg.ThenBy(c => c.Name, StringComparer.Ordinal); + } + return descending + ? commands.OrderByDescending(c => c.Name, StringComparer.Ordinal) + : commands.OrderBy(c => c.Name, StringComparer.Ordinal); + } + + /// + /// Case-insensitive substring match on the command's name, description, or any tag. + /// A null/empty query matches everything. + /// + private static bool MatchesQuery(CommandInfo command, string query) + { + if (string.IsNullOrEmpty(query)) + return true; + return command.Name.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0 + || command.Description.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0 + || command.Tags.Any(t => t.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0); + } + + /// + /// Segment-aware tag subtree match: 'assets' matches tags 'assets' and 'assets/import' + /// but not 'assetsx'. A null/empty tag matches everything. + /// + private static bool MatchesTagSubtree(CommandInfo command, string tag) + { + if (string.IsNullOrEmpty(tag)) + return true; + return command.Tags.Any(t => t.Equals(tag, StringComparison.OrdinalIgnoreCase) + || t.StartsWith(tag + "/", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Group a page of commands by originating package, sorted by package name. + /// + private static List BuildPackageGroups(List commands, Func project) + { + return commands + .GroupBy(c => c.Package ?? string.Empty) + .OrderBy(g => g.Key, StringComparer.Ordinal) + .Select(g => (object)new + { + package = g.Key, + count = g.Count(), + commands = g.Select(project).ToList() + }) + .ToList(); + } + + /// + /// Node of the group_by=tag tree. A command appears in the node of each tag it carries; + /// untagged commands land in a top-level node with an empty tag. + /// + private class TagTreeNode + { + public readonly List Commands = new List(); + public readonly SortedDictionary Children = + new SortedDictionary(StringComparer.Ordinal); + } + + /// + /// Build the nested group_by=tag tree mirroring the tag/subtag hierarchy. Each node's + /// 'count' covers its whole subtree (a command tagged with several tags under the same + /// node counts once per tag entry). + /// + private static List BuildTagTree(List commands, Func project) + { + var root = new TagTreeNode(); + foreach (var command in commands) + { + if (command.Tags.Count == 0) + { + GetOrAddChild(root, string.Empty).Commands.Add(command); + continue; + } + foreach (var tag in command.Tags) + { + var node = root; + foreach (var segment in tag.Split('/')) + node = GetOrAddChild(node, segment); + node.Commands.Add(command); + } + } + return root.Children + .Select(kv => ToTagGroup(kv.Key, kv.Value, project)) + .ToList(); + } + + private static TagTreeNode GetOrAddChild(TagTreeNode node, string key) + { + if (!node.Children.TryGetValue(key, out var child)) + { + child = new TagTreeNode(); + node.Children[key] = child; + } + return child; + } + + private static object ToTagGroup(string path, TagTreeNode node, Func project) + { + return new + { + tag = path, + count = SubtreeCommandCount(node), + commands = node.Commands.Select(project).ToList(), + children = node.Children + .Select(kv => ToTagGroup(path.Length == 0 ? kv.Key : path + "/" + kv.Key, kv.Value, project)) + .ToList() + }; + } + + private static int SubtreeCommandCount(TagTreeNode node) + { + return node.Commands.Count + node.Children.Values.Sum(SubtreeCommandCount); + } + + /// + /// 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, string allowedMethod) + { + await SendErrorResponse(response, "Method Not Allowed", + $"This endpoint only supports {allowedMethod} requests", statusCode: 405); + } + + /// + /// Handle /api/test-status endpoint - returns status of async test execution. + /// + private async Task HandleTestStatusRequest(HttpListenerResponse response) + { + try + { + // Queue behind the same gate as /api/exec/editor_status — see the comment in + // HandleEditorStatusRequest. (In practice this rarely blocks: the async-test-status + // workflow polls this only once run_tests --async_tests has already returned and + // released the gate.) + object result; + await m_ExecGate.WaitAsync(); + try + { + result = await ExecuteCommandByName("test_status", new JObject()); + } + finally + { + m_ExecGate.Release(); + } + + 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}"); + } + } + + /// + /// Runs under the exec gate: tracks the in-flight count for + /// /api/progress ("active" while any exec runs) via CliProgress's own atomic + /// BeginExecutionCount/EndExecutionCount, owns CliProgress for the duration + /// (BeginExecution/EndExecution) so a queued execution can't surface the previous one's + /// stale progress, and resets the explicit report when the last in-flight execution + /// completes. Shared by the synchronous /api/exec path and so + /// the two copies of this scaffold can't drift apart. + /// + /// Id CliProgress.Report calls during this execution are attributed to. + /// The gated work to run once the exec gate is acquired. + /// Runs after the gate is acquired but before + /// CliProgress.BeginExecution; returning false skips entirely + /// (used by the job path to honor a cancellation requested while still queued). + private async Task ExecuteGated(string executionId, Func body, Func preStartCheck = null) + { + Progress.BeginExecutionCount(); + try + { + await m_ExecGate.WaitAsync(); + try + { + if (preStartCheck != null && !preStartCheck()) + return; + + Progress.BeginExecution(executionId); + try + { + await body(); + } + finally + { + Progress.EndExecution(executionId); + } + } + finally + { + m_ExecGate.Release(); + } + } + finally + { + Progress.EndExecutionCount(); + } + } + + /// + /// Execute a detached job (CLI-335). A cancellation requested while the job is still + /// queued prevents it from starting; a running job is only cooperatively cancelable (see + /// PipelineCancellation). + /// + private async Task RunJobDetached(PipelineJobRecord record, CommandInfo command, CommandExecutionRequest commandRequest) + { + // Set once the command actually starts, so a job canceled while still queued reports + // nothing at all. + var ran = false; + var succeeded = false; + var timer = new System.Diagnostics.Stopwatch(); + try + { + await ExecuteGated(record.Id, async () => + { + JobRegistry.MarkRunning(record); + ran = true; + timer.Start(); + try + { + var result = await ExecuteCommandByName(command, commandRequest.Parameters, + UnboundedJobDispatcherTimeoutMs); + if (record.CancellationRequested) + { + // The code honored (or raced) a cancellation request; report + // canceled rather than a half-relevant result. + JobRegistry.MarkCanceled(record); + } + else + { + succeeded = IsSuccessfulResult(result); + JobRegistry.MarkCompleted(record, result); + } + } + catch (OperationCanceledException) + { + JobRegistry.MarkCanceled(record); + } + catch (ArgumentException ex) + { + // Match the synchronous handler, which classifies ArgumentException as + // "Parameter Validation Failed": without this arm a client polling + // GET /api/job cannot tell bad input from a command that ran and failed. + if (record.CancellationRequested) + { + JobRegistry.MarkCanceled(record); + } + else + { + JobRegistry.MarkFailed(record, "Parameter Validation Failed", ex.Message); + } + } + catch (Exception ex) + { + // The command layer wraps thrown exceptions (an + // OperationCanceledException from PipelineCancellation surfaces as + // a wrapped execution failure) — after a cancellation request, any + // failure is reported as the cancellation taking effect. + if (record.CancellationRequested) + { + JobRegistry.MarkCanceled(record); + } + else + { + JobRegistry.MarkFailed(record, "Command Execution Failed", ex.Message); + } + } + finally + { + timer.Stop(); + } + }, + preStartCheck: () => + { + if (record.CancellationRequested) + { + JobRegistry.MarkCanceled(record); + return false; + } + return true; + }); + } + catch (Exception ex) + { + // The runner is fire-and-forget; a failure here must be recorded, never thrown. + Debug.LogError($"RunJobDetached failed: {ex.Message}"); + JobRegistry.MarkFailed(record, "Internal Server Error", ex.Message); + } + + // The submission already reported its own transaction, which could only say the job was + // queued. This is the point where the command's real result is known. + if (ran) + OnCommandDone(CommandExecutionInfo.ForDetachedJob(command, succeeded, timer.ElapsedMilliseconds, + commandRequest.Parameters)); + } + + /// + /// Project a progress snapshot to the {title,info,current,total,pct} wire shape + /// shared by /api/progress and a running job's progress field — kept as one helper + /// so the two can't drift out of sync with each other or the documented contract. + /// + private static object BuildProgressPayload(CliProgressState.Snapshot snapshot) + { + if (!snapshot.HasReport) + return null; + + return new + { + title = snapshot.Title, + info = snapshot.Info, + current = snapshot.Current, + total = snapshot.Total, + pct = snapshot.Progress01 + }; + } + + /// Serialize one job record to the wire shape shared by the job endpoints. + private object BuildJobResponse(PipelineJobRecord record, string warning = null) + { + lock (record.Gate) + { + var running = record.State == PipelineJobState.Running; + var progress = running ? BuildProgressPayload(Progress.Current) : null; + + return new + { + jobId = record.Id, + command = record.Command, + state = record.State.ToString().ToLowerInvariant(), + cancellationRequested = record.CancellationRequested, + enqueuedAt = record.EnqueuedAt, + startedAt = record.StartedAt, + completedAt = record.CompletedAt, + result = record.Result, + error = record.Error, + errorDetails = record.ErrorDetails, + progress, + // Corrective guidance for the caller (e.g. an unparseable omit_nulls value); + // null when there is nothing to say. + warnings = warning == null ? null : new[] { warning } + }; + } + } + + /// + /// Handle GET /api/job?id=… — a detached job's state, progress, and (once terminal) + /// its retained result (CLI-335 poll/reattach). Served on the listener thread. + /// + private async Task HandleJobStatusRequest(HttpListenerRequest request, HttpListenerResponse response) + { + try + { + var id = request.QueryString["id"]; + if (string.IsNullOrEmpty(id)) + { + await SendStatusResponse(response, 400, "Bad Request", "Query parameter 'id' is required"); + return; + } + if (!JobRegistry.TryGet(id, out var record)) + { + await SendStatusResponse(response, 404, "Job Not Found", $"No job with id '{id}' (jobs do not survive domain reloads and are pruned after retention)"); + return; + } + + var omitNulls = ParseOmitNulls(request, out var omitNullsWarning); + await SendJsonResponse(response, 200, BuildJobResponse(record, omitNullsWarning), omitNulls); + } + catch (Exception ex) + { + Debug.LogError($"HandleJobStatusRequest failed: {ex.Message}"); + await SendErrorResponse(response, "Job Status Error", $"Failed to get job status: {ex.Message}"); + } + } + + /// + /// Handle POST /api/job/cancel — request cancellation of a detached job (CLI-335). + /// A queued job never starts; a running job gets the cooperative + /// PipelineCancellation flag (synchronous code cannot be aborted from outside). + /// + private async Task HandleJobCancelRequest(HttpListenerRequest request, HttpListenerResponse response) + { + try + { + if (request.ContentLength64 > MaxCancelBodyBytes) + { + await DrainRequestBody(request, MaxDrainBytes); + await SendStatusResponse(response, 413, "Payload Too Large", + $"Request body exceeds the maximum allowed size of {MaxCancelBodyBytes} bytes"); + return; + } + + string body; + try + { + using (var limited = new MaxLengthStream(request.InputStream, MaxCancelBodyBytes, leaveOpen: true)) + using (var reader = new StreamReader(limited)) + { + body = await reader.ReadToEndAsync(); + } + } + catch (RequestTooLargeException ex) + { + await DrainRequestBody(request, MaxDrainBytes); + await SendStatusResponse(response, 413, "Payload Too Large", ex.Message); + return; + } + finally + { + request.InputStream.Dispose(); + } + + string id = null; + try + { + var parsed = JObject.Parse(string.IsNullOrEmpty(body) ? "{}" : body); + id = parsed["id"]?.ToString(); + } + catch (JsonException) + { + // Fall through to the missing-id error below. + } + + if (string.IsNullOrEmpty(id)) + { + await SendStatusResponse(response, 400, "Bad Request", "Request body must be JSON with an 'id' field"); + return; + } + if (!JobRegistry.RequestCancel(id, out var record)) + { + await SendStatusResponse(response, 404, "Job Not Found", $"No job with id '{id}'"); + return; + } + + var omitNulls = ParseOmitNulls(request, out var omitNullsWarning); + await SendJsonResponse(response, 200, BuildJobResponse(record, omitNullsWarning), omitNulls); + } + catch (Exception ex) + { + Debug.LogError($"HandleJobCancelRequest failed: {ex.Message}"); + await SendErrorResponse(response, "Job Cancel Error", $"Failed to cancel job: {ex.Message}"); + } + } + + /// + /// Handle /api/progress endpoint — the currently executing command's task progress + /// (CLI-488): the server side of the CLI's terminal progress bars, the pipeline + /// equivalent of EditorUtility.DisplayProgressBar. + /// + /// Served entirely on the listener thread from CliProgress's lock-protected snapshot — + /// never marshaled to the main thread — so it stays responsive while a long synchronous + /// command has the main thread blocked (exactly when progress matters most). + /// + /// Contract (all progress fields optional; pct is 0–1; an idle server returns + /// {"active":false,"progress":null}): + /// {"active":true,"progress":{"title":"…","info":"…","current":42,"total":100,"pct":0.42}} + /// + private async Task HandleProgressRequest(HttpListenerRequest request, HttpListenerResponse response) + { + try + { + var active = Progress.IsActive; + var progress = active ? BuildProgressPayload(Progress.Current) : null; + + var omitNulls = ParseOmitNulls(request, out var omitNullsWarning); + await SendJsonResponse(response, 200, new + { + active, + progress, + // Corrective guidance for the caller (e.g. an unparseable omit_nulls value); + // null when there is nothing to say. + warnings = omitNullsWarning == null ? null : new[] { omitNullsWarning } + }, omitNulls); + } + catch (Exception ex) + { + Debug.LogError($"HandleProgressRequest failed: {ex.Message}"); + await SendErrorResponse(response, "Progress Error", $"Failed to get progress: {ex.Message}"); + } + } + + /// + /// Handle /api/dialog endpoint — currently-open modal dialogs, mirroring /api/progress's + /// shape and thread-safety: served entirely from DialogStateTracker's lock-protected + /// state, never marshaled to the main thread, so it answers even while a synchronous + /// command has the main thread blocked inside a modal dialog (exactly when it matters + /// most). + /// + /// Contract: {"active":true,"dialogs":[{"id","source","title","message","level","buttons","openedAt","dismissedAt"}]} + /// + /// "active": false means no dialog of a COVERED kind is open, not that the Editor isn't + /// blocked by some other popup — coverage is mechanism-based (see + /// UnityEditor.EditorDialogEvents' remarks in trunk): native message boxes and EditorWindow + /// modals are covered; OS file/folder pickers and any other dialog mechanism are not. A + /// caller still needs a fallback signal (e.g. a command timeout) for the uncovered cases. + /// + private async Task HandleDialogRequest(HttpListenerResponse response) + { + try + { + var dialogs = Dialogs.CurrentlyOpen; + await SendJsonResponse(response, 200, new + { + active = dialogs.Count > 0, + dialogs = dialogs.Select(BuildDialogPayload).ToList() + }); + } + catch (Exception ex) + { + Debug.LogError($"HandleDialogRequest failed: {ex.Message}"); + await SendErrorResponse(response, "Dialog Error", $"Failed to get dialog state: {ex.Message}"); + } + } + + /// + /// Shared wire projection of a DialogInfo, used by /api/dialog, dialogsDuringExecution, the + /// busy-gate, and (internal, via InternalsVisibleTo) EditorPipelineServer's /api/status. + /// + internal static object BuildDialogPayload(DialogInfo info) => new + { + id = info.Id, + source = info.Source, + title = info.Title, + message = info.Message, + level = info.Level, + buttons = info.Buttons, + openedAt = info.OpenedAtUtc, + dismissedAt = info.DismissedAtUtc + }; + + /// Human-readable one-line summary of a dialog for the busy-gate message. Message/Level are null for a ManagedCustomWindow source, so both are omitted when absent. + private static string FormatDialogSummary(DialogInfo info) + { + var levelPrefix = string.IsNullOrEmpty(info.Level) ? "" : $"[{info.Level}] "; + var messageSuffix = string.IsNullOrEmpty(info.Message) ? "" : $": {info.Message}"; + return $"{levelPrefix}{info.Title}{messageSuffix}"; + } + + /// + /// Handle /api/exec endpoint - execute CLI commands with parameters. + /// + private async Task HandleExecRequest(HttpListenerRequest request, HttpListenerResponse response) + { + var cmd = ""; + // Stay false for pre-parse failures (413/empty/invalid JSON), which cannot honor the + // request's flags; set from the request as soon as it deserializes — before structure + // validation, so a verbose request gets a verbose validation failure. (AUTHAPI-21) + var verbose = false; + var omitNulls = false; + string requestBody = null; + // The execution half of the info reported to OnCommandDone. Both stay at their + // defaults on every branch that answers without running a command, and the timer is + // readable from the catch blocks below because it is started inside the gated body. + CommandInfo executed = null; + JObject executedParameters = null; + var timer = new System.Diagnostics.Stopwatch(); + 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; + } + + // A body of literal JSON "null" parses without error but deserializes to null — + // reject it here so everything below can rely on a non-null request (previously + // this slid through the null-conditional validation and NRE'd at the Command read). + if (commandRequest == null) + { + await SendExecResponse(response, 400, + BaseResponse.Failure("Invalid Request", "Request body must be a JSON object"), requestBody); + return; + } + + // Honor the reply-shape flags from here on: the request has deserialized, so even + // the structure-validation failure below can respect them. + verbose = commandRequest.Verbose; + omitNulls = commandRequest.OmitNulls; + + // Validate request structure + var requestValidationError = commandRequest.Validate(); + if (!string.IsNullOrEmpty(requestValidationError)) + { + await SendExecResponse(response, 400, + BaseResponse.Failure("Invalid Request", requestValidationError), requestBody, verbose, omitNulls); + return; + } + + // Raw command-line forms. Tokenize `commandLine`, or take `argv` as-is (it is + // already split; re-splitting would corrupt any value containing a space), then + // normalize the request IN PLACE by assigning the command name. Normalizing here + // rather than deeper down is what leaves the rest of this method unchanged: + // RunJobDetached re-reads commandRequest, so `job` needs no special handling, and + // the CmdSuccess command echo stays correct. + // + // Placed after Validate() so transport concerns do not depend on the command + // registry, after the reply-shape flags are captured so a malformed line still + // honours verbose/omitNulls, and before ResolveCommand so an unknown raw command + // name produces the same Command Not Found envelope as the structured path. + List rawTokens = null; + if (commandRequest.IsRawForm) + { + if (commandRequest.Argv != null) + { + rawTokens = commandRequest.Argv; + } + else if (!CommandLineTokenizer.TryTokenize(commandRequest.CommandLine, out rawTokens, out var tokenizeError)) + { + // Reported as a malformed request shape, not as an argument error: no + // command was resolved, so there is no schema and no argProblems to carry. + // INVALID_COMMAND_ARGS promises a client both of those. + await SendExecResponse(response, 400, + BaseResponse.Failure("Invalid Request", tokenizeError), + requestBody, verbose, omitNulls); + return; + } + + // A blank first token is as malformed as no token at all, and it is reachable: + // a commandLine of `"" --message hi` is a NONBLANK source string, so Validate() + // passes, yet it tokenizes to an empty command name. Left unchecked that became + // a Command Not Found, telling the caller an empty command is merely + // unavailable. `argv` with an empty first element is already rejected by + // Validate(), so both raw forms answer the same way here. + if (rawTokens.Count == 0 || string.IsNullOrWhiteSpace(rawTokens[0])) + { + await SendExecResponse(response, 400, + BaseResponse.Failure("Invalid Request", "Command name is required"), + requestBody, verbose, omitNulls); + return; + } + + commandRequest.Command = rawTokens[0]; + } + + cmd = commandRequest.Command; + + // Resolve the command once — the busy gate and the execution path share this + // single registry lookup instead of scanning twice per request. An unknown name + // throws here and surfaces as the usual Command Not Found via the catch below + // (for a "job": true submission that now happens BEFORE a job is created, so a + // misnamed command fails fast instead of producing a job that fails later). + var command = ResolveCommand(cmd); + + // Bind the remaining tokens and normalize in place, so everything from + // ExtractCommandParameters downwards sees exactly one shape. + // + // Before the busy gate: a malformed command line is a deterministic client fault, + // and answering 503-retryable first would send clients into a retry loop over an + // error that will never clear. Also before job creation, so a mistyped parameter + // can never return a job id and exit 0. + if (rawTokens != null) + { + var args = rawTokens.GetRange(1, rawTokens.Count - 1); + if (!CommandLineBinder.TryBind(command, args, out var bound, out var argProblems)) + { + await SendExecResponse(response, 400, + CommandExecutionResponse.CmdInvalidArgs(cmd, + DescribeArgProblems(command, argProblems), argProblems, + BuildCommandEntry(command)), + requestBody, verbose, omitNulls); + return; + } + + commandRequest.Parameters = bound; + + // Refuse a known-invalid raw submission before the busy gate and before job + // creation. Binding SUCCEEDS when a required parameter simply was not supplied + // — that rule belongs to parameter validation, not the binder — so without + // this check `{"argv":["log_editor"],"job":true}` reached job creation, was + // acknowledged with a job id and a 200, and only failed later inside the + // detached run. The client would be told a deterministically invalid request + // had been accepted. + var missingRequired = ValidateRequiredParametersBound(command, bound); + if (!string.IsNullOrEmpty(missingRequired)) + { + await SendExecResponse(response, 400, + CommandExecutionResponse.CmdFailure(cmd, "Parameter Validation Failed", missingRequired), + requestBody, verbose, omitNulls); + return; + } + } + + // Dialog busy gate: checked before the settling gate below. A modal dialog genuinely + // blocks the main thread inside a nested OS message loop (unlike settling, which + // merely delays), so it's true regardless of settle state and is the more actionable + // fact for a caller — settling resolves on its own, a dialog needs a human. + // TryGetDialogBlockedFallback is the one exception: a command whose result can be + // served from a cached snapshot instead of running for real (editor_status). + if (command.MainThreadRequired) + { + var openDialogs = Dialogs.CurrentlyOpen; + if (openDialogs.Count > 0) + { + object fallback = TryGetDialogBlockedFallback(command, openDialogs[0]); + if (fallback != null) + { + await SendExecResponse(response, 200, + WithBoundParameters( + CommandExecutionResponse.CmdSuccess(cmd, fallback), commandRequest), + requestBody, verbose, omitNulls); + return; + } + + var summary = FormatDialogSummary(openDialogs[0]); + var separator = summary.EndsWith(".") || summary.EndsWith("!") || summary.EndsWith("?") ? " " : ". "; + var busyResponse = CommandExecutionResponse.CmdBusy(cmd, + $"A modal dialog is currently open and blocking the main thread: {summary}{separator}" + + "Poll GET /api/dialog for details, or retry once it is dismissed.", + "blocked_by_dialog"); + busyResponse.Dialogs = openDialogs.Select(BuildDialogPayload).ToList(); + await SendExecResponse(response, 503, busyResponse, requestBody, verbose, omitNulls); + return; + } + } + + // Busy gate (AUTHAPI-35): while the host is settling after startup, reject + // not-yet-serviceable commands up front — before sync execution AND before a + // detached job is created (a queued job would otherwise run into the half-ready + // Editor in the background). 503 with a retryable envelope, distinguishable from + // a genuine command failure. Only the exec endpoint gates; the status endpoints + // keep working so the busy state itself stays observable. + var busyReason = GetBusyReason(command); + if (busyReason != null) + { + // The busy reply is a standard exec envelope, so it follows the request's + // reply-shape flags like every other branch: lean drops the command echo and + // envelope metadata; verbose restores them (AUTHAPI-21 x AUTHAPI-35). + await SendExecResponse(response, 503, + CommandExecutionResponse.CmdBusy(cmd, busyReason, "settling"), requestBody, verbose, omitNulls); + return; + } + + // Detached job (CLI-335): reply with a job id immediately and run the command + // in the background — the client polls GET /api/job?id=… to reattach and + // collect the result after its own HTTP timeout would have expired. + if (commandRequest.Job) + { + if (!JobRegistry.TryCreate(commandRequest.Command, out var jobRecord)) + { + await SendExecResponse(response, 429, + CommandExecutionResponse.CmdFailure(commandRequest.Command, "Too Many Queued Jobs", + "Too many jobs are queued or running; wait for some to finish and retry."), requestBody, verbose, omitNulls); + return; + } + _ = RunJobDetached(jobRecord, command, commandRequest); + // Standard exec envelope; the job handle is the command's "result". + await SendExecResponse(response, 200, + WithBoundParameters( + CommandExecutionResponse.CmdSuccess(commandRequest.Command, + new { jobId = jobRecord.Id, state = "queued" }), commandRequest), + requestBody, verbose, omitNulls); + return; + } + + // Execute command using shared execution logic (see ExecuteGated: one command at + // a time, exactly as when the accept loop was serial). + DateTime execStartUtc = default; + object result = null; + await ExecuteGated(Guid.NewGuid().ToString("N"), async () => + { + // Claimed here rather than before ExecuteGated, so that a request which never + // gets past the gate reports no execution at all, and so that the wait for the + // one-at-a-time gate counts as queueing rather than as time the command ran. + executed = command; + executedParameters = commandRequest.Parameters; + execStartUtc = DateTime.UtcNow; + timer.Start(); + try + { + result = await ExecuteCommandByName(command, commandRequest.Parameters, + commandRequest.Timeout ?? 60000); + } + finally + { + timer.Stop(); + } + }); + + // Send success response, attaching any dialog(s) that opened during this call + // (dialogsDuringExecution) so a caller learns about them even without polling + // /api/dialog concurrently while the call was in flight. + var successResponse = WithBoundParameters( + CommandExecutionResponse.CmdSuccess(commandRequest.Command, result), commandRequest); + var dialogEvents = Dialogs.EventsSince(execStartUtc); + if (dialogEvents.Count > 0) + successResponse.DialogsDuringExecution = dialogEvents.Select(BuildDialogPayload).ToList(); + + await SendExecResponse(response, 200, successResponse, requestBody, verbose, omitNulls, + executed, IsSuccessfulResult(result), timer.ElapsedMilliseconds, commandRequest.Parameters); + } + catch (ArgumentException ex) + { + // Parameter validation errors + await SendExecResponse(response, 400, + CommandExecutionResponse.CmdFailure(cmd, "Parameter Validation Failed", ex.Message), requestBody, verbose, omitNulls, + executed, false, timer.ElapsedMilliseconds, executedParameters); + } + 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, verbose, omitNulls, + executed, false, timer.ElapsedMilliseconds, executedParameters); + } + catch (InvalidOperationException ex) + { + // Command execution errors + await SendExecResponse(response, 400, + CommandExecutionResponse.CmdFailure(cmd, "Command Execution Failed", ex.Message), requestBody, verbose, omitNulls, + executed, false, timer.ElapsedMilliseconds, executedParameters); + } + 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, verbose, omitNulls, + executed, false, timer.ElapsedMilliseconds, executedParameters); + } + } + + /// + /// 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, int statusCode = 400) + { + try + { + var errorResponse = BaseResponse.Failure(error, details); + await SendResponse(response, statusCode, errorResponse); + } + catch + { + // Ignore errors in error handling + } + } + + private async Task SendResponse(HttpListenerResponse response, int statusCode, BaseResponse pipelineResponse) + { + response.StatusCode = statusCode; + 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 report the finished interaction to + /// . Single send point for the exec handler so every branch + /// (success and error) is captured uniformly. + /// + /// The executed* arguments carry the execution half of the info. Branches that reject a + /// request before any command runs (413, malformed body, busy host, job queue full) leave + /// them at their defaults, which reports an info with no command. + /// + private async Task SendExecResponse(HttpListenerResponse response, int statusCode, + BaseResponse body, string requestJson, bool verbose = false, bool omitNulls = false, + CommandInfo executedCommand = null, bool executedSuccess = false, long executedDurationMs = 0, + JObject executedParameters = null) + { + response.StatusCode = statusCode; + response.ContentType = "application/json"; + // Lean, compact serialization by default; the request's `verbose` flag opts into the + // full envelope and `omitNulls` drops payload nulls. Single contract shared with the + // byte-size regression test. (AUTHAPI-21) + var json = ExecResponseSerializer.Serialize(body, verbose, omitNulls); + var buffer = Encoding.UTF8.GetBytes(json); + response.ContentLength64 = buffer.Length; + + await response.OutputStream.WriteAsync(buffer, 0, buffer.Length); + response.OutputStream.Close(); + + OnCommandDone(new CommandExecutionInfo(requestJson, json, executedCommand, executedSuccess, executedDurationMs, + executedParameters)); + } + + /// + /// Hook invoked once per finished /api/exec interaction, and once more when a detached job + /// completes. The single place to hang post-command work: the Editor server writes the + /// transaction log here and queues its analytics from here. No-op by default (the Player + /// does neither). + /// + /// Runs on the background HTTP thread, so an implementation must either stick to + /// thread-safe work or marshal to the main thread itself. It must also never throw — + /// an exception here would surface as a failed request for work the caller never asked for. + /// + /// The finished interaction: its transaction, its execution, or both. + protected virtual void OnCommandDone(in CommandExecutionInfo info) { } + + /// + /// Whether a command reported success. A command fails in two ways: by throwing, which the + /// caller has already accounted for, or by RETURNING a response that carries its own + /// failure — eval, code reload, run_script and test runs all do the latter, so nothing + /// throws and the outer envelope says success while the inner result says otherwise. + /// + private static bool IsSuccessfulResult(object result) + { + switch (result) + { + case CommandExecutionResponse commandResponse: + return commandResponse.Success; + case BaseResponse baseResponse: + return string.IsNullOrEmpty(baseResponse.Error); + default: + return true; + } + } + + /// + /// 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). + /// + /// + /// Internal rather than private so can DRY-RUN the exact + /// converter the executor will use. Binding against a different conversion would let a + /// command line pass validation and then fail during execution. + /// + internal 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. + /// reports arguments that could not be converted to + /// the parameter's type. Substituting the default there gives the caller a different result + /// than it asked for with no way to detect it — an ignored limit returns more than was + /// asked, an ignored timeout waits longer than allowed. The slot still gets the default + /// so the array stays well-formed, but the callers reject the request. + /// + private object[] ExtractCommandParameters(CommandInfo command, Newtonsoft.Json.Linq.JObject parametersJson, out string conversionError) + { + StringBuilder conversionErrors = null; + 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)) + { + var token = parametersJson[paramName]; + try + { + jsonValue = ConvertParameterToken(token, paramInfo.ParameterType); + + // A converter can DECLINE a token by returning null instead of throwing — + // ObjectRefConverter does exactly that for unsupported kinds, so + // {"parent": true} would otherwise read as an omitted argument and the + // command would run against the default. Anything that converts to null + // without being one of the intentional null forms is a conversion failure. + if (jsonValue == null && !IsIntentionalNullToken(token)) + { + RecordConversionError(ref conversionErrors, paramName, paramInfo, + $"unsupported JSON value ({token.Type})"); + } + } + catch (Exception ex) + { + // Record it rather than logging and moving on: a console warning is invisible + // to the HTTP caller, which is the only party that can act on it. + RecordConversionError(ref conversionErrors, paramName, paramInfo, ex.Message); + } + } + + // 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; + } + } + + conversionError = conversionErrors?.ToString(); + return parameterValues; + } + + /// + /// The token forms a converter may legitimately turn into null, as opposed to declining a + /// value it cannot represent: an explicit JSON null, and an empty or whitespace-only string. + /// The latter is a documented contract, not a quirk — ObjectRefConverter.FromString + /// returns null for it, and set_parent advertises "Omit (or empty) to move the object + /// to the scene root", so {"parent":""} must keep detaching rather than 400. + /// + private static bool IsIntentionalNullToken(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return true; + + return token.Type == JTokenType.String && string.IsNullOrWhiteSpace(token.Value()); + } + + /// Appends one parameter-conversion failure to the accumulated report. + private static void RecordConversionError( + ref StringBuilder errors, string paramName, CommandParameterInfo paramInfo, string detail) + { + if (errors == null) + errors = new StringBuilder(); + else + errors.Append("; "); + errors.Append( + $"Parameter '{paramName}' could not be converted to {paramInfo.ParameterType.Name}: {detail}"); + } + + /// + /// The one wording for a missing required parameter. Shared by the two validators below so + /// the structured and raw request forms cannot diagnose the same mistake differently. + /// + private static string MissingRequiredParameterMessage(string parameterName) + { + return $"Required parameter '{parameterName}' is missing or empty"; + } + + /// + /// Validates required parameters against a BOUND parameter object, before anything runs. + /// + /// applies the same rule to already-extracted CLR + /// values, but it runs inside command execution — which for a detached job is after the job + /// id has already been handed to the client. A raw submission missing a required parameter + /// would be acknowledged as accepted and only fail later, in the background. Checking the + /// bound object first lets that request be refused up front, with the same envelope the + /// synchronous path produces. + /// + /// The rule itself is shared, not reimplemented: both this and the extracted-value check + /// normalize to a CLR value and call , so the raw and + /// structured paths cannot drift on what counts as missing. + /// + private static string ValidateRequiredParametersBound(CommandInfo command, JObject parameters) + { + for (var i = 0; i < command.Parameters.Count; i++) + { + var paramInfo = command.Parameters[i]; + if (!paramInfo.Required) + continue; + + if (IsMissingRequiredValue(AsRequiredValue(parameters?[paramInfo.Name]))) + { + return MissingRequiredParameterMessage(paramInfo.Name); + } + } + + return null; + } + + /// + /// The one rule for "this required parameter was not supplied": absent, null, or the empty + /// string. Both the bound-JSON and extracted-CLR checks normalize to a CLR value and share + /// this, so a future change to what counts as missing cannot be applied to one path and + /// forgotten on the other. + /// + private static bool IsMissingRequiredValue(object value) + { + return value == null || (value is string text && string.IsNullOrEmpty(text)); + } + + /// + /// A bound JSON value as the CLR value expects. A JSON + /// null is a CLR null; a JSON string is its text; anything else is present by definition + /// and stands in for itself. + /// + private static object AsRequiredValue(JToken value) + { + if (value == null || value.Type == JTokenType.Null) + return null; + return value.Type == JTokenType.String ? value.Value() : (object)value; + } + + /// + /// 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]; + + if (paramInfo.Required && IsMissingRequiredValue(parameters[i])) + { + return MissingRequiredParameterMessage(paramInfo.Name); + } + } + + return null; // No validation errors + } + + /// + /// Resolve a command name to its , or throw the standard + /// Command Not Found error. Single registry scan — /api/exec resolves once and shares + /// the result between the busy gate and execution. + /// + private static CommandInfo ResolveCommand(string commandName) + { + 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); + } + return command; + } + + /// + /// Execute a command by name with JSON parameters. + /// Handles command lookup, parameter validation, and execution. + /// Used by callers that only hold a name (status endpoints, detached job runner); the + /// /api/exec handler resolves the command itself and uses the CommandInfo overload. + /// + private async Task ExecuteCommandByName(string commandName, JObject parametersJson, int dispatcherTimeoutMs = 60000) + { + return await ExecuteCommandByName(ResolveCommand(commandName), parametersJson, dispatcherTimeoutMs); + } + + /// + /// Execute a pre-resolved command with JSON parameters (parameter extraction, required + /// validation, threading). + /// + private async Task ExecuteCommandByName(CommandInfo command, JObject parametersJson, int dispatcherTimeoutMs = 60000) + { + // Extract parameters + var parameters = ExtractCommandParameters(command, parametersJson, out var conversionError); + + // Reject unconvertible arguments rather than silently running with the default + if (!string.IsNullOrEmpty(conversionError)) + { + Debug.LogError($"ExecuteCommandByName: Parameter conversion failed: {conversionError}"); + throw new ArgumentException(conversionError); + } + + // 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, dispatcherTimeoutMs); + } + + /// + /// Execute the command method with provided parameters. + /// Handles main thread execution if required. + /// + private async Task ExecuteCommand(CommandInfo command, object[] parameters, int dispatcherTimeoutMs = 60000) + { + object raw; + if (command.MainThreadRequired) + { + // Execute on the main thread using THIS server's dispatcher. + if (m_Dispatcher.IsMainThread()) + { + raw = ExecuteCommandDirect(command, parameters); + } + else + { + // If the command declares its own int "timeout" parameter (e.g. eval/eval_file), + // honor it as the wait budget instead of the caller-provided dispatcher budget + // (UUM-148641) — that's the only mechanism that actually enforces such a + // command's requested deadline end-to-end, in both directions: a request below + // the dispatcher default must time out early, not wait it out. Detached jobs + // are the one exception: they pass an unbounded budget (CLI-335) and rely on + // cooperative cancellation, so the requested value must not re-bound them. + var requestedTimeoutMs = ResolveRequestedTimeoutMs(command, parameters); + var waitBudgetMs = dispatcherTimeoutMs == UnboundedJobDispatcherTimeoutMs + ? dispatcherTimeoutMs + : requestedTimeoutMs ?? dispatcherTimeoutMs; + raw = await Task.Run(() => m_Dispatcher.Invoke(() => ExecuteCommandDirect(command, parameters), waitBudgetMs)); + } + } + else + { + // Execute on background thread + raw = await Task.Run(() => ExecuteCommandDirect(command, parameters)); + } + + return await UnwrapResult(raw); + } + + /// + /// Commands whose own "timeout" parameter is documented in milliseconds and is meant to bound + /// the whole main-thread call. Other commands happen to also declare a "timeout" parameter + /// (e.g. run_tests, in seconds) with different semantics, so this can't be a blanket + /// name/type match — it has to be opted into per command. + /// + private static readonly HashSet CommandsWithMillisecondTimeoutParameter = new HashSet { "eval", "eval_file", "run_script" }; + + /// + /// For commands in , return the value the + /// caller passed for their own "timeout" parameter. Returns null otherwise, in which case the + /// caller should fall back to Dispatcher.Invoke's own default wait. + /// + private static int? ResolveRequestedTimeoutMs(CommandInfo command, object[] parameters) + { + if (!CommandsWithMillisecondTimeoutParameter.Contains(command.Name)) + return null; + + for (int i = 0; i < command.Parameters.Count; i++) + { + // eval/eval_file name their budget "timeout"; run_script names it "timeout_ms". + var pName = command.Parameters[i].Name; + if ((pName == "timeout" || pName == "timeout_ms") && command.Parameters[i].ParameterType == typeof(int)) + { + // A non-positive value cannot be a meaningful wait budget. Forwarding it would + // make Dispatcher.Invoke fail with its own opaque timeout before the command + // ever runs — fall back to the default budget instead, so the command's own + // friendly "timeout must be between …" validation is what answers the caller. + var requested = (int)parameters[i]; + return requested > 0 ? requested : (int?)null; + } + } + + return null; + } + + /// + /// 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; + } + + /// + /// Extract, validate, and invoke a pre-resolved command with JSON parameters synchronously on + /// the current main thread, returning its unwrapped result. This is the exact parameter + /// extraction, required-parameter validation, reflection invoke, and Task unwrap that the + /// /api/exec path () uses, + /// exposed for composite commands (e.g. batch) that re-dispatch other registered + /// commands from inside their own main-thread execution — so each sub-operation behaves, and + /// its result is shaped, identically to a standalone call. + /// + /// MUST be called on the main thread. A composite command is itself + /// MainThreadRequired, so it already runs there; calling off the main thread throws. + /// A command that throws surfaces exactly as it would through /api/exec + /// ( preserved, other exceptions wrapped in + /// ) so the caller can record a per-operation failure. + /// + internal object DispatchCommandOnMainThread(CommandInfo command, JObject parametersJson) + { + if (command == null) + throw new ArgumentNullException(nameof(command)); + if (!m_Dispatcher.IsMainThread()) + throw new InvalidOperationException( + "DispatchCommandOnMainThread must be called on the main thread."); + + var parameters = ExtractCommandParameters(command, parametersJson, out var conversionError); + + // Same contract as ExecuteCommandByName: an argument that could not be converted is a + // rejected request, not a silent fallback to the parameter's default. + if (!string.IsNullOrEmpty(conversionError)) + throw new ArgumentException(conversionError); + + var validationError = ValidateCommandParameters(command, parameters); + if (!string.IsNullOrEmpty(validationError)) + throw new ArgumentException(validationError); + + var raw = ExecuteCommandDirect(command, parameters); + // Unwrap a Task result inline. WARNING: this GetResult() blocks the main thread, so it is + // only safe for results that are already complete (synchronous commands, or async methods + // that happened to complete synchronously). An async command driven to completion by + // EditorApplication.update callbacks (run_tests/list_tests) could NEVER finish here — + // update cannot pump while this call blocks — and the Editor would freeze permanently. + // That is why composite callers (batch) must reject commands with Task return types + // before dispatching them through this path. + return UnwrapResult(raw).GetAwaiter().GetResult(); + } + + /// + /// Direct command execution using reflection. Sets for the + /// duration so CliProgress/PipelineCancellation calls inside the command body resolve to + /// this server, regardless of which thread this ends up running on. + /// + private object ExecuteCommandDirect(CommandInfo command, object[] parameters) + { + var previousServer = m_CurrentServer; + m_CurrentServer = this; + try + { + // Target is null for every attribute-declared command (they are static) and carries + // the receiver for a dynamically registered instance-method handler. + return command.Method.Invoke(command.Target, 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); + } + finally + { + m_CurrentServer = previousServer; + } + } + + + /// + /// Get the port range for this server type. + /// Editor servers use 7800-7899, Runtime servers use 7900-7999. + /// + /// The inclusive port range to try when binding the listener. + 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/AIClient/Packages/com.unity.pipeline/Runtime/Common/BasePipelineServer.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/BasePipelineServer.cs.meta new file mode 100644 index 0000000..85365bd --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common/CliArgAttribute.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CliArgAttribute.cs new file mode 100644 index 0000000..9a17523 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common/CliArgAttribute.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CliArgAttribute.cs.meta new file mode 100644 index 0000000..debe1ba --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common/CliCommandAttribute.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CliCommandAttribute.cs new file mode 100644 index 0000000..c35c534 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CliCommandAttribute.cs @@ -0,0 +1,57 @@ +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; + + /// + /// Optional hierarchical tags used to group and browse commands. + /// Path-style: a '/' separates tag from subtag (e.g. "assets", "assets/import"). + /// Default: empty (untagged). + /// + public string[] Tags { get; set; } = Array.Empty(); + + /// + /// 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/AIClient/Packages/com.unity.pipeline/Runtime/Common/CliCommandAttribute.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CliCommandAttribute.cs.meta new file mode 100644 index 0000000..325d86d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common/CliProgress.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CliProgress.cs new file mode 100644 index 0000000..877fc66 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CliProgress.cs @@ -0,0 +1,234 @@ +using System; + +namespace Unity.Pipeline +{ + /// + /// Per-server progress-reporting state. Owned one-per- (the + /// same pattern as Dispatcher) so a test server's execution can never cross-attribute + /// progress with the live server's. See for the public static + /// façade that arbitrary command code calls. + /// + internal sealed class CliProgressState + { + /// Immutable copy of the current progress state (thread-safe to hand across threads). + internal readonly struct Snapshot + { + public readonly bool HasReport; + public readonly string Title; + public readonly string Info; + public readonly long? Current; + public readonly long? Total; + public readonly double? Progress01; + + public Snapshot(string title, string info, long? current, long? total, double? progress01) + { + HasReport = true; + Title = title; + Info = info; + Current = current; + Total = total; + Progress01 = progress01; + } + } + + readonly object m_Gate = new object(); + Snapshot m_Explicit; + string m_ExplicitOwnerId; + string m_CurrentOwnerId; + Snapshot m_Ambient; + int m_ActiveCount; + + /// Whether any execution is currently in flight. Read by /api/progress. + internal bool IsActive + { + get + { + lock (m_Gate) + { + return m_ActiveCount > 0; + } + } + } + + /// Marks one more execution in flight (see ). + internal void BeginExecutionCount() + { + lock (m_Gate) + { + m_ActiveCount++; + } + } + + /// + /// Marks one execution finished, resetting the explicit-report state when this was the + /// last one in flight. The decrement and the reset-on-zero check happen under the same + /// lock as , so a new execution starting in the gap can never + /// have its fresh BeginExecution/Report wiped by this reset — the previous implementation + /// decremented a separate counter and reset separately, leaving a window where a finishing + /// execution could decrement to zero, get preempted, and then wipe a new execution's + /// owner id and early report once it resumed. + /// + internal void EndExecutionCount() + { + lock (m_Gate) + { + m_ActiveCount--; + if (m_ActiveCount == 0) + { + m_Explicit = default; + m_ExplicitOwnerId = null; + m_CurrentOwnerId = null; + } + } + } + + /// + /// Report the current task's progress so a connected CLI can render it live. + /// Safe to call from any thread, at any frequency (the server samples on poll). + /// + /// Short task title, e.g. "Generating World". + /// Detail line, e.g. "Processing 42/100". + /// Current step, when the task has countable steps. + /// Total steps, when the task has countable steps. + /// Completion in the 0–1 range; omit for indeterminate tasks + /// (when omitted and both and are + /// present, the CLI derives a percentage from them). + internal void Report(string title, string info = null, long? current = null, long? total = null, double? progress = null) + { + var clamped = progress.HasValue ? Math.Max(0d, Math.Min(1d, progress.Value)) : (double?)null; + lock (m_Gate) + { + m_Explicit = new Snapshot(title, info, current, total, clamped); + m_ExplicitOwnerId = m_CurrentOwnerId; + } + } + + /// Clear an explicit (e.g. when the reported task finishes). + internal void Clear() + { + lock (m_Gate) + { + m_Explicit = default; + m_ExplicitOwnerId = null; + } + } + + /// Ambient-source mirror (UnityEditor.Progress items). Explicit reports win. + internal void ReportAmbient(string title, string info, long? current, long? total, double? progress01) + { + lock (m_Gate) + { + m_Ambient = new Snapshot(title, info, current, total, progress01); + } + } + + /// Clear the ambient mirror (no running UnityEditor.Progress items remain). + internal void ClearAmbient() + { + lock (m_Gate) + { + m_Ambient = default; + } + } + + /// + /// Marks the start of the execution identified by (a job id, + /// or a synthetic id for a plain /api/exec). Clears any leftover explicit report + /// from whatever ran before it and becomes the only owner will + /// accept explicit reports from until is called — so a queued + /// execution can never surface the previous one's progress before reporting its own. + /// + internal void BeginExecution(string ownerId) + { + lock (m_Gate) + { + m_CurrentOwnerId = ownerId; + m_Explicit = default; + m_ExplicitOwnerId = null; + } + } + + /// + /// Marks the end of the execution identified by . A no-op if + /// some other execution is already current (e.g. called after a swallowed exception), + /// so callers can call this unconditionally in a finally. + /// + internal void EndExecution(string ownerId) + { + lock (m_Gate) + { + if (m_CurrentOwnerId == ownerId) + { + m_CurrentOwnerId = null; + } + } + } + + /// + /// The snapshot the /api/progress endpoint serves: the explicit report, but only when it + /// was made by the currently executing owner (see ); falls + /// back to the ambient mirror otherwise. + /// + internal Snapshot Current + { + get + { + lock (m_Gate) + { + var explicitValid = m_Explicit.HasReport && m_ExplicitOwnerId == m_CurrentOwnerId; + return explicitValid ? m_Explicit : m_Ambient; + } + } + } + } + + /// + /// Structured progress reporting for long-running pipeline commands — the server side of the + /// CLI's terminal progress bars (CLI-488, coordinated with CLI-335). + /// + /// While a command executes over /api/exec, the CLI polls GET /api/progress and + /// renders whatever is reported here (a progress bar in a terminal, NDJSON progress frames in + /// machine formats). Command authors call from any thread — including a + /// main thread that is blocked inside a long synchronous command, which is exactly the + /// EditorUtility.DisplayProgressBar scenario this exists for. The state is read by the + /// HTTP listener thread, never the main thread, so progress stays visible even while the + /// Editor is busy. + /// + /// This is a thin static façade over the executing server's own + /// (resolved via ) so command code — which has + /// no reference to "which server is running me" — can keep calling this the same way + /// regardless of which server dispatched it. + /// + /// Ambient UnityEditor.Progress items are mirrored in by the Editor assembly + /// (EditorProgressMirror), targeting the live server directly; an explicit + /// always wins over the ambient mirror. Every execution (a plain + /// /api/exec or a detached job) is bracketed with BeginExecution/EndExecution on the + /// server's own , which tags who the current explicit report is + /// allowed to belong to — so a job queued behind one that just finished never inherits its + /// stale progress before making its own first report, no matter how long that job takes to + /// get around to reporting anything. + /// + public static class CliProgress + { + /// + /// Report the current task's progress so a connected CLI can render it live. + /// Safe to call from any thread, at any frequency (the server samples on poll). A no-op + /// when called outside a command's own execution (no server to attribute it to). + /// + /// Short title for the operation. + /// Optional detail text (e.g. current step). + /// Current progress amount, if determinate. + /// Total amount, if determinate. + /// Progress in [0, 1], used instead of current/total when set. + public static void Report(string title, string info = null, long? current = null, long? total = null, double? progress = null) + { + BasePipelineServer.CurrentServer?.Progress.Report(title, info, current, total, progress); + } + + /// Clear an explicit (e.g. when the reported task finishes). + public static void Clear() + { + BasePipelineServer.CurrentServer?.Progress.Clear(); + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/CliProgress.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CliProgress.cs.meta new file mode 100644 index 0000000..2ba2887 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CliProgress.cs.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: 314bec0f00e544458e7558fde1168e82 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetVersion: 1 diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandExecutionInfo.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandExecutionInfo.cs new file mode 100644 index 0000000..4acd0b8 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandExecutionInfo.cs @@ -0,0 +1,91 @@ +using Newtonsoft.Json.Linq; +using Unity.Pipeline.Commands; + +namespace Unity.Pipeline +{ + /// + /// Everything known about one finished /api/exec interaction, handed to + /// . + /// + /// The transaction half and the execution half are independent, because the two are not the + /// same set: a request can produce a transaction without executing anything (rejected for + /// size, malformed JSON, unknown command, busy host, job queue full), and a detached job + /// executes long after its own response was sent, producing no transaction at all. Consumers + /// read the half they care about and skip the ones that lack it. + /// + public readonly struct CommandExecutionInfo + { + /// + /// Raw request JSON, or null when there is no transaction to report — a detached job + /// completing after its submission response was already sent. + /// + public string RequestJson { get; } + + /// + /// Raw response JSON as it went on the wire, or null alongside a null + /// . + /// + public string ResponseJson { get; } + + /// + /// The command that ran, or null when the request was rejected before any command + /// executed. + /// + public CommandInfo Command { get; } + + /// + /// The parameters the command was invoked with, or null when no command ran. Lets a consumer + /// answer questions about WHAT was asked, not just which command served it — the analytics + /// reporter reads an eval body from here to decide whether a concrete command already covers + /// it. Never serialized anywhere: it is in-memory only, for the moment between the command + /// finishing and the post-command work running. + /// + public JObject Parameters { get; } + + /// + /// Whether the command reported success. False when it threw AND when it returned a + /// response carrying its own failure (eval, code reload and test runs all do the latter). + /// Meaningless when is null. + /// + public bool Success { get; } + + /// + /// Wall-clock milliseconds spent inside the command itself, excluding the wait for the + /// one-command-at-a-time execution gate. Zero when is null. + /// + public long DurationMs { get; } + + /// Create an info describing one finished interaction. + /// Raw request JSON, or null when there is no transaction. + /// Raw response JSON, or null alongside a null request. + /// The command that ran, or null when none did. + /// Whether the command reported success. + /// Milliseconds spent inside the command. + /// The parameters the command was invoked with, or null. + public CommandExecutionInfo(string requestJson, string responseJson, CommandInfo command, + bool success, long durationMs, JObject parameters = null) + { + RequestJson = requestJson; + ResponseJson = responseJson; + Command = command; + Success = success; + DurationMs = durationMs; + Parameters = parameters; + } + + /// + /// A detached job that just finished: an execution with no transaction, since the client + /// already received its job handle. + /// + /// The command the job ran. + /// Whether the command reported success. + /// Milliseconds spent inside the command. + /// The parameters the job ran the command with. + /// An info carrying the execution and no transaction. + public static CommandExecutionInfo ForDetachedJob(CommandInfo command, bool success, long durationMs, + JObject parameters = null) + { + return new CommandExecutionInfo(null, null, command, success, durationMs, parameters); + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandExecutionInfo.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandExecutionInfo.cs.meta new file mode 100644 index 0000000..0c2cdfa --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandExecutionInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 36cede5ed8322f54ab8790b9b19b6647 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandInfo.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandInfo.cs new file mode 100644 index 0000000..aad35bd --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandInfo.cs @@ -0,0 +1,101 @@ +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; } + + /// + /// Hierarchical tags used to group and browse commands (path-style, '/'-separated). + /// Empty for untagged commands; never null. + /// + public IReadOnlyList Tags { get; } + + /// + /// Name of the assembly this command originates from, used to group and filter + /// commands by contributing package/source. + /// + public string Package { 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; } + + /// + /// Instance to invoke on, or null when is static. + /// Commands declared with a [CliCommand] attribute are always static and leave this null; + /// a dynamically registered command bound to an instance method carries its receiver here. + /// + public object Target { get; } + + /// + /// Create command information from discovery. + /// + /// Unique name of the command for CLI execution. + /// Human-readable description of the command. + /// Whether this command requires Unity main thread execution. + /// Method that implements this command. + /// Parameters that this command accepts. + /// Whether this command is part of the runtime (Player) command surface only. + /// Hierarchical tags used to group and browse commands. + /// Name of the assembly this command originates from. + /// + /// Instance to invoke on, or null when it is static. Only a + /// dynamically registered command bound to an instance method supplies one. + /// + public CommandInfo(string name, string description, bool mainThreadRequired, + MethodInfo method, IReadOnlyList parameters, bool runtimeOnly = false, + IReadOnlyList tags = null, string package = null, object target = null) + { + 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; + Tags = tags ?? Array.Empty(); + Package = package; + Target = target; + } + + /// A short diagnostic summary of the command. + /// The summary string. + public override string ToString() + { + return $"{Name} MainThreadRequired:{MainThreadRequired} Parameters:{Parameters.Count}"; + } + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandInfo.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandInfo.cs.meta new file mode 100644 index 0000000..3c57877 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandLineBinder.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandLineBinder.cs new file mode 100644 index 0000000..0f0d293 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandLineBinder.cs @@ -0,0 +1,537 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Newtonsoft.Json.Linq; +using Unity.Pipeline.Commands; + +namespace Unity.Pipeline +{ + /// + /// Binds argv tokens (the tokens AFTER the command name) to a command's declared + /// parameters, producing the same a client would have sent as + /// structured parameters. + /// + /// Values are stored as the structured form would carry them. For a parameter with a + /// declared type that means a string JValue, never pre-converted — the single most important + /// constraint here, because it makes {"commandLine":"log_editor hi"} and + /// {"command":"log_editor","parameters":{"message":"hi"}} indistinguishable from + /// ExtractCommandParameters downwards, so there is exactly one execution path and + /// one place where coercion can differ. An UNTYPED parameter has no type to coerce toward, so + /// there the token's shape decides — see StoreValue. + /// + /// Every defect is accumulated rather than bailing on the first, so a user fixing a command + /// line sees all of its problems at once. Required-parameter checking deliberately stays + /// OUT of here — ValidateCommandParameters owns it, so the message and error type are + /// byte-identical across both request forms. + /// + /// Grammar: --key value, --key=value, bare --key. A follower is + /// consumed unless it starts with --, which is what makes --path -foo work. + /// -- ends flag parsing. No short flags, no -abc bundling, no --no-x, + /// no repeated-flag-becomes-array. + /// + internal static class CommandLineBinder + { + private static readonly CommandParameterInfo[] k_NoParameters = new CommandParameterInfo[0]; + + /// + /// Binds against . Returns true only + /// when no problems were found; is always populated as far + /// as binding got, so a caller may inspect a partial bind. + /// + public static bool TryBind(CommandInfo command, IReadOnlyList args, + out JObject parameters, out List problems) + { + if (command == null) + throw new ArgumentNullException(nameof(command)); + + parameters = new JObject(); + problems = new List(); + + var declared = command.Parameters ?? (IReadOnlyList)k_NoParameters; + if (args == null || args.Count == 0) + return true; + + // Pass 1 - classify flags. Positionals are collected with a flag recording whether + // they appeared after "--", which exempts them from the -foo and key=value + // heuristics below. + var flagFilled = new HashSet(StringComparer.OrdinalIgnoreCase); + // Which parameters were set by a BARE flag. Tracked rather than inferred from the + // stored value being boolean: an untyped parameter given the literal token `true` + // also stores a boolean, and conflating the two reported a phantom missing value. + // The token as typed, per parameter, so a type error can quote the user verbatim even + // when the stored value was literal-parsed into a non-string. + var rawTokens = new Dictionary(StringComparer.OrdinalIgnoreCase); + var bareFlagged = new HashSet(StringComparer.OrdinalIgnoreCase); + var positionals = new List(); + var positionalExempt = new List(); + var endOfFlags = false; + + for (var i = 0; i < args.Count; i++) + { + var arg = args[i] ?? string.Empty; + + if (!endOfFlags && arg == "--") + { + endOfFlags = true; + continue; + } + + if (endOfFlags || !arg.StartsWith("--", StringComparison.Ordinal)) + { + positionals.Add(arg); + positionalExempt.Add(endOfFlags); + continue; + } + + var body = arg.Substring(2); + var equals = body.IndexOf('='); + var name = equals >= 0 ? body.Substring(0, equals) : body; + var inlineValue = equals >= 0 ? body.Substring(equals + 1) : null; + + if (name.Length == 0) + { + problems.Add(new ArgProblem { Kind = ArgProblemKind.EmptyName, Token = arg }); + continue; + } + + var parameter = FindParameter(declared, name); + if (parameter == null) + { + problems.Add(new ArgProblem + { + Kind = ArgProblemKind.UnknownName, + Name = name, + Suggestion = SuggestParameterName(name, declared), + }); + // Swallow what was clearly meant as this flag's value, so it does not + // resurface as a spurious excess positional. + if (inlineValue == null && HasConsumableFollower(args, i)) + i++; + continue; + } + + // Checked before the empty-value bail below: a second `--key=` is BOTH a + // duplicate and an empty value, and reporting only the second would let a user + // fix what they were told about and still be duplicating the flag. + if (flagFilled.Contains(parameter.Name)) + problems.Add(new ArgProblem { Kind = ArgProblemKind.Duplicate, Name = parameter.Name }); + + if (inlineValue != null && inlineValue.Length == 0) + { + problems.Add(new ArgProblem { Kind = ArgProblemKind.EmptyValue, Name = parameter.Name }); + continue; + } + + JToken value; + if (inlineValue != null) + { + value = inlineValue; + } + else if (HasConsumableFollower(args, i)) + { + // Absence and emptiness are different: `--message ""` is the empty string, + // not the boolean true. + value = args[++i] ?? string.Empty; + } + else + { + value = new JValue(true); + bareFlagged.Add(parameter.Name); + } + + if (value.Type == JTokenType.String) + { + var text = value.Value(); + parameters[parameter.Name] = StoreValue(parameter, text); + rawTokens[parameter.Name] = text; + } + else + { + parameters[parameter.Name] = value; + } + flagFilled.Add(parameter.Name); + } + + // Pass 2 - positional slots: required parameters in declaration order, then + // optional in declaration order. A slot already filled by a flag is SKIPPED, not + // overwritten, so the explicit flag always wins its own slot. + var slots = new List(declared.Count); + for (var i = 0; i < declared.Count; i++) + if (declared[i].Required) + slots.Add(declared[i]); + for (var i = 0; i < declared.Count; i++) + if (!declared[i].Required) + slots.Add(declared[i]); + + var free = new List(slots.Count); + for (var i = 0; i < slots.Count; i++) + if (!flagFilled.Contains(slots[i].Name)) + free.Add(slots[i]); + + var next = 0; + var firstOverflow = -1; + + for (var i = 0; i < positionals.Count; i++) + { + var token = positionals[i]; + + if (!positionalExempt[i]) + { + // Both of these are reported but STILL consume a slot: the token was + // clearly meant as a positional, and pretending otherwise would cascade + // into misleading downstream problems. + if (IsSingleDashFlag(token)) + { + problems.Add(new ArgProblem { Kind = ArgProblemKind.SingleDash, Token = token }); + } + else if (next < free.Count && IsBareAssignmentNamingItsOwnSlot(token, free[next])) + { + problems.Add(new ArgProblem { Kind = ArgProblemKind.BareAssignment, Token = token }); + } + } + + if (next < free.Count) + { + var slot = free[next++]; + parameters[slot.Name] = StoreValue(slot, token); + rawTokens[slot.Name] = token; + } + else if (firstOverflow < 0) + { + firstOverflow = i; + } + } + + // Pass 3 - coerce, as a DRY RUN only. We never rewrite what we stored: the executor + // re-converts from the same string, so binding and execution cannot diverge. + for (var i = 0; i < declared.Count; i++) + { + var parameter = declared[i]; + var value = parameters[parameter.Name]; + if (value == null) + continue; + + rawTokens.TryGetValue(parameter.Name, out var rawToken); + var problem = CheckCoercible( + value, parameter, bareFlagged.Contains(parameter.Name), rawToken); + if (problem != null) + problems.Add(problem); + } + + if (firstOverflow >= 0) + { + // Which of the two overflow kinds this is depends on the slot the token WOULD + // have taken, not on whether the request used a flag anywhere. Positionals fill + // `free` in order, so the token at index n would have taken slots[n]: if that + // slot exists and a flag claimed it, the token collided with that flag and the + // parameter can be named. Otherwise the command simply received more values than + // it declares parameters. + var collided = firstOverflow < slots.Count && flagFilled.Contains(slots[firstOverflow].Name) + ? slots[firstOverflow] + : null; + + problems.Add(collided != null + ? new ArgProblem + { + Kind = ArgProblemKind.PositionalConflict, + Name = collided.Name, + Token = positionals[firstOverflow], + } + : new ArgProblem + { + Kind = ArgProblemKind.ExcessPositional, + Token = positionals[firstOverflow], + // Capacity and given must be commensurable, because they are rendered as + // one sentence: slots still FREE after the flags took theirs, against + // positionals supplied. Pairing the declared total with the positional + // count compares two different things and reads as nonsense whenever a + // flag is involved ("takes 2 but got 2"). + Capacity = free.Count, + Given = positionals.Count, + }); + } + + return problems.Count == 0; + } + + /// + /// Decides what JSON type a token is stored as. + /// + /// For a parameter with a DECLARED type the answer is always a string: the type + /// itself disambiguates, and ConvertParameterToken widens it downstream. That is + /// also the fix over the CLI's old client-side guessing, which turned 007 into the + /// number 7 even for a declared string. + /// + /// An UNTYPED parameter (JToken/JObject/JArray/object — + /// 14 of them across 12 shipped commands, e.g. set_serialized_field.value) has + /// nothing to coerce toward, so the token's shape is the only signal there is. Those keep + /// the literal rules the CLI used, or a working --value 5 would silently start + /// sending the string "5". + /// + private static JToken StoreValue(CommandParameterInfo parameter, string token) + { + return IsUntypedTarget(parameter.ParameterType) && TryParseLiteral(token, out var literal) + ? literal + : token; + } + + /// A declared type that can hold any JSON value, so it constrains nothing. + private static bool IsUntypedTarget(Type type) + { + return type == typeof(object) || typeof(JToken).IsAssignableFrom(type); + } + + /// + /// Whether a bare flag's implied true is a value this parameter could hold. + /// JObject and JArray are untyped but still cannot, so this is narrower + /// than . + /// + private static bool CanHoldBoolean(Type type) + { + var underlying = Nullable.GetUnderlyingType(type) ?? type; + return underlying == typeof(bool) + || underlying == typeof(object) + || underlying == typeof(JToken) + || underlying == typeof(JValue); + } + + /// + /// The CLI's own literal rules, reproduced exactly: true/false + /// case-insensitively, an all-digit integer with an optional sign (so 07 is 7 — no + /// radix handling), and a single-dot decimal. Everything else stays a string, which is why + /// 1.2.3, a bare - and asset paths pass through untouched. + /// + private static bool TryParseLiteral(string token, out JToken value) + { + value = null; + if (string.IsNullOrEmpty(token)) + return false; + + var lowered = token.ToLowerInvariant(); + if (lowered == "true") { value = new JValue(true); return true; } + if (lowered == "false") { value = new JValue(false); return true; } + + var body = token[0] == '-' ? token.Substring(1) : token; + if (body.Length == 0) + return false; + + var dot = body.IndexOf('.'); + if (dot < 0) + { + if (!AllAsciiDigits(body)) + return false; + if (long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var whole)) + { + value = new JValue(whole); + return true; + } + return false; + } + + // Exactly one dot, digits on both sides. + if (dot == 0 || dot == body.Length - 1 + || body.IndexOf('.', dot + 1) >= 0 + || !AllAsciiDigits(body.Substring(0, dot)) + || !AllAsciiDigits(body.Substring(dot + 1))) + { + return false; + } + + if (double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out var real)) + { + value = new JValue(real); + return true; + } + return false; + } + + private static bool AllAsciiDigits(string s) + { + for (var i = 0; i < s.Length; i++) + if (s[i] < '0' || s[i] > '9') + return false; + return s.Length > 0; + } + + /// + /// Dry-runs the executor's own converter against a bound value and reports the defect, + /// or null when the value is acceptable. + /// + /// Three distinct failures fold into one report: the converter throwing; the converter + /// returning NULL for a non-empty token, which would otherwise make the parameter vanish + /// and let the command run with a default; and a bare flag on a non-bool parameter, which + /// JValue(true).ToObject<string>() would happily turn into the word "True". + /// + private static ArgProblem CheckCoercible( + JToken value, CommandParameterInfo parameter, bool wasBareFlag, string rawToken) + { + var targetType = parameter.ParameterType; + + // A bare flag implied `true`. On a parameter that cannot hold a boolean at all the + // user simply omitted the value, which reads better as EmptyValue than as a type + // error. Untyped is not the same as bool-capable: `object`, `JToken` and `JValue` + // hold `true` happily, while `JObject` and `JArray` cannot, and letting the implied + // `true` reach the converter reported those as a type error instead. + if (wasBareFlag && !CanHoldBoolean(targetType)) + { + return new ArgProblem { Kind = ArgProblemKind.EmptyValue, Name = parameter.Name }; + } + + // The token as the user typed it. An untyped target stores a literal-parsed JValue, + // so the stored value is not always a string and its text is not always the token + // (`007` parses to 7) — reading it back from the value dropped or altered the quote + // the diagnostic exists to show. + var token = rawToken ?? (value.Type == JTokenType.String ? value.Value() : null); + + // Enums are NOT pre-matched against Enum.GetNames. The converter accepts a [Flags] + // comma combination and a numeric value; a hand-rolled name match rejects both, which + // would make the raw form refuse what the structured form binds. TypeMismatch already + // unwraps Nullable for the type name and the prose appends the valid values, so + // deferring costs the message nothing. + try + { + var converted = BasePipelineServer.ConvertParameterToken(value, targetType); + if (converted == null && !string.IsNullOrEmpty(token)) + return TypeMismatch(parameter, token, targetType); + } + catch (Exception) + { + return TypeMismatch(parameter, token, targetType); + } + + return null; + } + + private static ArgProblem TypeMismatch(CommandParameterInfo parameter, string token, Type targetType) + { + return new ArgProblem + { + Kind = ArgProblemKind.TypeMismatch, + Name = parameter.Name, + Token = token, + ExpectedType = (Nullable.GetUnderlyingType(targetType) ?? targetType).Name, + }; + } + + /// + /// True when the token after should be taken as the current + /// flag's value. Anything starting with -- is the next flag, not a value; a + /// single-dash token IS consumable, which is the escape hatch for values like + /// --message -foo. + /// + private static bool HasConsumableFollower(IReadOnlyList args, int index) + { + if (index + 1 >= args.Count) + return false; + var follower = args[index + 1]; + return follower == null || !follower.StartsWith("--", StringComparison.Ordinal); + } + + private static CommandParameterInfo FindParameter(IReadOnlyList declared, string name) + { + for (var i = 0; i < declared.Count; i++) + if (string.Equals(declared[i].Name, name, StringComparison.OrdinalIgnoreCase)) + return declared[i]; + return null; + } + + /// + /// A -x token in positional position: a single dash followed by a LETTER. + /// + /// Letter-anchored, deliberately matching the `unity` CLI's own rule byte for byte. + /// The CLI keeps this check client-side so a typo costs nothing (no Editor boot, no CI + /// license seat), which only stays safe while the local check is a strict SUBSET of this + /// one — any divergence would surface as the server rejecting what the CLI let through. + /// A looser test such as "not parseable as a number" would do exactly that for tokens + /// like -5x. Negative numbers and a bare - remain valid positionals. + /// + private static bool IsSingleDashFlag(string token) + { + return token != null && token.Length >= 2 && token[0] == '-' && IsAsciiLetter(token[1]); + } + + private static bool IsAsciiLetter(char c) + { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + + /// + /// A bare key=value positional whose left side names — the + /// very parameter the token is about to fill, which is what makes it a misremembered + /// --key value rather than a value. + /// + /// Testing the token against its OWN slot, rather than against every declared name, + /// is what keeps legal code clean: eval "timeout=5;" lands on code, and + /// timeout is a different parameter, so the token is a statement. Matching any + /// declared name rejected it while the equivalent structured request succeeded. + /// + /// A token that assigns its own slot (eval "code=1;") stays ambiguous by + /// nature and is still reported; -- is the documented way through. + /// + private static bool IsBareAssignmentNamingItsOwnSlot(string token, CommandParameterInfo slot) + { + if (string.IsNullOrEmpty(token) || token[0] == '-') + return false; + var equals = token.IndexOf('='); + if (equals <= 0) + return false; + return string.Equals(slot.Name, token.Substring(0, equals), StringComparison.OrdinalIgnoreCase); + } + + /// + /// Closest declared parameter name within an edit-distance budget that scales with + /// length, or null. Budget is min(3, max(1, longest/3)): tight enough that an + /// unrelated name gets no suggestion at all, loose enough to catch real typos. + /// + private static string SuggestParameterName(string name, IReadOnlyList declared) + { + string best = null; + var bestDistance = int.MaxValue; + + for (var i = 0; i < declared.Count; i++) + { + var candidate = declared[i].Name; + var budget = Math.Min(3, Math.Max(1, Math.Max(name.Length, candidate.Length) / 3)); + var distance = EditDistance(name, candidate); + if (distance <= budget && distance < bestDistance) + { + bestDistance = distance; + best = candidate; + } + } + + return best; + } + + /// Case-insensitive Levenshtein distance. + private static int EditDistance(string a, string b) + { + a = a.ToLowerInvariant(); + b = b.ToLowerInvariant(); + + var previous = new int[b.Length + 1]; + var current = new int[b.Length + 1]; + + for (var j = 0; j <= b.Length; j++) + previous[j] = j; + + for (var i = 1; i <= a.Length; i++) + { + current[0] = i; + for (var j = 1; j <= b.Length; j++) + { + var substitution = previous[j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1); + var deletion = previous[j] + 1; + var insertion = current[j - 1] + 1; + current[j] = Math.Min(substitution, Math.Min(deletion, insertion)); + } + + var swap = previous; + previous = current; + current = swap; + } + + return previous[b.Length]; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandLineBinder.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandLineBinder.cs.meta new file mode 100644 index 0000000..fb26250 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandLineBinder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eb75a7888547abb419286040fb9a0a58 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandLineTokenizer.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandLineTokenizer.cs new file mode 100644 index 0000000..0ae172f --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandLineTokenizer.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; +using System.Text; + +namespace Unity.Pipeline +{ + /// + /// Splits a raw command line into argv tokens for /api/exec's commandLine form. + /// + /// The dialect is POSIX-ish and is the pipeline's own — not the caller's shell, and + /// identical on every OS, so the same string tokenizes the same way from cmd.exe, bash, an + /// MCP client or a chat box. It is documented as a versioned contract in + /// Documentation~/connectivity.md; CommandLineTokenizerTests pins it. + /// + /// Rules: whitespace separates tokens; "…" groups with \ escaping the next + /// character; '…' groups with no escapes at all; \ escapes outside + /// quotes; adjacent spans concatenate (a"b c"d → ab cd); "" yields one + /// empty token. There is no expansion of any kind — no $VAR, globbing, ~, + /// comments or operators; && and | are ordinary characters. + /// + /// POSIX-ish rather than Windows CommandLineToArgvW because single quotes are + /// load-bearing here — eval 'obj.name = "x";' and batch --operations '[{…}]' + /// need a form that passes double quotes through untouched, and Windows semantics have no + /// single-quote concept. The usual argument for Windows rules (\ is the path + /// separator) does not apply: every path this API accepts is a forward-slashed Unity asset + /// path. + /// + internal static class CommandLineTokenizer + { + /// + /// Tokenizes . Returns false and sets + /// on an unbalanced quote or a dangling backslash. + /// A null, empty or all-whitespace input succeeds with zero tokens — a lexer has no + /// opinion about needing a command name, so the caller reports that (keeping the message + /// identical to the structured path). + /// + public static bool TryTokenize(string commandLine, out List tokens, out string error) + { + tokens = new List(); + error = null; + + if (string.IsNullOrEmpty(commandLine)) + return true; + + var current = new StringBuilder(); + // Distinguishes `""` (one empty token) from whitespace (no token at all). + var hasToken = false; + var inSingle = false; + var inDouble = false; + + for (var i = 0; i < commandLine.Length; i++) + { + var c = commandLine[i]; + + if (inSingle) + { + if (c == '\'') + inSingle = false; + else + current.Append(c); + continue; + } + + if (inDouble) + { + if (c == '"') + { + inDouble = false; + } + else if (c == '\\') + { + if (i + 1 >= commandLine.Length) + { + error = "Dangling backslash at end of command line"; + return false; + } + current.Append(commandLine[++i]); + } + else + { + current.Append(c); + } + continue; + } + + if (c == '\'') + { + inSingle = true; + hasToken = true; + } + else if (c == '"') + { + inDouble = true; + hasToken = true; + } + else if (c == '\\') + { + if (i + 1 >= commandLine.Length) + { + error = "Dangling backslash at end of command line"; + return false; + } + current.Append(commandLine[++i]); + hasToken = true; + } + else if (c == ' ' || c == '\t' || c == '\n' || c == '\r') + { + if (hasToken) + { + tokens.Add(current.ToString()); + current.Length = 0; + hasToken = false; + } + } + else + { + current.Append(c); + hasToken = true; + } + } + + if (inSingle) + { + error = "Unbalanced single quote in command line"; + return false; + } + + if (inDouble) + { + error = "Unbalanced double quote in command line"; + return false; + } + + if (hasToken) + tokens.Add(current.ToString()); + + return true; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandLineTokenizer.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandLineTokenizer.cs.meta new file mode 100644 index 0000000..97370da --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandLineTokenizer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 04dfc14e12dcc7f4dbd172a1e84380b2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandRegistry.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandRegistry.cs new file mode 100644 index 0000000..3d450f4 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandRegistry.cs @@ -0,0 +1,400 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using UnityEngine; +#if UNITY_6000_3_OR_NEWER +using UnityEngine.Assemblies; +#endif +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#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. + /// + /// + /// [NoAutoStaticsCleanup] on purpose: m_DiscoveryLock and the injected discovery mechanism are + /// set up once per domain and must not be reset under the running server. The dynamic-command + /// table is the one static here with a shorter useful life, and PlayModeSessionEnded resets the + /// part of it that a Play session owns. + /// +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + static class CommandRegistry + { + private static IReadOnlyList m_CachedCommands; + private static IReadOnlyList m_CachedAllCommands; + private static readonly Dictionary m_DynamicCommands = + new Dictionary(StringComparer.Ordinal); + // Names in m_DynamicCommands that predate the Play session currently running; null outside + // one. Lets PlayModeSessionEnded tell a registration made during the session apart from one + // that was already there. See PlayModeSessionStarted. + private static HashSet m_PrePlayModeCommands; + private static ICommandDiscovery m_Discovery; + private static readonly object m_DiscoveryLock = new object(); + + /// + /// Set the command discovery mechanism. + /// Editor assembly provides TypeCache-based discovery, Runtime uses reflection fallback. + /// + /// The discovery mechanism to use. + public static void SetDiscovery(ICommandDiscovery discovery) + { + m_Discovery = discovery; + ClearCache(); // Re-discover with new mechanism + } + + /// + /// Discover every available command: the methods marked with [CliCommand], plus any + /// registered through . + /// Attribute discovery uses the injected mechanism (TypeCache in Editor, reflection in + /// Runtime) and is cached until domain reload; the combined result is re-cached whenever + /// a dynamic command is registered or unregistered. + /// + /// All discovered commands. + public static IEnumerable DiscoverCommands() + { + var cached = m_CachedAllCommands; + if (cached != null) + { + return cached; + } + + lock (m_DiscoveryLock) + { + if (m_CachedCommands == null) + { + m_CachedCommands = DiscoverCommandsInternal().ToList(); + } + + if (m_CachedAllCommands == null) + { + m_CachedAllCommands = m_DynamicCommands.Count == 0 + ? m_CachedCommands + : m_CachedCommands.Concat(m_DynamicCommands.Values).ToList(); + } + } + + return m_CachedAllCommands; + } + + /// + /// Register a command at runtime, without a [CliCommand] attribute. The resulting command + /// is indistinguishable from an attribute-declared one: it is listed by /api/commands + /// and executable through /api/exec under . + /// + /// Parameter metadata is read from the handler's own parameters exactly as it is for an + /// attribute-declared command — a [CliArg] on a parameter supplies its CLI name, + /// description, and required flag; a parameter without one falls back to its declared name + /// and default value. Note that a lambda cannot carry [CliArg], so registering a named + /// method is what yields full metadata. + /// + /// Registrations live until they are unregistered or the domain reloads; they survive + /// and . One made while the Editor is in + /// Play Mode is additionally dropped when Play Mode exits — see + /// . + /// + /// Unique command name for CLI execution. + /// Human-readable description, shown in command listings. + /// + /// Method to invoke. An instance-method delegate keeps its receiver, so the command runs + /// against that instance; a static one runs with no receiver. + /// + /// Whether the command must run on the Unity main thread. + /// Whether the command belongs to the Player command surface only. + /// Optional path-style tags used to group and browse the command. + /// + /// A command called is already registered. Attribute-declared + /// commands can never be replaced; a dynamic one must be unregistered first. + /// + public static void RegisterCommand(string name, string description, Delegate handler, + bool mainThreadRequired = true, bool runtimeOnly = false, string[] tags = null) + { + if (name == null) + throw new ArgumentNullException(nameof(name)); + if (description == null) + throw new ArgumentNullException(nameof(description)); + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Command name must not be empty or whitespace.", nameof(name)); + + var method = handler.Method; + var command = new CommandInfo( + name, + description, + mainThreadRequired, + method, + DiscoverParameters(method).ToList(), + runtimeOnly, + tags, + method.DeclaringType?.Assembly.GetName().Name, + handler.Target); + + lock (m_DiscoveryLock) + { + if (m_DynamicCommands.ContainsKey(name)) + { + throw new InvalidOperationException( + $"A command named '{name}' is already registered dynamically. " + + "Unregister it before registering it again."); + } + + // Anything left matching here is attribute-declared: the dynamic table was just checked. + if (DiscoverCommands().Any(c => c.Name == name)) + { + throw new InvalidOperationException( + $"A command named '{name}' is already declared with a [CliCommand] attribute " + + "and cannot be replaced."); + } + + m_DynamicCommands[name] = command; + m_CachedAllCommands = null; + } + } + + /// + /// Remove a command previously registered with . + /// Attribute-declared commands are part of the code and are never removable. + /// + /// Name the command was registered under. + /// + /// True if a dynamic command was removed; false if no command is registered under that + /// name dynamically, including when the name belongs to an attribute-declared command. + /// + public static bool UnregisterCommand(string name) + { + if (name == null) + throw new ArgumentNullException(nameof(name)); + + lock (m_DiscoveryLock) + { + if (!m_DynamicCommands.Remove(name)) + return false; + + m_CachedAllCommands = null; + return true; + } + } + + /// + /// Note that a Play session is starting, so registrations made from here on can be told + /// apart from the ones that predate it. Called by the Editor on Play Mode entry, before any + /// user code runs. + /// + internal static void PlayModeSessionStarted() + { + lock (m_DiscoveryLock) + { + m_PrePlayModeCommands = new HashSet(m_DynamicCommands.Keys, StringComparer.Ordinal); + } + } + + /// + /// Drop every dynamic registration made during the Play session that just ended, the way a + /// domain reload would have. Registrations that predate the session are left alone: nothing + /// re-runs to restore them (an [InitializeOnLoadMethod] runs once per domain). + /// + /// A script registering from Awake or OnEnable re-registers on every Play Mode entry, so + /// without this the second entry would throw "already registered dynamically" whenever no + /// domain reload happened in between — and any surviving entry would still hold a delegate + /// bound to the previous session's destroyed object. That covers Enter Play Mode with domain + /// reload disabled on any Editor version, and on 6000.5+ the class-wide + /// [NoAutoStaticsCleanup] means AutoStaticsCleanup does not step in either. + /// + internal static void PlayModeSessionEnded() + { + lock (m_DiscoveryLock) + { + // No recorded session start (e.g. a domain reload wiped it on the way in, having + // already cleared the registry): nothing is known to belong to the session, so + // removing anything would be guesswork. + if (m_PrePlayModeCommands == null) + return; + + foreach (var name in m_DynamicCommands.Keys.Where(n => !m_PrePlayModeCommands.Contains(n)).ToList()) + m_DynamicCommands.Remove(name); + + m_PrePlayModeCommands = null; + m_CachedAllCommands = null; + } + } + + /// + /// Clear the discovered-command cache, so the next discovery re-scans for [CliCommand] + /// methods. Called automatically on domain reload, and whenever the discovery mechanism + /// changes. Commands registered through are not affected. + /// + public static void ClearCache() + { + lock (m_DiscoveryLock) + { + m_CachedCommands = null; + m_CachedAllCommands = 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, + commandAttr.Tags, + method.DeclaringType?.Assembly.GetName().Name + ); + } + + /// + /// 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); + } + } + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandRegistry.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/CommandRegistry.cs.meta new file mode 100644 index 0000000..607900e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common/DialogStateTracker.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/DialogStateTracker.cs new file mode 100644 index 0000000..6d143f6 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/DialogStateTracker.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Unity.Pipeline +{ + /// Snapshot of one dialog shown/dismissed event, as relayed by EditorDialogEvents. + internal readonly struct DialogInfo + { + public readonly int Id; + public readonly string Source; + public readonly string Title; + public readonly string Message; + public readonly string[] Buttons; + public readonly string Level; + public readonly DateTime OpenedAtUtc; + public readonly DateTime? DismissedAtUtc; + + public DialogInfo(int id, string source, string title, string message, string[] buttons, string level, DateTime openedAtUtc, DateTime? dismissedAtUtc) + { + Id = id; + Source = source; + Title = title; + Message = message; + Buttons = buttons; + Level = level; + OpenedAtUtc = openedAtUtc; + DismissedAtUtc = dismissedAtUtc; + } + + public DialogInfo WithDismissed(DateTime dismissedAtUtc) => + new DialogInfo(Id, Source, Title, Message, Buttons, Level, OpenedAtUtc, dismissedAtUtc); + } + + /// + /// Per-server modal-dialog state (the same one-instance-per- + /// ownership as CliProgressState/Progress, so a test server's dialog state never + /// crosses into the live server's). Unlike progress, dialogs are not scoped to a currently + /// executing command — they can be raised by anything (a human, a background job, an unrelated + /// command) at any time, so there is no owner-id concept here. + /// + internal sealed class DialogStateTracker + { + const int MaxRecentEvents = 50; + + readonly object m_Gate = new object(); + readonly Dictionary m_Open = new Dictionary(); + readonly LinkedList m_Recent = new LinkedList(); + + internal bool IsDialogOpen + { + get { lock (m_Gate) return m_Open.Count > 0; } + } + + /// + /// Ordered oldest-opened-first: callers (the dialog busy-gate, /api/status) treat index 0 + /// as "the first open dialog", and Dictionary<,>'s enumeration order isn't guaranteed + /// to match insertion order once entries have been removed and re-added. + /// + internal List CurrentlyOpen + { + get { lock (m_Gate) return m_Open.Values.OrderBy(e => e.OpenedAtUtc).ToList(); } + } + + internal void OnShown(int id, string source, string title, string message, string[] buttons, string level, DateTime openedAtUtc) + { + var info = new DialogInfo(id, source, title, message, buttons, level, openedAtUtc, null); + lock (m_Gate) + { + m_Open[id] = info; + } + } + + internal void OnDismissed(int id, DateTime dismissedAtUtc) + { + lock (m_Gate) + { + if (!m_Open.TryGetValue(id, out var info)) + return; + + m_Open.Remove(id); + m_Recent.AddLast(info.WithDismissed(dismissedAtUtc)); + while (m_Recent.Count > MaxRecentEvents) + m_Recent.RemoveFirst(); + } + } + + /// + /// Dialogs opened at or after — still-open ones and recently + /// dismissed ones. Used to attach dialogsDuringExecution to a command's exec response. + /// + internal List EventsSince(DateTime utc) + { + lock (m_Gate) + { + var result = m_Open.Values.Where(e => e.OpenedAtUtc >= utc).ToList(); + result.AddRange(m_Recent.Where(e => e.OpenedAtUtc >= utc)); + return result; + } + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/DialogStateTracker.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/DialogStateTracker.cs.meta new file mode 100644 index 0000000..8060cbe --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/DialogStateTracker.cs.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: 9700dd7e04984bd7a48f4d5f0f82d71c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetVersion: 1 diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/Dispatcher.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/Dispatcher.cs new file mode 100644 index 0000000..8c850ab --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/Dispatcher.cs @@ -0,0 +1,299 @@ +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 RuntimePipelineDriver.Update in a player. + /// + public class Dispatcher + { + private readonly ConcurrentQueue m_WorkQueue = new ConcurrentQueue(); + private volatile bool m_IsInitialized; + private int m_MainThreadId = -1; + + /// True once has run and this dispatcher is pumping its queue. + 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). + /// + /// The function's return type. + /// Function to run on the main thread. + /// Time to wait before throwing . + /// The function's result. + 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) + { + // No-op if execution already started on the main thread — only prevents a + // still-queued item from running after we've reported the timeout. + workItem.TryCancel(); + 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. + /// + /// Action to run on the main thread. + /// Time to wait before throwing . + 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). + /// + /// The function's return type. + /// Function to run on the main thread. + /// Time to wait before throwing . + /// The function's result. + 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). + /// + /// Action to run on the main thread. + /// Time to wait before throwing . + /// A task that completes once the action has run on the main thread. + public async Task InvokeAsync(Action action, int timeoutMs = 60000) + { + await Task.Run(() => Invoke(action, timeoutMs)); + } + + /// + /// Queue an action to run on the main thread and return immediately. + /// + /// For work with no caller left to return to — reporting, logging, telemetry, anything + /// whose result nobody reads. is wrong for that: it parks + /// the calling thread until the next pump, and on a request thread that delay is paid by + /// whoever is waiting on the response. InvokeAsync only moves the same wait onto a + /// threadpool thread. + /// + /// The action always runs on a later pump, never inline, even when called from the main + /// thread, so posted work runs in the order it was posted. It has nowhere to report a + /// failure, so an exception is logged and swallowed rather than propagated. Work still + /// queued when the dispatcher shuts down is dropped, and posting to a dispatcher that is + /// not running is a no-op for the same reason — losing a report is always preferable to + /// throwing at a caller that was only trying to record something. + /// + /// Action to run on the main thread on a later pump. + public void Post(Action action) + { + if (action == null) + throw new ArgumentNullException(nameof(action)); + + if (!m_IsInitialized) + return; + + m_WorkQueue.Enqueue(new PostedWorkItem(action)); + } + + /// + /// Check if we're currently on Unity's main thread. + /// + /// True if the calling thread is the main thread captured by . + 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++; + } + } + + /// Process queued work items, up to a default limit of 10 per call. + public void ProcessWorkQueue() + { + ProcessWorkQueue(10); + } + + private abstract class WorkItem + { + private const int Pending = 0; + private const int Started = 1; + private const int Canceled = 2; + private int m_State; + + /// Marks the item canceled if it hasn't started executing yet. Returns false + /// (no-op) once has already claimed it. + public bool TryCancel() => Interlocked.CompareExchange(ref m_State, Canceled, Pending) == Pending; + + public void Execute() + { + if (Interlocked.CompareExchange(ref m_State, Started, Pending) != Pending) + return; // Canceled before it could start. + + ExecuteCore(); + } + + protected abstract void ExecuteCore(); + public abstract void SetException(Exception exception); + } + + /// + /// A work item. Unlike it carries no + /// TaskCompletionSource: nobody is waiting on the result, so a fault has nowhere to go but + /// the log — and faulting a TCS no one awaits would surface later as an unobserved task + /// exception. A shutdown cancellation simply drops the work. + /// + private class PostedWorkItem : WorkItem + { + private readonly Action m_Action; + + public PostedWorkItem(Action action) + { + m_Action = action; + } + + protected override void ExecuteCore() + { + try + { + m_Action(); + } + catch (Exception ex) + { + Debug.LogWarning($"Dispatcher posted work failed: {ex.Message}"); + } + } + + public override void SetException(Exception exception) + { + // Nobody is waiting on this item, so there is nothing to hand the exception to. + } + } + + 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(); + } + + protected override void ExecuteCore() + { + 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/AIClient/Packages/com.unity.pipeline/Runtime/Common/Dispatcher.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/Dispatcher.cs.meta new file mode 100644 index 0000000..49e040a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common/ExecResponseSerializer.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/ExecResponseSerializer.cs new file mode 100644 index 0000000..62cb2b9 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/ExecResponseSerializer.cs @@ -0,0 +1,141 @@ +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Unity.Pipeline.Models; + +namespace Unity.Pipeline +{ + /// + /// Single source of truth for how /api/exec replies are serialized (AUTHAPI-21). Centralized + /// here so the server and the envelope byte-size regression test share one contract. + /// + /// Every mode serializes with (compact): agents consume the JSON, and + /// an MCP client can pretty-print for a human if ever needed — the wire form never needs indentation. + /// + /// Two independent request flags select the mode: + /// + /// * verbose — envelope metadata. Off (default): the always-on metadata (executedAt, + /// command, executionTimeMs) is stripped from every -derived object + /// in the graph (the outer envelope AND responses nested as result, e.g. eval's), and + /// the envelope's own null keys (message/error/errorDetails when null, result on failure — all + /// redundant given success) are omitted, so a minimal success is just + /// {"success":true,"result":...}. On: full fidelity for debugging/correlation. + /// * omitNulls — payload null fidelity. Off (default): the command's own result + /// payload keeps explicit nulls — an agent must be able to distinguish "this field is null" + /// from "this field doesn't exist / I got the name wrong". On: null keys are dropped + /// everywhere, for callers that already know the result schema and want the bytes back + /// (e.g. bulk list reads where a null column repeats hundreds of times). + /// + /// One invariant holds in EVERY mode: a successful reply always carries result, even + /// when its value is null — a null result on success is the command's actual value (e.g. a + /// format="value" read of a genuinely-null field), never a droppable envelope null. + /// + /// The two axes are deliberately separate booleans: envelope metadata and payload null fidelity + /// are orthogonal concerns, and each combination is meaningful. + /// + /// The gating lives in a contract resolver scoped to THIS serializer, not in ShouldSerialize* + /// hooks on the models: serializing these types anywhere else (other endpoints, logs, tests) is + /// unaffected, and Serialize never mutates the response object. + /// + internal static class ExecResponseSerializer + { + /// + /// Lean-envelope gating for every -derived object in the graph + /// (nested results included): strips the always-on metadata and omits the envelope's own + /// null keys — WITHOUT touching null handling on non-envelope payload objects, which keep + /// the serializer-level default. One shared instance per settings object: + /// DefaultContractResolver caches resolved contracts per resolver instance (thread-safe), + /// so the reflection cost is paid once per type, not per request. + /// + private class LeanEnvelopeContractResolver : DefaultContractResolver + { + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + var property = base.CreateProperty(member, memberSerialization); + + // DeclaringType guards against unrelated same-named members (e.g. + // CommandExecutionRequest.Command must keep serializing). + if (member.DeclaringType != null && typeof(BaseResponse).IsAssignableFrom(member.DeclaringType)) + { + switch (member.Name) + { + case nameof(BaseResponse.ExecutedAt): + case nameof(CommandExecutionResponse.Command): + case nameof(CommandExecutionResponse.ExecutionTimeMs): + property.ShouldSerialize = _ => false; + break; + + // The ONLY keys lean mode may null-drop: genuinely redundant given + // `success` (error/errorDetails are null exactly on success, message on + // both paths when there is nothing to say). Everything else declared on + // a BaseResponse-DERIVED type is payload (TestExecutionResponse's + // summary/results/filterApplied, EvalResponse.Output, …) and keeps + // explicit nulls — a dropped payload key is indistinguishable from a + // nonexistent or misspelled one; e.g. an async run_tests poller could + // not tell "not ready yet" from "not a field". Enumerated per key + // instead of type-wide for exactly that reason. + case nameof(BaseResponse.Message): + case nameof(BaseResponse.Error): + case nameof(BaseResponse.ErrorDetails): + property.NullValueHandling = NullValueHandling.Ignore; + break; + + case nameof(CommandExecutionResponse.Result): + // A null result on SUCCESS is not a redundant envelope null — it is + // the command's actual value (e.g. get_serialized_fields + // format="value" reading a genuinely-null field), so a successful + // reply always carries "result", explicitly null when null (Include + // also defeats the omitNulls serializer settings). On failure the + // result is structurally meaningless and stays omitted. + property.NullValueHandling = NullValueHandling.Include; + property.ShouldSerialize = instance => + !(instance is CommandExecutionResponse response) || response.Success; + break; + } + } + + return property; + } + } + + private static readonly JsonSerializerSettings m_LeanSettings = new JsonSerializerSettings + { + Formatting = Formatting.None, + NullValueHandling = NullValueHandling.Include, + ContractResolver = new LeanEnvelopeContractResolver(), + }; + + private static readonly JsonSerializerSettings m_LeanOmitNullsSettings = new JsonSerializerSettings + { + Formatting = Formatting.None, + NullValueHandling = NullValueHandling.Ignore, + ContractResolver = new LeanEnvelopeContractResolver(), + }; + + private static readonly JsonSerializerSettings m_VerboseSettings = new JsonSerializerSettings + { + Formatting = Formatting.None, + NullValueHandling = NullValueHandling.Include, + }; + + private static readonly JsonSerializerSettings m_VerboseOmitNullsSettings = new JsonSerializerSettings + { + Formatting = Formatting.None, + NullValueHandling = NullValueHandling.Ignore, + }; + + /// + /// Serialize an exec response body with the contract selected by the request's flags (see + /// the class doc for the two axes). Pure: the body is never mutated — the mode is carried + /// entirely by the serializer settings, so serializing the same instance repeatedly (in + /// any order) always yields the same JSON per mode. + /// + public static string Serialize(BaseResponse body, bool verbose, bool omitNulls = false) + { + var settings = verbose + ? (omitNulls ? m_VerboseOmitNullsSettings : m_VerboseSettings) + : (omitNulls ? m_LeanOmitNullsSettings : m_LeanSettings); + return JsonConvert.SerializeObject(body, settings); + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/ExecResponseSerializer.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/ExecResponseSerializer.cs.meta new file mode 100644 index 0000000..92223a3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/ExecResponseSerializer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 56ce3a87dbdb3c84bac7bccdf5c8c0fb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/FilePermissions.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/FilePermissions.cs new file mode 100644 index 0000000..044c06d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/FilePermissions.cs @@ -0,0 +1,110 @@ +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. + /// + static class FilePermissions + { + /// + /// Restrict the file at to the current user only. + /// Failures are logged but never thrown, so they cannot block server startup. + /// + /// The file to restrict. + 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/AIClient/Packages/com.unity.pipeline/Runtime/Common/FilePermissions.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/FilePermissions.cs.meta new file mode 100644 index 0000000..211ca38 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common/ICommandDiscovery.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/ICommandDiscovery.cs new file mode 100644 index 0000000..6f1f37a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/ICommandDiscovery.cs @@ -0,0 +1,20 @@ +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. + /// + interface ICommandDiscovery + { + /// + /// Find all methods marked with CliCommand attribute. + /// Implementation uses TypeCache in Editor or reflection fallback in Runtime. + /// + /// The attribute type to search for. + /// The matching methods. + IEnumerable GetMethodsWithAttribute() where T : System.Attribute; + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/ICommandDiscovery.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/ICommandDiscovery.cs.meta new file mode 100644 index 0000000..4110a5b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common/IStructuredCommandInput.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/IStructuredCommandInput.cs new file mode 100644 index 0000000..fafd213 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common/IStructuredCommandInput.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/IStructuredCommandInput.cs.meta new file mode 100644 index 0000000..cc7ec9a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common/JsonSchemaGenerator.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/JsonSchemaGenerator.cs new file mode 100644 index 0000000..75a5f74 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/JsonSchemaGenerator.cs @@ -0,0 +1,326 @@ +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. + /// + static class JsonSchemaGenerator + { + /// + /// Generate JSON Schema for a command. + /// Returns standard JSON Schema format for CLI validation. + /// + /// The command to generate a schema for. + /// The generated JSON Schema document. + 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/AIClient/Packages/com.unity.pipeline/Runtime/Common/JsonSchemaGenerator.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/JsonSchemaGenerator.cs.meta new file mode 100644 index 0000000..34f6582 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common/MaxLengthStream.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/MaxLengthStream.cs new file mode 100644 index 0000000..aa41352 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common/MaxLengthStream.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/MaxLengthStream.cs.meta new file mode 100644 index 0000000..1b0dca1 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common/ParameterInfo.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/ParameterInfo.cs new file mode 100644 index 0000000..a60d255 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/ParameterInfo.cs @@ -0,0 +1,62 @@ +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. + /// + /// Name of the parameter for CLI arguments. + /// Human-readable description of the parameter. + /// Whether this parameter is required for command execution. + /// Type of the parameter for validation and conversion. + /// Default value for optional parameters. + 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; + } + + /// A short diagnostic summary of the parameter. + /// The summary string. + public override string ToString() + { + return $"{Name} Required:{Required} Type:{ParameterType}"; + } + + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/ParameterInfo.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/ParameterInfo.cs.meta new file mode 100644 index 0000000..445c8ac --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common/PipelineJobs.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/PipelineJobs.cs new file mode 100644 index 0000000..8dc0dd9 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/PipelineJobs.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; + +namespace Unity.Pipeline +{ + /// + /// Lifecycle states of a detached command job (CLI-335). + /// Serialized to the wire as lower-case strings (see BasePipelineServer). + /// + internal enum PipelineJobState + { + Queued, + Running, + Completed, + Failed, + Canceled + } + + /// One detached command execution tracked by . + internal sealed class PipelineJobRecord + { + public string Id; + public string Command; + public PipelineJobState State; + public object Result; + public string Error; + public string ErrorDetails; + public DateTime EnqueuedAt; + public DateTime? StartedAt; + public DateTime? CompletedAt; + public volatile bool CancellationRequested; + + /// Guards State/Result/Error/timestamps; reads and writes cross threads. + public readonly object Gate = new object(); + } + + /// + /// Registry of detached command jobs (CLI-335): a client can submit a command as a job + /// (`/api/exec` with `"job": true`), get a job id back immediately, and later poll + /// `/api/job?id=…` to reattach and collect the result — surviving the client's own HTTP + /// timeout. Results are retained (bounded, see below) so a client that timed out or + /// disconnected can still fetch the outcome. + /// + /// Owned one-per- (the same pattern as Dispatcher), so + /// a test server's jobs can never cross-attribute state with the live server's. + /// + /// In-memory by design: a domain reload (script recompilation) discards pending jobs the + /// same way it discards every other C# static. That limitation is inherent to in-Editor + /// state and is tracked separately. + /// + internal sealed class PipelineJobRegistry + { + /// Completed/failed/canceled jobs retained beyond this count are pruned oldest-first. + private const int MaxRetainedTerminalJobs = 100; + + /// + /// Jobs allowed queued or running at once. Jobs execute strictly serially through the + /// exec gate, so a deep backlog beyond this is pure queued work with no throughput + /// benefit — an authorized client loop (or a retrying script) submitting faster than jobs + /// drain would otherwise grow this without bound. + /// + private const int MaxQueuedJobs = 100; + + /// Terminal jobs older than this are pruned regardless of count. + private static readonly TimeSpan m_RetentionTtl = TimeSpan.FromHours(1); + + private readonly ConcurrentDictionary m_Jobs = + new ConcurrentDictionary(); + + /// Guards the queued-job cap check against a concurrent create racing past it. + private readonly object m_CreateGate = new object(); + + /// + /// The record currently executing. At most one job runs at a time (jobs share the + /// server's exec gate), so a single reference suffices; read by + /// from whatever thread the command runs on. + /// + private volatile PipelineJobRecord m_CurrentRunning; + + internal PipelineJobRecord CurrentRunning => m_CurrentRunning; + + /// + /// Create a queued job for , unless + /// queued-or-running jobs already exist (returns false; the caller should reject the + /// submission rather than grow an unbounded backlog). + /// + internal bool TryCreate(string command, out PipelineJobRecord record) + { + Prune(); + + lock (m_CreateGate) + { + var nonTerminal = m_Jobs.Values.Count(job => + { + lock (job.Gate) + { + return job.State == PipelineJobState.Queued || job.State == PipelineJobState.Running; + } + }); + if (nonTerminal >= MaxQueuedJobs) + { + record = null; + return false; + } + + record = new PipelineJobRecord + { + Id = Guid.NewGuid().ToString("N"), + Command = command, + State = PipelineJobState.Queued, + EnqueuedAt = DateTime.UtcNow + }; + m_Jobs[record.Id] = record; + } + return true; + } + + internal bool TryGet(string id, out PipelineJobRecord record) + { + Prune(); + return m_Jobs.TryGetValue(id ?? string.Empty, out record); + } + + internal void MarkRunning(PipelineJobRecord record) + { + lock (record.Gate) + { + record.State = PipelineJobState.Running; + record.StartedAt = DateTime.UtcNow; + } + m_CurrentRunning = record; + } + + internal void MarkCompleted(PipelineJobRecord record, object result) + { + lock (record.Gate) + { + record.State = PipelineJobState.Completed; + record.Result = result; + record.CompletedAt = DateTime.UtcNow; + } + ClearCurrent(record); + } + + internal void MarkFailed(PipelineJobRecord record, string error, string details) + { + lock (record.Gate) + { + record.State = PipelineJobState.Failed; + record.Error = error; + record.ErrorDetails = details; + record.CompletedAt = DateTime.UtcNow; + } + ClearCurrent(record); + } + + internal void MarkCanceled(PipelineJobRecord record) + { + lock (record.Gate) + { + record.State = PipelineJobState.Canceled; + record.CompletedAt = DateTime.UtcNow; + } + ClearCurrent(record); + } + + /// + /// Request cancellation. A queued job is canceled before it ever starts (the runner + /// checks the flag under the exec gate); a running job gets a cooperative flag that + /// command/eval code can observe via — arbitrary + /// synchronous main-thread code cannot be aborted safely, so cancellation of a running + /// job takes effect only when the code cooperates (mirroring how test cancellation + /// requires the runner's cooperation). + /// + internal bool RequestCancel(string id, out PipelineJobRecord record) + { + if (!TryGet(id, out record)) + { + return false; + } + record.CancellationRequested = true; + return true; + } + + private void ClearCurrent(PipelineJobRecord record) + { + if (ReferenceEquals(m_CurrentRunning, record)) + { + m_CurrentRunning = null; + } + } + + private void Prune() + { + var now = DateTime.UtcNow; + var terminal = m_Jobs.Values + .Where(job => + { + lock (job.Gate) + { + return job.State == PipelineJobState.Completed + || job.State == PipelineJobState.Failed + || job.State == PipelineJobState.Canceled; + } + }) + .OrderBy(job => job.CompletedAt ?? job.EnqueuedAt) + .ToList(); + + // Oldest-first: drop everything beyond the retained count, plus anything expired. + var excess = terminal.Count - MaxRetainedTerminalJobs; + for (var i = 0; i < terminal.Count; i++) + { + var job = terminal[i]; + var expired = now - (job.CompletedAt ?? job.EnqueuedAt) > m_RetentionTtl; + if (i < excess || expired) + { + m_Jobs.TryRemove(job.Id, out _); + } + } + } + + /// Test hook: forget every job. + internal void Reset() + { + m_Jobs.Clear(); + m_CurrentRunning = null; + } + } + + /// + /// Cooperative cancellation for detached jobs (CLI-335). Long-running command or + /// eval code checks this periodically; when a client has requested cancellation + /// (POST /api/job/cancel), the code can wind down early: + /// + /// for (int i = 0; i < n; i++) + /// { + /// Unity.Pipeline.PipelineCancellation.ThrowIfCancellationRequested(); + /// // … chunk of work … + /// } + /// + /// Arbitrary synchronous code cannot be aborted safely from outside, so a running job + /// that never checks this flag runs to completion (its state still reports canceled). + /// This is a thin static façade over the executing server's own + /// (resolved via ) + /// so command code — which has no reference to "which server is running me" — can keep + /// calling this the same way regardless of which server dispatched it. + /// + public static class PipelineCancellation + { + /// True when the currently executing job has been asked to cancel. + public static bool IsCancellationRequested + { + get + { + var current = BasePipelineServer.CurrentServer?.JobRegistry.CurrentRunning; + return current != null && current.CancellationRequested; + } + } + + /// Throw when cancellation was requested. + public static void ThrowIfCancellationRequested() + { + if (IsCancellationRequested) + { + throw new OperationCanceledException("Pipeline job cancellation was requested"); + } + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/PipelineJobs.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/PipelineJobs.cs.meta new file mode 100644 index 0000000..1225aee --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/PipelineJobs.cs.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: 34693b29c0c343d9804a82d9b234ea3f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetVersion: 1 diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/PipelineUtils.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/PipelineUtils.cs new file mode 100644 index 0000000..1ae20f5 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/PipelineUtils.cs @@ -0,0 +1,231 @@ +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; + /// Wrap an . + /// The entity id to wrap. + public ObjectId(EntityId value) { m_Value = value; } + /// Unwrap to the underlying . + /// The id to unwrap. + /// The underlying entity id. + public static implicit operator EntityId(ObjectId id) => id.m_Value; + /// Wrap an . + /// The entity id to wrap. + /// The wrapped id. + 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); + /// Reconstruct an from its raw wire value. + /// The raw wire value. + /// The reconstructed id. + public static ObjectId FromRaw(ulong raw) => new ObjectId(EntityId.FromULong(raw)); + /// Value-equality on the underlying id. + /// The id to compare against. + /// True if the underlying ids are equal. + public bool Equals(ObjectId other) => m_Value.Equals(other.m_Value); + /// Hash of the underlying id. + /// The hash code. + public override int GetHashCode() => m_Value.GetHashCode(); +#else + readonly int m_Value; + /// Wrap a raw instance id. + /// The instance id to wrap. + public ObjectId(int value) { m_Value = value; } + /// Unwrap to the underlying instance id. + /// The id to unwrap. + /// The underlying instance id. + public static implicit operator int(ObjectId id) => id.m_Value; + /// Wrap a raw instance id. + /// The instance id to wrap. + /// The wrapped id. + public static implicit operator ObjectId(int value) => new ObjectId(value); + /// Raw id — the canonical wire / serialization form. + public long RawValue => m_Value; + /// Reconstruct an from its raw wire value. + /// The raw wire value. + /// The reconstructed id. + public static ObjectId FromRaw(long raw) => new ObjectId((int)raw); + /// Value-equality on the underlying id. + /// The id to compare against. + /// True if the underlying ids are equal. + public bool Equals(ObjectId other) => m_Value == other.m_Value; + /// Hash of the underlying id. + /// The hash code. + public override int GetHashCode() => m_Value; +#endif + /// Value-equality on the underlying id. + /// The object to compare against. + /// True if is an equal . + public override bool Equals(object obj) => obj is ObjectId other && Equals(other); + /// The canonical numeric wire form (see ). + /// The numeric string form. + public override string ToString() => RawValue.ToString(); + + /// Parse the canonical numeric form produced by . + /// The numeric string to parse. + /// The parsed id. + 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 . + /// The numeric string to parse. + /// The parsed id, if successful. + /// True if was a valid numeric form. + 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. + sealed class ObjectIdConverter : JsonConverter + { + /// Write an as its raw numeric wire value. + /// The JSON writer. + /// The id to write. + /// The active serializer. + public override void WriteJson(JsonWriter writer, ObjectId value, JsonSerializer serializer) + { + writer.WriteValue(value.RawValue); + } + + /// Read an from either its numeric or string wire form. + /// The JSON reader. + /// The declared property type. + /// The existing value, if any. + /// Whether is valid. + /// The active serializer. + /// The parsed id, or default for an unrecognized token type. + 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; + } + } + } + + /// Small cross-Unity-version compatibility shims used across the package. + public static class PipelineUtils + { + /// The on-disk path Unity loaded an assembly from. + /// The assembly to query. + /// The assembly's loaded path. + public static string GetLoadedAssemblyPath(System.Reflection.Assembly a) + { +#if UNITY_6000_5_OR_NEWER + return a.GetLoadedAssemblyPath(); +#else + return a.Location; +#endif + } + + /// Load a compiled assembly from raw bytes (used by the code-reload/eval compilers). + /// The assembly image. + /// The matching portable PDB, if any. + /// The loaded assembly. + 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 + } + + /// Find every loaded object of type , including inactive ones. + /// The object type to search for. + /// All matching objects. + public static T[] FindObjectsByType() where T : Object + { +#if UNITY_6000_4_OR_NEWER + return GameObject.FindObjectsByType(); +#else + return GameObject.FindObjectsByType(FindObjectsSortMode.None) ; +#endif + } + + /// All assemblies currently loaded in the domain. + /// The loaded assemblies. + 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). + /// The object to get an id for. + /// The object's version-stable id. + 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/AIClient/Packages/com.unity.pipeline/Runtime/Common/PipelineUtils.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/PipelineUtils.cs.meta new file mode 100644 index 0000000..436cb52 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Common/RotatingFileBackup.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Common/RotatingFileBackup.cs new file mode 100644 index 0000000..ad47f7b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/RotatingFileBackup.cs @@ -0,0 +1,31 @@ +using System.IO; + +namespace Unity.Pipeline +{ + /// + /// The one piece of "rotate a log file" that PipelineTransactionLog (a whole-file JSON + /// array, rotated once per Unity session) and EvalUsageTelemetry (true JSONL, rotated on + /// a byte-size cap) actually share verbatim: swapping the active file for a backup. Everything + /// else — the append shape, the lock, and what triggers a rotation — differs enough between the + /// two that forcing them into one bigger "rotating append log" abstraction would cost more + /// clarity than the duplication it removes, so only this file-swap step is factored out. + /// + static class RotatingFileBackup + { + /// + /// Move to , replacing any prior + /// backup, so the active path is free for a fresh file. No-op when there is no active file. + /// Callers are responsible for their own locking around this — it performs no synchronization + /// itself. + /// + public static void RotateToBackup(string activePath, string backupPath) + { + if (!File.Exists(activePath)) + return; + + if (File.Exists(backupPath)) + File.Delete(backupPath); + File.Move(activePath, backupPath); + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Common/RotatingFileBackup.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Common/RotatingFileBackup.cs.meta new file mode 100644 index 0000000..d50f94f --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Common/RotatingFileBackup.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 24f2295b1c6944c89c0615c9be0ed96a diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Compilation.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation.meta new file mode 100644 index 0000000..ded6006 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 257ad47737473da48b7569484405904e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/CodeReloadCompiler.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/CodeReloadCompiler.cs new file mode 100644 index 0000000..b01c52a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/CodeReloadCompiler.cs @@ -0,0 +1,578 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Unity.Pipeline.CodeReload; +using Unity.Pipeline.Models; +using Unity.Pipeline.Threading; +using UnityEngine; + +namespace Unity.Pipeline.Compilation +{ + /// + /// Compiles transformed code reload source using the shared RoslynCompilationService. + /// Creates versioned DLLs and manages code reload assembly lifecycle. + /// + static class CodeReloadCompiler + { + /// + /// Interpreter-targeted overrides (in-editor backend and push-to-player) carry an embedded + /// PDB and #line mapping, so runtime errors report the user's source file and line instead + /// of an IL offset. Costs a few KB per pushed override and compiles it unoptimized + /// (irrelevant to interpreted execution). Always on; this knob exists only so tests can + /// exercise the no-line-info error path — nothing in production writes it, and a domain + /// reload resets it to true. + /// + internal static bool EmitSourceLineInfo = true; + +#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG) + + private static readonly Dictionary _versionTracker = new(); + private const string CodeReloadTempDir = "Temp/CodeReload"; + + /// + /// Compile source code on main thread synchronously to avoid deadlocks. + /// + /// Code reload source code to compile. + /// Base filename for versioned assembly naming. + /// Optional directory to save compiled assembly to disk. + /// Emit a portable PDB and load symbols so breakpoints can bind. + /// Source document path recorded in the PDB (the original .cs file). + /// When set, the compiled bytes register with the interpreter backend instead of Assembly.Load. + /// The compilation result. + internal static CodeReloadCompileResult CompileSourceCodeOnMainThread(string sourceCode, string baseFileName, string assemblyDir = null, bool emitPdb = false, string documentPath = null, InterpreterOverrideSet interpreterMethods = null) + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var useInterpreter = interpreterMethods != null; + + try + { + Debug.Log($"CodeReload: Starting synchronous source code compilation for {baseFileName}"); + + if (string.IsNullOrWhiteSpace(sourceCode)) + { + return CodeReloadCompileResult.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(CodeReloadTempDir); + + // The interpreter path needs only the bytes (no Assembly.Load), with the PDB embedded + // in them: the interpreter reads sequence points only from an embedded PDB and uses + // them for source lines in runtime error messages. + var compilationResult = CompileCodeReloadAssembly(sourceCode, assemblyName, + emitPdb: emitPdb && !useInterpreter, documentPath, loadAssembly: !useInterpreter, + embedPdb: useInterpreter && EmitSourceLineInfo); + + if (!compilationResult.Success) + { + var dumpPath = DumpFailedSource(assemblyName, sourceCode); + return CodeReloadCompileResult.Failure( + "Compilation Failed", + "Code reload source code compilation failed" + + (dumpPath != null ? $" (generated source: {dumpPath})" : ""), + stopwatch.ElapsedMilliseconds, + compilationResult.Diagnostics.Select(FormatDiagnostic).ToList()); + } + + List registeredMethods; + List skippedOverrides; + if (useInterpreter) + { + registeredMethods = InterpreterCodeReloadExecutor.Register( + compilationResult.AssemblyBytes, interpreterMethods.TypeName, interpreterMethods.MethodNames, + out skippedOverrides, out var bindingWarnings); + // The reload response carries one flat Diagnostics list; append binding warnings + // (unbound host members that throw only if reached) so the client sees the gap + // in the same reply that reports the reload as applied. + skippedOverrides.AddRange(bindingWarnings); + } + else + { + registeredMethods = RegisterCodeReloadMethods(compilationResult.Assembly, assemblyName, out 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($"CodeReload: 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($"CodeReload: Symbols saved to disk: {pdbPath}"); + } + } + else + { + actualAssemblyPath = Path.Combine(CodeReloadTempDir, $"{assemblyName}.dll"); // For compatibility (in-memory) + } + + stopwatch.Stop(); + + Debug.Log($"CodeReload: Successfully compiled source code -> {assemblyName}.dll with {registeredMethods.Count} methods in {stopwatch.ElapsedMilliseconds}ms"); + + var sourceCompileResult = CodeReloadCompileResult.Success( + assemblyName, + actualAssemblyPath, + registeredMethods, + stopwatch.ElapsedMilliseconds); + sourceCompileResult.Diagnostics = skippedOverrides; + return sourceCompileResult; + } + catch (Exception ex) + { + stopwatch.Stop(); + Debug.LogError($"CodeReload: Synchronous source code compilation failed for {baseFileName}: {ex.Message}"); + return CodeReloadCompileResult.Failure( + "Exception", + ex.ToString(), + stopwatch.ElapsedMilliseconds); + } + } + + /// + /// Clean up old code reload DLL versions, keeping only the latest for each base filename. + /// + /// Directory containing code-reload assemblies, or null for the default temp directory. + /// Which files were deleted, and whether cleanup succeeded. + public static CodeReloadCleanupResult CleanupCodeReloadDlls(string assemblyDir = null) + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var deletedFiles = new List(); + + try + { + // Use specified assemblyDir or default to CodeReloadTempDir + var targetDir = !string.IsNullOrWhiteSpace(assemblyDir) ? assemblyDir : CodeReloadTempDir; + + if (!Directory.Exists(targetDir)) + { + return new CodeReloadCleanupResult + { + Success = true, + DeletedFiles = deletedFiles, + Message = $"No code 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 code 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($"CodeReload: Deleted old version {fileToDelete.FileName}.dll"); + } + } + + // Clear registry and reset version tracker + CodeReloadRegistry.ClearAllOverrides(); + _versionTracker.Clear(); + + stopwatch.Stop(); + + Debug.Log($"CodeReload: Cleanup completed - deleted {deletedFiles.Count} files in {stopwatch.ElapsedMilliseconds}ms"); + + return new CodeReloadCleanupResult + { + Success = true, + DeletedFiles = deletedFiles, + Message = $"Deleted {deletedFiles.Count} old DLL versions", + ExecutionTimeMs = stopwatch.ElapsedMilliseconds + }; + } + catch (Exception ex) + { + stopwatch.Stop(); + Debug.LogError($"CodeReload: Cleanup failed: {ex.Message}"); + + return new CodeReloadCleanupResult + { + 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]; + } + + /// + /// Compile transformed override source to raw assembly bytes without loading or registering + /// anything, for the push-to-player path (the device runs the bytes through the interpreter). + /// The PDB is embedded so the interpreter can report source lines instead of IL offsets; + /// is recorded for source outside the transform's #line + /// directives. + /// + internal static bool TryCompileOverrideBytes(string transformedCode, string baseFileName, out byte[] ilBytes, out List diagnostics, string documentPath = null, string[] preprocessorSymbols = null) + { + ilBytes = null; + diagnostics = new List(); + + var assemblyName = $"{baseFileName}_{GetNextVersion(baseFileName):D3}"; + var result = CompileCodeReloadAssembly(transformedCode, assemblyName, emitPdb: false, documentPath: documentPath, loadAssembly: false, embedPdb: EmitSourceLineInfo, preprocessorSymbols: preprocessorSymbols); + if (!result.Success) + { + diagnostics = result.Diagnostics.Select(FormatDiagnostic).ToList(); + var dumpPath = DumpFailedSource(assemblyName, transformedCode); + if (dumpPath != null) + diagnostics.Add($"Generated source written to {dumpPath}"); + return false; + } + + ilBytes = result.AssemblyBytes; + return ilBytes != null && ilBytes.Length > 0; + } + + /// + /// One diagnostic as the user should read it: the line of THEIR file it maps to (via the + /// transform's #line directives), the compiler message, and the generated line that + /// actually failed. The generated source differs from what the user wrote (bodies are + /// lifted into a static class, members re-qualified), so a bare compiler message such as + /// "the type name 'var' does not exist in the type 'Foo'" is not debuggable on its own. + /// + internal static string FormatDiagnostic(DiagnosticInfo d) + { + var where = d.File != null + ? $"{Path.GetFileName(d.File)}({d.FileLine})" + : $"generated line {d.Line + 1}"; + var text = $"{where}: {d.Severity} {d.Id}: {d.Message}"; + if (!string.IsNullOrEmpty(d.Source)) + text += $"\n generated line {d.Line + 1}: {d.Source}"; + return text; + } + + /// + /// Write the generated override source next to the versioned DLLs so a failed compile can + /// be inspected whole. Returns the path, or null when the write failed (never fatal). + /// + private static string DumpFailedSource(string assemblyName, string sourceCode) + { + try + { + Directory.CreateDirectory(CodeReloadTempDir); + var path = Path.Combine(CodeReloadTempDir, $"{assemblyName}.failed.cs"); + File.WriteAllText(path, sourceCode); + return path; + } + catch (Exception) + { + return null; + } + } + + /// + /// Compile code reload source code using shared RoslynCompilationService. + /// Synchronous compilation for main thread use. + /// + private static CompilationResult CompileCodeReloadAssembly(string sourceCode, string assemblyName, bool emitPdb = false, string documentPath = null, bool loadAssembly = true, bool embedPdb = false, string[] preprocessorSymbols = null) + { + var request = new CompilationRequest + { + // Null = the project's define set; the push path passes the player's defines. + PreprocessorSymbols = preprocessorSymbols, + SourceCode = sourceCode, + AssemblyName = assemblyName, + // Ensure Unity.Pipeline assemblies are included (contains CodeReloadOverrideMethodAttribute) + // Also include test assemblies for testing scenarios + AdditionalAssemblyPrefixes = new[] { "Unity.Pipeline", "Unity.Pipeline.Tests" }, + EmitDebugInformation = emitPdb, + EmbedDebugInformation = embedPdb, + DocumentPath = documentPath, + // The interpreter path runs the bytes directly; skip Assembly.Load (IL2CPP-unsafe). + SkipLoad = !loadAssembly, + // Interpreter-bound bytes only: the interpreter reaches non-public members via + // reflection. Loaded assemblies keep standard access checks — Mono JIT-enforces + // accessibility on Assembly.Load'd IL, so relaxing them would only trade a compile + // error for a throw at first dispatch (see AccessibilityValidator). + AllowNonPublicMemberAccess = !loadAssembly + }; + + return RoslynCompilationService.Compile(request); + } + + /// + /// Register the [CodeReloadOverrideMethod] methods of a compiled assembly with the registry. + /// Only overrides that actually bind are returned; overrides that were skipped (e.g. the + /// target is not a registered [CodeReload] method, or a signature mismatch) are reported via + /// with a user-facing reason. + /// + private static List RegisterCodeReloadMethods(Assembly assembly, string assemblyId, out List skipped) + { + var registeredMethods = new List(); + skipped = new List(); + + try + { + // Register assembly types for discovery + foreach (var type in assembly.GetTypes()) + { + CodeReloadRegistry.RegisterCodeReloadType(type, assemblyId); + + // Find methods with [CodeReloadOverrideMethod] attribute for overrides + var staticMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Static); + + foreach (var method in staticMethods) + { + // Try multiple ways to find the attribute + var codeReloadAttr = method.GetCustomAttribute(); + if (codeReloadAttr == null) + { + // Try by name in case of type loading issues + var attrByName = method.GetCustomAttributes(true) + .FirstOrDefault(a => a.GetType().Name == "CodeReloadOverrideMethodAttribute"); + if (attrByName != null) + { + // Cast to the attribute type + codeReloadAttr = attrByName as CodeReloadOverrideMethodAttribute; + } + } + + if (codeReloadAttr != null) + { + if (CodeReloadRegistry.RegisterMethodOverride(method, codeReloadAttr, type, out var skipReason)) + { + registeredMethods.Add(codeReloadAttr.TargetMethodId); + } + else + { + skipped.Add($"{method.Name} -> {codeReloadAttr.TargetMethodId}: {skipReason}"); + } + } + } + } + + Debug.Log($"CodeReload: Registered {registeredMethods.Count} override methods: {string.Join(", ", registeredMethods)}"); + } + catch (Exception ex) + { + Debug.LogError($"CodeReload: Error registering methods from assembly {assemblyId}: {ex.Message}"); + Debug.LogError($"CodeReload: 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 + // Code 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 surface used by callers (CodeReloadCommands, InPlaceReloadProcessor) + // so the project still builds, and returning a clear "not supported" failure at runtime. + const string NotSupportedMessage = + "Code reload compilation is only supported on Desktop development builds (Windows/Mac/Linux)."; + + /// + /// Code reload source compilation (in-place editing) not supported on this build. + /// + /// Unused on this build. + /// Unused on this build. + /// Unused on this build. + /// Unused on this build. + /// Unused on this build. + /// Unused on this build. + /// A "Platform Not Supported" failure. + internal static CodeReloadCompileResult CompileSourceCodeOnMainThread(string sourceCode, string baseFileName, string assemblyDir = null, bool emitPdb = false, string documentPath = null, InterpreterOverrideSet interpreterMethods = null) + { + return CodeReloadCompileResult.Failure("Platform Not Supported", NotSupportedMessage); + } + + /// + /// Override compilation to bytes (push path) not supported on this build (no Roslyn). + /// + /// Unused on this build. + /// Unused on this build. + /// Always null on this build. + /// Always contains the "not supported" message on this build. + /// Unused on this build. + /// Unused on this build. + /// Always false on this build. + internal static bool TryCompileOverrideBytes(string transformedCode, string baseFileName, out byte[] ilBytes, out List diagnostics, string documentPath = null, string[] preprocessorSymbols = null) + { + ilBytes = null; + diagnostics = new List { NotSupportedMessage }; + return false; + } + + /// + /// Code reload cleanup not supported on this build. + /// + /// Unused on this build. + /// A failure result explaining code reload isn't supported on this build. + public static CodeReloadCleanupResult CleanupCodeReloadDlls(string assemblyDir = null) + { + return new CodeReloadCleanupResult + { + Success = false, + Message = NotSupportedMessage, + ExecutionTimeMs = 0 + }; + } +#endif + } + + /// + /// The override set an interpreter-backed compile registers instead of Assembly.Load: the + /// reloaded type plus its surviving method names. Passing one to + /// selects the interpreter path. + /// + internal class InterpreterOverrideSet + { + public string TypeName { get; set; } + public List MethodNames { get; set; } + } + + /// + /// Result from code reload compilation operation. + /// + class CodeReloadCompileResult + { + /// Whether compilation succeeded. + public bool IsSuccess { get; set; } + /// Name of the compiled code-reload assembly. + public string AssemblyName { get; set; } + /// Path the assembly was written to, if saved to disk. + public string OutputPath { get; set; } + /// Method ids registered as overrides. + public List RegisteredMethods { get; set; } = new List(); + /// How long compilation took, in milliseconds. + public long ExecutionTimeMs { get; set; } + /// Error message, if compilation failed. + public string Error { get; set; } + /// Additional error details for debugging. + public string ErrorDetails { get; set; } + /// Compiler diagnostics (errors/warnings), if any. + public List Diagnostics { get; set; } = new List(); + + /// Create a successful compile result. + /// Name of the compiled assembly. + /// Path the assembly was written to, if saved to disk. + /// Method ids registered as overrides. + /// How long compilation took, in milliseconds. + /// A successful result. + public static CodeReloadCompileResult Success(string assemblyName, string outputPath, List registeredMethods, long executionTimeMs) + { + return new CodeReloadCompileResult + { + IsSuccess = true, + AssemblyName = assemblyName, + OutputPath = outputPath, + RegisteredMethods = registeredMethods, + ExecutionTimeMs = executionTimeMs + }; + } + + /// Create a failed compile result. + /// Error message. + /// Additional error details for debugging. + /// How long compilation took, in milliseconds. + /// Compiler diagnostics (errors/warnings), if any. + /// A failed result. + public static CodeReloadCompileResult Failure(string error, string errorDetails, long executionTimeMs = 0, List diagnostics = null) + { + return new CodeReloadCompileResult + { + IsSuccess = false, + Error = error, + ErrorDetails = errorDetails, + ExecutionTimeMs = executionTimeMs, + Diagnostics = diagnostics ?? new List() + }; + } + } + + /// + /// Result from code reload cleanup operation. + /// + class CodeReloadCleanupResult + { + /// Whether cleanup succeeded. + public bool Success { get; set; } + /// Paths of the deleted files. + public List DeletedFiles { get; set; } = new List(); + /// Human-readable summary or error message. + public string Message { get; set; } + /// How long cleanup took, in milliseconds. + public long ExecutionTimeMs { get; set; } + } + +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/CodeReloadCompiler.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/CodeReloadCompiler.cs.meta new file mode 100644 index 0000000..5c30a10 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/CodeReloadCompiler.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/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/EvalCodeCompiler.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/EvalCodeCompiler.cs new file mode 100644 index 0000000..ffb1094 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/EvalCodeCompiler.cs @@ -0,0 +1,335 @@ +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. + /// + 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. + /// + /// The C# expression/statement(s) to evaluate. + /// Timeout for compilation, in milliseconds. + /// Running stopwatch used to report execution time in the response. + /// The evaluation result. + 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). + /// + /// Unused on this build. + /// Unused on this build. + /// Unused on this build. + /// A failure result explaining runtime compilation isn't supported on this build. + 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/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/EvalCodeCompiler.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/EvalCodeCompiler.cs.meta new file mode 100644 index 0000000..09eebd0 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/IlInterpreterHostBindings.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/IlInterpreterHostBindings.cs new file mode 100644 index 0000000..82e99c7 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/IlInterpreterHostBindings.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using UnityEngine; + +namespace Unity.Pipeline.Compilation +{ + /// + /// Shared IlInterpreter surface used by the + /// interpreter-backed code-reload path. Mirrors IlInterpreter's + /// UnityHostBinding.Prototyping() minus the Input System block, Audio, and physics — + /// anything outside UnityEngine's core module binds on demand via , + /// so the package forces no engine-module dependency. Reference types use AllowType<T>() + /// and blittable structs AllowTypeStruct<T>() for AOT/IL2CPP safety; static classes + /// and enums use AllowType(typeof(...)). + /// + static class IlInterpreterHostBindings + { +// Used by the interpreter-backed code-reload sink; available in dev players (incl. IL2CPP). +#if UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE + /// + /// Single source of truth for the interpreter's host surface: BCL + the standard UnityEngine + /// types + Debug. Every consumer — eval binding, code-reload binding, and the build-time + /// link.xml preservation set — is a projection of this one builder, so they cannot + /// drift. Callers overlay only what is site-specific: eval overlays capturing Debug.Log + /// variants; code reload overlays AllowType(targetType); link.xml reads + /// off the result. + /// + public static IlInterpreter.Interpreter.HostBinding CreateStandard() + { + // Route IlInterpreter registration warnings to the Unity console (the default sink is + // stderr, which Unity players never surface). + IlInterpreter.Interpreter.HostBinding.Warn = msg => Debug.LogWarning(msg); + + var binding = AddStandardUnity(new IlInterpreter.Interpreter.HostBinding().AllowBcl()) + .AllowType(typeof(Debug)); // logging from eval/reloaded bodies (eval overlays capture on top) + // Demand-time auto-bind: the interpreter resolves every TypeRef a loaded script carries + // through this policy, so engine/game types outside the curated surface bind on first use + // instead of throwing "not registered". The curated list above stays the fast-path core + // (typed delegates, flat structs) and the link.xml baseline — a floor, not a ceiling. + // Types the policy declines (see ResolveAutoBindType) are not bindable — the + // project-extension seam that could promote them was deferred to a future release. + binding.AutoBindResolver = ResolveAutoBindType; + return binding; + } + + /// + /// Auto-bind policy for : resolve a CLR full name from the loaded + /// assemblies, skipping (a) the BCL — the hand-rolled AllowBcl shims collapse doubles + /// into the script's float number space and must stay authoritative — and (b) this package's + /// own assemblies. On IL2CPP dev players this self-limits to types the stripper kept (see the + /// dev-build link.xml widening in CodeReloadLinkXmlGenerator). + /// + static Type ResolveAutoBindType(string fullName) + { + foreach (var asm in PipelineUtils.GetLoadedAssemblies()) + { + if (IsAutoBindSkippedAssembly(asm.GetName().Name ?? string.Empty)) continue; + Type t = null; + try { t = asm.GetType(fullName, throwOnError: false); } catch { } + if (t != null) return t; + } + return null; + } + + // Internal for tests: the classification itself is the contract (a misclassified game + // assembly silently loses auto-bind), and no test can load a real assembly by these names. + internal static bool IsAutoBindSkippedAssembly(string name) => + name == "System" || + name.StartsWith("System.", StringComparison.Ordinal) || + name.StartsWith("Microsoft.", StringComparison.Ordinal) || + name.StartsWith("Mono.", StringComparison.Ordinal) || + name == "mscorlib" || + name == "netstandard" || + name.StartsWith("nunit.", StringComparison.Ordinal) || + name.StartsWith("Unity.Pipeline", StringComparison.Ordinal); + + /// + /// Register the standard UnityEngine type surface onto (fluent). + /// Private: is the only supported way to build the surface, + /// closing the compose-it-yourself seam where per-site drift used to enter. + /// + static IlInterpreter.Interpreter.HostBinding AddStandardUnity(IlInterpreter.Interpreter.HostBinding binding) + { + return binding + .AllowType(typeof(Resources)) + .AllowType(typeof(PrimitiveType)) + // The base Object surface carries the fake-null operators (op_Equality, + // op_Implicit -> bool) every `target == null` / `if (component)` guard compiles + // against, plus Destroy/Instantiate/name. Explicit, not auto-bound: the guards + // must work on IL2CPP without relying on the on-demand resolver. + .AllowType() + .AllowType() + .AllowType() + .AllowType() + .AllowType() + .AllowType() + .AllowType() + .AllowType() + .AllowType() + .AllowType() + .AllowType() + .AllowType() + .AllowType(typeof(Shader)) // Shader.Find for material creation + .AllowType(typeof(CameraClearFlags)) // cam.clearFlags + .AllowType(typeof(TextAnchor)) // TextMesh.anchor + .AllowType(typeof(TextAlignment)) // TextMesh.alignment + .AllowType() + .AllowTypeStruct() + .AllowType() + .AllowType() + .AllowType(typeof(Mathf)) + .AllowType(typeof(Time)) + // 3D physics (Rigidbody, Collider, Physics, ForceMode, RaycastHit, ...) is NOT + // registered here: those types live in UnityEngine.PhysicsModule, and a compile-time + // reference would force a physics module dependency on every consumer. They bind on + // demand through AutoBindResolver instead — reflection-tier, and present in a player + // only when the game's own code kept the module alive. + .AllowType(typeof(Space)) + .AllowType() + .AllowType(typeof(LayerMask)) + .AllowType(typeof(Cursor)) + .AllowType(typeof(CursorLockMode)) + .AllowType(typeof(Screen)) + .AllowType(typeof(Rect)) + .AllowType(typeof(ScreenOrientation)) + .AllowType(typeof(Application)) + .AllowType(typeof(PlayerPrefs)) + .AllowType(typeof(Color32)) + .AllowType(typeof(System.Collections.IEnumerator)) + .AllowType(typeof(UnityEngine.Random)) + // UITK value-changed callbacks read evt.newValue/previousValue on ChangeEvent. + // Those are members on an OPEN generic type, which auto-bind can't resolve at Load + // (there's no closed instantiation to invoke against) — so reflect on the receiver's + // concrete type at call time. Covers Slider/Toggle/TextField/DropdownField/... the + // whole RegisterValueChangedCallback surface, without a compile-time UITK dependency. + .Allow("ChangeEvent`1", "get_newValue", + (recv, _) => recv?.GetType().GetProperty("newValue")?.GetValue(recv), 0) + .Allow("ChangeEvent`1", "get_previousValue", + (recv, _) => recv?.GetType().GetProperty("previousValue")?.GetValue(recv), 0) + // AllowType registers a type's methods/fields/props but not its constructors, so the + // common ctors are wired explicitly (Vector3's comes from AllowTypeStruct above). + .AllowConstructor("Vector2", + (_, args) => (object)new Vector2(Convert.ToSingle(args[0]), Convert.ToSingle(args[1])), 2) + .AllowConstructor("Color", + (_, args) => (object)new Color(Convert.ToSingle(args[0]), Convert.ToSingle(args[1]), Convert.ToSingle(args[2]), Convert.ToSingle(args[3])), 4) + .AllowConstructor("GameObject", (_, args) => (object)new GameObject(), 0) + .AllowConstructor("GameObject", (_, args) => (object)new GameObject((string)args[0]), 1) + .AllowConstructor("Material", (_, args) => (object)new Material((Shader)args[0]), 1); + } +#endif + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/IlInterpreterHostBindings.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/IlInterpreterHostBindings.cs.meta new file mode 100644 index 0000000..afb53b9 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/IlInterpreterHostBindings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fb251aef26c42704ca3d81d67fd13fc5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/InterpolationToFormatRewriter.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/InterpolationToFormatRewriter.cs new file mode 100644 index 0000000..af0f340 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/InterpolationToFormatRewriter.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.Text; +// The package bundles Roslyn renamed with a "UnityPipeline." prefix (Runtime/Plugins/CodeAnalysis); +// the dotnet test projects reference the same DLLs (IlInterpreter/BundledRoslyn.props). +using UnityPipeline.Microsoft.CodeAnalysis; +using UnityPipeline.Microsoft.CodeAnalysis.CSharp; +using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Unity.Pipeline.Compilation +{ + /// + /// Rewrites C# string interpolation ($"...") into string.Format(fmt, args…) at the + /// source level: the interpreter can't run the compiler's normal interpolation lowering + /// (DefaultInterpolatedStringHandler, a ref struct), but it can call String.Format + /// (bound in ). Applied by + /// before an in-place reload body is compiled. + /// + /// Purely syntactic — no semantic model. Format specifiers, alignment, escaped braces, verbatim + /// strings and nested interpolation are handled. The one unserved case is an interpolation whose + /// target type is FormattableString/IFormattable: rewriting yields a string, + /// so that rare usage becomes a compile error rather than a silent wrong result. + /// + sealed class InterpolationToFormatRewriter : CSharpSyntaxRewriter + { + public override SyntaxNode VisitInterpolatedStringExpression(InterpolatedStringExpressionSyntax node) + { + var fmt = new StringBuilder(); + var args = new List(); + int index = 0; + + foreach (var content in node.Contents) + { + switch (content) + { + case InterpolatedStringTextSyntax text: + // ValueText decodes escapes (\t etc.) but leaves literal braces doubled + // ({{ / }}) — exactly the escaping a Format string wants, so append as-is. + fmt.Append(text.TextToken.ValueText); + break; + + case InterpolationSyntax interp: + fmt.Append('{').Append(index++); + if (interp.AlignmentClause != null) + fmt.Append(',').Append(interp.AlignmentClause.Value.ToString().Trim()); + if (interp.FormatClause != null) + fmt.Append(':').Append(interp.FormatClause.FormatStringToken.ValueText); + fmt.Append('}'); + // Visit the hole so nested interpolations rewrite bottom-up and later + // member-qualification passes see it. + args.Add(SyntaxFactory.Argument((ExpressionSyntax)Visit(interp.Expression))); + break; + } + } + + // No holes: emit a plain string literal (no Format, no brace doubling), matching how + // Roslyn treats a hole-less interpolated string. + if (index == 0) + { + var raw = new StringBuilder(); + foreach (var content in node.Contents) + if (content is InterpolatedStringTextSyntax t) + // A plain literal wants single braces, so undo the {{ / }} escaping ValueText keeps. + raw.Append(t.TextToken.ValueText.Replace("{{", "{").Replace("}}", "}")); + return SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal(raw.ToString())) + .WithTriviaFrom(node); + } + + var allArgs = new List(args.Count + 1) + { + SyntaxFactory.Argument(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal(fmt.ToString()))), + }; + allArgs.AddRange(args); + + // Passing holes as loose args lets Roslyn pick the Format overload: fixed arity for ≤3, + // params object[] for ≥4 — both are bound on the interpreter side. + return SyntaxFactory.InvocationExpression( + SyntaxFactory.ParseExpression("string.Format"), + SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(allArgs))) + .WithTriviaFrom(node); + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/InterpolationToFormatRewriter.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/InterpolationToFormatRewriter.cs.meta new file mode 100644 index 0000000..5ac457d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/InterpolationToFormatRewriter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d10aa4b9388057a4f96c43b0c41c35d1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/InterpreterCodeReloadExecutor.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/InterpreterCodeReloadExecutor.cs new file mode 100644 index 0000000..d9dce1b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/InterpreterCodeReloadExecutor.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using Unity.Pipeline.CodeReload; +using IlInterpreter.Interpreter; + +namespace Unity.Pipeline.Compilation +{ + /// + /// IL2CPP-safe in-place code reload backend: instead of Assembly.Load + reflection, the + /// transformed override assembly bytes run in the IlInterpreter interpreter, and each override is + /// registered as an interpreter dispatch in . + /// + /// Override methods are static {ret} {Method}({TargetType} instance, ...), dispatched as + /// interpreter.Invoke("{Method}", instance, ...args). The host binding is the shared + /// standard surface plus the target type — public and non-public members (the override is + /// compiled with access checks disabled). Other referenced types bind on demand during Load via + /// the binding's AutoBindResolver. + /// + /// Inherits the interpreter's limits: constructs IlInterpreter doesn't lower yet throw at dispatch + /// time, and falls back to the original + /// compiled body for that call. Member refs unbound at Load are reported through the + /// warnings out param so the reload response shows the gap before play-mode execution hits it. + /// + static class InterpreterCodeReloadExecutor + { +// Editor and any development player (incl. IL2CPP on device): the interpreter runs pushed IL +// bytes without Assembly.Load or Roslyn. +#if UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE + + /// + /// Load into an interpreter and register an interpreter-backed + /// override for each entry on . + /// A name with no woven dispatch target (the method — or its whole type — was added since + /// the running build was compiled) cannot be dispatched from compiled code and is reported + /// through ; it still loads into the interpreter, so other + /// reloaded bodies in the same file can call it. + /// Returns the method ids that bound; carries + /// reasons for any that didn't; carries non-fatal load-time + /// diagnostics (host members that resolved to throwing stubs — the reload still applies, and + /// a stub only throws if its call site is reached). + /// + public static List Register( + byte[] assemblyBytes, string typeName, IEnumerable methodNames, + out List skipped, out List warnings) + { + var registered = new List(); + skipped = new List(); + warnings = new List(); + + var names = new List(methodNames ?? Array.Empty()); + if (assemblyBytes == null || names.Count == 0) + return registered; + + // All methods in one in-place file share the target type, but not every NAME resolves: + // a [CodeReload] method added since the last compile has no woven prologue to dispatch + // from, so its id is not in the registry. Resolve the type from the first name that is; + // the rest load but only intra-file calls reach them (reported below). A file whose type + // was never compiled resolves nothing — targetType stays null and the binding runs + // without the target-type overlay. + Type targetType = null; + var resolvable = new List(); + var unwovenNames = new List(); + foreach (var m in names) + { + if (CodeReloadRegistry.TryGetReloadableDeclaringType($"{typeName}.{m}", out var t)) + { + targetType = targetType ?? t; + resolvable.Add(m); + } + else + { + unwovenNames.Add(m); + } + } + + IlInterpreter.Interpreter.ScriptInterpreter interpreter; + try + { + // Standard shared surface plus the target type so the rewritten body can touch its + // own members, public and non-public. Every other type the override references is + // bound on demand by the Load pass via the binding's AutoBindResolver. + var binding = IlInterpreterHostBindings.CreateStandard(); + if (targetType != null) + binding = binding.AllowType(targetType) + .AllowNonPublicInstanceMembers(targetType); + + interpreter = new IlInterpreter.Interpreter.ScriptInterpreter( + binding, msg => Debug.Log(msg)); + // Lenient: a method that can't be lowered becomes a skipped stub (reported per method + // in the registration loop below) instead of failing the whole file's reload — so one + // bad method (e.g. SetupUI referencing a member missing on the running build) doesn't + // drop the other reloaded methods (UIUpdate) in the same file. + interpreter.Load(new OverrideScript(assemblyBytes), lenient: true); + } + catch (Exception ex) + { + // Lenient load no longer throws for per-method lowering failures, so this is a + // whole-assembly failure (parse error, cctor, a genuine crash). Anything but a + // ScriptValidationException carries an unactionable message ("Index was outside the + // bounds of the array") — name the type and put the full stack in the log. + var detail = ex is IlInterpreter.ScriptValidationException + ? ex.Message + : $"{ex.GetType().Name}: {ex.Message} (full stack in the editor log)"; + Debug.LogError($"CodeReload: interpreter load failed for '{typeName}': {ex}"); + foreach (var m in names) + skipped.Add($"{m}: interpreter load failed: {detail}"); + return registered; + } + + // These member refs resolved to throwing stubs. The reload still applies (a stub only + // throws if reached), but report the gap in the reload response rather than as a mid-play + // ScriptRuntimeException on whichever frame first hits it. + if (interpreter.UnboundHostMembers.Count > 0) + warnings.Add( + $"warning: {interpreter.UnboundHostMembers.Count} host member(s) referenced by the " + + $"reloaded code are not in the binding and will throw if reached: " + + $"{string.Join(", ", interpreter.UnboundHostMembers)}. Types outside the standard " + + "surface and demand-time auto-bind are not callable from interpreted code; " + + "BCL types beyond the shims are intentionally not auto-bound."); + + foreach (var methodName in resolvable) + { + var targetMethodId = $"{typeName}.{methodName}"; + var name = methodName; // capture per iteration + + // Skipped under lenient load — its body couldn't be lowered. Report the reason and + // move on rather than register a dispatcher that throws on first call. The most + // common cause across the editor→player boundary is a reference the running build + // lacks, so make that actionable. + if (interpreter.TryGetLoweringSkip(methodName, out var lowerSkip)) + { + if (lowerSkip.Contains("did not resolve to a host or script method")) + lowerSkip += " — the referenced member is neither an interpreted " + + "(reloaded/[CodeReload]) method nor a bound host member. If it was added " + + "since the running build was made, rebuild/redeploy the player to sync, " + + "or mark it [CodeReload]."; + skipped.Add($"{methodName}: interpreter load skipped this method: {lowerSkip}"); + continue; + } + + object Dispatch(object instance, object[] parameters) + { + // Override signature: static M(TargetType instance, ...originalArgs). Use typed + // Invoke overloads for the common arities to avoid a per-call object[] (args are + // already boxed by the woven prologue). Returns the interpreter's (boxed) result, + // which the woven prologue of a value-returning method unboxes; void methods + // return null, which that prologue ignores. + int n = parameters?.Length ?? 0; + switch (n) + { + case 0: return interpreter.Invoke(name, instance); + case 1: return interpreter.Invoke(name, instance, parameters[0]); + case 2: return interpreter.Invoke(name, instance, parameters[0], parameters[1]); + default: + var args = new object[1 + n]; + args[0] = instance; + Array.Copy(parameters, 0, args, 1, n); + return interpreter.Invoke(name, args); + } + } + + if (CodeReloadRegistry.RegisterInterpreterMethodOverride(targetMethodId, Dispatch, out var skipReason)) + registered.Add(targetMethodId); + else + skipped.Add($"{methodName} -> {targetMethodId}: {skipReason}"); + } + + // A method with no woven target has no compiled caller to dispatch from. It is still + // part of the loaded assembly, so other reloaded bodies in this file call it fine — + // report it so the reload response says why it isn't independently dispatchable. + foreach (var methodName in unwovenNames) + { + if (interpreter.TryGetLoweringSkip(methodName, out var lowerSkip)) + { + skipped.Add($"{methodName}: interpreter load skipped this method: {lowerSkip}"); + continue; + } + // An empty registry means discovery never ran (nothing entered Play Mode / no + // driver booted), not that this one method is new — say so instead of blaming the + // build. + if (!CodeReloadRegistry.HasReloadableMethods) + { + skipped.Add($"{methodName}: no [CodeReload] targets are registered in this session, so " + + "there is nothing to dispatch to. Targets are discovered when Play Mode starts " + + "(or a Player boots); enter Play Mode and save again."); + continue; + } + skipped.Add($"{methodName}: no woven dispatch target — the method (or its type) was " + + "added after the running build was compiled. Other reloaded [CodeReload] bodies in " + + "this file can call it; dispatching it on its own requires a rebuild."); + } + + return registered; + } + + /// + /// Push-time host-surface check: load the compiled override bytes into a throwaway + /// interpreter against the same binding a player builds on receipt, and return the member + /// refs that resolved to throwing stubs. Running the device's load pass in the editor turns + /// a mid-play ScriptRuntimeException into a synchronous diagnostic in the + /// reload_file_player_interpreter response (the device ack is async and carries only a warning + /// count). + /// + /// The editor's surface is a superset of a device player's (no IL2CPP stripping, and + /// editor-only assemblies resolve), so a clean result here is no guarantee — the player's + /// own load pass stays the backstop. The converse holds strictly: every member reported + /// here is a throwing stub on the player too. + /// + /// Non-fatal context when the check was degraded: the target type + /// could not be resolved (its non-public members may be reported as unbound), or the + /// interpreter load itself threw (the player's load will likely fail the same way). + public static List ValidateBindingSurface( + byte[] assemblyBytes, string typeName, IReadOnlyList methodNames, out string note) + { + note = null; + var unbound = new List(); + if (assemblyBytes == null || assemblyBytes.Length == 0) + return unbound; + + try + { + var binding = IlInterpreterHostBindings.CreateStandard(); + var targetType = ResolveTargetType(typeName, methodNames); + if (targetType != null) + binding.AllowType(targetType).AllowNonPublicInstanceMembers(targetType); + else + note = $"target type '{typeName}' not resolved in the editor — its own " + + "non-public members may show up as unbound"; + + using var interpreter = new IlInterpreter.Interpreter.ScriptInterpreter(binding, _ => { }); + // Lenient like the player's Register: a method that can't lower is skipped, not + // thrown, so the pre-check still reports the unbound members of the methods that DID + // lower instead of failing the whole validation on the first bad method. + interpreter.Load(new OverrideScript(assemblyBytes), lenient: true); + unbound.AddRange(interpreter.UnboundHostMembers); + } + catch (Exception ex) + { + note = $"interpreter load failed during validation: {ex.Message} — the player's " + + "load pass will likely fail the same way"; + } + return unbound; + } + + /// + /// Resolve the override's target type the way does (the code-reload + /// registry, only populated in play mode), falling back to a scan of the loaded user + /// assemblies for a unique short-name match (override extraction records short names). + /// Returns null when unknown or ambiguous; validation then runs without the target-type overlay. + /// + static Type ResolveTargetType(string typeName, IReadOnlyList methodNames) + { + if (methodNames != null && methodNames.Count > 0 && + CodeReloadRegistry.TryGetReloadableDeclaringType($"{typeName}.{methodNames[0]}", out var fromRegistry)) + return fromRegistry; + + // Skip engine/BCL assemblies ([CodeReload] target types live in user code); mirrors + // IlInterpreterHostBindings.IsAutoBindSkippedAssembly's classification intent. + Type unique = null; + foreach (var asm in PipelineUtils.GetLoadedAssemblies()) + { + var name = asm.GetName().Name ?? string.Empty; + if (name.StartsWith("Unity", StringComparison.Ordinal) || + name.StartsWith("System", StringComparison.Ordinal) || + name.StartsWith("Microsoft.", StringComparison.Ordinal) || + name.StartsWith("Mono.", StringComparison.Ordinal) || + name.StartsWith("mscorlib", StringComparison.Ordinal) || + name.StartsWith("netstandard", StringComparison.Ordinal) || + name.StartsWith("nunit.", StringComparison.Ordinal)) + continue; + + Type[] types; + try { types = asm.GetTypes(); } + catch (System.Reflection.ReflectionTypeLoadException ex) { types = ex.Types; } + catch { continue; } + + foreach (var t in types) + { + if (t == null || t.Name != typeName) continue; + if (unique != null && unique != t) return null; // ambiguous — skip the overlay + unique = t; + } + } + return unique; + } + + /// Minimal over the Roslyn-emitted override PE bytes. + private sealed class OverrideScript : IlInterpreter.IScript + { + public OverrideScript(byte[] bytes) { Il = bytes; } + public string Name => "PipelineCodeReload"; + public ReadOnlyMemory Il { get; } + } + +#else + public static List Register( + byte[] assemblyBytes, string typeName, IEnumerable methodNames, + out List skipped, out List warnings) + { + skipped = new List { "Interpreter code reload only supported on Desktop development builds" }; + warnings = new List(); + return new List(); + } + + public static List ValidateBindingSurface( + byte[] assemblyBytes, string typeName, IReadOnlyList methodNames, out string note) + { + note = "Binding validation only supported in the editor and Desktop development builds"; + return new List(); + } +#endif + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/InterpreterCodeReloadExecutor.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/InterpreterCodeReloadExecutor.cs.meta new file mode 100644 index 0000000..ad826bd --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/InterpreterCodeReloadExecutor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6cf557e8738ed1441a08144565d882d9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/RoslynCompilationService.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/RoslynCompilationService.cs new file mode 100644 index 0000000..7c806c6 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/RoslynCompilationService.cs @@ -0,0 +1,534 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using UnityPipeline.Microsoft.CodeAnalysis; +using UnityPipeline.Microsoft.CodeAnalysis.CSharp; +using UnityPipeline.Microsoft.CodeAnalysis.Emit; +using UnityPipeline.Microsoft.CodeAnalysis.Text; +using Unity.Pipeline.Models; +using UnityEngine; + +namespace Unity.Pipeline.Compilation +{ + /// + /// Shared Roslyn compilation service for both code evaluation and code reload systems. + /// Eliminates code duplication by providing common compilation infrastructure. + /// Thread-safe and can be called from any thread. + /// + 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. + /// + /// The source and compilation options. + /// The compilation result. + 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. Explicit + // symbols (e.g. run_script's `defines`) win; otherwise the project's define set + // applies so the source's #if regions match the running assemblies. + var emitDebug = request.EmitDebugInformation || request.EmbedDebugInformation; + var parseOptions = ProjectParseOptions(request.PreprocessorSymbols); + var syntaxTree = emitDebug + ? CSharpSyntaxTree.ParseText( + SourceText.From(request.SourceCode, Encoding.UTF8), + parseOptions, + path: request.DocumentPath ?? request.AssemblyName + ".cs") + : CSharpSyntaxTree.ParseText(request.SourceCode, parseOptions); + + // 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 (emitDebug) + options = options.WithOptimizationLevel(OptimizationLevel.Debug); + // Non-public member access needs two pieces: MetadataImportOptions.All (the default, + // Public, doesn't import non-public members of referenced assemblies at all — CS1061) + // and BinderFlags.IgnoreAccessibility (the compiler does not honor + // [assembly: IgnoresAccessChecksTo] — CS0122 otherwise). Interpreter-bound compiles + // only (see the property doc): the interpreter executes the result by reflection, + // which reaches non-public members. Unity's Mono JIT-enforces accessibility on + // Assembly.Load'd IL and ignores IgnoresAccessChecksTo too (verified on Mono 6.13) — + // see CodeReloadInPlaceTests.ReloadFileInPlace_PrivateFieldAccess_FailsValidationUpFront. + if (request.AllowNonPublicMemberAccess) + options = WithIgnoreAccessibility(options.WithMetadataImportOptions(MetadataImportOptions.All)); + + 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.EmbedDebugInformation) + { + // Embed the portable PDB in the PE debug directory so the single byte[] + // pushed to a player carries its own sequence points; the IlInterpreter + // interpreter reads line info only from an embedded PDB. + emitResult = compilation.Emit( + peStream, + options: new EmitOptions(debugInformationFormat: DebugInformationFormat.Embedded)); + } + else 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. + // SkipLoad = emit-only (run_script's dry_run, and the interpreter path — see + // the property doc). + var assemblyBytes = peStream.ToArray(); + Assembly assembly = null; + if (!request.SkipLoad) + { + 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. + /// + /// The source and compilation options. + /// The compilation result. + public static Task CompileAsync(CompilationRequest request) + { + return Task.Run(() => Compile(request)); + } + + // MetadataReference cache keyed by assembly path. Reusing the same instances, both within one + // reload and across successive reloads, lets Roslyn cache each assembly's parsed metadata + // instead of re-reading all ~190 DLLs per compilation (~830 ms each). Static: cleared by + // domain reload, which is when the loaded-assembly set can change. + static readonly System.Collections.Concurrent.ConcurrentDictionary s_ReferenceCache = + new System.Collections.Concurrent.ConcurrentDictionary(); + + static MetadataReference GetCachedReference(string path) => + s_ReferenceCache.GetOrAdd(path, p => MetadataReference.CreateFromFile(p)); + + // The internal BinderFlags.IgnoreAccessibility is the only way to disable Roslyn's + // compile-time access checks (no public API; [IgnoresAccessChecksTo] is ignored, see Compile). + // Resolve the internal CSharpCompilationOptions.WithTopLevelBinderFlags(BinderFlags) once. + static bool s_BinderFlagResolved; + static MethodInfo s_WithTopLevelBinderFlags; + static object s_IgnoreAccessibilityFlag; + + static CSharpCompilationOptions WithIgnoreAccessibility(CSharpCompilationOptions options) + { + if (!s_BinderFlagResolved) + { + s_BinderFlagResolved = true; + try + { + var binderFlags = typeof(CSharpCompilationOptions).Assembly + .GetType(typeof(CSharpCompilationOptions).Namespace + ".BinderFlags"); + if (binderFlags != null) + { + s_IgnoreAccessibilityFlag = Enum.Parse(binderFlags, "IgnoreAccessibility"); + s_WithTopLevelBinderFlags = typeof(CSharpCompilationOptions).GetMethod( + "WithTopLevelBinderFlags", BindingFlags.NonPublic | BindingFlags.Instance); + } + } + catch (Exception ex) + { + Debug.LogWarning($"Pipeline: could not resolve IgnoreAccessibility binder flag; code-reload " + + $"overrides touching non-public members may fail to compile. {ex.Message}"); + } + } + + if (s_WithTopLevelBinderFlags != null && s_IgnoreAccessibilityFlag != null) + return (CSharpCompilationOptions)s_WithTopLevelBinderFlags.Invoke(options, new[] { s_IgnoreAccessibilityFlag }); + return options; + } + + /// + /// Get metadata references for compilation with optional additional assembly prefixes. + /// Handles both Editor and Runtime scenarios with appropriate filtering. Reference instances + /// are cached by path (see ) so repeated compilations are cheap. + /// + /// Extra assembly name prefixes to include as references. + /// The metadata references to compile against. + 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(GetCachedReference(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 code reload: "UnityPipeline.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(GetCachedReference(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; + + var info = new DiagnosticInfo + { + Severity = diagnostic.Severity.ToString().ToLower(), + Message = diagnostic.GetMessage(), + Line = line, + Column = column, + Id = diagnostic.Id + }; + + // #line directives (code reload maps rewritten bodies back to the user's file) give + // the user a location they can open; the raw line text shows what was compiled. + var mapped = diagnostic.Location.GetMappedLineSpan(); + if (mapped.HasMappedPath) + { + info.File = mapped.Path; + info.FileLine = mapped.StartLinePosition.Line + 1; + } + var text = diagnostic.Location.SourceTree?.GetText(); + var rawLine = location.StartLinePosition.Line; + if (text != null && rawLine >= 0 && rawLine < text.Lines.Count) + info.Source = text.Lines[rawLine].ToString().Trim(); + + result.Add(info); + } + + return result; + } + +#else + /// + /// Runtime compilation not supported on this platform. + /// Desktop development builds only (Windows/Mac/Linux). + /// + /// Unused on this build. + /// A failure result explaining runtime compilation isn't supported on this build. + 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. + /// + /// Unused on this build. + /// A completed task carrying a failure result. + public static Task CompileAsync(CompilationRequest request) + { + return Task.FromResult(Compile(request)); + } + + /// + /// Runtime compilation not supported on this platform. + /// + /// Unused on this build. + /// An empty list. + public static List GetMetadataReferences(string[] additionalPrefixes = null) + { + return new List(); + } +#endif + + // Preprocessor symbols the surrounding project was compiled with. Reload/eval sources must + // parse under the same defines as the assemblies they patch, or every #if UNITY_EDITOR / + // ENABLE_INPUT_SYSTEM / UNITY_STANDALONE region resolves to the wrong branch. The editor + // snapshots its live define set; a Mono dev player has no runtime API for it, so callers + // that care (the editor-side push path) pass explicit symbols. + // + // Kept outside the desktop-dev #if region: the parse sites that consume this + // (InPlaceReloadProcessor, SourceCodeTransformer) also compile on + // release standalone, where the region above is compiled out. + static string[] s_LastProjectDefines = Array.Empty(); + + static string[] ProjectDefines() + { +#if UNITY_EDITOR + // Editor API — main thread only. Off-thread callers reuse the last snapshot. + try { s_LastProjectDefines = UnityEditor.EditorUserBuildSettings.activeScriptCompilationDefines; } + catch { } +#endif + return s_LastProjectDefines; + } + + /// + /// Seed the define snapshot from the main thread. Called at editor load + /// (PipelineServerStartup's [InitializeOnLoad] ctor) so a background compile that runs + /// before any main-thread parse doesn't see an EMPTY define set — its #if UNITY_EDITOR / + /// UNITY_STANDALONE regions would resolve to the wrong branch. + /// + internal static void SnapshotProjectDefines() => ProjectDefines(); + + /// Parse options carrying , or the project's + /// define set (see ) when null. Every parse of user source in the + /// reload/eval pipeline should use this so #if regions match the running assemblies. + internal static CSharpParseOptions ProjectParseOptions(string[] preprocessorSymbols = null) + => CSharpParseOptions.Default.WithPreprocessorSymbols(preprocessorSymbols ?? ProjectDefines()); + } + + /// + /// 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 code reload scenarios that need "UnityPipeline.Microsoft.CodeAnalysis" etc. + /// + public string[] AdditionalAssemblyPrefixes { get; set; } + + /// + /// Preprocessor symbols to parse with. Null = the project's define set + /// (), so #if regions in the source + /// resolve the same way they did when the running assemblies were compiled. The push-to-player + /// path passes the target player's defines explicitly, and run_script's + /// defines parameter arrives here too. + /// + internal string[] PreprocessorSymbols { 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; } + + /// + /// When true, emit the portable PDB inside the assembly itself (PE debug directory) instead + /// of as separate . Compiles unoptimized, like + /// . Use for the interpreter push path, which ships a single + /// byte[] to the player — the IlInterpreter interpreter resolves error line numbers only from an + /// embedded PDB. + /// + internal bool EmbedDebugInformation { get; set; } + + /// + /// Document path recorded in the syntax tree when or + /// is set. + /// Source that is not covered by explicit #line directives maps to this path. + /// + public string DocumentPath { get; set; } + + /// + /// When true, compile and collect diagnostics but do NOT load the emitted assembly into + /// the domain: stays null; + /// is still returned. Two callers need this: + /// compile-only paths such as run_script's dry_run (in-memory assemblies are + /// not unloadable under Mono, so loading would leak one per compile), and the IL2CPP-safe + /// interpreter path, which runs the raw bytes directly — Assembly.Load is + /// unavailable under IL2CPP. Default false — existing callers keep the load behavior. + /// + public bool SkipLoad { get; set; } + + /// + /// When true, referenced assemblies' non-public members are imported and access checks against them + /// are relaxed (). Interpreter-backend compiles only: the + /// interpreter executes the emitted IL via reflection, which reaches non-public members. Never set + /// this for IL that will be Assembly.Load'd — Mono JIT-enforces accessibility on loaded IL, + /// so the access would compile and then throw at first dispatch. Off by default. + /// + internal bool AllowNonPublicMemberAccess { 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 and + /// was not set). + /// + 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/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/RoslynCompilationService.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Compilation/RoslynCompilationService.cs.meta new file mode 100644 index 0000000..b2ec11a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Console.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Console.meta new file mode 100644 index 0000000..7742891 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Console.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7c1f4a9e2b6d4c8f9a0e3d5b7c1a2f48 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Console/ConsoleLogBuffer.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Console/ConsoleLogBuffer.cs new file mode 100644 index 0000000..3c7b460 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Console/ConsoleLogBuffer.cs @@ -0,0 +1,427 @@ +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. + /// + 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; + string m_Session = Guid.NewGuid().ToString("N"); + + struct StoredEntry + { + public int Severity; + public ConsoleLogEntry Entry; + } + + /// + /// Identifies the capture session that values belong to. + /// Sequence numbers restart from zero with the process, so a cursor only names the same + /// entries within one session. In the Editor this is adopted from SessionState, which has + /// exactly the lifetime of one editor launch; in a player the constructor's value stands. + /// + public string Session + { + get { lock (m_Lock) { return m_Session; } } + } + + /// Adopt an externally owned session identifier. See the Editor bootstrap. + /// The identifier to report as . + public void SetSession(string session) + { + if (string.IsNullOrEmpty(session)) + return; + + lock (m_Lock) + m_Session = session; + } + + /// + /// 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. + /// + /// Unity log type; mapped to a severity via . + /// The log message. + /// The stack trace, if any. + /// UTC time the entry was captured. + /// + /// True when the entry was read from the Editor console's own store rather than captured + /// live, which makes the read time. + /// + public void Add(LogType type, string message, string stackTrace, DateTime timestampUtc, bool seeded = false) + { + 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), + LogType = type.ToString(), + Message = message ?? string.Empty, + StackTrace = stackTrace ?? string.Empty, + Seeded = seeded + } + }); + } + } + + /// + /// 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). + /// + /// The that issued . When it names a different + /// session the cursor is refused; omit it and the cursor is taken at face value. + /// + /// The matching entries plus cursor/drop metadata for the console command. + public ConsoleLogResponse Query(long since, int tail, int minSeverity, string sinceSession = null) + { + lock (m_Lock) + { + // A cursor issued in another session, or one past the counter, cannot be honored: + // sequence numbers restart from zero with the process, so such a cursor filters out + // every entry held and reads as "nothing new". Ignore it, return the tail, and say so. + var reset = since > m_LastSeq + || (!string.IsNullOrEmpty(sinceSession) && sinceSession != m_Session); + if (reset) + since = -1; + + var counts = new ConsoleLevelCounts(); + var matches = new List(); + foreach (var stored in m_Entries) + { + // Counted before the level filter and the tail trim, so the counts describe + // what the buffer holds rather than what this call returned. + Tally(counts, stored.Severity); + + 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, + Session = m_Session, + Returned = matches.Count, + Dropped = reset || ComputeDropped(since), + Reset = reset, + Counts = counts + }; + } + } + + /// + /// The buffer's counters without its entries: retained counts per severity, the cursor, and + /// the session that issued it. Backs console_status, which clients poll in a loop, so + /// it deliberately avoids materializing the entry list. + /// + /// A response with an empty . + public ConsoleLogResponse Stats() + { + lock (m_Lock) + { + var counts = new ConsoleLevelCounts(); + foreach (var stored in m_Entries) + Tally(counts, stored.Severity); + + return new ConsoleLogResponse + { + Entries = Array.Empty(), + Cursor = m_LastSeq, + Session = m_Session, + Counts = counts + }; + } + } + + /// + /// 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; } } + } + + /// + /// Raise the sequence counter to if it is behind. Never lowers it, so + /// a cursor already handed to a client can never be issued again for different entries. + /// + /// The floor the counter must be at or above. + public void RaiseLastSeq(long seq) + { + lock (m_Lock) + { + if (seq > m_LastSeq) + m_LastSeq = seq; + } + } + + #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). + /// + /// File to write the snapshot to. + 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}"); + } + } + + /// + /// Merge a buffer previously written by into this one. Missing or + /// unreadable files leave the buffer untouched. Returns true if a snapshot was loaded. + /// + /// File previously written by . + /// True if a snapshot was found and 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) + { + // Merge rather than replace. Where [NoAutoStaticsCleanup] keeps this buffer alive + // across a domain reload, entries logged since the snapshot are already here, and + // a snapshot entry whose seq the counter has already issued is one of those. + // Anything with a higher seq was never seen in memory, so it is newer than + // everything held and appends in order. + if (snapshot.Entries != null) + { + foreach (var entry in snapshot.Entries) + { + if (entry == null || entry.Seq <= m_LastSeq) + continue; + if (m_Entries.Count >= Capacity) + m_Entries.Dequeue(); + + m_Entries.Enqueue(new StoredEntry + { + Severity = SeverityFromLevelName(entry.Level), + Entry = entry + }); + m_LastSeq = entry.Seq; + } + } + + // Keep the counter ahead of the snapshot even when it held no entries, and never + // lower it: a cursor already handed out must not be issued a second time. + if (snapshot.LastSeq > m_LastSeq) + m_LastSeq = snapshot.LastSeq; + } + + 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. + /// The Unity log type. + /// One of the Severity* constants. + 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. + /// + /// Level name to parse (case-insensitive). + /// One of the Severity* constants. + 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; + } + } + + /// Add one entry of to . + /// The tally to add to. + /// One of the Severity* constants. + static void Tally(ConsoleLevelCounts counts, int severity) + { + switch (severity) + { + case SeverityError: + counts.Error++; + break; + case SeverityWarn: + counts.Warn++; + break; + default: + counts.Log++; + break; + } + } + + /// The canonical level name for a severity. + /// One of the Severity* constants. + /// The matching Level* constant. + public static string LevelName(int severity) + { + switch (severity) + { + case SeverityError: + return LevelError; + case SeverityWarn: + return LevelWarn; + default: + return LevelLog; + } + } + + #endregion + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Console/ConsoleLogBuffer.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Console/ConsoleLogBuffer.cs.meta new file mode 100644 index 0000000..1a81c69 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Console/ConsoleLogBuffer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1e014974635df5fe6a6787c4ba983f80 diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Console/ConsoleLogCapture.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Console/ConsoleLogCapture.cs new file mode 100644 index 0000000..5518f61 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Console/ConsoleLogCapture.cs @@ -0,0 +1,89 @@ +using System; +using System.Threading; +using Unity.Pipeline.Models; +using UnityEngine; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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), + /// and re-arms on every play-mode transition (Unity clears + /// 's subscribers on play-mode exit even + /// though exiting play mode does not reload the domain — undocumented behavior, UUM-148802; + /// re-verify before retiring this). Both paths funnel through + /// , which is safe to call repeatedly: it always removes any + /// existing subscription before adding, so repeat calls (e.g. the runtime hook firing again + /// when Play mode is entered in the Editor) can never double-subscribe. + /// - 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. + /// +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + public static class ConsoleLogCapture + { + static readonly ConsoleLogBuffer s_Buffer = new ConsoleLogBuffer(); + static readonly object s_SubscriptionLock = new object(); + static ConsoleGroundTruth s_GroundTruth; + + /// The shared buffer holding captured console entries. + internal static ConsoleLogBuffer Buffer => s_Buffer; + + /// + /// The last Editor ground-truth sample, or null outside the Editor and before the first + /// sample. Written on the main thread by the Editor sampler and read from request threads, + /// so the console command can report it while staying off the main thread — which is the + /// point, since clients poll it while the Editor is busy compiling or importing. The + /// snapshot is immutable once published, so a reference swap is all the synchronization a + /// reader needs. + /// + internal static ConsoleGroundTruth GroundTruth + { + get => Volatile.Read(ref s_GroundTruth); + set => Volatile.Write(ref s_GroundTruth, value); + } + + /// + /// 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, removing any existing subscription first. Safe to call + /// repeatedly and from either the runtime bootstrap or the Editor bootstrap. + /// + public static void EnsureCapturing() + { + lock (s_SubscriptionLock) + { + Application.logMessageReceivedThreaded -= OnLogMessage; + Application.logMessageReceivedThreaded += OnLogMessage; + } + } + + static void OnLogMessage(string condition, string stackTrace, LogType type) + { + s_Buffer.Add(type, condition, stackTrace, DateTime.UtcNow); + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Console/ConsoleLogCapture.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Console/ConsoleLogCapture.cs.meta new file mode 100644 index 0000000..88136c4 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Console/ConsoleLogCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3d9b6e1a4f2c40a78c5e0b1d2a6f9c84 diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter.meta new file mode 100644 index 0000000..8f5c97d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 128b6cd5b150eb54e940418250b3ebf1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/AssemblyInfo.cs b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/AssemblyInfo.cs new file mode 100644 index 0000000..b10f6de --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/AssemblyInfo.cs @@ -0,0 +1,17 @@ +using System.Runtime.CompilerServices; + +// The IlInterpreter VM is an implementation detail of eval/code reload, not a public scripting API: +// only the Script* exceptions are public (user code catches them from eval/reload failures). +// Everything else is internal, visible to the package +// assemblies that drive the VM and to the Unity test assemblies. The standalone dotnet suites +// (IlInterpreter/tests/IlInterpreter.*) compile these sources directly into their own assembly, so they need no +// grant here. +[assembly: InternalsVisibleTo("Unity.Pipeline")] +[assembly: InternalsVisibleTo("Unity.Pipeline.Editor")] +[assembly: InternalsVisibleTo("Unity.Pipeline.Tests.Editor")] +[assembly: InternalsVisibleTo("Unity.Pipeline.Tests.Runtime")] +// IlInterpreter/tests/IlInterpreter/IlInterpreter.csproj compiles these same sources (this file included) into a +// standalone IlInterpreter.dll so the fuzzer can instrument the VM in isolation; the fuzz and bench +// executables consume that DLL cross-assembly. +[assembly: InternalsVisibleTo("IlInterpreter.Fuzz")] +[assembly: InternalsVisibleTo("IlInterpreter.Bench")] diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/AssemblyInfo.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/AssemblyInfo.cs.meta new file mode 100644 index 0000000..cf67ea0 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 56c0e5c2691d76d46a9000322eec91dd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/HostBinding.cs b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/HostBinding.cs new file mode 100644 index 0000000..fbd8420 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/HostBinding.cs @@ -0,0 +1,3220 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +// The package bundles System.Runtime.CompilerServices.Unsafe renamed with a "UnityPipeline." prefix (Runtime/Plugins/CodeAnalysis); +// the dotnet test projects reference the same DLLs (IlInterpreter/BundledRoslyn.props). +using Unsafe = UnityPipeline.System.Runtime.CompilerServices.Unsafe; + +namespace IlInterpreter.Interpreter +{ + +// Pre-built delegate invoked when a script calls a host method. +// receiver: the `this` object for instance methods, null for static. +// args: explicit parameters (not including receiver). +// return: return value, or null for void methods. +internal delegate object? HostCallDelegate(object? receiver, object?[] args); + +// Fast-path delegate: reads args directly from the IR dual frame, bypassing +// ArgBuf allocation and Entry.Invoke coercion. Populated by BuildTypedDelegates. +// recv: host receiver (null for static). +// num: the Vm's unmanaged numeric stack base; slot S is at (bp+S)*4. +// ref_: refStack (object?[]) — the shared call stack; slot S is at bp+S. +// ir: the uint[] instruction stream. +// argBase: index in ir where arg slot indices start. +// dstSlot: frame-relative slot to write result into (-1 for void returns). +// bp: base pointer — offset into num/ref_ for the current frame. +internal unsafe delegate void FastCallDelegate(object? recv, byte* num, object?[] ref_, SType[] slotT, uint[] ir, int argBase, int dstSlot, int bp); + +// Open-instance delegate shapes for VALUE-TYPE receivers: the CLR requires the first +// parameter of an open-instance delegate over a struct method to be `ref T`. These power +// the FastVtRecv closures (computed struct members like Vector3.magnitude/normalized). +internal delegate TR RefRecvFn(ref TRecv self); +internal delegate TR RefRecvFn1F(ref TRecv self, float a); +internal delegate TR RefRecvFn1I(ref TRecv self, int a); +internal delegate void RefRecvAct(ref TRecv self); +internal delegate void RefRecvAct1F(ref TRecv self, float a); +internal delegate void RefRecvAct1I(ref TRecv self, int a); +internal delegate void RefRecvAct2F(ref TRecv self, float a, float b); +internal delegate void RefRecvAct3F(ref TRecv self, float a, float b, float c); + +// Holds all registered host method, constructor, and field delegates. +// Use the fluent Allow*() API to register; acts as both binding and builder. +internal sealed unsafe class HostBinding +{ + // Diagnostic warning sink. IlInterpreter has no UnityEngine dependency (these sources also compile in + // the standalone dotnet test/fuzz builds), so warnings default to stderr; IlInterpreterHostBindings + // redirects this to Debug.LogWarning on the Unity side. + internal static Action Warn = msg => Console.Error.WriteLine(msg); + + internal sealed class Entry + { + // Always set by the Allow*/Register* construction sites; `= null!` marks that invariant. + public HostCallDelegate Delegate = null!; + public int ParamCount; // number of explicit params (not this) + public bool HasThis; + public ParameterInfo[]? Params; // set by AllowType; enables int→bool coercion for Mono + // Optional fast path built by BuildTypedDelegates; null = use Delegate path. + // When non-null, FastIsFlat tells the executor whether the closure writes its result + // to numFrame (true) or to refFrame (false). + public FastCallDelegate? Fast; + public bool FastIsFlat; + // When true, Fast handles a FLAT (Vt) receiver itself: it reads the receiver slot + // index from ir[argBase - 3] and takes the struct bytes in place from the numeric + // frame (mutations land directly — no box, no write-back). Without it the executor + // must drop to the boxing slow path for Vt receivers. + public bool FastVtRecv; + // Optional return-type override consulted by the lowerer when allocating the call_host dst slot. + // Default O = "unset, use Method.ReturnType (or O if Method is null too)". Set to R4 or I4 when + // the C# signature doesn't reflect the script-visible return type — e.g. AllowBcl's Math.Sqrt + // is C# (double)→double but the closure narrows to float, so AttachFlatFloat sets this to R4 + // so the lowerer picks an R4 slot and the closure can write flat bytes into it. + public SType FlatReturnSType; + // The Fast closure reads/writes WIDE (I8/R8) slots itself — the executor's wide-slot + // fast-path guard lets it through. Set by AttachFlatDouble. + public bool FastWideOk; + // Optional method/ctor info — used by the IR loader to resolve struct param/return types + // for the flat-struct (Vt) lowering path. Set by RegisterMethods / AllowConstructor when + // the underlying MethodBase is available; null for hand-rolled Allow* delegates. + public MethodBase? Method; + public Type? DeclaringType; + // S4 operator inlining: set by InspectAndMarkOperators when the operator's IL + // is verified as pure arithmetic. The lowerer synthesizes IR instead of call_host. + public bool IsInlineableOp; + // Verified trivial struct accessor: the getter/setter IL is exactly a single backing-field + // load/store (`ldarg.0; ldfld f; ret` / `ldarg.0; ldarg.1; stfld f; ret`). On a FLAT (Vt) + // receiver the lowerer emits ldfld_vt_*/stfld_vt_* at this byte offset instead of a host call. + // -1 = not a trivial accessor. Set by MarkTrivialAccessors for flat-registered structs. + public int AccessorOffset = -1; + public SType AccessorSt; + // Layout of the backing field's type when the accessor is STRUCT-typed (Bounds.center → + // Vector3): AccessorSt is Vt and the lowerer sizes the copy from this layout. + public StructLayout? AccessorVtLayout; + + public object? Invoke(object? receiver, object?[] args) + { + if (Params != null) CoerceArgs(args, Params); + var result = Delegate(receiver, args); + // Enum and sub-int32/unsigned returns box as their real type, which fails the `is int` + // checks in WrObj/RdI4 and reads 0 even though the lowerer types those slots I4 — + // normalize the whole integer family to a boxed int. These returns always take this + // slow path (Fast delegates are only built for exact int/float/bool shapes). + return result is Enum ? unchecked((int)Convert.ToInt64(result)) : NormalizeIntegralReturn(result); + } + } + + // Coerce VM-boxed args to the parameter types reflection requires: every I4-slot value + // (int, bool, byte, enum, …) arrives as a boxed int, but MethodBase.Invoke needs the real + // type. Shared by Entry.Invoke (single-overload entries, driven by Entry.Params) and the + // same-arity ctor dispatcher (which must coerce AFTER picking its overload). + internal static void CoerceArgs(object?[] args, ParameterInfo[] ps) + { + for (int i = 0; i < args.Length && i < ps.Length; i++) + { + var pt = ps[i].ParameterType; + if (pt == typeof(bool) && args[i] is int ib) args[i] = ib != 0; + else if (pt == typeof(char) && args[i] is int ic) args[i] = (char)ic; + else if (pt == typeof(byte) && args[i] is int i8) args[i] = (byte)i8; + else if (pt == typeof(sbyte) && args[i] is int si8) args[i] = (sbyte)si8; + else if (pt == typeof(short) && args[i] is int i16) args[i] = (short)i16; + else if (pt == typeof(ushort) && args[i] is int u16) args[i] = (ushort)u16; + else if (pt == typeof(uint) && args[i] is int u32) args[i] = unchecked((uint)u32); + // Native int (ELEMENT_TYPE_I) rides a numeric slot and arrives boxed as + // int/long; reflection Invoke has no implicit widening to IntPtr. + else if (pt == typeof(IntPtr)) + { + if (args[i] is long pl) args[i] = new IntPtr(pl); + else if (args[i] is int pi) args[i] = new IntPtr(pi); + } + else if (pt == typeof(UIntPtr)) + { + if (args[i] is long ul) args[i] = new UIntPtr(unchecked((ulong)ul)); + else if (args[i] is int ui) args[i] = new UIntPtr(unchecked((uint)ui)); + } + else if (pt.IsEnum && args[i] is int ie) args[i] = Enum.ToObject(pt, ie); + // Script-created reference-element arrays are object?[]-backed regardless of + // their IL element type, but reflection Invoke needs the runtime array type + // assignable to the declared parameter type (arrays only widen, string[] → + // object[]). Rebuild as the declared array type; SetValue casts/unboxes per + // element, so a genuinely wrong element still throws like C# would. + else if (pt.IsArray && pt != typeof(object[]) && args[i] is object?[] oa + && args[i]!.GetType() == typeof(object[])) + { + var typed = Array.CreateInstance(pt.GetElementType()!, oa.Length); + for (int j = 0; j < oa.Length; j++) typed.SetValue(oa[j], j); + args[i] = typed; + } + } + } + + // Box a sub-int32/unsigned integer value as a plain int (the lowerer types those slots I4), so + // RdI4/WrObj/ldfld_i4's `is int` checks see the value instead of reading 0. Leaves int, long, + // float, char, and reference types untouched. uint wraps like C# `(int)uintValue`. + internal static object? NormalizeIntegralReturn(object? v) => v switch + { + byte b => (int)b, + sbyte sb => (int)sb, + short s => (int)s, + ushort us => (int)us, + uint u => unchecked((int)u), + _ => v, + }; + + // Convert a VM I4-slot value (boxed int) back to a host field's real integer type for reflective + // SetValue — a raw boxed int throws when assigned to a byte/short/uint field. Unchecked, matching C#. + internal static object ToFieldIntegral(object? val, Type ft) + { + int iv = val is int i ? i : Convert.ToInt32(val); + if (ft == typeof(byte)) return (byte)iv; + if (ft == typeof(sbyte)) return (sbyte)iv; + if (ft == typeof(short)) return (short)iv; + if (ft == typeof(ushort)) return (ushort)iv; + if (ft == typeof(uint)) return unchecked((uint)iv); + return iv; + } + + internal sealed class FieldEntry + { + // Both delegates are always set at registration; `= null!` marks that invariant. + public Func Get = null!; + public Action Set = null!; + // "Type.field", for runtime error messages (null-target reads/writes). + public string? Name; + // When the declaring type is a flat-layout struct (registered via AllowTypeStruct), + // these record the field's byte offset and primitive SType (I4/R4/O) so the IR lowerer + // can emit direct ldfld_vt_* / stfld_vt_* ops bypassing the boxed Get/Set delegates. + // DeclaringStruct == null for non-flat-struct fields. + public StructLayout? DeclaringStruct; + public int ByteOffset; + public SType PrimitiveSt; + // Storage kind for I4-classified flat fields: 0 = full 4-byte cell, 1 = u1, 2 = i1, + // 3 = u2, 4 = i2. Sub-4 fields need widening loads / truncating stores in the VM. + public byte PrimitiveKind; + // Script-visible SType of the field, from its declared CLR type (enum → I4). The interpreter's + // field-token classifier prefers this over its signature-byte guess (which can't tell an enum + // 0x11 VALUETYPE from a struct, and so mis-typed enum fields as O — boxed — breaking `switch` + // and comparisons on them). Default O ("unclassified") falls back to the signature guess. + public SType FieldSt; + // Simple name of the field's declared CLR type. Lets the lowerer resolve the layout of a + // NESTED flat struct field (Pose.P where P is a Vec2) at lowering time — registration + // order-independent, unlike capturing the layout here. + public string? FieldTypeName; + } + + // Layout descriptor for a host value type registered via AllowTypeStruct(). + // Built lazily from reflection at registration time. + internal sealed class StructLayout + { + // All fields are set by AllowTypeStruct's registration path; `= null!` marks that invariant. + public Type Type = null!; + public int Size; // total byte size (Marshal.SizeOf) + public string TypeName = null!; // type's simple name, used by parser + // Per-field offsets, indexed by field name. Built from Marshal.OffsetOf. + public Dictionary Fields = null!; + // Storage kind for I4-classified sub-4-byte fields (absent = full 4-byte cell): + // 1 = u1, 2 = i1, 3 = u2, 4 = i2. Loads must widen and stores must truncate. + public Dictionary? FieldKinds; + // Boundary marshalling straight off the unmanaged frame — no managed staging buffer. + // Generic registrations implement these with Unsafe.ReadUnaligned/WriteUnaligned; + // the non-generic (Type-only) path uses a pinned-box memcpy (AOT-safe, no + // MakeGenericMethod). BoxFromPtr's box is the ONLY allocation on the boundary. + public BoxFromPtrDelegate BoxFromPtr = null!; + public CopyToPtrDelegate CopyToPtr = null!; + } + + // Allocates a boxed T and copies Size bytes from src into its payload. + internal unsafe delegate object BoxFromPtrDelegate(byte* src); + // Reads the payload bytes of a boxed T (must be of layout's type) into dst. + internal unsafe delegate void CopyToPtrDelegate(byte* dst, object boxed); + + readonly Dictionary _structLayouts = new(); + readonly Dictionary _structLayoutsByType = new(); + + readonly Dictionary _entries = new(); + readonly Dictionary _fields = new(); + readonly HashSet _registeredTypes = new(); + readonly HashSet _collisions = new(); + readonly Dictionary _byHandle = new(); + readonly Dictionary<(string, string), Type> _genericTypes = new(); + + // Demand-time auto-bind policy. Consulted by ScriptInterpreter.Load for every TypeRef the + // script carries: maps a CLR full name ("UnityEngine.AI.NavMeshPath"; nested types use '+', + // matching Assembly.GetType) to the Type to register, or null to decline — unknown name, or + // policy says skip (e.g. the BCL, whose hand-rolled AllowBcl shims collapse doubles into the + // script's float number space and must stay authoritative). Null resolver = strict allowlist: + // only explicitly Allow*'d members are callable. With a resolver installed the curated surface + // becomes the fast-path core rather than a capability boundary. + internal Func? AutoBindResolver { get; set; } + // Resolver results per full name, INCLUDING negative ones, so repeated Loads against the same + // binding don't rescan the AppDomain for every TypeRef of every reloaded script. + readonly Dictionary _autoBindCache = new(); + + // Open generic method defs (e.g. GameObject.AddComponent) captured at AllowType time. + // The loader instantiates these lazily when it sees a MethodSpec token. + // A (type, name, arity) key can hold several overloads (Object.Instantiate(T,Transform,bool) + // vs (T,Vector3,Quaternion)); the loader picks by matching the MemberRef signature. + readonly Dictionary<(string, string, int), List> _openGenericMethods = new(); + // Cache of closed-method instantiations so repeated reloads don't re-MakeGenericMethod. + readonly Dictionary<(IntPtr, string), MethodInfo> _closedCache = new(); + + // Frame base (bp) of the call currently invoking a Fast delegate. The interpreter sets this + // immediately before each Fast call so the arg readers below can translate an absolute stack + // slot (bp + relativeSlot, used to index numStack/refStack) back to the method-relative index + // that slotT (= LoweredMethod.SlotTypes, length FrameSize) uses. A Fast call reads all its args + // before invoking the host method (and doesn't re-enter the interpreter before doing so), so a + // thread-static is safe. Defaults to 0, which is correct for top-level (bp == 0) calls. + [ThreadStatic] internal static int FastFrameBase; + + // Register an instance method: "TypeName.MethodName/N" + public HostBinding Allow(string typeName, string methodName, + HostCallDelegate del, int paramCount, + ParameterInfo[]? ps = null, MethodBase? mi = null) + { + var entry = new Entry + { + Delegate = del, + ParamCount = paramCount, + HasThis = true, + Params = ps, + Method = mi, + DeclaringType = mi?.DeclaringType, + }; + var key = $"{typeName}.{methodName}/{paramCount}"; + if (_entries.ContainsKey(key)) _collisions.Add(key); + _entries[key] = entry; + if (mi != null) _byHandle[mi.MethodHandle.Value] = entry; + return this; + } + + public HostBinding AllowStatic(string typeName, string methodName, + HostCallDelegate del, int paramCount, + ParameterInfo[]? ps = null, MethodBase? mi = null) + { + var entry = new Entry + { + Delegate = del, + ParamCount = paramCount, + HasThis = false, + Params = ps, + Method = mi, + DeclaringType = mi?.DeclaringType, + }; + var key = $"{typeName}.{methodName}/{paramCount}"; + if (_entries.ContainsKey(key)) _collisions.Add(key); + _entries[key] = entry; + if (mi != null) _byHandle[mi.MethodHandle.Value] = entry; + return this; + } + + // Register a constructor (called via newobj). The delegate receives null as + // receiver and the constructor arguments; it must return the new instance. + public HostBinding AllowConstructor(string typeName, + HostCallDelegate del, int paramCount, + ParameterInfo[]? ps = null, + ConstructorInfo? ci = null) + { + _entries[$"{typeName}..ctor/{paramCount}"] = new Entry + { + Delegate = del, + ParamCount = paramCount, + HasThis = false, + Params = ps, + Method = ci, + DeclaringType = ci?.DeclaringType, + }; + return this; + } + + // Register a host FIELD from reflection, classifying its script SType from the CLR type and + // marshalling at the boundary so the interpreter sees a primitive, not a box: an enum reads/writes + // as its underlying int (a boxed enum in an O slot makes `switch`/`==` on it silently never match); + // a bool reads boxed (the VM's ldfld_i4 coerces bool→1/0) and writes from int→bool. + // `obj` is the instance (null for static). + void RegisterField(string typeName, FieldInfo fi) + => _fields[$"{typeName}.{fi.Name}"] = BuildFieldEntry(fi); + + // The marshalling core of RegisterField, reusable for fields the parser resolves lazily + // through a TypeSpec parent (e.g. ValueTuple`2.Item1) — those are token-keyed per closed + // instantiation and never enter _fields. + internal static FieldEntry BuildFieldEntry(FieldInfo fi) + { + var ft = fi.FieldType; + SType st = ft.IsEnum ? SType.I4 + : ft == typeof(float) ? SType.R4 + : (ft == typeof(int) || ft == typeof(uint) || ft == typeof(bool) + || ft == typeof(byte) || ft == typeof(sbyte) || ft == typeof(short) || ft == typeof(ushort)) + ? SType.I4 + : SType.O; + + Func get; + Action set; + if (ft.IsEnum) + { + // Read through the 64-bit underlying value and truncate (unchecked) to the I4 slot — matching + // C#'s `(int)someEnum`. `Convert.ToInt32` throws OverflowException for an enum whose value + // exceeds Int32 range (a `: long`/`: uint` enum), aborting the whole reload; truncation does not. + get = obj => unchecked((int)Convert.ToInt64(fi.GetValue(obj))); + set = (obj, val) => fi.SetValue(obj, Enum.ToObject(ft, val is int iv ? iv : Convert.ToInt32(val))); + } + else if (ft == typeof(bool)) + { + get = obj => fi.GetValue(obj); + set = (obj, val) => fi.SetValue(obj, val is int iv ? iv != 0 : val); + } + else if (st == SType.I4 && ft != typeof(int)) + { + // Sub-int32/unsigned field (byte/sbyte/short/ushort/uint): present it to the VM as a boxed int + // (reflection boxes it as its real type, which reads 0), and convert back on write (a boxed int + // can't be SetValue'd directly into a byte/short/uint field). + get = obj => NormalizeIntegralReturn(fi.GetValue(obj)); + set = (obj, val) => fi.SetValue(obj, ToFieldIntegral(val, ft)); + } + else + { + get = obj => fi.GetValue(obj); + set = (obj, val) => fi.SetValue(obj, val); + } + return new FieldEntry + { Get = get, Set = set, FieldSt = st, FieldTypeName = ft.Name, Name = $"{fi.DeclaringType?.Name}.{fi.Name}" }; + } + + // Register a minimal set of BCL methods that Roslyn emits calls to. + public HostBinding AllowBcl() + { + // Type — GetTypeFromHandle is an identity: ldtoken already pushed the Type directly + AllowStatic("Type", "GetTypeFromHandle", (_, args) => args[0], 1); + Allow("Type", "get_Name", (recv, _) => ((Type)recv!).Name, 0); + // `t1 == t2` on System.Type operands compiles to Type.op_Equality (Roslyn resolves the + // user-defined operator, not ceq), e.g. `a.GetType() == b.GetType()` in gameplay code. + AllowStatic("Type", "op_Equality", (_, a) => (a[0] as Type) == (a[1] as Type) ? 1 : 0, 2); + AllowStatic("Type", "op_Inequality", (_, a) => (a[0] as Type) != (a[1] as Type) ? 1 : 0, 2); + // `.Name` on a Type receiver usually resolves to the declaring property MemberInfo.Name, + // so Roslyn emits the MemberRef against MemberInfo, not Type — both keys are needed. + Allow("MemberInfo", "get_Name", (recv, _) => ((System.Reflection.MemberInfo)recv!).Name, 0); + + Allow("Object", "ToString", (recv, _) => recv?.ToString() ?? "", 0); + Allow("Object", "GetHashCode", (recv, _) => recv?.GetHashCode() ?? 0, 0); + + // IDisposable — Roslyn emits `constrained.; callvirt IDisposable.Dispose` in the finally of + // every foreach over a struct-enumerator collection (List, Dictionary, …). + Allow("IDisposable", "Dispose", (recv, _) => { (recv as IDisposable)?.Dispose(); return null; }, 0); + + // List — the workhorse gameplay collection. Its members arrive as MemberRefs on a + // TypeSpec parent, keyed by the open type's short name ("List`1"), and dispatch through + // the NON-generic collection interfaces so one shim serves every instantiation with no + // MakeGenericType (AOT/IL2CPP-safe). Element values stay boxed, matching the O-slot + // representation. `new List()` inside a reloaded body is NOT covered — a ctor shim + // can't know T; only members on existing instances are. + // Element values COERCE to the list's T: an enum (or char/byte/short/float-from-int) + // element arrives boxed as int off its I4 slot, and the boxed-int probe would silently + // miss every Contains/IndexOf/Remove on a List — same identity erasure as + // enum dictionary keys. + static object? ListElem(object recv, object? val) + { + var t = recv.GetType(); + if (t.IsGenericType && val is int iv) + { + var e = t.GetGenericArguments()[0]; + if (e.IsEnum) return Enum.ToObject(e, iv); + if (e == typeof(float)) return (float)iv; + if (e == typeof(char)) return (char)iv; + if (e == typeof(byte)) return (byte)iv; + if (e == typeof(short)) return (short)iv; + if (e == typeof(long)) return (long)iv; + } + return val; + } + Allow("List`1", "get_Item", (recv, a) => ((System.Collections.IList)recv!)[Convert.ToInt32(a[0])], 1); + Allow("List`1", "set_Item", (recv, a) => { ((System.Collections.IList)recv!)[Convert.ToInt32(a[0])] = ListElem(recv!, a[1]); return null; }, 2); + Allow("List`1", "get_Count", (recv, _) => ((System.Collections.ICollection)recv!).Count, 0); + Allow("List`1", "Add", (recv, a) => { ((System.Collections.IList)recv!).Add(ListElem(recv!, a[0])); return null; }, 1); + Allow("List`1", "Insert", (recv, a) => { ((System.Collections.IList)recv!).Insert(Convert.ToInt32(a[0]), ListElem(recv!, a[1])); return null; }, 2); + Allow("List`1", "RemoveAt", (recv, a) => { ((System.Collections.IList)recv!).RemoveAt(Convert.ToInt32(a[0])); return null; }, 1); + Allow("List`1", "Clear", (recv, _) => { ((System.Collections.IList)recv!).Clear(); return null; }, 0); + Allow("List`1", "Contains", (recv, a) => ((System.Collections.IList)recv!).Contains(ListElem(recv!, a[0])) ? 1 : 0, 1); + Allow("List`1", "IndexOf", (recv, a) => ((System.Collections.IList)recv!).IndexOf(ListElem(recv!, a[0])), 1); + Allow("List`1", "Remove", (recv, a) => + { + var l = (System.Collections.IList)recv!; + var v = ListElem(recv!, a[0]); + if (!l.Contains(v)) return 0; + l.Remove(v); + return 1; + }, 1); + // HashSet — the visited-set staple. No non-generic interface carries Contains/Add, + // so reflection dispatch on the closed type, with the same element coercion. + Allow("HashSet`1", "Contains", (recv, a) => (bool)recv!.GetType().GetMethod("Contains")!.Invoke(recv, new[] { ListElem(recv!, a[0]) })! ? 1 : 0, 1); + Allow("HashSet`1", "Add", (recv, a) => (bool)recv!.GetType().GetMethod("Add")!.Invoke(recv, new[] { ListElem(recv!, a[0]) })! ? 1 : 0, 1); + Allow("HashSet`1", "Remove", (recv, a) => (bool)recv!.GetType().GetMethod("Remove", new[] { recv.GetType().GetGenericArguments()[0] })!.Invoke(recv, new[] { ListElem(recv!, a[0]) })! ? 1 : 0, 1); + Allow("HashSet`1", "Clear", (recv, _) => { recv!.GetType().GetMethod("Clear")!.Invoke(recv, null); return null; }, 0); + Allow("HashSet`1", "UnionWith", (recv, a) => { recv!.GetType().GetMethod("UnionWith")!.Invoke(recv, new[] { a[0] }); return null; }, 1); + Allow("HashSet`1", "ExceptWith", (recv, a) => { recv!.GetType().GetMethod("ExceptWith")!.Invoke(recv, new[] { a[0] }); return null; }, 1); + Allow("HashSet`1", "IntersectWith", (recv, a) => { recv!.GetType().GetMethod("IntersectWith")!.Invoke(recv, new[] { a[0] }); return null; }, 1); + Allow("HashSet`1", "get_Count", (recv, _) => recv!.GetType().GetProperty("Count")!.GetValue(recv), 0); + Allow("HashSet`1", "GetEnumerator", (recv, _) => ((System.Collections.IEnumerable)recv!).GetEnumerator(), 0); + // foreach support: IEnumerable.GetEnumerator boxes List.Enumerator (a struct), and the + // loop's MoveNext/get_Current calls — MemberRefs on the nested "Enumerator" TypeSpec — + // dispatch on that box via IEnumerator, mutating it in place. Dispose is normally reached + // through the constrained IDisposable shim above; registered here too for direct calls. + // The "Enumerator" key is shared by every BCL struct enumerator (Dictionary, HashSet, …), + // and the IEnumerator dispatch is correct for all of them. + Allow("List`1", "GetEnumerator", (recv, _) => ((System.Collections.IEnumerable)recv!).GetEnumerator(), 0); + Allow("Enumerator", "MoveNext", (recv, _) => ((System.Collections.IEnumerator)recv!).MoveNext() ? 1 : 0, 0); + Allow("Enumerator", "get_Current", (recv, _) => ((System.Collections.IEnumerator)recv!).Current, 0); + Allow("Enumerator", "Dispose", (recv, _) => { (recv as IDisposable)?.Dispose(); return null; }, 0); + // foreach over a value typed as the INTERFACE IEnumerable (not the concrete List etc.) + // — or explicit IEnumerator use in a reloaded coroutine — dispatches through the generic + // interface members, which are distinct TypeSpec MemberRefs from the "Enumerator" struct + // above and from the non-generic IEnumerator. get_Current returns T via the non-generic + // IEnumerator.Current; the same boxed-object dispatch is correct for every instantiation. + Allow("IEnumerable`1", "GetEnumerator", (recv, _) => ((System.Collections.IEnumerable)recv!).GetEnumerator(), 0); + Allow("IEnumerator`1", "MoveNext", (recv, _) => ((System.Collections.IEnumerator)recv!).MoveNext() ? 1 : 0, 0); + Allow("IEnumerator`1", "get_Current", (recv, _) => ((System.Collections.IEnumerator)recv!).Current, 0); + Allow("IEnumerator`1", "Dispose", (recv, _) => { (recv as IDisposable)?.Dispose(); return null; }, 0); + + // Dictionary — the other workhorse (inventories, lookups, pools). Same non-generic + // dispatch strategy as List`1: one shim per member serves every instantiation, AOT-safe, + // values stay boxed. `new Dictionary()` in a reloaded body is NOT covered (no T at + // shim time) — instances come from the host. Keys are COERCED to the dictionary's TKey: + // an enum key arrives boxed as int (the I4 slot erased its identity), and the boxed-int + // probe would silently miss every entry of a Dictionary (found by probing — + // reads returned zero while writes coerced through the IDictionary cast). + static object DictKey(object recv, object? key) + { + var t = recv.GetType(); + if (t.IsGenericType && key is int ik) + { + var k = t.GetGenericArguments()[0]; + if (k.IsEnum) return Enum.ToObject(k, ik); + if (k == typeof(char)) return (char)ik; + if (k == typeof(byte)) return (byte)ik; + if (k == typeof(short)) return (short)ik; + if (k == typeof(long)) return (long)ik; + } + return key!; + } + Allow("Dictionary`2", "get_Item", (recv, a) => + { + var d = (System.Collections.IDictionary)recv!; + var key = DictKey(recv!, a[0]); + // The non-generic indexer returns null on a miss; C# throws. Fault parity matters — + // a silent null reads as 0 through an I4 slot. + if (!d.Contains(key)) throw new KeyNotFoundException($"The given key '{key}' was not present in the dictionary."); + return d[key]; + }, 1); + Allow("Dictionary`2", "set_Item", (recv, a) => { ((System.Collections.IDictionary)recv!)[DictKey(recv!, a[0])] = a[1]; return null; }, 2); + Allow("Dictionary`2", "get_Count", (recv, _) => ((System.Collections.ICollection)recv!).Count, 0); + Allow("Dictionary`2", "Add", (recv, a) => { ((System.Collections.IDictionary)recv!).Add(DictKey(recv!, a[0]), a[1]); return null; }, 2); + Allow("Dictionary`2", "ContainsKey", (recv, a) => ((System.Collections.IDictionary)recv!).Contains(DictKey(recv!, a[0])) ? 1 : 0, 1); + Allow("Dictionary`2", "Remove", (recv, a) => + { + var d = (System.Collections.IDictionary)recv!; + var key = DictKey(recv!, a[0]); + if (!d.Contains(key)) return 0; + d.Remove(key); + return 1; + }, 1); + // TryGetValue writes the out slot through the byref write-back path (the shim mutates + // a[1] in place, exactly like reflection does for a MethodInfo out param). The lowerer + // detects byref from Entry.Params, which hand shims don't have — borrow the parameter + // shape from a marker method so the call site takes the write-back path. + Allow("Dictionary`2", "TryGetValue", (recv, a) => + { + var d = (System.Collections.IDictionary)recv!; + var key = DictKey(recv!, a[0]); + if (d.Contains(key)) { a[1] = d[key]; return 1; } + a[1] = null; // value-typed outs read the null as their zero (CoerceBoxed*) + return 0; + }, 2, typeof(HostBinding).GetMethod(nameof(TryGetValueShape), + BindingFlags.NonPublic | BindingFlags.Static)!.GetParameters()); + // foreach: boxed struct enumerator via IEnumerable (yields boxed KeyValuePair); + // MoveNext/Current ride the shared "Enumerator" shims above. Key/Value dispatch by + // reflection over the closed instantiation — cheap relative to a loop body. + Allow("Dictionary`2", "GetEnumerator", (recv, _) => ((System.Collections.IEnumerable)recv!).GetEnumerator(), 0); + Allow("KeyValuePair`2", "get_Key", (recv, _) => recv!.GetType().GetProperty("Key")!.GetValue(recv), 0); + Allow("KeyValuePair`2", "get_Value", (recv, _) => recv!.GetType().GetProperty("Value")!.GetValue(recv), 0); + // foreach (var (k, v) in dict) — Roslyn calls KeyValuePair.Deconstruct(out, out). + Allow("KeyValuePair`2", "Deconstruct", (recv, a) => + { + var t = recv!.GetType(); + a[0] = t.GetProperty("Key")!.GetValue(recv); + a[1] = t.GetProperty("Value")!.GetValue(recv); + return null; + }, 2, typeof(HostBinding).GetMethod(nameof(DeconstructShape), + BindingFlags.NonPublic | BindingFlags.Static)!.GetParameters()); + // foreach over dict.Keys / dict.Values: the collections come back by reflection (real + // KeyCollection/ValueCollection instances) and their struct enumerators ride the shared + // "Enumerator" shims; GetEnumerator dispatches via IEnumerable like every other one. + Allow("Dictionary`2", "get_Keys", (recv, _) => recv!.GetType().GetProperty("Keys")!.GetValue(recv), 0); + Allow("Dictionary`2", "get_Values", (recv, _) => recv!.GetType().GetProperty("Values")!.GetValue(recv), 0); + Allow("KeyCollection", "GetEnumerator", (recv, _) => ((System.Collections.IEnumerable)recv!).GetEnumerator(), 0); + Allow("ValueCollection", "GetEnumerator", (recv, _) => ((System.Collections.IEnumerable)recv!).GetEnumerator(), 0); + Allow("KeyCollection", "get_Count", (recv, _) => ((System.Collections.ICollection)recv!).Count, 0); + Allow("ValueCollection", "get_Count", (recv, _) => ((System.Collections.ICollection)recv!).Count, 0); + + // Delegate invocation — System.Action/Func fields fire from gameplay code (event-style + // callbacks like `CurrentSegementChanged.Invoke(seg)`). They're BCL TypeSpec MemberRefs the + // auto-bind policy skips, so shim per arity via DynamicInvoke; bool results follow the + // 1/0-int convention. User-declared delegate types resolve via their own auto-bound type. + static object? CoerceDelegateResult(object? r) => r is bool b ? (b ? 1 : 0) : r; + // Coerce against the delegate's OWN Invoke signature before DynamicInvoke: a char/bool + // argument arrives boxed as int (it rides an I4 slot), and DynamicInvoke's binder refuses + // Int32 → Char. Direct host calls get the same treatment from Entry.Params; these + // string-keyed shims have no static ParameterInfo, so resolve it from the instance. + static object? InvokeDelegate(object? recv, object?[] a) + { + var d = (Delegate)recv!; + if (a.Length > 0) + CoerceArgs(a, d.GetType().GetMethod("Invoke")!.GetParameters()); + return d.DynamicInvoke(a); + } + Allow("Action", "Invoke", (recv, _) => ((Delegate)recv!).DynamicInvoke(), 0); + Allow("Action`1", "Invoke", (recv, a) => InvokeDelegate(recv, a), 1); + Allow("Action`2", "Invoke", (recv, a) => InvokeDelegate(recv, a), 2); + Allow("Action`3", "Invoke", (recv, a) => InvokeDelegate(recv, a), 3); + Allow("Action`4", "Invoke", (recv, a) => InvokeDelegate(recv, a), 4); + Allow("Func`1", "Invoke", (recv, _) => CoerceDelegateResult(((Delegate)recv!).DynamicInvoke()), 0); + Allow("Func`2", "Invoke", (recv, a) => CoerceDelegateResult(InvokeDelegate(recv, a)), 1); + Allow("Func`3", "Invoke", (recv, a) => CoerceDelegateResult(InvokeDelegate(recv, a)), 2); + Allow("Func`4", "Invoke", (recv, a) => CoerceDelegateResult(InvokeDelegate(recv, a)), 3); + // 4 args — the ScriptDelegateAdapter's arity ceiling; the invoke surface matches it. + Allow("Func`5", "Invoke", (recv, a) => CoerceDelegateResult(InvokeDelegate(recv, a)), 4); + // Combining delegate VALUES (`a += b` on an Action local/field) compiles to + // Delegate.Combine/Remove + castclass. Null-tolerant like the BCL originals. + AllowStatic("Delegate", "Combine", (_, a) => Delegate.Combine((Delegate?)a[0], (Delegate?)a[1]), 2); + AllowStatic("Delegate", "Remove", (_, a) => Delegate.Remove((Delegate?)a[0], (Delegate?)a[1]), 2); + // `a == b` on delegates binds op_Equality — on MulticastDelegate for Action/Func, on + // Delegate for the base type. Value equality (same target + method), like the BCL. + AllowStatic("Delegate", "op_Equality", (_, a) => Equals(a[0], a[1]) ? 1 : 0, 2); + AllowStatic("Delegate", "op_Inequality", (_, a) => Equals(a[0], a[1]) ? 0 : 1, 2); + AllowStatic("MulticastDelegate", "op_Equality", (_, a) => Equals(a[0], a[1]) ? 1 : 0, 2); + AllowStatic("MulticastDelegate", "op_Inequality", (_, a) => Equals(a[0], a[1]) ? 0 : 1, 2); + AllowStatic("Object", "Equals", (_, a) => Equals(a[0], a[1]) ? 1 : 0, 2); + + // 5+ string concatenations: Roslyn lowers `a + b + c + d + e` to String.Concat(new object[] {...}). + AllowStatic("String", "Concat", (_, a) => + { + if (a[0] is object?[] objs) return string.Concat(objs); + if (a[0] is string[] strs) return string.Concat(strs); + return a[0]?.ToString() ?? ""; + }, 1); + AllowStatic("String", "Concat", (_, a) => string.Concat(a[0]?.ToString(), a[1]?.ToString()), 2); + AllowStatic("String", "Concat", (_, a) => string.Concat(a[0]?.ToString(), a[1]?.ToString(), a[2]?.ToString()), 3); + AllowStatic("String", "Concat", (_, a) => string.Concat(a[0]?.ToString(), a[1]?.ToString(), a[2]?.ToString(), a[3]?.ToString()), 4); + // String.Format — the target of the interpolation rewrite (InterpolationToFormatRewriter). + // Arity 2 serves BOTH Format(string, object) [1 hole] and Format(string, params object[]) [4+ + // holes, Roslyn passes a constructed object[]] — they share the same 2-arg dispatch key, so the + // delegate branches on whether the second arg is an object[]. Culture is default (CurrentCulture), + // matching C# interpolation. + AllowStatic("String", "Format", (_, a) => + a[1] is object?[] fargs ? string.Format((string)a[0]!, fargs) : string.Format((string)a[0]!, a[1]), 2); + AllowStatic("String", "Format", (_, a) => string.Format((string)a[0]!, a[1], a[2]), 3); + AllowStatic("String", "Format", (_, a) => string.Format((string)a[0]!, a[1], a[2], a[3]), 4); + AllowStatic("String", "op_Equality", (_, a) => string.Equals(a[0] as string, a[1] as string) ? 1 : 0, 2); + AllowStatic("String", "op_Inequality", (_, a) => !string.Equals(a[0] as string, a[1] as string) ? 1 : 0, 2); + Allow("String", "get_Length", (recv, _) => ((string)recv!).Length, 0); + // String indexing `s[i]` lowers to get_Chars. The analyzer permits it (it bans constructs, + // not member calls) and get_Length's sibling was missing, so `s[i]` compiled + passed the + // analyzer but threw "not registered" at runtime. Returns char -> I4; a bad index throws the + // real IndexOutOfRangeException (fault parity). (found by fuzzing char/string indexing.) + Allow("String", "get_Chars", (recv, a) => ((string)recv!)[(int)a[0]!], 1); + + // Primitives — ToString (Roslyn emits Int32.ToString(), not Object.ToString()) + Allow("Int32", "ToString", (recv, _) => recv?.ToString() ?? "", 0); + Allow("Int64", "ToString", (recv, _) => recv?.ToString() ?? "", 0); + // (Single.ToString/0 registers below with the Convert.ToSingle receiver coercion — + // registering it here too would be shadowed: entries key by name+arity, last wins.) + Allow("Double", "ToString", (recv, _) => recv?.ToString() ?? "", 0); + Allow("Boolean", "ToString", (recv, _) => recv?.ToString() ?? "", 0); + Allow("Char", "ToString", (recv, _) => recv?.ToString() ?? "", 0); + // Sub-int integrals: reachable e.g. via Math.Max(char,char) -> ushort. Their decimal + // rendering is identical to the (int-boxed) receiver value, so no ReceiverBox is needed. + Allow("Byte", "ToString", (recv, _) => recv?.ToString() ?? "", 0); + Allow("SByte", "ToString", (recv, _) => recv?.ToString() ?? "", 0); + Allow("Int16", "ToString", (recv, _) => recv?.ToString() ?? "", 0); + Allow("UInt16", "ToString", (recv, _) => recv?.ToString() ?? "", 0); + + // Int32.Parse — let FormatException propagate as ScriptRuntimeException + AllowStatic("Int32", "Parse", (_, a) => int.Parse((string)a[0]!), 1); + + AllowStatic("String", "IsNullOrEmpty", (_, a) => string.IsNullOrEmpty(a[0] as string) ? 1 : 0, 1); + AllowStatic("String", "IsNullOrWhiteSpace", (_, a) => string.IsNullOrWhiteSpace(a[0] as string) ? 1 : 0, 1); + AllowStatic("String", "Join", (_, a) => + { + if (a[1] is string[] sj) return string.Join((string?)a[0], sj); + var src = (object?[])a[1]!; + var parts = new string?[src.Length]; + for (int i = 0; i < src.Length; i++) parts[i] = src[i]?.ToString(); + return string.Join((string?)a[0], parts); + }, 2); + // The everyday instance surface HUD/parsing code leans on. Auto-bind deliberately skips + // the BCL, so anything not shimmed here throws at runtime in a reloaded body. char args + // arrive boxed as int (I4 slots) — coerce like CoerceArgs would. + static char AsChar(object? v) => v is char c ? c : (char)Convert.ToInt32(v); + Allow("String", "ToUpper", (recv, _) => ((string)recv!).ToUpper(), 0); + Allow("String", "ToLower", (recv, _) => ((string)recv!).ToLower(), 0); + Allow("String", "ToUpperInvariant", (recv, _) => ((string)recv!).ToUpperInvariant(), 0); + Allow("String", "ToLowerInvariant", (recv, _) => ((string)recv!).ToLowerInvariant(), 0); + Allow("String", "Trim", (recv, _) => ((string)recv!).Trim(), 0); + Allow("String", "TrimStart", (recv, _) => ((string)recv!).TrimStart(), 0); + Allow("String", "TrimEnd", (recv, _) => ((string)recv!).TrimEnd(), 0); + Allow("String", "Substring", (recv, a) => ((string)recv!).Substring(Convert.ToInt32(a[0])), 1); + Allow("String", "Substring", (recv, a) => ((string)recv!).Substring(Convert.ToInt32(a[0]), Convert.ToInt32(a[1])), 2); + Allow("String", "IndexOf", (recv, a) => a[0] is string ixs ? ((string)recv!).IndexOf(ixs) : ((string)recv!).IndexOf(AsChar(a[0])), 1); + Allow("String", "LastIndexOf",(recv, a) => a[0] is string lxs ? ((string)recv!).LastIndexOf(lxs) : ((string)recv!).LastIndexOf(AsChar(a[0])), 1); + Allow("String", "Contains", (recv, a) => (a[0] is string cs ? ((string)recv!).Contains(cs) : ((string)recv!).Contains(AsChar(a[0]))) ? 1 : 0, 1); + Allow("String", "Replace", (recv, a) => a[0] is string rs ? ((string)recv!).Replace(rs, (string?)a[1]) : ((string)recv!).Replace(AsChar(a[0]), AsChar(a[1])), 2); + Allow("String", "StartsWith", (recv, a) => ((string)recv!).StartsWith((string)a[0]!) ? 1 : 0, 1); + Allow("String", "EndsWith", (recv, a) => ((string)recv!).EndsWith((string)a[0]!) ? 1 : 0, 1); + Allow("String", "Trim", (recv, a) => a[0] is char[] tcs ? ((string)recv!).Trim(tcs) : ((string)recv!).Trim(AsChar(a[0])), 1); + // Comparison-mode overloads — the StringComparison enum arrives boxed from an I4 slot. + // Instance comparison-mode only: the entry key is name+arity, and the lowering counts + // stack args from HasThis, so STATIC string.Equals(a, b) can't share this slot — it + // stays loud-unbound (write `a == b`); the 3-arg static form below has its own arity. + Allow("String", "Equals", (recv, a) => ((string)recv!).Equals(a[0] as string, (StringComparison)Convert.ToInt32(a[1])) ? 1 : 0, 2); + Allow("String", "StartsWith", (recv, a) => ((string)recv!).StartsWith((string)a[0]!, (StringComparison)Convert.ToInt32(a[1])) ? 1 : 0, 2); + Allow("String", "EndsWith", (recv, a) => ((string)recv!).EndsWith((string)a[0]!, (StringComparison)Convert.ToInt32(a[1])) ? 1 : 0, 2); + Allow("String", "IndexOf", (recv, a) => a[1] is string || a[1] == null + ? ((string)recv!).IndexOf((string)a[0]!, StringComparison.Ordinal) + : ((string)recv!).IndexOf((string)a[0]!, (StringComparison)Convert.ToInt32(a[1])), 2); + Allow("String", "Contains", (recv, a) => ((string)recv!).Contains((string)a[0]!, (StringComparison)Convert.ToInt32(a[1])) ? 1 : 0, 2); + AllowStatic("String", "Equals", (_, a) => string.Equals(a[0] as string, a[1] as string, (StringComparison)Convert.ToInt32(a[2])) ? 1 : 0, 3); + Allow("String", "Remove", (recv, a) => ((string)recv!).Remove(Convert.ToInt32(a[0])), 1); + Allow("String", "Remove", (recv, a) => ((string)recv!).Remove(Convert.ToInt32(a[0]), Convert.ToInt32(a[1])), 2); + Allow("String", "Insert", (recv, a) => ((string)recv!).Insert(Convert.ToInt32(a[0]), (string)a[1]!), 2); + Allow("String", "ToCharArray",(recv, _) => ((string)recv!).ToCharArray(), 0); + Allow("String", "CompareTo", (recv, a) => ((string)recv!).CompareTo(a[0] as string), 1); + AllowStatic("String", "Compare", (_, a) => string.Compare(a[0] as string, a[1] as string, StringComparison.CurrentCulture), 2); + Allow("String", "PadLeft", (recv, a) => ((string)recv!).PadLeft(Convert.ToInt32(a[0])), 1); + Allow("String", "PadLeft", (recv, a) => ((string)recv!).PadLeft(Convert.ToInt32(a[0]), AsChar(a[1])), 2); + Allow("String", "PadRight", (recv, a) => ((string)recv!).PadRight(Convert.ToInt32(a[0])), 1); + Allow("String", "PadRight", (recv, a) => ((string)recv!).PadRight(Convert.ToInt32(a[0]), AsChar(a[1])), 2); + Allow("String", "Split", (recv, a) => ((string)recv!).Split(AsChar(a[0])), 1); + Allow("String", "ToString", (recv, _) => recv, 0); + + // RuntimeHelpers.InitializeArray — fallback no-op. The interpreter pattern-matches + // the Roslyn-emitted "newarr; dup; ldtoken ; call InitializeArray" sequence in + // ScriptInterpreter.Newarr and does the fill eagerly, so this delegate is normally + // unreachable. Registered so BclCoverageTests sees the MemberRef as bound. + AllowStatic("RuntimeHelpers", "InitializeArray", (_, _) => null, 2); + + // Array.Empty() — Roslyn emits this when a params T[] parameter receives no arguments. + var arrayEmptyDef = typeof(System.Array).GetMethod(nameof(System.Array.Empty))!; + _openGenericMethods[("Array", "Empty", 0)] = new List { arrayEmptyDef }; + + // string.Join(string, IEnumerable) — the HUD staple `string.Join(",", list)` + // binds the generic overload through a MethodSpec. + foreach (var sjm in typeof(string).GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (sjm.Name == "Join" && sjm.IsGenericMethodDefinition && sjm.GetParameters().Length == 2 + && sjm.GetParameters()[0].ParameterType == typeof(string)) + { _openGenericMethods[("String", "Join", 2)] = new List { sjm }; break; } + } + + // Array.IndexOf(T[], T) / Sort(T[]) / Reverse(T[]) — C# binds the GENERIC + // overloads for typed arrays, reached through MethodSpecs; register the open definitions + // for lazy instantiation. + foreach (var aim in typeof(System.Array).GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (!aim.IsGenericMethodDefinition) continue; + int argc = aim.GetParameters().Length; + if ((aim.Name == "IndexOf" && argc == 2) || + ((aim.Name == "Sort" || aim.Name == "Reverse") && argc == 1)) + { + var key = ("Array", aim.Name, argc); + if (!_openGenericMethods.ContainsKey(key)) + _openGenericMethods[key] = new List { aim }; + } + } + + // Math — double-true now that R8 slots exist: the call site widens through conv.r8 + // and the result rides an R8 slot (bit-exact parity with compiled C#). + // Methods with int and float overloads runtime-switch on first-arg type. Each arm casts + // to (object) to suppress C# switch-expression type unification (which would otherwise + // widen int→float across arms and box every result as float). + AllowStatic("Math", "Abs", (_, a) => a[0] switch + { + int i => (object)Math.Abs(i), + long l => (object)Math.Abs(l), + float f => (object)Math.Abs(f), + _ => (object)(float)Math.Abs(Convert.ToSingle(a[0])), + }, 1); + AllowStatic("Math", "Min", (_, a) => (a[0], a[1]) switch + { + (int x, int y) => (object)Math.Min(x, y), + (long x, long y) => (object)Math.Min(x, y), + (float x, float y) => (object)Math.Min(x, y), + _ => (object)(float)Math.Min(Convert.ToSingle(a[0]), Convert.ToSingle(a[1])), + }, 2); + AllowStatic("Math", "Max", (_, a) => (a[0], a[1]) switch + { + (int x, int y) => (object)Math.Max(x, y), + (long x, long y) => (object)Math.Max(x, y), + (float x, float y) => (object)Math.Max(x, y), + _ => (object)(float)Math.Max(Convert.ToSingle(a[0]), Convert.ToSingle(a[1])), + }, 2); + AllowStatic("Math", "Clamp", (_, a) => (a[0], a[1], a[2]) switch + { + (int x, int lo, int hi) => (object)Math.Clamp(x, lo, hi), + (float x, float lo, float hi) => (object)Math.Clamp(x, lo, hi), + _ => (object)(float)Math.Clamp(Convert.ToSingle(a[0]), Convert.ToSingle(a[1]), Convert.ToSingle(a[2])), + }, 3); + AllowStatic("Math", "Sign", (_, a) => a[0] switch + { + int i => (object)Math.Sign(i), + long l => (object)Math.Sign(l), + float f => (object)Math.Sign(f), + _ => (object)Math.Sign(Convert.ToSingle(a[0])), + }, 1); + AllowStatic("Math", "Sqrt", (_, a) => Math.Sqrt(AsR8(a[0])), 1); + AllowStatic("Math", "Pow", (_, a) => Math.Pow(AsR8(a[0]), AsR8(a[1])), 2); + AllowStatic("Math", "Sin", (_, a) => Math.Sin(AsR8(a[0])), 1); + AllowStatic("Math", "Cos", (_, a) => Math.Cos(AsR8(a[0])), 1); + AllowStatic("Math", "Tan", (_, a) => Math.Tan(AsR8(a[0])), 1); + AllowStatic("Math", "Asin", (_, a) => Math.Asin(AsR8(a[0])), 1); + AllowStatic("Math", "Acos", (_, a) => Math.Acos(AsR8(a[0])), 1); + AllowStatic("Math", "Atan", (_, a) => Math.Atan(AsR8(a[0])), 1); + AllowStatic("Math", "Atan2", (_, a) => Math.Atan2(AsR8(a[0]), AsR8(a[1])), 2); + AllowStatic("Math", "Floor", (_, a) => Math.Floor(AsR8(a[0])), 1); + AllowStatic("Math", "Ceiling", (_, a) => Math.Ceiling(AsR8(a[0])), 1); + AllowStatic("Math", "Round", (_, a) => Math.Round(AsR8(a[0])), 1); + AllowStatic("Math", "Truncate", (_, a) => Math.Truncate(AsR8(a[0])), 1); + AllowStatic("Math", "Exp", (_, a) => Math.Exp(AsR8(a[0])), 1); + AllowStatic("Math", "Log", (_, a) => Math.Log(AsR8(a[0])), 1); + AllowStatic("Math", "Log10", (_, a) => Math.Log10(AsR8(a[0])), 1); + // Math.PI and Math.E are `const double` — Roslyn inlines as ldc.r8 literals at use + // sites, so no MemberRef is emitted and no binding is needed. + + // MathF — the float-native twin gameplay code actually writes. Same surface as the Math + // shims above, no double round-trip. + AllowStatic("MathF", "Abs", (_, a) => MathF.Abs(Convert.ToSingle(a[0])), 1); + AllowStatic("MathF", "Min", (_, a) => MathF.Min(Convert.ToSingle(a[0]), Convert.ToSingle(a[1])), 2); + AllowStatic("MathF", "Max", (_, a) => MathF.Max(Convert.ToSingle(a[0]), Convert.ToSingle(a[1])), 2); + AllowStatic("MathF", "Sign", (_, a) => MathF.Sign(Convert.ToSingle(a[0])), 1); + AllowStatic("MathF", "Sqrt", (_, a) => MathF.Sqrt(Convert.ToSingle(a[0])), 1); + AllowStatic("MathF", "Pow", (_, a) => MathF.Pow(Convert.ToSingle(a[0]), Convert.ToSingle(a[1])), 2); + AllowStatic("MathF", "Sin", (_, a) => MathF.Sin(Convert.ToSingle(a[0])), 1); + AllowStatic("MathF", "Cos", (_, a) => MathF.Cos(Convert.ToSingle(a[0])), 1); + AllowStatic("MathF", "Tan", (_, a) => MathF.Tan(Convert.ToSingle(a[0])), 1); + AllowStatic("MathF", "Atan2", (_, a) => MathF.Atan2(Convert.ToSingle(a[0]), Convert.ToSingle(a[1])), 2); + AllowStatic("MathF", "Floor", (_, a) => MathF.Floor(Convert.ToSingle(a[0])), 1); + AllowStatic("MathF", "Ceiling", (_, a) => MathF.Ceiling(Convert.ToSingle(a[0])), 1); + AllowStatic("MathF", "Round", (_, a) => MathF.Round(Convert.ToSingle(a[0])), 1); + AllowStatic("MathF", "Round", (_, a) => MathF.Round(Convert.ToSingle(a[0]), Convert.ToInt32(a[1])), 2); + AllowStatic("MathF", "Truncate", (_, a) => MathF.Truncate(Convert.ToSingle(a[0])), 1); + AllowStatic("MathF", "Exp", (_, a) => MathF.Exp(Convert.ToSingle(a[0])), 1); + AllowStatic("MathF", "Log", (_, a) => MathF.Log(Convert.ToSingle(a[0])), 1); + + // Numeric formatting/parsing — the HUD staples. Formatted ToString on the I4/R4 slot + // families needs the receiver's real type; the slot has erased char/bool identity but a + // FORMATTED ToString is only meaningful on int/float anyway. + Allow("Single", "ToString", (recv, _) => Convert.ToSingle(recv).ToString(), 0); + Allow("Single", "ToString", (recv, a) => Convert.ToSingle(recv).ToString((string?)a[0]), 1); + Allow("Int32", "ToString", (recv, a) => Convert.ToInt32(recv).ToString((string?)a[0]), 1); + // uint is analyzer-legal but rides I4 slots: the receiver may arrive boxed as a wrapped + // NEGATIVE int (3000000000u -> -1294967296), so reinterpret the bits, never Convert.ToUInt32. + Allow("UInt32", "ToString", (recv, _) => unchecked((uint)Convert.ToInt64(recv)).ToString(), 0); + Allow("UInt32", "ToString", (recv, a) => unchecked((uint)Convert.ToInt64(recv)).ToString((string?)a[0]), 1); + // 64-bit receivers arrive boxed as their true type (box_prim), but a value that crossed + // through an O slot may be boxed long — reinterpret bits, never checked-Convert. + Allow("Int64", "ToString", (recv, a) => AsI8(recv).ToString((string?)a[0]), 1); + Allow("UInt64", "ToString", (recv, _) => unchecked((ulong)AsI8(recv)).ToString(), 0); + Allow("UInt64", "ToString", (recv, a) => unchecked((ulong)AsI8(recv)).ToString((string?)a[0]), 1); + Allow("Double", "ToString", (recv, _) => AsR8(recv).ToString(), 0); + Allow("Double", "ToString", (recv, a) => AsR8(recv).ToString((string?)a[0]), 1); + // NaN/Infinity guards — the classic defensive checks around physics and division. + AllowStatic("Single", "IsNaN", (_, a) => float.IsNaN(Convert.ToSingle(a[0])) ? 1 : 0, 1); + AllowStatic("Single", "IsInfinity", (_, a) => float.IsInfinity(Convert.ToSingle(a[0])) ? 1 : 0, 1); + AllowStatic("Int32", "TryParse", (_, a) => + { + if (int.TryParse(a[0] as string, out var v)) { a[1] = v; return 1; } + a[1] = 0; return 0; + }, 2, typeof(HostBinding).GetMethod(nameof(TryGetValueShape), + BindingFlags.NonPublic | BindingFlags.Static)!.GetParameters()); + AllowStatic("Int64", "TryParse", (_, a) => + { + if (long.TryParse(a[0] as string, out var v)) { a[1] = v; return 1; } + a[1] = 0L; return 0; + }, 2, typeof(HostBinding).GetMethod(nameof(TryGetValueShape), + BindingFlags.NonPublic | BindingFlags.Static)!.GetParameters()); + AllowStatic("Double", "TryParse", (_, a) => + { + if (double.TryParse(a[0] as string, out var v)) { a[1] = v; return 1; } + a[1] = 0d; return 0; + }, 2, typeof(HostBinding).GetMethod(nameof(TryGetValueShape), + BindingFlags.NonPublic | BindingFlags.Static)!.GetParameters()); + AllowStatic("Single", "TryParse", (_, a) => + { + if (float.TryParse(a[0] as string, out var v)) { a[1] = v; return 1; } + a[1] = 0f; return 0; + }, 2, typeof(HostBinding).GetMethod(nameof(TryGetValueShape), + BindingFlags.NonPublic | BindingFlags.Static)!.GetParameters()); + + // Bitmask checks on [Flags] enums. Receiver and argument both arrive as properly-typed + // boxed enums (box_enum). + Allow("Enum", "HasFlag", (recv, a) => ((Enum)recv!).HasFlag((Enum)a[0]!) ? 1 : 0, 1); + + // Primitive/enum receiver basics — GetHashCode/CompareTo/Equals on I4/R4 slot values. + Allow("Int32", "GetHashCode", (recv, _) => Convert.ToInt32(recv).GetHashCode(), 0); + Allow("Single", "GetHashCode", (recv, _) => Convert.ToSingle(recv).GetHashCode(), 0); + Allow("Int32", "CompareTo", (recv, a) => Convert.ToInt32(recv).CompareTo(Convert.ToInt32(a[0])), 1); + Allow("Single", "CompareTo", (recv, a) => Convert.ToSingle(recv).CompareTo(Convert.ToSingle(a[0])), 1); + // Enum receivers arrive as typed boxed enums (box_enum / the constrained-enum path). + Allow("Enum", "GetHashCode", (recv, _) => recv!.GetHashCode(), 0); + Allow("Enum", "CompareTo", (recv, a) => ((IComparable)recv!).CompareTo(a[0]), 1); + + // char classification statics — input/text parsing. Args ride I4 slots boxed as int. + AllowStatic("Char", "IsDigit", (_, a) => char.IsDigit(AsChar(a[0])) ? 1 : 0, 1); + AllowStatic("Char", "IsLetter", (_, a) => char.IsLetter(AsChar(a[0])) ? 1 : 0, 1); + AllowStatic("Char", "IsLetterOrDigit", (_, a) => char.IsLetterOrDigit(AsChar(a[0])) ? 1 : 0, 1); + AllowStatic("Char", "IsWhiteSpace", (_, a) => char.IsWhiteSpace(AsChar(a[0])) ? 1 : 0, 1); + AllowStatic("Char", "IsUpper", (_, a) => char.IsUpper(AsChar(a[0])) ? 1 : 0, 1); + AllowStatic("Char", "IsLower", (_, a) => char.IsLower(AsChar(a[0])) ? 1 : 0, 1); + AllowStatic("Char", "ToUpper", (_, a) => char.ToUpper(AsChar(a[0])), 1); + AllowStatic("Char", "ToLower", (_, a) => char.ToLower(AsChar(a[0])), 1); + + // Array.IndexOf — the generic static binds as IndexOf; the non-generic overload + // serves every element type. + AllowStatic("Array", "IndexOf", (_, a) => Array.IndexOf((Array)a[0]!, a[1]), 2); + AllowStatic("Array", "Copy", (_, a) => { Array.Copy((Array)a[0]!, (Array)a[1]!, Convert.ToInt32(a[2])); return null; }, 3); + AllowStatic("Array", "Copy", (_, a) => { Array.Copy((Array)a[0]!, Convert.ToInt32(a[1]), (Array)a[2]!, Convert.ToInt32(a[3]), Convert.ToInt32(a[4])); return null; }, 5); + AllowStatic("Array", "Clear", (_, a) => { Array.Clear((Array)a[0]!, Convert.ToInt32(a[1]), Convert.ToInt32(a[2])); return null; }, 3); + + // StringBuilder — HUD string building without per-frame concat garbage. + AllowConstructor("StringBuilder", (_, _2) => new StringBuilder(), 0); + AllowConstructor("StringBuilder", (_, a) => a[0] is int cap ? new StringBuilder(cap) : new StringBuilder((string?)a[0]), 1); + Allow("StringBuilder", "Append", (recv, a) => ((StringBuilder)recv!).Append(a[0]), 1); + Allow("StringBuilder", "AppendLine", (recv, _) => ((StringBuilder)recv!).AppendLine(), 0); + Allow("StringBuilder", "AppendLine", (recv, a) => ((StringBuilder)recv!).AppendLine(a[0]?.ToString()), 1); + Allow("StringBuilder", "Clear", (recv, _) => ((StringBuilder)recv!).Clear(), 0); + Allow("StringBuilder", "ToString", (recv, _) => ((StringBuilder)recv!).ToString(), 0); + Allow("StringBuilder", "get_Length", (recv, _) => ((StringBuilder)recv!).Length, 0); + + // System.Random — seeded, deterministic across both sides of the oracle. + // Defensive throws — `throw new InvalidOperationException("...")` on bad state. + AllowConstructor("Exception", (_, a) => new Exception((string?)a[0]), 1); + AllowConstructor("InvalidOperationException", (_, _2) => new InvalidOperationException(), 0); + AllowConstructor("InvalidOperationException", (_, a) => new InvalidOperationException((string?)a[0]), 1); + AllowConstructor("ArgumentException", (_, a) => new ArgumentException((string?)a[0]), 1); + AllowConstructor("ArgumentOutOfRangeException",(_, a) => new ArgumentOutOfRangeException((string?)a[0]), 1); + AllowConstructor("ArgumentNullException", (_, a) => new ArgumentNullException((string?)a[0]), 1); + AllowConstructor("NotSupportedException", (_, a) => new NotSupportedException((string?)a[0]), 1); + AllowConstructor("IndexOutOfRangeException", (_, a) => new IndexOutOfRangeException((string?)a[0]), 1); + AllowConstructor("KeyNotFoundException", (_, a) => new System.Collections.Generic.KeyNotFoundException((string?)a[0]), 1); + + // Range slices — `arr[1..4]` lowers to Index conversions + `new Range` + + // RuntimeHelpers.GetSubArray(array, range). + AllowStatic("Index", "op_Implicit", (_, a) => (Index)Convert.ToInt32(a[0]), 1); + AllowConstructor("Index", (_, a) => new Index(Convert.ToInt32(a[0]), + a[1] is bool fromEnd ? fromEnd : Convert.ToInt32(a[1]) != 0), 2); + AllowConstructor("Range", (_, a) => new Range((Index)a[0]!, (Index)a[1]!), 2); + var gsa = typeof(System.Runtime.CompilerServices.RuntimeHelpers) + .GetMethod("GetSubArray", BindingFlags.Public | BindingFlags.Static); + if (gsa != null) + _openGenericMethods[("RuntimeHelpers", "GetSubArray", 2)] = new List { gsa }; + + foreach (var afm in typeof(System.Array).GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (afm.Name == "Fill" && afm.IsGenericMethodDefinition && afm.GetParameters().Length == 2) + { _openGenericMethods[("Array", "Fill", 2)] = new List { afm }; break; } + } + + AllowConstructor("Random", (_, _2) => new Random(), 0); + AllowConstructor("Random", (_, a) => new Random(Convert.ToInt32(a[0])), 1); + Allow("Random", "Next", (recv, _) => ((Random)recv!).Next(), 0); + Allow("Random", "Next", (recv, a) => ((Random)recv!).Next(Convert.ToInt32(a[0])), 1); + Allow("Random", "Next", (recv, a) => ((Random)recv!).Next(Convert.ToInt32(a[0]), Convert.ToInt32(a[1])), 2); + // netstandard2.1 (the editor profile) has no Random.NextSingle — same distribution via NextDouble. + Allow("Random", "NextSingle", (recv, _) => (float)((Random)recv!).NextDouble(), 0); + + // Wall-clock cooldowns and save stamps. DateTime/TimeSpan are host structs the script + // only ever holds boxed; float-view accessors keep results in the script's number space. + AllowStatic("DateTime", "get_Now", (_, _2) => DateTime.Now, 0); + AllowStatic("DateTime", "get_UtcNow", (_, _2) => DateTime.UtcNow, 0); + AllowStatic("DateTime", "op_Subtraction", (_, a) => (DateTime)a[0]! - (DateTime)a[1]!, 2); + Allow("DateTime", "get_Hour", (recv, _) => ((DateTime)recv!).Hour, 0); + Allow("DateTime", "get_Minute", (recv, _) => ((DateTime)recv!).Minute, 0); + Allow("DateTime", "get_Second", (recv, _) => ((DateTime)recv!).Second, 0); + Allow("DateTime", "ToString", (recv, a) => ((DateTime)recv!).ToString((string?)a[0]), 1); + Allow("TimeSpan", "get_TotalSeconds", (recv, _) => (float)((TimeSpan)recv!).TotalSeconds, 0); + Allow("TimeSpan", "get_TotalMilliseconds", (recv, _) => (float)((TimeSpan)recv!).TotalMilliseconds, 0); + AllowStatic("Environment", "get_TickCount", (_, _2) => Environment.TickCount, 0); + // Roslyn's IEnumerable iterator boilerplate checks thread affinity in GetEnumerator. + AllowStatic("Environment", "get_CurrentManagedThreadId", (_, _2) => Environment.CurrentManagedThreadId, 0); + + // Enum.TryParse(string, out T) — config/save parsing; the MethodSpec instantiation + // carries real ParameterInfo, so the byref out rides the standard write-back path. + foreach (var etp in typeof(Enum).GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (etp.Name == "TryParse" && etp.IsGenericMethodDefinition && etp.GetParameters().Length == 2 + && etp.GetParameters()[0].ParameterType == typeof(string)) + { _openGenericMethods[("Enum", "TryParse", 2)] = new List { etp }; break; } + } + AllowStatic("Guid", "NewGuid", (_, _2) => Guid.NewGuid(), 0); + Allow("Guid", "ToString", (recv, _) => ((Guid)recv!).ToString(), 0); + + // List extras beyond the core set above; Sort/ToArray need the closed generic, so + // dispatch by reflection on the instance (AOT-safe — the instantiation already exists). + Allow("List`1", "Sort", (recv, _) => { recv!.GetType().GetMethod("Sort", Type.EmptyTypes)!.Invoke(recv, null); return null; }, 0); + Allow("List`1", "ToArray", (recv, _) => recv!.GetType().GetMethod("ToArray")!.Invoke(recv, null), 0); + // Predicate members — the arg is a real closed delegate (Predicate) built by the + // delegate machinery, so reflection Invoke dispatches it directly. + Allow("List`1", "AddRange", (recv, a) => { recv!.GetType().GetMethod("AddRange")!.Invoke(recv, new[] { a[0] }); return null; }, 1); + Allow("List`1", "Reverse", (recv, _) => { recv!.GetType().GetMethod("Reverse", Type.EmptyTypes)!.Invoke(recv, null); return null; }, 0); + Allow("List`1", "GetRange", (recv, a) => recv!.GetType().GetMethod("GetRange")!.Invoke(recv, new object?[] { Convert.ToInt32(a[0]), Convert.ToInt32(a[1]) }), 2); + Allow("List`1", "Find", (recv, a) => NormalizeIntegralReturn(recv!.GetType().GetMethod("Find")!.Invoke(recv, new[] { a[0] })), 1); + Allow("List`1", "FindIndex", (recv, a) => recv!.GetType().GetMethod("FindIndex", new[] { typeof(Predicate<>).MakeGenericType(recv.GetType().GetGenericArguments()) })!.Invoke(recv, new[] { a[0] }), 1); + Allow("List`1", "Exists", (recv, a) => (bool)recv!.GetType().GetMethod("Exists")!.Invoke(recv, new[] { a[0] })! ? 1 : 0, 1); + Allow("List`1", "RemoveAll", (recv, a) => recv!.GetType().GetMethod("RemoveAll")!.Invoke(recv, new[] { a[0] }), 1); + + // Queue/Stack — pooling and undo stacks. No non-generic interface carries their + // members, so reflection-dispatch per member. + Allow("Queue`1", "Enqueue", (recv, a) => { recv!.GetType().GetMethod("Enqueue")!.Invoke(recv, new[] { a[0] }); return null; }, 1); + Allow("Queue`1", "Dequeue", (recv, _) => recv!.GetType().GetMethod("Dequeue")!.Invoke(recv, null), 0); + Allow("Queue`1", "Peek", (recv, _) => recv!.GetType().GetMethod("Peek")!.Invoke(recv, null), 0); + Allow("Queue`1", "Clear", (recv, _) => { recv!.GetType().GetMethod("Clear")!.Invoke(recv, null); return null; }, 0); + Allow("Queue`1", "get_Count", (recv, _) => ((System.Collections.ICollection)recv!).Count, 0); + Allow("Stack`1", "Push", (recv, a) => { recv!.GetType().GetMethod("Push")!.Invoke(recv, new[] { a[0] }); return null; }, 1); + Allow("Stack`1", "Pop", (recv, _) => recv!.GetType().GetMethod("Pop")!.Invoke(recv, null), 0); + Allow("Stack`1", "Peek", (recv, _) => recv!.GetType().GetMethod("Peek")!.Invoke(recv, null), 0); + Allow("Stack`1", "Clear", (recv, _) => { recv!.GetType().GetMethod("Clear")!.Invoke(recv, null); return null; }, 0); + Allow("Stack`1", "get_Count", (recv, _) => ((System.Collections.ICollection)recv!).Count, 0); + + // Attach FastIsFlat paths to the always-float Math methods above — AllowBcl doesn't run + // BuildTypedDelegates for Math, so Fasts are attached by hand. Multi-arm dispatch methods + // (Abs, Sign, Min, Max, Clamp) stay on the slow delegate path: their result type depends + // on the argument type, and mixing int and float results in the same R4 dst slot would + // corrupt the int arm's value. + + // (double) → double — the closures compute at full width and write R8 dsts + AttachFlatDouble("Math", "Sqrt", 1, (_, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + Math.Sqrt(NumReadDouble(num, ref_, slotT, bp + (int)ir[argBase])))); + AttachFlatDouble("Math", "Sin", 1, (_, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + Math.Sin(NumReadDouble(num, ref_, slotT, bp + (int)ir[argBase])))); + AttachFlatDouble("Math", "Cos", 1, (_, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + Math.Cos(NumReadDouble(num, ref_, slotT, bp + (int)ir[argBase])))); + AttachFlatDouble("Math", "Tan", 1, (_, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + Math.Tan(NumReadDouble(num, ref_, slotT, bp + (int)ir[argBase])))); + AttachFlatDouble("Math", "Asin", 1, (_, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + Math.Asin(NumReadDouble(num, ref_, slotT, bp + (int)ir[argBase])))); + AttachFlatDouble("Math", "Acos", 1, (_, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + Math.Acos(NumReadDouble(num, ref_, slotT, bp + (int)ir[argBase])))); + AttachFlatDouble("Math", "Atan", 1, (_, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + Math.Atan(NumReadDouble(num, ref_, slotT, bp + (int)ir[argBase])))); + AttachFlatDouble("Math", "Floor", 1, (_, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + Math.Floor(NumReadDouble(num, ref_, slotT, bp + (int)ir[argBase])))); + AttachFlatDouble("Math", "Ceiling", 1, (_, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + Math.Ceiling(NumReadDouble(num, ref_, slotT, bp + (int)ir[argBase])))); + AttachFlatDouble("Math", "Round", 1, (_, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + Math.Round(NumReadDouble(num, ref_, slotT, bp + (int)ir[argBase])))); + AttachFlatDouble("Math", "Truncate", 1, (_, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + Math.Truncate(NumReadDouble(num, ref_, slotT, bp + (int)ir[argBase])))); + AttachFlatDouble("Math", "Exp", 1, (_, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + Math.Exp(NumReadDouble(num, ref_, slotT, bp + (int)ir[argBase])))); + AttachFlatDouble("Math", "Log", 1, (_, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + Math.Log(NumReadDouble(num, ref_, slotT, bp + (int)ir[argBase])))); + AttachFlatDouble("Math", "Log10", 1, (_, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + Math.Log10(NumReadDouble(num, ref_, slotT, bp + (int)ir[argBase])))); + + // (double, double) → double + AttachFlatDouble("Math", "Atan2", 2, (_, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + Math.Atan2(NumReadDouble(num, ref_, slotT, s0), NumReadDouble(num, ref_, slotT, s1))); + }); + AttachFlatDouble("Math", "Pow", 2, (_, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + Math.Pow(NumReadDouble(num, ref_, slotT, s0), NumReadDouble(num, ref_, slotT, s1))); + }); + + return this; + } + + // Parameter shape borrowed by the Dictionary`2.TryGetValue shim: the lowerer classifies + // byref call sites from Entry.Params, which hand-written shims otherwise don't carry. + internal static long AsI8(object? v) => v switch + { + long l => l, ulong u => unchecked((long)u), int i => i, uint u4 => u4, + double d => (long)d, float f => (long)f, _ => Convert.ToInt64(v), + }; + + internal static double AsR8(object? v) => v switch + { + double d => d, float f => f, long l => l, ulong u => u, int i => i, + _ => Convert.ToDouble(v), + }; + + static bool TryGetValueShape(object key, out object? value) { value = null; return false; } + static void DeconstructShape(out object? key, out object? value) { key = null; value = null; } + + // Auto-register all public members of T and its base types via reflection. + // Walks the inheritance chain so base-class members (e.g. ButtonControl.isPressed + // inherited by KeyControl) are registered under their declaring type name. + // Skips open generic type definitions; closed generics (e.g. InputControl) + // are registered so their members resolve correctly. Stops at Object / ValueType. + public HostBinding AllowType(Type type) + { + AllowTypeWithBases(type); + // Auto-flatten 4-byte-field structs even without a generic type argument: the pin-based + // marshaller needs no MakeGenericMethod, so auto-bound structs (Rect, LayerMask, game + // structs, ...) stop boxing at every host-call boundary. Blittable structs that don't + // qualify (sub-4-byte fields, e.g. Color32) keep the boxed path and the perf warning. + if (type.IsValueType && !type.IsEnum && IsSlot4FlatCandidate(type)) + { + BuildStructLayoutNonGeneric(type); + if (_structLayoutsByType.ContainsKey(type)) + { + // Without generic Fast closures (value-type MakeGenericMethod is off-limits + // here), flat args would be pin-boxed per OPERATOR call — costlier than the old + // stay-boxed flow. Pure-arithmetic operators sidestep the boundary entirely: + // the IL inspection below marks them and the lowerer synthesizes per-field IR. + InspectAndMarkOperators(type, warnOnReject: false); + } + } + else + WarnIfFlatStructCandidate(type); + // Build the allocation-free Fast delegates the generic AllowType() would — otherwise + // every method on a type registered this way (e.g. UnityHostBinding's Mathf/Time/Physics) + // takes the boxing slow path. Reference types only: a reference T uses IL2CPP shared + // generics (AOT-safe); value types must use AllowTypeStruct() and a value-type + // MakeGenericMethod is an AOT hazard, so they're skipped here. + if (!type.IsValueType && !type.IsEnum) + { + try + { + typeof(HostBinding) + .GetMethod(nameof(BuildTypedDelegates), BindingFlags.NonPublic | BindingFlags.Instance)! + .MakeGenericMethod(type) + .Invoke(this, null); + } + catch { /* best-effort fast-path; falls back to the (correct) slow path on failure */ } + } + // AOT-safe static-method fast paths (no MakeGenericMethod). Fills static-class members + // (Mathf/Time/Physics) that the generic path above can't, and any static methods it missed. + BuildStaticPrimitiveFastDelegates(type); + return this; + } + + /// + /// Register the NON-PUBLIC members (fields, properties, methods — instance AND static) of + /// and its user-defined base types, so an interpreter-run [CodeReload] body — + /// compiled with access checks disabled (BinderFlags.IgnoreAccessibility) — can read/write private + /// state (including private statics like the `s_Instance = this;` singleton pattern) and call private + /// helpers on its target type. Public members are already covered by . + /// + /// The base-chain walk stops at the first UnityEngine / System / Unity.* type, so we never register + /// (nor, via the dev link.xml, force-preserve) the private internals of MonoBehaviour, Component or the + /// BCL — only the user's own component hierarchy. Non-public members go through reflection Get/Set/Invoke, + /// which is AOT-safe on IL2CPP as long as the members survive stripping. + /// + public HostBinding AllowNonPublicInstanceMembers(Type type) + { + const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly; + for (var t = type; t != null; t = t.BaseType) + { + // Always process the target type itself; only stop CLIMBING once we reach an engine/BCL base + // (MonoBehaviour, Component, object, …) — we never want to register their private internals. + if (t != type) + { + var ns = t.Namespace ?? string.Empty; + if (ns.StartsWith("UnityEngine", StringComparison.Ordinal) || + ns.StartsWith("UnityEditor", StringComparison.Ordinal) || + ns.StartsWith("System", StringComparison.Ordinal) || + ns.StartsWith("Unity.", StringComparison.Ordinal)) + break; + } + + var name = t.Name; + + foreach (var f in t.GetFields(flags)) + { + if (SkipType(f.FieldType)) continue; + RegisterField(name, f); + } + + // Non-public STATIC fields and methods too: a reloaded body may touch its own + // type's private statics (the ubiquitous `s_Instance = this;` singleton pattern). + // Public statics are already covered by AllowType's static walk. + const BindingFlags statFlags = BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly; + foreach (var f in t.GetFields(statFlags)) + { + if (SkipType(f.FieldType)) continue; + RegisterField(name, f); + } + RegisterMethods(t.GetMethods(statFlags), name, isStatic: true); + + // Non-public instance properties → get_/set_ method calls. Accessor arity from the + // accessor itself — indexers take the index (see the public property pass). + foreach (var p in t.GetProperties(flags)) + { + if (SkipType(p.PropertyType)) continue; + if (p.GetMethod != null && !SkipMethod(p.GetMethod)) + { + var g = p.GetMethod; + var gp = g.GetParameters(); + if (gp.Length == 0) + Allow(name, g.Name, (recv, _) => g.Invoke(recv, null), 0, mi: g); + else + Allow(name, g.Name, (recv, args) => g.Invoke(recv, args), gp.Length, gp, mi: g); + } + if (p.SetMethod != null && !SkipMethod(p.SetMethod)) + { + var s = p.SetMethod; + var spms = s.GetParameters(); + Allow(name, s.Name, (recv, args) => { s.Invoke(recv, args); return null; }, spms.Length, spms, mi: s); + } + } + + // Non-public instance methods (RegisterMethods skips get_/set_ via IsSpecialName) + RegisterMethods(t.GetMethods(flags), name, isStatic: false); + } + return this; + } + + public HostBinding AllowType() + { + AllowTypeWithBases(typeof(T)); + // Auto-promote a blittable struct to the non-boxing flat path (equivalent to + // AllowTypeStruct). Compile-time generic over the concrete T → AOT-safe, no + // reflection. Must run before BuildTypedDelegates so its flat ctor/operator fast paths + // see the layout. Component-wise operators/ctors additionally inline (structurally + // verified, quiet on rejection). + if (IsFlatStructCandidate(typeof(T))) + { + BuildStructLayoutCore(); + InspectAndMarkOperators(typeof(T), warnOnReject: false); + } + BuildTypedDelegates(); + return this; + } + + // A blittable (reference-free) struct registered via AllowType is left boxed — every value + // crossing the host-call boundary allocates. AllowTypeStruct() flows it through flat numeric + // frame slots with zero boxing. Warn so the perf footgun is visible at registration time. + static void WarnIfFlatStructCandidate(Type type) + { + if (IsFlatStructCandidate(type)) + Warn( + $"[IlInterpreter] {type.Name} is a blittable struct registered via AllowType — values box on " + + $"every host-call boundary. Use AllowTypeStruct<{type.Name}>() for the non-boxing flat path."); + } + + static bool IsFlatStructCandidate(Type t) + { + if (!t.IsValueType || t.IsEnum || t.IsPrimitive || t.IsPointer || t.IsByRef) return false; + foreach (var f in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + var ft = f.FieldType; + if (ft.IsPrimitive || ft.IsEnum) continue; + if (ft.IsValueType && IsFlatStructCandidate(ft)) continue; + return false; // reference field (or non-blittable) → AllowTypeStruct doesn't apply + } + return true; + } + + // Flat-eligibility used by the AUTOMATIC (non-generic) flattening path: every transitive + // field must be a fixed-width int/float — 4-byte, or a sub-4 integer handled by the + // ldfld_vt_{u1,i1,u2,i2} widening / stfld_vt_{b1,b2} truncating ops — an int-backed enum, + // or a nested struct that qualifies. bool and char stay excluded: their Marshal layout + // diverges from the managed layout (bool marshals 4-byte, char ANSI 1-byte), so the flat + // byte image would not match the managed struct. Explicit AllowTypeStruct() keeps the + // wider IsFlatStructCandidate behavior the caller opted into. + static bool IsSlot4FlatCandidate(Type t) + { + if (!t.IsValueType || t.IsEnum || t.IsPrimitive || t.IsPointer || t.IsByRef) return false; + foreach (var f in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + var ft = f.FieldType; + if (ft == typeof(int) || ft == typeof(uint) || ft == typeof(float)) continue; + if (ft == typeof(byte) || ft == typeof(sbyte) || ft == typeof(short) || ft == typeof(ushort)) continue; + if (ft.IsEnum && Enum.GetUnderlyingType(ft) == typeof(int)) continue; + if (ft.IsValueType && !ft.IsPrimitive && !ft.IsEnum && IsSlot4FlatCandidate(ft)) continue; + return false; + } + return true; + } + + // Non-generic fast-delegate builder for STATIC methods + static property getters with primitive + // (or ref) signatures. Static members have no `this`, so Func/Func/... bind + // without the declaring type — which is the only way to fast-path STATIC CLASSES (Mathf, Time, + // Physics): they can't be generic type args, so BuildTypedDelegates can't run for them, and + // the non-generic AllowType(Type) MakeGenericMethod path throws on them. Uses no MakeGenericMethod + // → AOT-safe on IL2CPP. Sets FlatReturnSType so the lowerer allocates a flat (R4/I4) dst slot. + void BuildStaticPrimitiveFastDelegates(Type type) + { + var statBind = BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly; + string tn = type.Name; + foreach (var m in type.GetMethods(statBind)) + { + if (m.IsGenericMethodDefinition) continue; + // Skip operators/set_ accessors; allow get_ property getters (e.g. Time.deltaTime). + if (m.IsSpecialName && !m.Name.StartsWith("get_", StringComparison.Ordinal)) continue; + var ps = m.GetParameters(); + // Plain static methods live in _byHandle; property getters (IsSpecialName) live in _entries. + if (!_byHandle.TryGetValue(m.MethodHandle.Value, out var entry) + && !_entries.TryGetValue($"{tn}.{m.Name}/{ps.Length}", out entry)) continue; + if (entry.Fast != null) continue; // already fast (e.g. ref type via BuildTypedDelegates) + var rt = m.ReturnType; + + if (rt == typeof(float)) + { + entry.FlatReturnSType = SType.R4; entry.FastIsFlat = true; + if (ps.Length == 0) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), m); + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del()); + } + else if (ps.Length == 1 && ps[0].ParameterType == typeof(float)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), m); + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del(NumReadFloat(num, ref_, slotT, bp + (int)ir[argBase]))); + } + else if (ps.Length == 2 && ps[0].ParameterType == typeof(float) && ps[1].ParameterType == typeof(float)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), m); + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del(NumReadFloat(num, ref_, slotT, s0), NumReadFloat(num, ref_, slotT, s1))); }; + } + else if (ps.Length == 3 && ps[0].ParameterType == typeof(float) && ps[1].ParameterType == typeof(float) && ps[2].ParameterType == typeof(float)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), m); + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1], s2 = bp + (int)ir[argBase + 2]; + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del(NumReadFloat(num, ref_, slotT, s0), NumReadFloat(num, ref_, slotT, s1), NumReadFloat(num, ref_, slotT, s2))); }; + } + else { entry.FastIsFlat = false; entry.FlatReturnSType = SType.O; } // unhandled arity — leave slow + } + else if (rt == typeof(int)) + { + entry.FlatReturnSType = SType.I4; entry.FastIsFlat = true; + if (ps.Length == 0) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), m); + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del()); + } + else if (ps.Length == 1 && ps[0].ParameterType == typeof(int)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), m); + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del(NumReadInt(num, ref_, slotT, bp + (int)ir[argBase]))); + } + else if (ps.Length == 2 && ps[0].ParameterType == typeof(int) && ps[1].ParameterType == typeof(int)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), m); + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del(NumReadInt(num, ref_, slotT, s0), NumReadInt(num, ref_, slotT, s1))); }; + } + else { entry.FastIsFlat = false; entry.FlatReturnSType = SType.O; } + } + else if (ps.Length == 0 && !rt.IsValueType && rt != typeof(void)) + { + // static () → reference type (factory/getter): store the ref straight into refFrame. + var del = (Func)Delegate.CreateDelegate(typeof(Func), m); + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => ref_[bp + dst] = del(); + } + } + } + + // Like AllowType() but also installs Unsafe.Unbox-based field accessors for struct types. + // Replaces FieldInfo.GetValue (~23 ns) with direct byte-frame reads (~2 ns) for float/int fields. + // IL2CPP-safe: the where T : struct constraint ensures Unsafe.Unbox is AOT-compiled for T. + // Call this instead of AllowType() for performance-critical struct host types (V3, Color, etc.). + // operatorsArePure: opt-in S4 operator inlining. When true, the IL of each operator is + // inspected at registration time; pure arithmetic operators are marked inlineable and the + // lowerer will synthesize field-by-field IR instead of emitting call_host. + public HostBinding AllowTypeStruct(bool operatorsArePure = false) where T : struct + { + AllowTypeWithBases(typeof(T)); + // Struct field/layout delegates first so BuildTypedDelegates can consult + // _structLayoutsByType when building flat-write ctor FastFlat paths. + BuildStructLayoutCore(); + InstallUnsafeStructFieldAccessors(); + BuildTypedDelegates(); + // Operator/ctor inlining is verified STRUCTURALLY (component-wise IL match), so it is + // always safe to attempt; operatorsArePure now only controls whether rejections warn. + InspectAndMarkOperators(typeof(T), warnOnReject: operatorsArePure); + return this; + } + + // Builds the StructLayout the IR lowerer consumes for flat (Vt-slot) struct support, plus per-field + // flat metadata (byte offset + SType). Constraint-free so the unconstrained AllowType() can call + // it to auto-promote a blittable struct to the non-boxing flat path. It's a compile-time generic + // (each concrete T at a call site), so no reflection MakeGenericMethod — AOT-safe. + void BuildStructLayoutCore() + { + int size = Marshal.SizeOf(); + // Boundary marshalling straight off the unmanaged frame. `(T)boxed` unboxes a copy (no + // `where T : struct` needed, unlike Unsafe.Unbox); the bytes written are identical, + // and it's a read-from-box → write-to-frame so a copy is fine. + BoxFromPtrDelegate box = src => { T val = Unsafe.ReadUnaligned(src); return val!; }; + CopyToPtrDelegate copy = (dst, boxed) => Unsafe.WriteUnaligned(dst, (T)boxed!); + FinishStructLayout(typeof(T), size, box, copy); + } + + // Type-only flavor for structs that reach us without a generic type argument — the + // non-generic AllowType(Type) path that auto-bind uses. The layout DATA is plain reflection; + // the marshalling delegates use a pinned-box memcpy instead of Unsafe.ReadUnaligned, + // avoiding the value-type MakeGenericMethod that is unsafe under IL2CPP/AOT. Registration + // runs a byte round-trip self-check and refuses the flat path (boxed fallback) on any + // Marshal-vs-managed layout surprise. + void BuildStructLayoutNonGeneric(Type type) + { + int size = Marshal.SizeOf(type); + BoxFromPtrDelegate box = src => + { + object boxed = Activator.CreateInstance(type)!; + var h = GCHandle.Alloc(boxed, GCHandleType.Pinned); + try { Buffer.MemoryCopy(src, (void*)h.AddrOfPinnedObject(), size, size); } + finally { h.Free(); } + return boxed; + }; + CopyToPtrDelegate copy = (dst, boxed) => + { + var h = GCHandle.Alloc(boxed, GCHandleType.Pinned); + try { Buffer.MemoryCopy((void*)h.AddrOfPinnedObject(), dst, size, size); } + finally { h.Free(); } + }; + + // Round-trip self-check: a recognizable byte pattern must survive box → copy-back. + // Catches pinned-layout drift (e.g. a runtime where the box payload isn't the raw + // struct bytes) at registration time instead of corrupting frames at run time. + if (size <= 1024) + { + byte* probe = stackalloc byte[size]; + byte* echo = stackalloc byte[size]; + for (int i = 0; i < size; i++) probe[i] = (byte)(0xA5 ^ i); + try + { + copy(echo, box(probe)); + for (int i = 0; i < size; i++) + if (echo[i] != probe[i]) + { + Warn($"[IlInterpreter] {type.Name}: pinned-box round-trip mismatch at byte {i} — keeping the boxed path."); + return; + } + } + catch (Exception ex) + { + Warn($"[IlInterpreter] {type.Name}: pinned-box marshalling unavailable ({ex.GetType().Name}) — keeping the boxed path."); + return; + } + } + + FinishStructLayout(type, size, box, copy); + } + + void FinishStructLayout(Type type, int size, BoxFromPtrDelegate box, CopyToPtrDelegate copy) + { + var typeName = type.Name; + var layout = new StructLayout + { + Type = type, + Size = size, + TypeName = typeName, + Fields = new Dictionary(), + BoxFromPtr = box, + CopyToPtr = copy, + }; + RuntimeHelpers.PrepareDelegate(layout.BoxFromPtr); + RuntimeHelpers.PrepareDelegate(layout.CopyToPtr); + var instBind = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; + foreach (var fi in type.GetFields(instBind)) + { + var key = $"{typeName}.{fi.Name}"; + int byteOffset; + try { byteOffset = (int)Marshal.OffsetOf(type, fi.Name); } + catch { continue; } // non-blittable field — skip + + SType pst; + byte kind = 0; // full 4-byte cell unless a sub-4 primitive + if (fi.FieldType == typeof(float)) pst = SType.R4; + else if (fi.FieldType == typeof(int) || fi.FieldType == typeof(uint) + || fi.FieldType == typeof(bool)) pst = SType.I4; + else if (fi.FieldType == typeof(byte)) { pst = SType.I4; kind = 1; } + else if (fi.FieldType == typeof(sbyte)) { pst = SType.I4; kind = 2; } + else if (fi.FieldType == typeof(ushort)) { pst = SType.I4; kind = 3; } + else if (fi.FieldType == typeof(short)) { pst = SType.I4; kind = 4; } + else if (fi.FieldType.IsEnum && Enum.GetUnderlyingType(fi.FieldType) == typeof(int)) pst = SType.I4; + else pst = SType.O; + + layout.Fields[fi.Name] = (byteOffset, pst); + if (kind != 0) (layout.FieldKinds ??= new Dictionary())[fi.Name] = kind; + + if (!_fields.TryGetValue(key, out var fe)) continue; + fe.DeclaringStruct = layout; + fe.ByteOffset = byteOffset; + fe.PrimitiveSt = pst; + fe.PrimitiveKind = kind; + } + _structLayouts[typeName] = layout; + _structLayoutsByType[type] = layout; + MarkTrivialAccessors(type, layout); + } + + // IL-verify each public property accessor of a flat struct as a single backing-field + // access; matches get {ldarg.0; ldfld f; ret} and set {ldarg.0; ldarg.1; stfld f; ret} + // (the exact Release shape of Rect.width-style wrappers). Verified accessors get their + // backing field's byte offset stamped on the Entry so the lowerer can inline them. + void MarkTrivialAccessors(Type type, StructLayout layout) + { + var tn = type.Name; + foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + // Offsets come straight from the backing FieldInfo — layout.Fields only carries + // PUBLIC fields, and Rect-style backing fields (m_Width) are private. + var g = prop.GetMethod; + if (g != null && TryReadIl(g, out var gil) + && TryMatchAccessorIl(gil, g, isSetter: false, out var gfld) + && TryAccessorField(type, layout, gfld, out int gOff, out SType gSt, out var gVtLay) + && _entries.TryGetValue($"{tn}.get_{prop.Name}/0", out var gentry)) + { + gentry.AccessorOffset = gOff; + gentry.AccessorSt = gSt; + gentry.AccessorVtLayout = gVtLay; + } + var st = prop.SetMethod; + if (st != null && TryReadIl(st, out var sil) + && TryMatchAccessorIl(sil, st, isSetter: true, out var sfld) + && TryAccessorField(type, layout, sfld, out int sOff, out SType sSt, out var sVtLay) + && _entries.TryGetValue($"{tn}.set_{prop.Name}/1", out var sentry)) + { + sentry.AccessorOffset = sOff; + sentry.AccessorSt = sSt; + sentry.AccessorVtLayout = sVtLay; + } + } + } + + bool TryAccessorField(Type type, StructLayout layout, FieldInfo fld, out int off, out SType st, + out StructLayout? vtLay) + { + off = 0; st = SType.O; vtLay = null; + if (fld.DeclaringType != type) return false; + var ft = fld.FieldType; + int fieldSize = 4; + if (ft == typeof(float)) st = SType.R4; + else if (ft == typeof(int) || ft == typeof(uint) + || (ft.IsEnum && Enum.GetUnderlyingType(ft) == typeof(int))) st = SType.I4; + // Struct-typed backing field (Bounds.m_Center : Vector3) with a flat layout of its own: + // the accessor becomes a byte-range copy. The field type must already be registered flat — + // curated types are (Vector3 precedes any auto-bound consumer in CreateStandard). + else if (ft.IsValueType && !ft.IsEnum && _structLayoutsByType.TryGetValue(ft, out vtLay)) + { st = SType.Vt; fieldSize = vtLay.Size; } + else return false; + try { off = (int)Marshal.OffsetOf(type, fld.Name); } + catch (Exception) { return false; } + return off >= 0 && off + fieldSize <= layout.Size; + } + + static bool TryMatchAccessorIl(byte[] il, MethodBase scope, bool isSetter, out FieldInfo field) + { + field = null!; + int idx = 0; + if (!ExpectLdarg(il, ref idx, 0)) return false; + if (isSetter && !ExpectLdarg(il, ref idx, 1)) return false; + byte opByte = isSetter ? (byte)0x7D /* stfld */ : (byte)0x7B /* ldfld */; + if (idx + 5 > il.Length || il[idx] != opByte) return false; + int tok = BitConverter.ToInt32(il, idx + 1); + idx += 5; + if (idx + 1 != il.Length || il[idx] != 0x2A /* ret */) return false; + try { field = (FieldInfo)scope.Module.ResolveField(tok)!; } + catch (Exception) { return false; } + return field != null; + } + + // Optimization: replace the reflective float/int field accessors with Unsafe.Unbox-based ones + // (~2 ns vs ~23 ns) for boxed-struct field access. Requires `where T : struct` (Unsafe.Unbox), + // so it's only installed via AllowTypeStruct(); AllowType auto-promotion keeps the (correct, + // slower) reflective accessors for the rare boxed-field case. Run after BuildStructLayoutCore. + void InstallUnsafeStructFieldAccessors() where T : struct + { +#if ILINTERPRETER_UNSAFE_UNBOX + var type = typeof(T); + var typeName = type.Name; + var instBind = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; + foreach (var fi in type.GetFields(instBind)) + { + if (!_fields.TryGetValue($"{typeName}.{fi.Name}", out var fe)) continue; + int off = fe.ByteOffset; + if (fi.FieldType == typeof(float)) + { + fe.Get = obj => (object)Unsafe.ReadUnaligned(ref Unsafe.Add(ref Unsafe.As(ref Unsafe.Unbox(obj!)), off)); + fe.Set = (obj, val) => Unsafe.WriteUnaligned(ref Unsafe.Add(ref Unsafe.As(ref Unsafe.Unbox(obj!)), off), val is float f ? f : val is int i ? (float)i : 0f); + RuntimeHelpers.PrepareDelegate(fe.Get); + RuntimeHelpers.PrepareDelegate(fe.Set); + } + else if (fi.FieldType == typeof(int)) + { + fe.Get = obj => (object)Unsafe.ReadUnaligned(ref Unsafe.Add(ref Unsafe.As(ref Unsafe.Unbox(obj!)), off)); + fe.Set = (obj, val) => Unsafe.WriteUnaligned(ref Unsafe.Add(ref Unsafe.As(ref Unsafe.Unbox(obj!)), off), val is int i ? i : 0); + RuntimeHelpers.PrepareDelegate(fe.Get); + RuntimeHelpers.PrepareDelegate(fe.Set); + } + } +#else + // Unity's bundled System.Runtime.CompilerServices.Unsafe predates Unsafe.Unbox (added in + // 6.0), so the boxed-struct fast path won't compile here. The reflective float/int accessors + // installed by AllowTypeWithBases stay in place — correct, just slower for boxed-struct field + // access (the flat VM path uses byte offsets directly and is unaffected). Define + // ILINTERPRETER_UNSAFE_UNBOX (and supply a 6.0+ Unsafe) to restore the optimization. +#endif + } + + internal bool TryGetStructLayout(string typeName, out StructLayout? layout) => + _structLayouts.TryGetValue(typeName, out layout); + + internal bool TryGetStructLayout(Type type, out StructLayout? layout) => + _structLayoutsByType.TryGetValue(type, out layout); + + // Verify and mark operators/ctors the lowerer may INLINE as per-field arithmetic + // (EmitInlinedOp). Marking is only safe when the IL is STRUCTURALLY component-wise — + // "uses only pure opcodes" is not enough (a Quaternion-style op_Multiply is pure IL but + // not per-field, and name-driven synthesis would silently mis-evaluate it). The verifier + // accepts exactly: + // operator: for each field f_i in layout order: ARITH — where a + // struct param contributes `ldarg.k; ldfld f_i` and a scalar param `ldarg.k` + // (unary: `ldarg.0; ldfld f_i; neg`) — then `newobj ; ret`. + // ctor: for each field f_i: `ldarg.0; ldarg.(i+1); stfld f_i` — then `ret`. + // Anything else (locals, branches, calls, reordered fields, debug-shaped IL) degrades to + // the normal call path. warnOnReject keeps the explicit AllowTypeStruct(operatorsArePure) + // opt-in loud while the automatic flatten path stays quiet. + void InspectAndMarkOperators(Type type, bool warnOnReject = true) + { + if (!_structLayoutsByType.TryGetValue(type, out var lay) || lay == null) return; + var fieldOrder = FieldOrderByOffset(type, lay); + if (fieldOrder == null) return; // non-numeric or unresolvable fields — nothing to inline + + var statBind = BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly; + (string Name, byte Arith)[] ops = + { + ("op_Addition", (byte)0x58), ("op_Subtraction", (byte)0x59), + ("op_Multiply", (byte)0x5A), ("op_Division", (byte)0x5B), + ("op_UnaryNegation", (byte)0x65), + }; + foreach (var (opName, arith) in ops) + { + // Overloads of one operator (e.g. op_Multiply(V3,float) and (float,V3)) share ONE + // Entry, and the entry-level flag can't distinguish them — so mark only when EVERY + // overload verifies; a single unverified sibling would otherwise inline with the + // verified overload's semantics. + bool any = false, all = true; + foreach (var m in type.GetMethods(statBind)) + { + if (m.Name != opName) continue; + any = true; + if (m.ReturnType == type + && VerifyComponentwiseOperator(m, type, fieldOrder, arith, out var ctorUsed) + && ctorUsed != null && VerifyFieldOrderCtor(ctorUsed, fieldOrder)) + continue; + all = false; + if (warnOnReject) + Warn($"[IlInterpreter] operator {m.Name} on {type.Name} is not component-wise field arithmetic — not inlined (falls back to the call path)"); + } + if (any && all) + { + foreach (var m in type.GetMethods(statBind)) + if (m.Name == opName && _byHandle.TryGetValue(m.MethodHandle.Value, out var entry)) + entry.IsInlineableOp = true; + } + } + // The all-fields, field-order ctor: `new V3(x, y, z)` inlines to per-field stores. + foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.Instance)) + { + var ps = ctor.GetParameters(); + if (ps.Length != fieldOrder.Length) continue; + if (!VerifyFieldOrderCtor(ctor, fieldOrder)) continue; + var key = $"{type.Name}..ctor/{ps.Length}"; + if (_entries.TryGetValue(key, out var ctorEntry)) + ctorEntry.IsInlineableOp = true; + } + } + + // The struct's instance fields sorted by flat byte offset. Null when any field is missing + // from the layout or is not a 4-byte numeric (the inliner emits I4/R4 arithmetic only). + FieldInfo[]? FieldOrderByOffset(Type type, StructLayout lay) + { + var fis = type.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + if (fis.Length == 0 || fis.Length != lay.Fields.Count) return null; + var order = new FieldInfo[fis.Length]; + var offs = new int[fis.Length]; + for (int i = 0; i < fis.Length; i++) + { + if (!lay.Fields.TryGetValue(fis[i].Name, out var e)) return null; + if (e.St != SType.I4 && e.St != SType.R4) return null; + // Sub-4 field: the inliner's per-field 4-byte ops would read/clobber neighbors. + if (lay.FieldKinds != null && lay.FieldKinds.ContainsKey(fis[i].Name)) return null; + order[i] = fis[i]; offs[i] = e.Offset; + } + for (int a = 0; a < order.Length - 1; a++) + for (int b = a + 1; b < order.Length; b++) + if (offs[b] < offs[a]) + { + (offs[a], offs[b]) = (offs[b], offs[a]); + (order[a], order[b]) = (order[b], order[a]); + } + return order; + } + + static bool TryReadIl(MethodBase m, out byte[] il) + { + il = Array.Empty(); + try + { + var body = m.GetMethodBody(); + if (body == null) return false; + var bytes = body.GetILAsByteArray(); + if (bytes == null || bytes.Length == 0) return false; + il = bytes; + return true; + } + catch (Exception) { return false; } // AOT runtimes may throw instead of returning null + } + + static bool ExpectLdarg(byte[] il, ref int idx, int argIndex) + { + if (idx >= il.Length) return false; + byte b = il[idx]; + if (argIndex <= 3 && b == (byte)(0x02 + argIndex)) { idx++; return true; } + if (b == 0x0E && idx + 1 < il.Length && il[idx + 1] == argIndex) { idx += 2; return true; } // ldarg.s + return false; + } + + static bool ExpectFieldToken(byte[] il, ref int idx, byte opByte, MethodBase scope, FieldInfo want) + { + if (idx + 5 > il.Length || il[idx] != opByte) return false; + int tok = BitConverter.ToInt32(il, idx + 1); + idx += 5; + if (tok == want.MetadataToken) return true; // FieldDef in the same module — the common case + try { return scope.Module.ResolveField(tok) == want; } + catch (Exception) { return false; } + } + + bool VerifyComponentwiseOperator(MethodInfo m, Type type, FieldInfo[] fieldOrder, byte arith, + out ConstructorInfo? ctor) + { + ctor = null; + if (!TryReadIl(m, out var il)) return false; + var ps = m.GetParameters(); + if (ps.Length < 1 || ps.Length > 2) return false; + bool anyStruct = false; + var isStructArg = new bool[ps.Length]; + for (int a = 0; a < ps.Length; a++) + { + var pt = ps[a].ParameterType; + if (pt == type) { isStructArg[a] = true; anyStruct = true; } + else if (pt != typeof(float) && pt != typeof(int)) return false; + } + if (!anyStruct) return false; + + int idx = 0; + for (int f = 0; f < fieldOrder.Length; f++) + { + for (int a = 0; a < ps.Length; a++) + { + if (!ExpectLdarg(il, ref idx, a)) return false; + if (isStructArg[a] && !ExpectFieldToken(il, ref idx, 0x7B /* ldfld */, m, fieldOrder[f])) return false; + } + if (idx >= il.Length || il[idx++] != arith) return false; + } + if (idx + 5 > il.Length || il[idx] != 0x73 /* newobj */) return false; + int ctorTok = BitConverter.ToInt32(il, idx + 1); + idx += 5; + ConstructorInfo? cb; + try { cb = m.Module.ResolveMethod(ctorTok) as ConstructorInfo; } + catch (Exception) { return false; } + if (cb == null || cb.DeclaringType != type) return false; + if (idx + 1 != il.Length || il[idx] != 0x2A /* ret */) return false; + ctor = cb; + return true; + } + + bool VerifyFieldOrderCtor(ConstructorInfo ctor, FieldInfo[] fieldOrder) + { + if (ctor.GetParameters().Length != fieldOrder.Length) return false; + if (!TryReadIl(ctor, out var il)) return false; + int idx = 0; + for (int f = 0; f < fieldOrder.Length; f++) + { + if (!ExpectLdarg(il, ref idx, 0)) return false; // this + if (!ExpectLdarg(il, ref idx, f + 1)) return false; // param f + if (!ExpectFieldToken(il, ref idx, 0x7D /* stfld */, ctor, fieldOrder[f])) return false; + } + return idx + 1 == il.Length && il[idx] == 0x2A /* ret */; + } + + HostBinding AllowTypeWithBases(Type type) + { + var t = type; + while (t != null && t != typeof(object) && t != typeof(ValueType)) + { + if (!t.IsGenericTypeDefinition && _registeredTypes.Add(t)) + { + if (t.IsGenericType) + { + var args = t.GetGenericArguments(); + var key = (t.Name, args[0].Name); + if (!_genericTypes.ContainsKey(key)) + _genericTypes[key] = t; + } + AllowTypeImpl(t); + } + t = t.BaseType; + } + return this; + } + + HostBinding AllowTypeImpl(Type type) + { + var name = type.Name; + var instBind = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; + var statBind = BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly; + + foreach (var f in type.GetFields(instBind)) + { + if (SkipType(f.FieldType)) continue; + RegisterField(name, f); + } + + // Static fields (ldsfld/stsfld both dispatch through FieldEntry). fi.GetValue(null)/SetValue(null, v) + // work whether obj is null or not for a static, so the same RegisterField marshalling applies. + foreach (var f in type.GetFields(statBind)) + { + if (SkipType(f.FieldType)) continue; + RegisterField(name, f); + } + + // Instance properties → get_Prop / set_Prop method calls. Accessor arity comes from the + // accessor itself, NOT a plain-property assumption: an INDEXER's get_Item takes the index + // (set_Item takes index + value). Hardcoding 0/1 registered `get_Item/0` whose entry + // popped no argument — the call site then read the index as the RECEIVER + // (`root[root.childCount-1]` failed with "cannot run on a receiver of type Int32"). + foreach (var p in type.GetProperties(instBind)) + { + if (SkipType(p.PropertyType)) continue; + if (p.GetMethod != null && !SkipMethod(p.GetMethod)) + { + var g = p.GetMethod; + var gp = g.GetParameters(); + if (gp.Length == 0) + Allow(name, g.Name, (recv, _) => g.Invoke(recv, null), 0, mi: g); + else + Allow(name, g.Name, (recv, args) => g.Invoke(recv, args), gp.Length, gp, mi: g); + } + if (p.SetMethod != null && !SkipMethod(p.SetMethod)) + { + var s = p.SetMethod; + var spms = s.GetParameters(); + Allow(name, s.Name, (recv, args) => { s.Invoke(recv, args); return null; }, spms.Length, spms, mi: s); + } + } + + foreach (var p in type.GetProperties(statBind)) + { + if (SkipType(p.PropertyType)) continue; + if (p.GetMethod != null && !SkipMethod(p.GetMethod)) + { + var g = p.GetMethod; + AllowStatic(name, g.Name, (_, _2) => g.Invoke(null, null), 0, mi: g); + } + if (p.SetMethod != null && !SkipMethod(p.SetMethod)) + { + var s = p.SetMethod; + AllowStatic(name, s.Name, (_, args) => { s.Invoke(null, args); return null; }, 1, s.GetParameters(), mi: s); + } + } + + RegisterMethods(type.GetMethods(instBind), name, isStatic: false); + RegisterMethods(type.GetMethods(statBind), name, isStatic: true); + + // Operator methods (op_Addition, op_Multiply, op_UnaryNegation, etc.) + // Group by name so overloads (e.g. Vector3*float and float*Vector3) can be dispatched + // at call-time by checking the runtime type of the first argument. + var opByName = new Dictionary>(); + foreach (var m in type.GetMethods(statBind)) + { + if (!m.IsSpecialName || !m.Name.StartsWith("op_", StringComparison.Ordinal)) continue; + if (SkipMethod(m)) continue; + if (!opByName.TryGetValue(m.Name, out var lst)) opByName[m.Name] = lst = new List(); + lst.Add(m); + } + foreach (var (opName, overloads) in opByName) + { + if (overloads.Count == 1) + { + var method = overloads[0]; + var mp = method.GetParameters(); + AllowStatic(name, opName, (_, args) => method.Invoke(null, args), mp.Length, mp, mi: method); + } + else if (overloads.Count == 2) + { + // Two overloads: find first param index where signatures diverge, dispatch on that. + // Using args[0] alone breaks when both overloads share the same first-arg type + // (e.g. op_Multiply(Quaternion,Quaternion) vs op_Multiply(Quaternion,Vector3)). + var m0 = overloads[0]; + var m1 = overloads[1]; + var p0 = m0.GetParameters(); + var p1 = m1.GetParameters(); + int dispatchIdx = 0; + for (int i = 0; i < p0.Length; i++) + if (p0[i].ParameterType != p1[i].ParameterType) { dispatchIdx = i; break; } + var tDispatch = p0[dispatchIdx].ParameterType; + AllowStatic(name, opName, (_, args) => + { + var pick = args[dispatchIdx]?.GetType() == tDispatch ? m0 : m1; + return pick.Invoke(null, args); + }, p0.Length, mi: m0); + _byHandle[m1.MethodHandle.Value] = _byHandle[m0.MethodHandle.Value]; + } + else + { + // 3+ overloads (StyleLength.op_Implicit(float | Length | StyleKeyword)): the + // aliased-pair Fast machinery above doesn't scale past two, so these were + // skipped entirely — leaving the operator unbound whenever the call site's + // signature didn't decode. Register a name-keyed dispatcher like the same-arity + // ctor one: pick by the args' runtime types, coerce boxed ints after the pick. + // Deliberately NOT put in _byHandle (no mi): a call site that resolves its exact + // MethodInfo synthesizes a per-overload fallback entry instead, and the + // Fast/inline builders (which attach per-handle) never see this reflective entry. + var byArity = new Dictionary>(); + foreach (var m in overloads) + { + int n = m.GetParameters().Length; + if (!byArity.TryGetValue(n, out var lst)) byArity[n] = lst = new List(); + lst.Add(m); + } + foreach (var (arity, group) in byArity) + { + if (group.Count == 1) + { + var m = group[0]; + var mp = m.GetParameters(); + AllowStatic(name, opName, (_, args) => m.Invoke(null, args), arity, mp, mi: m); + continue; + } + var all = group.ToArray(); + var pss = new ParameterInfo[all.Length][]; + for (int i = 0; i < all.Length; i++) pss[i] = all[i].GetParameters(); + AllowStatic(name, opName, (_, args) => + { + int pick = PickOverloadByArgs(pss, args); + if (pick < 0) pick = 0; + CoerceArgs(args, pss[pick]); + return all[pick].Invoke(null, args); + }, arity); + } + } + } + + var ctors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + var ctorGroups = new Dictionary>(); + foreach (var ctor in ctors) + { + if (SkipMethod(ctor)) continue; + int n = ctor.GetParameters().Length; + if (!ctorGroups.TryGetValue(n, out var lst)) ctorGroups[n] = lst = new List(); + lst.Add(ctor); + } + foreach (var (arity, overloads) in ctorGroups) + { + if (overloads.Count == 1) + { + var ctor = overloads[0]; + var cp = ctor.GetParameters(); + AllowConstructor(name, (_, args) => ctor.Invoke(args), arity, cp, ctor); + continue; + } + // Same-arity overloads (e.g. StyleLength(float | Length | StyleKeyword)): one entry + // that picks the overload by the args' runtime types at call time. Entry.Params stays + // null — the boxed-int coercions depend on WHICH overload wins, so the delegate + // coerces after picking instead of letting Entry.Invoke coerce up front. + var all = overloads.ToArray(); + var pss = new ParameterInfo[all.Length][]; + for (int i = 0; i < all.Length; i++) pss[i] = all[i].GetParameters(); + AllowConstructor(name, (_, args) => + { + int pick = PickOverloadByArgs(pss, args); + if (pick < 0) pick = 0; + CoerceArgs(args, pss[pick]); + return all[pick].Invoke(args); + }, arity, ci: all[0]); + } + + return this; + } + + // Index of the same-arity overload whose parameters best accept the runtime args, -1 when + // none fits. Exact type matches outscore coercible ones, so `(float)` vs `(MyEnum)` resolves + // to float for a boxed float and to MyEnum for a boxed int — every I4-slot value (int, bool, + // enum, …) crosses the boundary as a boxed int, so an int arg matches any CoerceArgs target. + // Null args match any reference-type parameter. + static int PickOverloadByArgs(ParameterInfo[][] pss, object?[] args) + { + int best = -1, bestScore = -1; + for (int c = 0; c < pss.Length; c++) + { + var ps = pss[c]; + int score = 0; bool ok = true; + for (int i = 0; i < ps.Length && ok; i++) + { + var pt = ps[i].ParameterType; + var a = i < args.Length ? args[i] : null; + if (a == null) { ok = !pt.IsValueType; continue; } + var at = a.GetType(); + if (at == pt) { score += 2; continue; } + if (a is int && (pt.IsEnum || pt == typeof(bool) || pt == typeof(char) + || pt == typeof(byte) || pt == typeof(sbyte) + || pt == typeof(short) || pt == typeof(ushort) + || pt == typeof(uint))) + { score += 1; continue; } + if (pt.IsAssignableFrom(at)) { score += 1; continue; } + ok = false; + } + if (ok && score > bestScore) { best = c; bestScore = score; } + } + return best; + } + + // Build typed-delegate fast paths for all operators and methods on T that match a known + // signature shape: replaces the MethodInfo.Invoke-based Delegate with a typed closure and + // populates Entry.Fast so the interpreter can bypass ArgBuf + Entry.Invoke entirely. + // + // Called only from AllowType() where T is a concrete type known at compile time — that's + // the AOT-safety guarantee: every generic instantiation here is written explicitly; no + // MakeGenericMethod calls happen at call time. + void BuildTypedDelegates() + { + var type = typeof(T); + bool isRef = !type.IsValueType; // open-instance delegates require reference receiver + // FastIsFlat can only be set when T has a StructLayout — without one the lowerer + // can't allocate a Vt dst slot, and the flat-write closure would scribble into a + // boxed-result O slot. AllowType alone (no AllowTypeStruct) leaves T flat-incapable. + bool flatOk = type.IsValueType && _structLayoutsByType.ContainsKey(type); + var statBind = BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly; + var instBind = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; + + // Safe slot readers used by Fast delegates — a slot's actual frame depends on slotT. + static float RF(byte* num, object?[] ref_, SType[] slotT, int s) + { + var t = slotT[s - FastFrameBase]; + if (t == SType.R4) return Unsafe.ReadUnaligned(num + (s * 4)); + if (t == SType.I4) return (float)Unsafe.ReadUnaligned(num + (s * 4)); + if (t == SType.Vt) return 0f; // Vt refFrame entries are not cleared; never read them + var v = ref_[s]; return v is float f ? f : v is int i ? (float)i : 0f; + } + static int RI(byte* num, object?[] ref_, SType[] slotT, int s) + { + var t = slotT[s - FastFrameBase]; + if (t == SType.I4) return Unsafe.ReadUnaligned(num + (s * 4)); + if (t == SType.R4) return (int)Unsafe.ReadUnaligned(num + (s * 4)); + if (t == SType.Vt) return 0; // Vt refFrame entries are not cleared; never read them + var v = ref_[s]; return v is int i ? i : v is bool b ? (b?1:0) : 0; + } + // Generic Vt-or-boxed reader for value-type T. Picks flat bytes when the slot is Vt + // (Stage 2 fast path) or unboxes the refFrame entry otherwise (compatibility with + // boxed args that flow in from ldfld_o on a class field). + static TVal RT(byte* num, object?[] ref_, SType[] slotT, int s) + { + if (slotT[s - FastFrameBase] == SType.Vt) return Unsafe.ReadUnaligned(num + (s * 4)); + return (TVal)ref_[s]!; + } + + // --- Static operators and methods --- + // T args (including struct types) always live in refFrame O-slots: host call returns are + // allocated as SType.O (AllocSlot(..., SType.O) in the lowerer), and there is no slot + // reuse — every AllocSlot call creates a fresh slot with a fixed type for the method's + // lifetime. So ref_[s] is the correct way to read a T arg regardless of IsValueType. + // + // Overloaded operators aliased in AllowTypeImpl (e.g. op_Multiply(V3,float) and + // op_Multiply(float,V3)) share a single Entry. We handle them separately below: + // collect the two overloads, build typed delegates for each, then attach a Fast that + // dispatches by whether ref_[s0] is T (true → T-first overload, false → float-first). + var seenMethods = new Dictionary>(EntryReferenceComparer.Instance); + foreach (var m in type.GetMethods(statBind)) + { + if (m.IsGenericMethodDefinition) continue; + if (!_byHandle.TryGetValue(m.MethodHandle.Value, out var e2)) continue; + if (!seenMethods.TryGetValue(e2, out var lst)) seenMethods[e2] = lst = new List(); + lst.Add(m); + } + foreach (var (entry, methods) in seenMethods) + { + if (methods.Count == 2) + { + // Aliased pair: two overloads sharing one Entry. Build per-overload typed delegates + // and a Fast that dispatches by whether the first arg is T. + var mA = methods[0]; var mB = methods[1]; + var psA = mA.GetParameters(); var psB = mB.GetParameters(); + var rtA = mA.ReturnType; + bool aIsTV = psA[0].ParameterType == type; + bool bIsTV = psB[0].ParameterType == type; + + // (T,float)→T + (float,T)→T — the common operator*(V3,float) + operator*(float,V3) case. + // Guard aIsTV != bIsTV: pairs like (Color,Color)+(Color,float) both have T first and must + // not enter this path — the wrong method would be passed to CreateDelegate. + if (psA.Length == 2 && rtA == type && rtA == mB.ReturnType && aIsTV != bIsTV) + { + var mTF = aIsTV ? mA : mB; var mFT = aIsTV ? mB : mA; + var delTF = (Func)Delegate.CreateDelegate(typeof(Func), mTF); + var delFT = (Func)Delegate.CreateDelegate(typeof(Func), mFT); + entry.Delegate = (_, a) => a[0] is T tv + ? (object)delTF(tv, (float)a[1]!)! + : (object)delFT((float)a[0]!, (T)a[1]!)!; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + if (ref_[s0] is T t0) ref_[bp + dst] = delTF(t0, RF(num, ref_, slotT, s1)); + else ref_[bp + dst] = delFT(RF(num, ref_, slotT, s0), (T)ref_[s1]!); + }; + if (flatOk) + { + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + // Dispatch by which arg looks like T (Vt slot or boxed T). + // Either arg may be a boxed T arriving from an ldfld_o on a class field, + // so RT handles both Vt and refFrame paths. + bool s0IsT = slotT[s0 - FastFrameBase] == SType.Vt + ? true + : ref_[s0] is T; + if (s0IsT) + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + delTF(RT(num, ref_, slotT, s0), RF(num, ref_, slotT, s1))); + else + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + delFT(RF(num, ref_, slotT, s0), RT(num, ref_, slotT, s1))); + }; + } + } + + // (T,T)→T + (T,float)→T — Color-like: same first arg, dispatch on second arg type. + // Both overloads must return T and have T as first arg. Second arg is T for one, float for other. + // Scoped to same-return-type pairs only (differing-return Quaternion case stays on slow path). + if (psA.Length == 2 && rtA == type && mB.ReturnType == type && aIsTV && bIsTV) + { + bool aTT = psA[1].ParameterType == type; + bool bTT = psB[1].ParameterType == type; + bool aTF = psA[1].ParameterType == typeof(float); + bool bTF = psB[1].ParameterType == typeof(float); + if ((aTT && bTF) || (aTF && bTT)) + { + var mTT = aTT ? mA : mB; var mTF = aTT ? mB : mA; + var delTT = (Func)Delegate.CreateDelegate(typeof(Func), mTT); + var delTF = (Func)Delegate.CreateDelegate(typeof(Func), mTF); + entry.Delegate = (_, a) => a[1] is T t1 + ? (object)delTT((T)a[0]!, t1)! + : (object)delTF((T)a[0]!, (float)a[1]!)!; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + if (ref_[s1] is T t1v) ref_[bp + dst] = delTT((T)ref_[s0]!, t1v); + else ref_[bp + dst] = delTF((T)ref_[s0]!, RF(num, ref_, slotT, s1)); + }; + if (flatOk) + { + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + if (slotT[s1 - FastFrameBase] == SType.Vt) + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + delTT(RT(num, ref_, slotT, s0), RT(num, ref_, slotT, s1))); + else + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + delTF(RT(num, ref_, slotT, s0), RF(num, ref_, slotT, s1))); + }; + } + } + } + continue; + } + + // Single overload — build typed Delegate + Fast. + var method = methods[0]; + var ps = method.GetParameters(); + var rt = method.ReturnType; + + // (T, T) → T e.g. op_Addition(V3, V3) → V3 + if (ps.Length == 2 && ps[0].ParameterType == type && ps[1].ParameterType == type && rt == type) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), method); + entry.Delegate = (_, a) => (object)del((T)a[0]!, (T)a[1]!)!; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + ref_[bp + dst] = del((T)ref_[s0]!, (T)ref_[s1]!); + }; + if (flatOk) + { + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + del(RT(num, ref_, slotT, s0), + RT(num, ref_, slotT, s1))); + }; + } + continue; + } + + // (T, float) → T e.g. op_Multiply(V3, float) → V3 + if (ps.Length == 2 && ps[0].ParameterType == type && ps[1].ParameterType == typeof(float) && rt == type) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), method); + entry.Delegate = (_, a) => (object)del((T)a[0]!, (float)a[1]!)!; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + ref_[bp + dst] = del((T)ref_[s0]!, RF(num, ref_, slotT, s1)); + }; + if (flatOk) + { + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + del(RT(num, ref_, slotT, s0), + RF(num, ref_, slotT, s1))); + }; + } + continue; + } + + // (float, T) → T e.g. op_Multiply(float, V3) → V3 + if (ps.Length == 2 && ps[0].ParameterType == typeof(float) && ps[1].ParameterType == type && rt == type) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), method); + entry.Delegate = (_, a) => (object)del((float)a[0]!, (T)a[1]!)!; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + ref_[bp + dst] = del(RF(num, ref_, slotT, s0), (T)ref_[s1]!); + }; + if (flatOk) + { + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + del(RF(num, ref_, slotT, s0), + RT(num, ref_, slotT, s1))); + }; + } + continue; + } + + // (T, T) → int e.g. op_Equality, op_LessThan returning bool (int in script) + if (ps.Length == 2 && ps[0].ParameterType == type && ps[1].ParameterType == type + && (rt == typeof(int) || rt == typeof(bool))) + { + if (rt == typeof(bool)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), method); + entry.Delegate = (_, a) => del((T)a[0]!, (T)a[1]!) ? (object)1 : 0; + // FastIsFlat: write 1/0 int directly into numFrame, no boxing. An int result is + // always flat-safe, so no non-flat fallback is needed (RT reads either frame). + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + del(RT(num, ref_, slotT, s0), RT(num, ref_, slotT, s1)) ? 1 : 0); + }; + } + else + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), method); + entry.Delegate = (_, a) => (object)del((T)a[0]!, (T)a[1]!); + // FastIsFlat: write int directly into numFrame (always flat-safe). + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + del(RT(num, ref_, slotT, s0), RT(num, ref_, slotT, s1))); + }; + } + continue; + } + + // (T) → float e.g. Vector3.Magnitude + if (ps.Length == 1 && ps[0].ParameterType == type && rt == typeof(float)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), method); + entry.Delegate = (_, a) => (object)del((T)a[0]!); + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase]; + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del(RT(num, ref_, slotT, s0))); + }; + continue; + } + + // (T, T) → float e.g. Vector3.Dot, Vector3.Distance + if (ps.Length == 2 && ps[0].ParameterType == type && ps[1].ParameterType == type && rt == typeof(float)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), method); + entry.Delegate = (_, a) => (object)del((T)a[0]!, (T)a[1]!); + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + del(RT(num, ref_, slotT, s0), RT(num, ref_, slotT, s1))); + }; + continue; + } + + // (T) → T e.g. op_UnaryNegation(V3) → V3 + if (ps.Length == 1 && ps[0].ParameterType == type && rt == type) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), method); + entry.Delegate = (_, a) => (object)del((T)a[0]!)!; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase]; + ref_[bp + dst] = del((T)ref_[s0]!); + }; + if (flatOk) + { + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase]; + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + del(RT(num, ref_, slotT, s0))); + }; + } + continue; + } + + // () → T e.g. static factory like V3.Zero (rare; no args) + if (ps.Length == 0 && rt == type) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), method); + entry.Delegate = (_, _) => (object)del()!; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => ref_[bp + dst] = del(); + if (flatOk) + { + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del()); + } + continue; + } + + // (float, float) → float e.g. Vector2.Dot, Distance etc. + if (ps.Length == 2 && ps[0].ParameterType == typeof(float) && ps[1].ParameterType == typeof(float) && rt == typeof(float)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), method); + entry.Delegate = (_, a) => (object)del((float)a[0]!, (float)a[1]!); + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + del(RF(num, ref_, slotT, s0), RF(num, ref_, slotT, s1))); + }; + continue; + } + + // (float) → float e.g. Mathf.Sqrt, Abs, Cos, Sin etc. + if (ps.Length == 1 && ps[0].ParameterType == typeof(float) && rt == typeof(float)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), method); + entry.Delegate = (_, a) => (object)del((float)a[0]!); + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + del(RF(num, ref_, slotT, bp + (int)ir[argBase]))); + continue; + } + + // (int) → int e.g. Math.Abs(int), Math.Sign(int) + if (ps.Length == 1 && ps[0].ParameterType == typeof(int) && rt == typeof(int)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), method); + entry.Delegate = (_, a) => (object)del((int)a[0]!); + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + del(RI(num, ref_, slotT, bp + (int)ir[argBase]))); + continue; + } + + // (int, int) → int e.g. Math.Min(int,int), Math.Max(int,int) + if (ps.Length == 2 && ps[0].ParameterType == typeof(int) && ps[1].ParameterType == typeof(int) && rt == typeof(int)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), method); + entry.Delegate = (_, a) => (object)del((int)a[0]!, (int)a[1]!); + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase], s1 = bp + (int)ir[argBase + 1]; + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + del(RI(num, ref_, slotT, s0), RI(num, ref_, slotT, s1))); + }; + continue; + } + } + + // --- Constructors for value-type T: build flat-write delegates for the common shapes. + // Optimisation: when every param matches a same-named primitive field on T, we bypass + // ConstructorInfo.Invoke entirely and write the fields' bytes directly into numFrame at + // their layout offsets. This is what V3(float,float,float) — the bench's hot path — looks + // like in practice. The fallback uses ctor.Invoke on a temp args array (slow but correct + // for ctors that do non-trivial work). + if (type.IsValueType + && _structLayoutsByType.TryGetValue(type, out var ctorLayout)) + { + var ctors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + // Entries key ctors by NAME/ARITY, and Entry.Invoke dispatches same-arity overloads + // by runtime arg type. A Fast closure binds ONE ConstructorInfo, so installing it on + // an ambiguous arity would clobber that dispatch (TriCtor(float) running the int + // overload) — skip those arities and leave them on the reflective path. + var ctorArityCounts = new Dictionary(); + for (int ci = 0; ci < ctors.Length; ci++) + { + int a = ctors[ci].GetParameters().Length; + ctorArityCounts[a] = ctorArityCounts.TryGetValue(a, out var c) ? c + 1 : 1; + } + for (int ci = 0; ci < ctors.Length; ci++) + { + var ctor = ctors[ci]; + var cps = ctor.GetParameters(); + if (ctorArityCounts[cps.Length] > 1) continue; + if (!_entries.TryGetValue($"{type.Name}..ctor/{cps.Length}", out var ctorEntry)) continue; + + // 0-arg ctor: just zero the destination bytes. + if (cps.Length == 0) + { + int sz = ctorLayout.Size; + ctorEntry.FastIsFlat = true; ctorEntry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int dstOff = (bp + dst) * 4; + for (int b = 0; b < sz; b++) num[dstOff + b] = 0; + }; + continue; + } + + // Field-init shortcut: every param has same name and matching primitive type as a + // declared field on T. Build a per-arg (offset, kind) plan once. + int[]? argOffsets = new int[cps.Length]; + int[]? argKinds = new int[cps.Length]; // 0 = float, 1 = int + bool allFieldInit = true; + for (int p = 0; p < cps.Length; p++) + { + var pn = cps[p].Name; + if (pn == null || !ctorLayout.Fields.TryGetValue(pn, out var fld)) + { allFieldInit = false; break; } + if (cps[p].ParameterType == typeof(float) && fld.St == SType.R4) + { argOffsets[p] = fld.Offset; argKinds[p] = 0; } + else if (cps[p].ParameterType == typeof(int) && fld.St == SType.I4) + { argOffsets[p] = fld.Offset; argKinds[p] = 1; } + else + { allFieldInit = false; break; } + } + + if (allFieldInit) + { + int sz = ctorLayout.Size; + var offs = argOffsets!; + var kinds = argKinds!; + ctorEntry.FastIsFlat = true; ctorEntry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int dstOff = (bp + dst) * 4; + // Zero any holes so padding bytes match GetUninitializedObject's bytes. + for (int b = 0; b < sz; b++) num[dstOff + b] = 0; + for (int p = 0; p < offs.Length; p++) + { + int srcSlot = bp + (int)ir[argBase + p]; + if (kinds[p] == 0) + Unsafe.WriteUnaligned(num + (dstOff + offs[p]), + RF(num, ref_, slotT, srcSlot)); + else + Unsafe.WriteUnaligned(num + (dstOff + offs[p]), + RI(num, ref_, slotT, srcSlot)); + } + }; + continue; + } + + // Generic fallback for ctors that do real work: pay the per-call args array + // and Invoke + Unsafe.As copy. Better than the boxed path because we + // still skip the heap box for the result and read the args we have flat. + var captured = ctor; + int paramCount = cps.Length; + ctorEntry.FastIsFlat = true; ctorEntry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + var args = new object?[paramCount]; + for (int p = 0; p < paramCount; p++) + { + int s = bp + (int)ir[argBase + p]; + var pt = cps[p].ParameterType; + // Every I4-classified primitive lives in the NUMERIC frame — ref_[s] is + // null for them, and ConstructorInfo.Invoke turns a null arg into 0 + // (silent zeroed fields; caught by the host-surface sweep on ColorLike). + if (pt == typeof(float)) args[p] = RF(num, ref_, slotT, s); + else if (pt == typeof(int)) args[p] = RI(num, ref_, slotT, s); + else if (pt == typeof(bool)) args[p] = RI(num, ref_, slotT, s) != 0; + else if (pt == typeof(char)) args[p] = (char)RI(num, ref_, slotT, s); + else if (pt == typeof(byte)) args[p] = (byte)RI(num, ref_, slotT, s); + else if (pt == typeof(sbyte)) args[p] = (sbyte)RI(num, ref_, slotT, s); + else if (pt == typeof(short)) args[p] = (short)RI(num, ref_, slotT, s); + else if (pt == typeof(ushort)) args[p] = (ushort)RI(num, ref_, slotT, s); + else if (pt == typeof(uint)) args[p] = unchecked((uint)RI(num, ref_, slotT, s)); + else if (pt.IsEnum) args[p] = Enum.ToObject(pt, RI(num, ref_, slotT, s)); + // A flat-struct arg (Vector3 into Bounds..ctor) lives in the numeric + // frame — ref_[s] is null there, and Invoke would zero the param. + else if (slotT[s] == SType.Vt && pt.IsValueType + && _structLayoutsByType.TryGetValue(pt, out var pLay)) + args[p] = pLay.BoxFromPtr(num + s * 4); + else args[p] = ref_[s]; + } + var boxed = captured.Invoke(args); + T val = (T)boxed!; + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), val); + }; + } + } + + // --- Instance methods & property accessors for VALUE-TYPE T with a flat layout --- + // Open-instance delegates over a struct method take `ref T`, so these closures read + // the receiver IN PLACE: from its Vt frame slot (no box; mutations land directly in + // the frame bytes — the lowerer's nested-offset/write-back machinery composes on top), + // or, for boxed receivers (O slot from a class field), by deferring to Entry.Invoke, + // whose reflective call mutates the box in place as recvWriteBack expects. + // FastVtRecv tells the executor the closure handles Vt receivers itself; it reads the + // receiver slot index from ir[argBase - 3] (the call_host recv operand). + if (!isRef && flatOk) + { + foreach (var m in type.GetMethods(instBind)) // includes get_/set_ accessor methods + { + if (m.IsGenericMethodDefinition) continue; + if (!_byHandle.TryGetValue(m.MethodHandle.Value, out var entry)) continue; + if (entry.Fast != null) continue; + var e = entry; // captured by the closures below + var ps = m.GetParameters(); + var rt = m.ReturnType; + + // () → float (get_magnitude, get_sqrMagnitude) + if (ps.Length == 0 && rt == typeof(float)) + { + var del = (RefRecvFn)Delegate.CreateDelegate(typeof(RefRecvFn), m); + entry.FastVtRecv = true; entry.FastIsFlat = true; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int rs = (int)ir[argBase - 3]; + if (slotT[rs] == SType.Vt) + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del(ref Unsafe.AsRef(num + ((bp + rs) * 4)))); + else + { + var r = e.Invoke(recv, Array.Empty()); + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), r is float f ? f : r is int ri ? (float)ri : 0f); + } + }; + continue; + } + + // () → int / bool (get_isNormalized-style) + if (ps.Length == 0 && (rt == typeof(int) || rt == typeof(bool))) + { + if (rt == typeof(bool)) + { + var del = (RefRecvFn)Delegate.CreateDelegate(typeof(RefRecvFn), m); + entry.FastVtRecv = true; entry.FastIsFlat = true; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int rs = (int)ir[argBase - 3]; + if (slotT[rs] == SType.Vt) + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del(ref Unsafe.AsRef(num + ((bp + rs) * 4))) ? 1 : 0); + else + { + var r = e.Invoke(recv, Array.Empty()); + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), r is int ri ? ri : r is bool rb ? (rb ? 1 : 0) : 0); + } + }; + } + else + { + var del = (RefRecvFn)Delegate.CreateDelegate(typeof(RefRecvFn), m); + entry.FastVtRecv = true; entry.FastIsFlat = true; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int rs = (int)ir[argBase - 3]; + if (slotT[rs] == SType.Vt) + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del(ref Unsafe.AsRef(num + ((bp + rs) * 4)))); + else + { + var r = e.Invoke(recv, Array.Empty()); + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), r is int ri ? ri : 0); + } + }; + } + continue; + } + + // () → T (get_normalized) — dst is a Vt slot (ReturnStruct is set by the parser). + if (ps.Length == 0 && rt == type) + { + var del = (RefRecvFn)Delegate.CreateDelegate(typeof(RefRecvFn), m); + entry.FastVtRecv = true; entry.FastIsFlat = true; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int rs = (int)ir[argBase - 3]; + if (slotT[rs] == SType.Vt) + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del(ref Unsafe.AsRef(num + ((bp + rs) * 4)))); + else + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), (T)e.Invoke(recv, Array.Empty())!); + }; + continue; + } + + // () → void (Normalize()) + if (ps.Length == 0 && rt == typeof(void)) + { + var del = (RefRecvAct)Delegate.CreateDelegate(typeof(RefRecvAct), m); + entry.FastVtRecv = true; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int rs = (int)ir[argBase - 3]; + if (slotT[rs] == SType.Vt) del(ref Unsafe.AsRef(num + ((bp + rs) * 4))); + else e.Invoke(recv, Array.Empty()); + }; + continue; + } + + // (float) → void (Scale(f), computed set_ accessors) + if (ps.Length == 1 && ps[0].ParameterType == typeof(float) && rt == typeof(void)) + { + var del = (RefRecvAct1F)Delegate.CreateDelegate(typeof(RefRecvAct1F), m); + entry.FastVtRecv = true; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int rs = (int)ir[argBase - 3]; + float a0 = RF(num, ref_, slotT, bp + (int)ir[argBase]); + if (slotT[rs] == SType.Vt) del(ref Unsafe.AsRef(num + ((bp + rs) * 4)), a0); + else e.Invoke(recv, new object?[] { a0 }); + }; + continue; + } + + // (int) → void + if (ps.Length == 1 && ps[0].ParameterType == typeof(int) && rt == typeof(void)) + { + var del = (RefRecvAct1I)Delegate.CreateDelegate(typeof(RefRecvAct1I), m); + entry.FastVtRecv = true; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int rs = (int)ir[argBase - 3]; + int a0 = RI(num, ref_, slotT, bp + (int)ir[argBase]); + if (slotT[rs] == SType.Vt) del(ref Unsafe.AsRef(num + ((bp + rs) * 4)), a0); + else e.Invoke(recv, new object?[] { a0 }); + }; + continue; + } + + // (float, float) → void and (float, float, float) → void (Set(x, y[, z])) + if (rt == typeof(void) && (ps.Length == 2 || ps.Length == 3) + && ps[0].ParameterType == typeof(float) && ps[1].ParameterType == typeof(float) + && (ps.Length == 2 || ps[2].ParameterType == typeof(float))) + { + if (ps.Length == 2) + { + var del = (RefRecvAct2F)Delegate.CreateDelegate(typeof(RefRecvAct2F), m); + entry.FastVtRecv = true; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int rs = (int)ir[argBase - 3]; + float a0 = RF(num, ref_, slotT, bp + (int)ir[argBase]); + float a1 = RF(num, ref_, slotT, bp + (int)ir[argBase + 1]); + if (slotT[rs] == SType.Vt) del(ref Unsafe.AsRef(num + ((bp + rs) * 4)), a0, a1); + else e.Invoke(recv, new object?[] { a0, a1 }); + }; + } + else + { + var del = (RefRecvAct3F)Delegate.CreateDelegate(typeof(RefRecvAct3F), m); + entry.FastVtRecv = true; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int rs = (int)ir[argBase - 3]; + float a0 = RF(num, ref_, slotT, bp + (int)ir[argBase]); + float a1 = RF(num, ref_, slotT, bp + (int)ir[argBase + 1]); + float a2 = RF(num, ref_, slotT, bp + (int)ir[argBase + 2]); + if (slotT[rs] == SType.Vt) del(ref Unsafe.AsRef(num + ((bp + rs) * 4)), a0, a1, a2); + else e.Invoke(recv, new object?[] { a0, a1, a2 }); + }; + } + continue; + } + + // (float) → float + if (ps.Length == 1 && ps[0].ParameterType == typeof(float) && rt == typeof(float)) + { + var del = (RefRecvFn1F)Delegate.CreateDelegate(typeof(RefRecvFn1F), m); + entry.FastVtRecv = true; entry.FastIsFlat = true; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int rs = (int)ir[argBase - 3]; + float a0 = RF(num, ref_, slotT, bp + (int)ir[argBase]); + if (slotT[rs] == SType.Vt) + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del(ref Unsafe.AsRef(num + ((bp + rs) * 4)), a0)); + else + { + var r = e.Invoke(recv, new object?[] { a0 }); + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), r is float f ? f : r is int ri ? (float)ri : 0f); + } + }; + continue; + } + + // (int) → int + if (ps.Length == 1 && ps[0].ParameterType == typeof(int) && rt == typeof(int)) + { + var del = (RefRecvFn1I)Delegate.CreateDelegate(typeof(RefRecvFn1I), m); + entry.FastVtRecv = true; entry.FastIsFlat = true; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int rs = (int)ir[argBase - 3]; + int a0 = RI(num, ref_, slotT, bp + (int)ir[argBase]); + if (slotT[rs] == SType.Vt) + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del(ref Unsafe.AsRef(num + ((bp + rs) * 4)), a0)); + else + { + var r = e.Invoke(recv, new object?[] { a0 }); + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), r is int ri ? ri : 0); + } + }; + continue; + } + } + } + + // --- Instance methods: only for reference types (open-instance delegates require ref receiver) --- + if (isRef) + { + foreach (var m in type.GetMethods(instBind)) + { + if (m.IsSpecialName || m.IsGenericMethodDefinition) continue; + if (!_byHandle.TryGetValue(m.MethodHandle.Value, out var entry)) continue; + var ps = m.GetParameters(); + var rt = m.ReturnType; + + // () → float + if (ps.Length == 0 && rt == typeof(float)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), null, m); + entry.Delegate = (recv, _) => (object)del((T)recv!); + // FastFlat shape for primitive returns: writes float bytes directly into + // numFrame, dropping the per-call float box. Lowerer allocates an R4 dst + // when this is set on a primitive-returning entry. + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del((T)recv!)); + continue; + } + + // () → T + if (ps.Length == 0 && rt == type) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), null, m); + entry.Delegate = (recv, _) => (object)del((T)recv!)!; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + ref_[bp + dst] = del((T)recv!); + continue; + } + + // (T) → void + if (ps.Length == 1 && ps[0].ParameterType == type && rt == typeof(void)) + { + var del = (Action)Delegate.CreateDelegate(typeof(Action), null, m); + entry.Delegate = (recv, a) => { del((T)recv!, (T)a[0]!); return null; }; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + del((T)recv!, (T)ref_[bp + (int)ir[argBase]]!); + continue; + } + + // (float) → void + if (ps.Length == 1 && ps[0].ParameterType == typeof(float) && rt == typeof(void)) + { + var del = (Action)Delegate.CreateDelegate(typeof(Action), null, m); + entry.Delegate = (recv, a) => { del((T)recv!, (float)a[0]!); return null; }; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + del((T)recv!, RF(num, ref_, slotT, bp + (int)ir[argBase])); + continue; + } + + // (int) → void + if (ps.Length == 1 && ps[0].ParameterType == typeof(int) && rt == typeof(void)) + { + var del = (Action)Delegate.CreateDelegate(typeof(Action), null, m); + entry.Delegate = (recv, a) => { del((T)recv!, (int)a[0]!); return null; }; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + del((T)recv!, RI(num, ref_, slotT, bp + (int)ir[argBase])); + continue; + } + + // () → void (e.g. Jump(), Reset()) + if (ps.Length == 0 && rt == typeof(void)) + { + var del = (Action)Delegate.CreateDelegate(typeof(Action), null, m); + entry.Delegate = (recv, _) => { del((T)recv!); return null; }; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => del((T)recv!); + continue; + } + + // (float, float) → void and (float, float, float) → void — the Translate/Rotate/ + // Set(x, y[, z]) family. Profiling showed these falling to the reflection slow path + // (MethodBaseInvoker + per-call arg boxing), ~25% of a host-call-heavy loop. + if (rt == typeof(void) && (ps.Length == 2 || ps.Length == 3) + && ps[0].ParameterType == typeof(float) && ps[1].ParameterType == typeof(float) + && (ps.Length == 2 || ps[2].ParameterType == typeof(float))) + { + if (ps.Length == 2) + { + var del = (Action)Delegate.CreateDelegate(typeof(Action), null, m); + entry.Delegate = (recv, a) => { del((T)recv!, (float)a[0]!, (float)a[1]!); return null; }; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + del((T)recv!, + RF(num, ref_, slotT, bp + (int)ir[argBase]), + RF(num, ref_, slotT, bp + (int)ir[argBase + 1])); + } + else + { + var del = (Action)Delegate.CreateDelegate(typeof(Action), null, m); + entry.Delegate = (recv, a) => { del((T)recv!, (float)a[0]!, (float)a[1]!, (float)a[2]!); return null; }; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + del((T)recv!, + RF(num, ref_, slotT, bp + (int)ir[argBase]), + RF(num, ref_, slotT, bp + (int)ir[argBase + 1]), + RF(num, ref_, slotT, bp + (int)ir[argBase + 2])); + } + continue; + } + + // (float) → float (e.g. GetAxis-style scaling helpers) + if (ps.Length == 1 && ps[0].ParameterType == typeof(float) && rt == typeof(float)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), null, m); + entry.Delegate = (recv, a) => (object)del((T)recv!, (float)a[0]!); + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + del((T)recv!, RF(num, ref_, slotT, bp + (int)ir[argBase]))); + continue; + } + + // (int) → int + if (ps.Length == 1 && ps[0].ParameterType == typeof(int) && rt == typeof(int)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), null, m); + entry.Delegate = (recv, a) => (object)del((T)recv!, (int)a[0]!); + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), + del((T)recv!, RI(num, ref_, slotT, bp + (int)ir[argBase]))); + continue; + } + + // () → S where S is a registered struct (e.g. IWorld.GetPosition() → V3). + // A Func closed over a value-type S is the usual IL2CPP/AOT risk; + // link.xml registration of the script's host types covers it. + if (ps.Length == 0 && rt.IsValueType && _structLayoutsByType.TryGetValue(rt, out var retLay)) + { + var helper = typeof(HostBinding).GetMethod(nameof(BindInstReturnsStruct), + BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(type, rt); + helper.Invoke(null, new object[] { entry, m }); + continue; + } + + // (S) → void where S is a registered struct (e.g. IWorld.SetPosition(V3)). + if (ps.Length == 1 && ps[0].ParameterType.IsValueType && rt == typeof(void) + && _structLayoutsByType.TryGetValue(ps[0].ParameterType, out var argLay)) + { + var helper = typeof(HostBinding).GetMethod(nameof(BindInstTakesStructVoid), + BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(type, ps[0].ParameterType); + helper.Invoke(null, new object[] { entry, m }); + continue; + } + + // () → int or () → bool (e.g. Count, IsValid). FastIsFlat is REQUIRED for + // numeric returns: the lowerer allocates a numeric (I4) dst whenever the + // method's return type is numeric, so the closure must write numFrame — + // a boxed write to refFrame leaves the numeric cell uninitialized (zeros on + // Mono, garbage on IL2CPP: the Transform.childCount player bug). + if (ps.Length == 0 && (rt == typeof(int) || rt == typeof(bool))) + { + if (rt == typeof(bool)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), null, m); + entry.Delegate = (recv, _) => del((T)recv!) ? (object)1 : 0; + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del((T)recv!) ? 1 : 0); + } + else + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), null, m); + entry.Delegate = (recv, _) => (object)del((T)recv!); + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del((T)recv!)); + } + continue; + } + + // () → reference type (any object return not handled above). Func via + // reference covariance; store the ref straight into refFrame — no boxing/reflection. + if (ps.Length == 0 && !rt.IsValueType && rt != typeof(void)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), null, m); + entry.Delegate = (recv, _) => del((T)recv!); + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + ref_[bp + dst] = del((T)recv!); + continue; + } + } + } + + // --- Instance property getters/setters: only for reference types --- + if (!isRef) return; + var typeName = type.Name; + foreach (var p in type.GetProperties(instBind)) + { + if (SkipType(p.PropertyType)) continue; + // Indexers don't fit the zero-arg getter shapes below (their /1 keys wouldn't match + // the /0 lookups anyway, but a same-name /0 twin must never bind a 2-arg accessor). + if (p.GetIndexParameters().Length > 0) continue; + if (p.GetMethod != null && !SkipMethod(p.GetMethod)) + { + var g = p.GetMethod; + var key = $"{typeName}.{g.Name}/0"; + if (_entries.TryGetValue(key, out var entry)) + { + // Numeric getters must be FastIsFlat and write numFrame — the lowerer + // allocates a numeric dst for numeric return types (see the () → int + // method shape above for the failure mode). + if (g.ReturnType == typeof(float)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), null, g); + entry.Delegate = (recv, _) => (object)del((T)recv!); + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del((T)recv!)); + } + else if (g.ReturnType == type) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), null, g); + entry.Delegate = (recv, _) => (object)del((T)recv!)!; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + ref_[bp + dst] = del((T)recv!); + } + else if (g.ReturnType == typeof(bool)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), null, g); + entry.Delegate = (recv, _) => del((T)recv!) ? (object)1 : 0; + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del((T)recv!) ? 1 : 0); + } + else if (g.ReturnType == typeof(int)) + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), null, g); + entry.Delegate = (recv, _) => (object)del((T)recv!); + entry.FastIsFlat = true; entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del((T)recv!)); + } + else if (!g.ReturnType.IsValueType) + { + // Returns a reference type (e.g. GameObject.transform → Transform). Func + // binds via reference covariance; the result is already an object, so store it + // straight into refFrame — no boxing, no reflection. AOT-safe (closed over T). + var del = (Func)Delegate.CreateDelegate(typeof(Func), null, g); + entry.Delegate = (recv, _) => del((T)recv!); + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + ref_[bp + dst] = del((T)recv!); + } + } + } + if (p.SetMethod != null && !SkipMethod(p.SetMethod)) + { + var s = p.SetMethod; + var pt = s.GetParameters()[0].ParameterType; + var key = $"{typeName}.{s.Name}/1"; + if (_entries.TryGetValue(key, out var entry)) + { + if (pt == typeof(float)) + { + var del = (Action)Delegate.CreateDelegate(typeof(Action), null, s); + entry.Delegate = (recv, a) => { del((T)recv!, (float)a[0]!); return null; }; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + del((T)recv!, RF(num, ref_, slotT, bp + (int)ir[argBase])); + } + else if (pt == type) + { + var del = (Action)Delegate.CreateDelegate(typeof(Action), null, s); + entry.Delegate = (recv, a) => { del((T)recv!, (T)a[0]!); return null; }; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + del((T)recv!, (T)ref_[bp + (int)ir[argBase]]!); + } + else if (pt == typeof(int)) + { + var del = (Action)Delegate.CreateDelegate(typeof(Action), null, s); + entry.Delegate = (recv, a) => { del((T)recv!, (int)a[0]!); return null; }; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + del((T)recv!, RI(num, ref_, slotT, bp + (int)ir[argBase])); + } + else if (pt == typeof(bool)) + { + var del = (Action)Delegate.CreateDelegate(typeof(Action), null, s); + entry.Delegate = (recv, a) => { del((T)recv!, a[0] is int iv ? iv != 0 : (bool)a[0]!); return null; }; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + del((T)recv!, RI(num, ref_, slotT, bp + (int)ir[argBase]) != 0); + } + } + } + } + + } + + // Templates instantiated via reflection from BuildTypedDelegates when an instance method + // either returns or accepts a registered host struct. Cross-type generic shapes can't be + // expressed inline because BuildTypedDelegates only knows about T, not the struct type S. + // The reflection step (MakeGenericMethod with a value-type S) is the same IL2CPP risk that + // any AllowTypeStruct + AllowType combination already takes, and is exercised at registration + // time so the closed delegate is hot before the script runs. + static void BindInstReturnsStruct(Entry entry, MethodInfo m) + where TStruct : struct + { + var del = (Func)Delegate.CreateDelegate(typeof(Func), null, m); + entry.Delegate = (recv, _) => (object)del((TRecv)recv!)!; + entry.FastIsFlat = true; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + Unsafe.WriteUnaligned(num + ((bp + dst) * 4), del((TRecv)recv!)); + } + + static void BindInstTakesStructVoid(Entry entry, MethodInfo m) + where TStruct : struct + { + var del = (Action)Delegate.CreateDelegate(typeof(Action), null, m); + entry.Delegate = (recv, a) => { del((TRecv)recv!, (TStruct)a[0]!); return null; }; + entry.FastIsFlat = true; + entry.Fast = (recv, num, ref_, slotT, ir, argBase, dst, bp) => + { + int s0 = bp + (int)ir[argBase]; + TStruct val = slotT[s0 - FastFrameBase] == SType.Vt + ? Unsafe.ReadUnaligned(num + (s0 * 4)) + : (TStruct)ref_[s0]!; + del((TRecv)recv!, val); + }; + } + + // Flat-frame readers shared between BuildTypedDelegates and AllowBcl fast-path helpers. + // These mirror the local RF/RI statics inside BuildTypedDelegates but are accessible + // as private static members so AllowBcl can close over them without a generic instantiation. + static float NumReadFloat(byte* num, object?[] ref_, SType[] slotT, int s) + { + var t = slotT[s - FastFrameBase]; + if (t == SType.R4) return Unsafe.ReadUnaligned(num + (s * 4)); + if (t == SType.I4) return (float)Unsafe.ReadUnaligned(num + (s * 4)); + if (t == SType.Vt) return 0f; // Vt refFrame entries are not cleared; never read them + var v = ref_[s]; return v is float f ? f : v is int i ? (float)i : 0f; + } + + static double NumReadDouble(byte* num, object?[] ref_, SType[] slotT, int s) + { + var t = slotT[s - FastFrameBase]; + if (t == SType.R8) return Unsafe.ReadUnaligned(num + (s * 4)); + if (t == SType.R4) return Unsafe.ReadUnaligned(num + (s * 4)); + if (t == SType.I4) return Unsafe.ReadUnaligned(num + (s * 4)); + if (t == SType.I8) return Unsafe.ReadUnaligned(num + (s * 4)); + if (t == SType.Vt) return 0d; + var v = ref_[s]; return v is double d ? d : v is float f ? f : v is int i ? i : v is long l ? l : 0d; + } + + static int NumReadInt(byte* num, object?[] ref_, SType[] slotT, int s) + { + var t = slotT[s - FastFrameBase]; + if (t == SType.I4) return Unsafe.ReadUnaligned(num + (s * 4)); + if (t == SType.R4) return (int)Unsafe.ReadUnaligned(num + (s * 4)); + if (t == SType.Vt) return 0; // Vt refFrame entries are not cleared; never read them + var v = ref_[s]; return v is int i ? i : v is bool b ? (b?1:0) : 0; + } + + // Attach a FastIsFlat float-returning fast path to an already-registered entry. + // Sets FlatReturnSType = R4 so the lowerer allocates an R4 dst slot (required for + // flat writes when Method is null, e.g. AllowBcl hand-rolled entries). + // No-ops if the entry is not found (e.g. if AllowStatic was never called for it). + void AttachFlatDouble(string typeName, string methodName, int paramCount, FastCallDelegate fast) + { + var key = $"{typeName}.{methodName}/{paramCount}"; + if (!_entries.TryGetValue(key, out var entry)) return; + entry.FastIsFlat = true; + entry.FlatReturnSType = SType.R8; + entry.FastWideOk = true; + entry.Fast = fast; + } + + void RegisterMethods(MethodInfo[] methods, string typeName, bool isStatic) + { + // All non-special methods go directly into _byHandle; signature-based MemberRef + // resolution at parse time picks the exact overload via GetMethod + handle. + foreach (var m in methods) + { + if (m.IsSpecialName || SkipMethod(m)) continue; + if (m.IsGenericMethodDefinition) + { + var openKey = (typeName, m.Name, m.GetParameters().Length); + if (!_openGenericMethods.TryGetValue(openKey, out var overloads)) + _openGenericMethods[openKey] = overloads = new List(); + overloads.Add(m); + continue; + } + var mp = m.GetParameters(); + var captured = m; + _byHandle[m.MethodHandle.Value] = new Entry + { + Delegate = isStatic ? (_, args) => captured.Invoke(null, args) : (recv, args) => captured.Invoke(recv, args), + ParamCount = mp.Length, + HasThis = !isStatic, + Params = mp, + Method = m, + DeclaringType = m.DeclaringType, + }; + } + } + + static bool SkipType(Type t) => t.IsPointer || t.IsByRef || t.IsGenericTypeDefinition; + + // IsGenericMethodDefinition is NOT skipped — RegisterMethods diverts those into + // _openGenericMethods for lazy instantiation at script load time. Byref (ref/out) + // params are supported; only pointer params disqualify a method. + static bool SkipMethod(MethodBase m) + { + foreach (var p in m.GetParameters()) + if (p.ParameterType.IsPointer) return true; + return false; + } + + // Test helper; constructors resolve via methodName ".ctor". + internal bool HasMethod(string typeName, string methodName, int arity) => + _entries.ContainsKey($"{typeName}.{methodName}/{arity}"); + + internal bool TryGet(string key, int arity, [NotNullWhen(true)] out Entry? entry) => + _entries.TryGetValue($"{key}/{arity}", out entry); + + internal bool TryGet(string key, [NotNullWhen(true)] out Entry? entry) => + _entries.TryGetValue(key, out entry); + + internal bool TryGetField(string key, [NotNullWhen(true)] out FieldEntry? entry) => + _fields.TryGetValue(key, out entry); + + internal IReadOnlyCollection Collisions => _collisions; + internal IReadOnlyCollection RegisteredTypes => _registeredTypes; + + internal bool TryGetByHandle(IntPtr handle, [NotNullWhen(true)] out Entry? entry) => + _byHandle.TryGetValue(handle, out entry); + + internal bool TryGetGenericType(string outerName, string arg0Name, out Type? type) => + _genericTypes.TryGetValue((outerName, arg0Name), out type); + + internal bool TryGetOpenGenerics(string typeName, string methodName, int arity, out List? overloads) + { + if (_openGenericMethods.TryGetValue((typeName, methodName, arity), out var lst) && lst.Count > 0) + { overloads = lst; return true; } + overloads = null; return false; + } + + internal bool TryGetTypeByName(string name, out Type? type) + { + foreach (var t in _registeredTypes) + if (t.Name == name) { type = t; return true; } + type = null; return false; + } + + // Namespace-qualified variant: simple names collide across namespaces (UnityEngine.Application + // vs UnityEngine.WSA/Device.Application) and the first registered twin would win above. + internal bool TryGetTypeByFullName(string fullName, out Type? type) + { + foreach (var t in _registeredTypes) + if (t.FullName == fullName) { type = t; return true; } + type = null; return false; + } + + // Resolve a CLR full name through AutoBindResolver and AllowType the result on first use. + // Returns true when the type is registered after the call — whether this call bound it or a + // prior one did. Open generic definitions can't be AllowType'd directly, but they're + // remembered by short name so TryMakeClosedGenericType can instantiate them on demand when a + // TypeSpec member (e.g. AsyncOperationHandle`1.op_Implicit) needs the closed type. + internal bool TryAutoBindType(string fullName) + { + var resolver = AutoBindResolver; + if (resolver == null) return false; + if (!_autoBindCache.TryGetValue(fullName, out var t)) + { + try { t = resolver(fullName); } catch { t = null; } + _autoBindCache[fullName] = t; + } + if (t == null || t.IsPointer || t.IsByRef) return false; + if (t.IsGenericTypeDefinition) + { + if (!_openGenericTypeDefs.ContainsKey(t.Name)) _openGenericTypeDefs[t.Name] = t; + return false; + } + if (!_registeredTypes.Contains(t)) AllowType(t); + return true; + } + + // Open generic type definitions seen by the auto-bind pass, keyed by metadata short name + // ("AsyncOperationHandle`1"). Source for on-demand closed-type construction below. + readonly Dictionary _openGenericTypeDefs = new(); + + // Construct and register <arg0> from an auto-bind-seen open + // definition and an already-registered type argument. Registering via AllowType also populates + // _genericTypes, so subsequent members of the same instantiation hit TryGetGenericType directly. + // Runtime MakeGenericType is safe on IL2CPP when the closed type exists in the build's metadata + // (it does whenever the reloaded source's original assembly used it) — same caveat as + // GetOrMakeClosedMethod for generic methods. + internal bool TryMakeClosedGenericType(string outerName, string arg0Name, out Type? closed) + { + closed = null; + if (!_openGenericTypeDefs.TryGetValue(outerName, out var def)) return false; + if (def.GetGenericArguments().Length != 1) return false; + if (!TryGetTypeByName(arg0Name, out var arg) || arg == null) return false; + try { closed = def.MakeGenericType(arg); } catch { return false; } + if (!_registeredTypes.Contains(closed)) AllowType(closed); + return true; + } + + internal MethodInfo GetOrMakeClosedMethod(MethodInfo openDef, Type[] typeArgs) + { + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < typeArgs.Length; i++) + { + if (i > 0) sb.Append(','); + sb.Append(typeArgs[i].FullName); + } + var key = (openDef.MethodHandle.Value, sb.ToString()); + if (_closedCache.TryGetValue(key, out var cached)) return cached; + var closed = openDef.MakeGenericMethod(typeArgs); + _closedCache[key] = closed; + return closed; + } +} + +sealed class EntryReferenceComparer : IEqualityComparer +{ + public static readonly EntryReferenceComparer Instance = new(); + public bool Equals(HostBinding.Entry? x, HostBinding.Entry? y) => ReferenceEquals(x, y); + public int GetHashCode(HostBinding.Entry obj) => RuntimeHelpers.GetHashCode(obj); +} +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/HostBinding.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/HostBinding.cs.meta new file mode 100644 index 0000000..51a950f --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/HostBinding.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 47a7b8492a07c4348a96952a3f0ea322 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IFrameAllocator.cs b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IFrameAllocator.cs new file mode 100644 index 0000000..6510ede --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IFrameAllocator.cs @@ -0,0 +1,18 @@ +#nullable enable + +namespace IlInterpreter.Interpreter +{ + // Supplies the Vm engine's unmanaged numeric frame. Injected so the engine stays + // UnityEngine-free: the default is Marshal-based (works everywhere incl. IL2CPP), while a + // Unity host can provide a NativeArray/UnsafeUtility-backed implementation. + // + // Contract: Alloc returns memory aligned to at least 8 bytes (both AllocHGlobal and + // UnsafeUtility.Malloc(...,8,...) satisfy this), so a Value* frame and inline structs up to + // 8-byte field alignment are safe to address. The returned block is owned by the caller until + // passed back to Free; implementations must not move or GC it. + unsafe interface IFrameAllocator + { + void* Alloc(int byteCount); + void Free(void* ptr); + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IFrameAllocator.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IFrameAllocator.cs.meta new file mode 100644 index 0000000..9437c2b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IFrameAllocator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4d4b0ac718cfb5d4e9ed7c0fe4803d74 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IScript.cs b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IScript.cs new file mode 100644 index 0000000..465e708 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IScript.cs @@ -0,0 +1,23 @@ +#nullable enable +using System; +using System.Collections.Generic; +namespace IlInterpreter +{ + +/// +/// Represents a compiled, validated script that can be executed by the interpreter. +/// Instances are produced by the host compilation layer (Unity.Pipeline.Compilation) and +/// consumed by IlInterpreter.Interpreter. +/// +interface IScript +{ + /// Name of the script class (as declared in source). + string Name { get; } + + /// + /// The compiled IL bytes. These have been validated by the IL validator and + /// are safe to interpret. + /// + ReadOnlyMemory Il { get; } +} +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IScript.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IScript.cs.meta new file mode 100644 index 0000000..1337ad8 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IScript.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 285de7e71faedf64da123cc7351300c0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IrDisassembler.cs b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IrDisassembler.cs new file mode 100644 index 0000000..1c0b00a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IrDisassembler.cs @@ -0,0 +1,106 @@ +#nullable enable +using System; +using System.Text; + +namespace IlInterpreter.Interpreter +{ + // Debug tool: render a lowered method's IR (slot types, token/string tables, decoded ops) as text + // — the missing "see what the lowerer produced" view for chasing slot-typing/marshalling bugs. + // Internal, for tests and probe harnesses. Op widths come from OpWidthForCoalesce (never drifts). + partial class ScriptInterpreter + { + /// Disassemble one loaded method by simple name (first match). Null if not loaded/found. + internal string? DisassembleMethod(string name) + { + if (_parsed == null || !_parsed.ByName.TryGetValue(name, out var m)) return null; + return DisassembleParsed(m); + } + + /// Disassemble every loaded method (declaration order by token). + internal string DisassembleAll() + { + var sb = new StringBuilder(); + if (_parsed == null) return "(no script loaded)"; + foreach (var m in _parsed.ByToken.Values) + sb.Append(DisassembleParsed(m)).Append('\n'); + return sb.ToString(); + } + + string DisassembleParsed(ParsedMethod m) + { + var sb = new StringBuilder(); + sb.Append($"=== {m.Name} (static={m.IsStatic}, args={m.ArgCount}, locals={m.LocalCount}, ret={m.ReturnSType}, argSTypes=[{string.Join(",", m.ArgSTypes)}]"); + var lo = m.Lowered; + if (lo == null) + { + sb.Append($") NOT LOWERED: {m.LoweringSkipReason ?? "(no reason)"}\n"); + return sb.ToString(); + } + sb.Append($", frameSize={lo.FrameSize})\n"); + + // Slot type table (slot -> SType), compact. + sb.Append(" slots: "); + for (int s = 0; s < lo.SlotTypes.Length; s++) + sb.Append($"{s}:{lo.SlotTypes[s]} "); + sb.Append('\n'); + + if (lo.Strings.Length > 0) + { + sb.Append(" strings: "); + for (int i = 0; i < lo.Strings.Length; i++) + sb.Append($"[{i}]=\"{lo.Strings[i]}\" "); + sb.Append('\n'); + } + + // Instruction stream. + var ir = lo.Ir; + int ip = 0; + while (ip < ir.Length) + { + var op = (Op)ir[ip]; + int w = IrLowerer.OpWidthForCoalesce(op, ir, ip); + if (w <= 0) { sb.Append($" {ip,4}: \n"); break; } + int il = ip < lo.IrToIlOffset.Length ? lo.IrToIlOffset[ip] : -1; + sb.Append($" {ip,4} (IL 0x{il:X4}): {op,-16}"); + for (int k = 1; k < w; k++) sb.Append($" {(int)ir[ip + k]}"); + sb.Append(Annotate(op, ir, ip, w, lo)); + sb.Append('\n'); + ip += w; + } + return sb.ToString(); + } + + // Human-readable suffix for the operands we can resolve: the ldstr literal, the target of a + // host/script call, and the field behind a ld/stsfld/ld/stfld. + string Annotate(Op op, uint[] ir, int ip, int w, LoweredMethod lo) + { + switch (op) + { + case Op.ldstr: + // [op, dst, strIdx] + int si = (int)ir[ip + 2]; + return si < lo.Strings.Length ? $" ; dst=s{(int)ir[ip + 1]} \"{lo.Strings[si]}\"" : " ; "; + case Op.call_host: + // [op, dst, recvSlot, tokIdx, argc, args...] + return $" ; dst=s{(int)ir[ip + 1]} recv=s{(int)ir[ip + 2]} -> {TokName(lo, (int)ir[ip + 3])}"; + case Op.call_script: + case Op.newobj_script: + case Op.newobj_host: + // [op, dst, tokIdx, argc, args...] + return $" ; dst=s{(int)ir[ip + 1]} -> {TokName(lo, (int)ir[ip + 2])}"; + case Op.ldsfld_i4: case Op.ldsfld_r4: case Op.ldsfld_o: + case Op.stsfld_i4: case Op.stsfld_r4: case Op.stsfld_o: + return $" ; {TokName(lo, (int)ir[ip + 2])}"; + default: + return ""; + } + } + + string TokName(LoweredMethod lo, int tokIdx) + { + if (_parsed == null || lo.Tokens == null || (uint)tokIdx >= (uint)lo.Tokens.Length) return $"tok#{tokIdx}"; + int tok = lo.Tokens[tokIdx]; + return _parsed.TokenNames.TryGetValue(tok, out var n) ? n : $"tok 0x{tok:X8}"; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IrDisassembler.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IrDisassembler.cs.meta new file mode 100644 index 0000000..fe90a8c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/IrDisassembler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 52ef5529fb77441e29e1687a62aeaf5a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/MarshalFrameAllocator.cs b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/MarshalFrameAllocator.cs new file mode 100644 index 0000000..bebac2d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/MarshalFrameAllocator.cs @@ -0,0 +1,23 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace IlInterpreter.Interpreter +{ + +// Default frame allocator used outside Unity (and anywhere a host doesn't inject its own). +// Marshal.AllocHGlobal is platform-neutral and AOT/IL2CPP-safe, and keeps the engine free of +// any Unity.Collections dependency. Returns 8-byte-aligned memory on all supported runtimes. +sealed unsafe class MarshalFrameAllocator : IFrameAllocator +{ + public static readonly MarshalFrameAllocator Instance = new(); + + public void* Alloc(int byteCount) => (void*)Marshal.AllocHGlobal(byteCount); + + public void Free(void* ptr) + { + if (ptr != null) Marshal.FreeHGlobal((nint)ptr); + } +} +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/MarshalFrameAllocator.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/MarshalFrameAllocator.cs.meta new file mode 100644 index 0000000..ecd0ab3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/MarshalFrameAllocator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 34bdbc5d5dc71af4ababeaa8abc6be1c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Op.cs b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Op.cs new file mode 100644 index 0000000..f719021 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Op.cs @@ -0,0 +1,387 @@ +#nullable enable +using System; +using System.Collections.Generic; +namespace IlInterpreter.Interpreter +{ + +// Typed IR opcodes for the lowered-IR interpreter. +// +// Type suffixes: +// _i4 32-bit int (includes bool as 0/1) +// _r4 32-bit float +// _o object reference (string, ScriptObject, host ref, null) +// _struct boxed value type (flat blittable structs use the _vt ops instead) +// +// Encoding (all in uint[]): +// [op, dst, src1, src2] arithmetic, compare +// [op, dst, imm] ldc_i4, ldc_r4, ldnull, ldstr +// [op, target_ip] br +// [op, cond, target_ip] brtrue_i4, brfalse_i4, brtrue_o, brfalse_o +// [op, dst, recv, argc, arg0, ...] call_static, call_virtual, call_host, newobj +// [op, dst, src] mov, conv_*, ldfld_*, ldsfld_*, ldlen, newarr, ldelem_*, neg_* +// [op, recv, fldIdx/tok, src] stfld_*, stsfld_* +// [op, dst, arr, idx] ldelem_* (arr=slot, idx=slot) +// [op, arr, idx, src] stelem_* +// [op] ret_void +// [op, src] ret_i4, ret_r4, ret_o +enum Op : uint +{ + // --- No-op (usually elided during lowering) --- + nop, + + // --- Move (type-agnostic; routes between frames by slot type) --- + mov, // [op, dst, src] + + // --- Constants --- + ldc_i4, // [op, dst, imm_value] imm is the int value cast to uint + ldc_r4, // [op, dst, imm_bits] imm is BitConverter.SingleToUInt32Bits(f) + ldnull, // [op, dst] + ldstr, // [op, dst, str_idx] str_idx indexes into LoweredMethod.Strings + + // --- Integer arithmetic (i4) --- + add_i4, // [op, dst, src1, src2] + add_i4_nn, // both srcs in numFrame as I4 + sub_i4, + sub_i4_nn, + mul_i4, + mul_i4_nn, + div_i4, + div_i4_nn, + rem_i4, + rem_i4_nn, + neg_i4, // [op, dst, src] + neg_i4_n, // src in numFrame as I4 + + // --- Float arithmetic (r4) --- + add_r4, // [op, dst, src1, src2] + add_r4_nn, // both srcs in numFrame as R4 + sub_r4, + sub_r4_nn, + mul_r4, + mul_r4_nn, + div_r4, + div_r4_nn, + rem_r4, + rem_r4_nn, + neg_r4, // [op, dst, src] + neg_r4_n, // src in numFrame as R4 + + // --- Bitwise (i4 only; float has no bitwise) --- + and_i4, // [op, dst, src1, src2] + and_i4_nn, + or_i4, + or_i4_nn, + xor_i4, + xor_i4_nn, + not_i4, // [op, dst, src] + not_i4_n, + shl_i4, // [op, dst, src1, src2] + shl_i4_nn, + shr_i4, + shr_i4_nn, + shr_un_i4, + shr_un_i4_nn, + div_un_i4, // unsigned div/rem: uint is analyzer-legal, so `u / 2u` must run + div_un_i4_nn, + rem_un_i4, + rem_un_i4_nn, + + // --- Integer comparisons (produce 1/0 int) --- + ceq_i4, // [op, dst, src1, src2] + ceq_i4_nn, + cgt_i4, + cgt_i4_nn, + clt_i4, + clt_i4_nn, + cgt_un_i4, + cgt_un_i4_nn, + clt_un_i4, + clt_un_i4_nn, + + // --- Float comparisons --- + ceq_r4, // [op, dst, src1, src2] + ceq_r4_nn, + cgt_r4, + cgt_r4_nn, + clt_r4, + clt_r4_nn, + // Unordered float comparisons — what C# emits for float `<=`/`>=` (via cgt.un/clt.un). + // cgt_un_r4 = !(a <= b) (true when a > b OR either is NaN); clt_un_r4 = !(a >= b). + cgt_un_r4, // [op, dst, src1, src2] + clt_un_r4, // [op, dst, src1, src2] + + // --- Object/reference comparisons --- + ceq_o, // [op, dst, src1, src2] — reference equality + cgt_un_o, // [op, dst, src1, src2] — cgt.un on object (null check pattern) + + // --- Branches --- + br, // [op, target_ip] + brtrue_i4, // [op, cond, target_ip] + brfalse_i4, // [op, cond, target_ip] + brtrue_o, // [op, cond, target_ip] + brfalse_o, // [op, cond, target_ip] + + // --- Conversions --- + conv_i4_r4, // [op, dst, src] int → float + conv_r4_i4, // [op, dst, src] float → int + conv_i4_i1, // [op, dst, src] int → sbyte (sign-extend) + conv_i4_u1, // [op, dst, src] int → byte (zero-extend) + conv_i4_i2, // [op, dst, src] int → short + conv_i4_u2, // [op, dst, src] int → ushort + + // --- Host field access (via token + reflection-style lookup) --- + // dst = frame slot; obj = frame slot holding receiver; tok = field token index + ldfld_i4, // [op, dst, obj, tok_idx] + ldfld_r4, + ldfld_o, + ldfld_struct, + ldflda, // [op, dst, obj, tok_idx] — load field value (same as ldfld_o) + stfld_i4, // [op, obj, tok_idx, src] + stfld_r4, + stfld_o, + stfld_struct, + + // --- Script-class field access (flat typed storage) --- + // For script-defined classes, fields are stored in ScriptObject.PrimBytes (primitives) + // or ScriptObject.RefSlots (references). Byte offset is baked at lowering time. + // ldfld_sc_i4/r4: [op, dst, obj, byteOffset] — read from PrimBytes + // ldfld_sc_o: [op, dst, obj, refIdx] — read from RefSlots + // stfld_sc_i4/r4: [op, obj, byteOffset, src] — write to PrimBytes + // stfld_sc_o: [op, obj, refIdx, src] — write to RefSlots + // ldfld_sc_vt: [op, dst, obj, byteOffset] — copy N bytes from PrimBytes into Vt slot + // stfld_sc_vt: [op, obj, byteOffset, src] — copy N bytes from Vt slot into PrimBytes + ldfld_sc_i4, + ldfld_sc_r4, + ldfld_sc_o, + ldfld_sc_vt, + stfld_sc_i4, + stfld_sc_r4, + stfld_sc_o, + stfld_sc_vt, + + // --- Static field access --- + ldsfld_i4, // [op, dst, tok_idx] + ldsfld_r4, + ldsfld_o, + ldsfld_struct, + stsfld_i4, // [op, tok_idx, src] + stsfld_r4, + stsfld_o, + stsfld_struct, + + // --- Arrays --- + newarr, // [op, dst, len_src, elem_tok_idx] + // Typed-backing variants (same 5-word encoding as newarr): when the element type is + // statically System.Int32 / System.Single the executor allocates a real int[]/float[] + // instead of object?[], and ldelem/stelem take non-boxing direct paths — an int store + // into an object?[] element boxes (GC pressure in hot Update bodies) and Mono pays ~3x + // CoreCLR on that path. + newarr_i4, + newarr_r4, + // Flat-struct element arrays (ScriptVtArray backing): zero-init bytes = C# array semantics, + // no per-element allocation. Same 5-word encoding as newarr; elem layout resolved at lowering + // into LoweredMethod.LayoutByTokIdx. + newarr_vt, + ldlen, // [op, dst, arr_src] + ldelem_i4, // [op, dst, arr, idx] + ldelem_r4, + ldelem_o, + ldelem_struct, + stelem_i4, // [op, arr, idx, src] + stelem_r4, + stelem_o, + stelem_struct, + // Flat-struct element access: memcpy between the array's byte backing and a Vt frame slot + // (value semantics without clone_sc). Fall back to boxed forms for object?[]/host T[] arrays. + ldelem_vt, // [op, dst(Vt), arr, idx] + stelem_vt, // [op, arr, idx, src(Vt)] + + // --- Calls --- + // [op, dst, recv, argc, arg0, arg1, ...] + // dst = 0xFFFFFFFF for void calls (ret discarded) + // recv = 0xFFFFFFFF for static calls + call_script, // callee is a script-defined method; tok_idx in recv slot (reuse) + call_host, // callee is a host entry + // call_host with byref args. After arg0..argN comes a writeback table: + // wbCount, [argIdx, frameSlot, kind] × wbCount + // kind: 0 = frame slot, 1 = field (frameSlot=objSlot, plus tokIdx in next word), + // 2 = array element (frameSlot=arrSlot, plus idxSlot in next word). + // The slow Invoke path is always used (Fast skipped) so MethodInfo.Invoke writes + // back into the boxed args[] array, which we then copy into the right destination. + call_host_byref, + + // --- Object construction --- + newobj_script, // [op, dst, tok_idx, argc, arg0, ...] script-defined type ctor + newobj_host, // [op, dst, tok_idx, argc, arg0, ...] host type ctor + box_enum, // [op, dst, src, tok_idx] box an I4 value as its HOST ENUM type (resolved + // via ParsedAssembly.TokenTypes) so ToString/Format render the member name + new_delegate, // [op, dst, tok_idx, recv] delegate creation (ldftn/ldvirtftn + newobj); + // tok_idx indexes LoweredMethod.DelegateSiteByTokIdx (pre-resolved target + + // delegate type); recv is the receiver's O slot (holds null for statics) + + // --- Misc --- + box, // [op, dst, src] — read slot as object into refFrame + box_prim, // [op, dst, src, typecode] — box a primitive as its TRUE type (bool/char/… not int) + unbox_any, // [op, dst, src, tok_idx] + castclass, // [op, dst, src, tok_idx] + isinst, // [op, dst, src, tok_idx] + ldtoken, // [op, dst, tok_idx] + initobj, // [op, dst] — zero-initialize slot (for script struct locals) + switch_i4, // [op, val, n, ip0, ip1, ...] variable length; ip_default follows + + // --- Return --- + ret_void, // [op] + ret_i4, // [op, src] + ret_r4, // [op, src] + ret_o, // [op, src] + throw_o, // [op, src] — `throw` of a script-constructed exception; ends the block like ret + // Flat-struct return: reads the callee's Vt src bytes, memcpys them into the caller's + // (Vt) RetDst slot on frame pop; boxes once via the layout at the top-level boundary. + ret_vt, // [op, src(Vt)] + + // Zero-initialize a script-defined struct (O slot) without invoking a ctor. + // Used when the IL emits ldloca + initobj for a parameterless default ctor. + initobj_script, // [op, dst, typeDefTokIdx] + + // Ensure a script-defined struct O slot is non-null. Allocates a fresh zero-initialized + // ScriptObject only if the slot is currently null; leaves it untouched otherwise. + // Emitted by the stfld lowerer before field writes on struct locals that Roslyn considers + // "definitely assigned by field stores" (so no initobj in the IL) — our O slots start null. + ensure_script, // [op, dst, typeDefTokIdx] + + // Value-copy a script-defined struct: dst = shallow clone of the ScriptObject in src + // (copies PrimBytes + RefSlots). Emitted when a script struct is loaded as a VALUE + // (ldloc/ldarg/ldfld/ldelem) so each named location owns its instance — script structs + // are represented as ScriptObject references, so without this a copy would alias. A null + // or non-ScriptObject src is passed through unchanged. + clone_sc, // [op, dst, src] + + // --- Flat-struct (Vt slots): read/write primitive fields by byte offset, copy/box at boundary --- + // Vt slots reserve ceil(size/4) consecutive logical slots in numFrame. + // ldfld_vt_*/stfld_vt_* take a byte offset (within the Vt slot) instead of a token, + // since the field's offset is resolved at lowering time. + // box_vt/unbox_vt/mov_vt look up the StructLayout via lm.StructLayouts[src or dst]. + ldfld_vt_i4, // [op, dst, vtObj, byteOffset] + ldfld_vt_r4, // [op, dst, vtObj, byteOffset] + stfld_vt_i4, // [op, vtObj, byteOffset, src] + stfld_vt_r4, // [op, vtObj, byteOffset, src] + mov_vt, // [op, dst, src] — copy struct bytes (size from layout) + // Nested flat-struct field access: copy a SUB-RANGE of a Vt slot (a blittable struct field + // inlined inside another flat struct). Offsets are byte offsets within the receiver slot; + // the sub-struct's size comes from the dst/src slot's own layout. + ldfld_vt_vt, // [op, dst(Vt), vtObj, byteOffset] — memcpy size(dst) bytes out + stfld_vt_vt, // [op, vtObj, byteOffset, src(Vt)] — memcpy size(src) bytes in + // Sub-4-byte fields of flat host structs (Color32.r-class): loads widen into an I4 slot + // (zero- or sign-extended); stores truncate. Script structs never need these — the parser + // gives every script field its own 4-byte cell. + ldfld_vt_u1, // [op, dst(I4), vtObj, byteOffset] + ldfld_vt_i1, + ldfld_vt_u2, + ldfld_vt_i2, + stfld_vt_b1, // [op, vtObj, byteOffset, src] — writes low byte + stfld_vt_b2, // [op, vtObj, byteOffset, src] — writes low 2 bytes + box_vt, // [op, dst, src] — read Vt bytes, box as T into refFrame[dst] + unbox_vt, // [op, dst, src] — unbox refFrame[src] into Vt dst bytes + // Reuse existing newobj_host / call_host opcodes; executor branches on dst's slot type. + + // --- Operand-immediate forms: RHS literal embedded in the IR word --- + // Encoding: [op, dst, src, k_imm] (4 words). k_imm is the constant value cast to uint + // (BitConverter.SingleToUInt32Bits for floats). Executor reads it directly; no separate + // K-table lookup. Emitted by the lowerer when an `ldc_X tmp; binop_X_nn dst, src, tmp` + // pattern has tmp single-use. Subset is corpus-driven — only ops that show up in bench + // loops; expand on demand. Compare ops cover RHS-constant only (the canonical loop-bound + // pattern); LHS-constant doesn't appear in the bench corpus. + add_i4_nk, // [op, dst, src, k_int] + add_r4_nk, // [op, dst, src, k_bits] + sub_i4_nk, + sub_r4_nk, + mul_r4_nk, + clt_i4_nk, + clt_r4_nk, + cgt_i4_nk, + cgt_r4_nk, + ceq_i4_nk, + // Second _nk batch (profiling-driven): masks (`i & 255`), shifts (`acc >> 5`), scaling + // (`i * 3`) and float division/equality against a literal all showed up as hot + // `ldc; binop_nn` pairs in the bench op mix. Same [op, dst, src, k] encoding. + mul_i4_nk, + div_i4_nk, // k is the divisor (RHS only); k == 0 still faults like the slot form + rem_i4_nk, + and_i4_nk, + or_i4_nk, + xor_i4_nk, + shl_i4_nk, + shr_i4_nk, + shr_un_i4_nk, + div_r4_nk, + ceq_r4_nk, + + // --- Fused compare-and-branch --- + // Encoding: [op, s1, s2_or_k, target_ip] (4 words). _nn forms read both + // operands as slots; _nk forms read s1 as a slot and s2_or_k as an inline + // immediate. Always test "branch if cmp true" — for compare-then-brfalse + // patterns the lowerer fuses ceq → bne. Subset is corpus-driven (only the + // compare→branch patterns observed in bench loop bodies; no bge/ble, no + // LHS-constant `_kn` forms); add more on demand. + blt_i4_nn, // [op, s1, s2, target_ip] if (slot[s1] < slot[s2]) goto target + blt_i4_nk, // [op, s1, k, target_ip] if (slot[s1] < k) goto target + bgt_r4_nn, + bgt_r4_nk, + beq_i4_nn, + beq_i4_nk, + bne_i4_nn, + bne_i4_nk, + + // --- Numeric for-loop super-instruction --- + // Encoding: [op, induction_slot, limit_imm, target_ip] (4 words). + // Executor body: slot[N] += 1; if (slot[N] < limit) goto target. Three + // semantic steps (increment + bound check + back-edge) in one dispatch — + // analogous to Luau's LOP_FORNLOOP. Step is hardcoded to 1 (Roslyn-emitted + // C# `for (int i = ...; i < L; i++)` pattern). Variants for step != 1, + // reverse loops, and r4 induction are deferred until the bench corpus + // demands them. + // + // Lowerer pass detects `add_i4_nk slot[N], slot[N], 1; blt_i4_nk slot[N], + // K_limit, body_top` plus a preceding `ldc_i4 slot[N] = K_init; br -> + // blt_ip` with K_init statically less than K_limit. Replaces add+blt with + // for_i4_nk and redirects the entry br to body_top so the first body + // iteration isn't skipped. + for_i4_nk, // [op, induction_slot, limit_int, target_ip] + + // --- try/finally (non-exceptional path only) --- + // IL `leave T` lowers to push_cont(T), push_cont(outer handlers)…, br(innermost handler); + // IL `endfinally` lowers to br_cont, which pops the next continuation and jumps to it — + // chaining inner finally → outer finally → leave target. Exceptions thrown inside a try + // still propagate without running the finally (the whole dispatch falls back to the + // original compiled body via CodeReloadRegistry.TryInvokeCodeReload). + push_cont, // [op, target_ip] — push an IR continuation address + br_cont, // [op] — pop a continuation address and jump to it + + // --- 64-bit family (I8 = long/ulong, R8 = double) --- + // Wide slots: two consecutive 4-byte frame cells, filler cell typed O/null like Vt + // continuation cells. Methods whose frames contain a wide slot SKIP the optimizer + // passes wholesale (see LowerMethod), so no _nn/_nk fast forms exist and none of + // these ops appear in the pass tables. + ldc_i8, // [op, dst, lo, hi] + ldc_r8, // [op, dst, lo, hi] — IEEE754 double bits + add_i8, sub_i8, mul_i8, div_i8, rem_i8, // [op, dst, s1, s2] + div_un_i8, rem_un_i8, + and_i8, or_i8, xor_i8, + shl_i8, shr_i8, shr_un_i8, // [op, dst, value(I8), count(I4)] + neg_i8, not_i8, // [op, dst, src] + add_r8, sub_r8, mul_r8, div_r8, rem_r8, // [op, dst, s1, s2] + neg_r8, // [op, dst, src] + ceq_i8, cgt_i8, clt_i8, cgt_un_i8, clt_un_i8, // [op, dst(I4), s1, s2] + ceq_r8, cgt_r8, clt_r8, cgt_un_r8, clt_un_r8, // unordered variants for float semantics + conv_i8_i4, // [op, dst, src] — sign-extend i4 → i8 + conv_i8_u4, // zero-extend u4 → i8 + conv_i4_i8, // truncate i8 → i4 (also serves conv.u4) + conv_i8_r4, conv_r4_i8, + conv_i8_r8, conv_r8_i8, + conv_r8_r4, conv_r4_r8, + conv_r8_i4, conv_i4_r8, + conv_r8_u8, // ulong bits → double (conv.r.un) + brtrue_i8, brfalse_i8, // [op, src, target_ip] + ret_i8, ret_r8, // [op, src] +} +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Op.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Op.cs.meta new file mode 100644 index 0000000..336fccd --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Op.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bd7e60fa09b64c9458e396035345a26c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptDelegateAdapter.cs b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptDelegateAdapter.cs new file mode 100644 index 0000000..3a658ce --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptDelegateAdapter.cs @@ -0,0 +1,105 @@ +#nullable enable +using System; +using System.Reflection; + +namespace IlInterpreter.Interpreter +{ + partial class ScriptInterpreter + { + /// + /// The object behind a delegate whose target is an INTERPRETED method (a method group over a + /// script method, or a lambda's display-class method). The host holds an ordinary CLR delegate + /// of the exact requested type — System.Action, EventCallback<T>, a custom + /// delegate — and each invocation re-enters the VM, like does + /// for iterators. + /// + /// No Reflection.Emit: the delegate is built with Delegate.CreateDelegate over a + /// shape-matched generic forwarder (V0..V4 for void, F0..F4 otherwise) closed + /// over the delegate's Invoke signature. Same AOT posture as HostBinding's typed fast paths; + /// on IL2CPP a value-typed signature relies on full generic sharing. + /// + /// Lifetime: valid until the interpreter is re-Loaded or disposed — the same contract + /// as every other object that crosses the boundary. + /// + internal sealed class ScriptDelegateAdapter + { + readonly ScriptInterpreter _owner; + readonly ParsedMethod _target; + readonly object? _receiver; // null for static targets; prepended as arg 0 otherwise + readonly object _generation; // the ParsedAssembly this target was lowered with + + ScriptDelegateAdapter(ScriptInterpreter owner, ParsedMethod target, object? receiver) + { + _owner = owner; + _target = target; + _receiver = receiver; + _generation = owner._parsed!; + } + + public static Delegate Create( + ScriptInterpreter owner, Type delegateType, ParsedMethod target, object? receiver) + { + var invoke = delegateType.GetMethod("Invoke") + ?? throw new ScriptRuntimeException($"'{delegateType}' has no Invoke — not a delegate type"); + var ps = invoke.GetParameters(); + bool isVoid = invoke.ReturnType == typeof(void); + if (ps.Length > MaxArity) + throw new ScriptRuntimeException( + $"Delegate '{delegateType.Name}' has {ps.Length} parameters — method groups over " + + $"interpreted methods support up to {MaxArity}"); + foreach (var p in ps) + if (p.ParameterType.IsByRef) + throw new ScriptRuntimeException( + $"Delegate '{delegateType.Name}' has a ref/out parameter — not supported " + + "for method groups over interpreted methods"); + + var adapter = new ScriptDelegateAdapter(owner, target, receiver); + var shape = typeof(ScriptDelegateAdapter).GetMethod( + (isVoid ? "V" : "F") + ps.Length, BindingFlags.Public | BindingFlags.Instance)!; + int typeArgCount = ps.Length + (isVoid ? 0 : 1); + if (typeArgCount > 0) + { + var typeArgs = new Type[typeArgCount]; + for (int i = 0; i < ps.Length; i++) typeArgs[i] = ps[i].ParameterType; + if (!isVoid) typeArgs[ps.Length] = invoke.ReturnType; + shape = shape.MakeGenericMethod(typeArgs); + } + try + { + return Delegate.CreateDelegate(delegateType, adapter, shape); + } + catch (ArgumentException ex) + { + throw new ScriptRuntimeException( + $"Could not bind '{target.Name}' to delegate type '{delegateType.Name}': {ex.Message}"); + } + } + + const int MaxArity = 4; + + object? Call(object?[] args) + { + if (_receiver == null) + return _owner.InvokeDelegateTarget(_target, args, _generation); + var withReceiver = new object?[args.Length + 1]; + withReceiver[0] = _receiver; + Array.Copy(args, 0, withReceiver, 1, args.Length); + return _owner.InvokeDelegateTarget(_target, withReceiver, _generation); + } + + static readonly object?[] s_NoArgs = Array.Empty(); + + // Shape-matched forwarders — one per (void?, arity). Public so CreateDelegate binds them. + public void V0() => Call(s_NoArgs); + public void V1(A a) => Call(new object?[] { a }); + public void V2(A a, B b) => Call(new object?[] { a, b }); + public void V3(A a, B b, C c) => Call(new object?[] { a, b, c }); + public void V4(A a, B b, C c, D d) => Call(new object?[] { a, b, c, d }); + public R F0() => (R)Call(s_NoArgs)!; + public R F1(A a) => (R)Call(new object?[] { a })!; + public R F2(A a, B b) => (R)Call(new object?[] { a, b })!; + public R F3(A a, B b, C c) => (R)Call(new object?[] { a, b, c })!; + public R F4(A a, B b, C c, D d) => (R)Call(new object?[] { a, b, c, d })!; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptDelegateAdapter.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptDelegateAdapter.cs.meta new file mode 100644 index 0000000..e51f859 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptDelegateAdapter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 73f82ba15825e4cb38b92f41cecbb73c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptEnumerator.cs b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptEnumerator.cs new file mode 100644 index 0000000..23d46c8 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptEnumerator.cs @@ -0,0 +1,113 @@ +#nullable enable +using System; +using System.Collections; + +namespace IlInterpreter.Interpreter +{ + +sealed partial class ScriptInterpreter +{ + // Host-side IEnumerator over an interpreted iterator state machine. The host (Unity's + // coroutine scheduler, foreach, a manual MoveNext loop) drives the interface; each call + // re-enters the VM with the state machine ScriptObject as the receiver. Produced by + // WrapForHost whenever a script enumerator crosses the host boundary. + // + // Lifetime: valid until the owning interpreter is Unload()ed/Dispose()d or reLoad()ed — + // the state machine's methods belong to the loaded assembly. Driving a bridge after that + // ends the iterator like any other script error (see the guard below). + internal sealed class ScriptEnumerator : IEnumerator, IDisposable + { + readonly ScriptInterpreter _owner; + readonly EnumeratorMembers _members; + + // Set when a pump threw: the iterator is over. Without this guard an exception from + // an interpreted body propagates out of MoveNext into Unity's coroutine scheduler and + // kills the ENTIRE host coroutine chain that yielded on this bridge (a code-reload + // error froze the whole scene loop). A compiled body has the registry's + // fall-back-to-original safety net at dispatch; a coroutine body runs after dispatch, + // so this is its equivalent: log through the interpreter's sink and end the iterator — + // the outer coroutine resumes as if it completed. + bool _faulted; + + // Exposed for tests/diagnostics: the interpreted state machine instance. + internal ScriptObject StateMachine { get; } + + // Exposed for tests/diagnostics: the exception that ended this iterator, if any. The + // bridge deliberately swallows it (see _faulted) — but a differential harness driving + // oracle and interpreter side by side still needs to SEE the fault to check parity. + internal Exception? FaultException { get; private set; } + + internal ScriptEnumerator(ScriptInterpreter owner, ScriptObject stateMachine, + EnumeratorMembers members) + { + _owner = owner; + StateMachine = stateMachine; + _members = members; + } + + public bool MoveNext() + { + if (_faulted) return false; + try + { + return _owner.InvokeEnumeratorMember(_members.MoveNext, StateMachine) is bool b && b; + } + catch (Exception ex) + { + Fault("MoveNext", ex); + return false; + } + } + + // get_Current is a plain field read on the state machine — cheap and side-effect + // free, so no caching. A nested interpreted enumerator yielded as Current comes back + // wrapped (InvokeEnumeratorMember runs WrapForHost on its result). + public object? Current + { + get + { + if (_faulted) return null; + try + { + return _owner.InvokeEnumeratorMember(_members.GetCurrent, StateMachine); + } + catch (Exception ex) + { + Fault("Current", ex); + return null; + } + } + } + + // Iterator state machines don't support Reset (their own Reset throws + // NotSupportedException — the exact body the interpreter skips as a cold stub). + public void Reset() => throw new NotSupportedException(); + + public void Dispose() + { + if (_faulted || _members.Dispose == null) return; + try + { + _owner.InvokeEnumeratorMember(_members.Dispose, StateMachine); + } + catch (Exception ex) + { + Fault("Dispose", ex); + } + } + + void Fault(string where, Exception ex) + { + _faulted = true; + FaultException = ex; + // Full exception (type + stack), not just the message: a ScriptRuntimeException + // carries its location in the message, but a raw exception escaping the VM (an + // interpreter bug or a fast-path host throw) is only diagnosable from its stack. + _owner._logSink( + $"CodeReload: interpreted coroutine '{StateMachine.Type.Name}' threw in {where} — " + + $"ending this iterator so the host coroutine chain survives: {ex}"); + } + } +} + +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptEnumerator.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptEnumerator.cs.meta new file mode 100644 index 0000000..3ab94dd --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptEnumerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2bc4e4aa52414750ab81e831065292cd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptException.cs b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptException.cs new file mode 100644 index 0000000..ede26aa --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptException.cs @@ -0,0 +1,53 @@ +#nullable enable +using System; +using System.Collections.Generic; +namespace IlInterpreter +{ + +/// Base exception for all IlInterpreter errors. +public class ScriptException : Exception +{ + /// Create the exception with a message. + /// The exception message. + public ScriptException(string message) : base(message) { } + /// Create the exception with a message and an inner cause. + /// The exception message. + /// The exception that caused this one, if any. + public ScriptException(string message, Exception? inner) : base(message, inner) { } +} + +/// Raised when script source fails to compile. +public class ScriptCompileException : ScriptException +{ + /// The compiler diagnostics that failed the compilation. + public IReadOnlyList Diagnostics { get; } + + /// Create the exception from the diagnostics that failed the compilation. + /// The compiler diagnostics that failed the compilation. + public ScriptCompileException(IReadOnlyList diagnostics) + : base($"Script compilation failed with {diagnostics.Count} error(s).") + { + Diagnostics = diagnostics; + } +} + +/// Raised when compiled IL fails validation. +public class ScriptValidationException : ScriptException +{ + /// Create the exception with a message. + /// The exception message. + public ScriptValidationException(string message) : base(message) { } +} + +/// Raised during interpreter execution. +public class ScriptRuntimeException : ScriptException +{ + /// Create the exception with a message. + /// The exception message. + public ScriptRuntimeException(string message) : base(message) { } + /// Create the exception with a message and an inner cause. + /// The exception message. + /// The exception that caused this one, if any. + public ScriptRuntimeException(string message, Exception? inner) : base(message, inner) { } +} +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptException.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptException.cs.meta new file mode 100644 index 0000000..8a49e95 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptException.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 32382bdf2a41c4141a26fb433b9395f6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptInterpreter.cs b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptInterpreter.cs new file mode 100644 index 0000000..e1e8658 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptInterpreter.cs @@ -0,0 +1,9104 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +// The package bundles System.Reflection.Metadata renamed with a "UnityPipeline." prefix (Runtime/Plugins/CodeAnalysis); +// the dotnet test projects reference the same DLLs (IlInterpreter/BundledRoslyn.props). +using UnityPipeline.System.Reflection.Metadata; +using UnityPipeline.System.Reflection.Metadata.Ecma335; +using UnityPipeline.System.Reflection.PortableExecutable; +using System.Runtime.CompilerServices; + +namespace IlInterpreter.Interpreter +{ + +sealed partial class ScriptInterpreter : IDisposable +{ + public const int DefaultStepLimit = 1_000_000; + + // Per-invocation IL-opcode budget. Set higher for benchmarks or long-running + // workloads; the default catches accidental infinite loops in dev. + public int StepLimit = DefaultStepLimit; + + readonly HostBinding? _binding; + readonly Action _logSink; + readonly IFrameAllocator _allocator; + Vm? _vm; // lazily created on first Invoke + ParsedAssembly? _parsed; + ScriptObject? _instance; // the live "Script" instance (null for static-only scripts) + + // Per-size thread-local buffers for host-call arguments (sizes 0-8). + // Delegates registered via AllowType use MethodInfo.Invoke which requires an exact-length array. + [ThreadStatic] + static object?[][]? _argBufs; + + static object?[] ArgBuf(int count) + { + _argBufs ??= new object?[9][]; + if (count < _argBufs.Length) return _argBufs[count] ??= new object?[count]; + return new object?[count]; // rare: >8 args, just allocate + } + + public ScriptInterpreter(HostBinding? binding = null, Action? log = null, + IFrameAllocator? allocator = null) + { + _binding = binding; + _logSink = log ?? (_ => { }); + _allocator = allocator ?? MarshalFrameAllocator.Instance; + } + + // Parse the script IL; disposes any previously loaded assembly first (code reload). + // Throws ScriptValidationException if any method can't be lowered — failing at Load, + // where the user can act on it, rather than at Invoke time. + // lenient=true keeps loading when individual methods can't be lowered (each becomes a skipped + // stub queryable via TryGetLoweringSkip) instead of throwing for the whole assembly — see + // IrLowerer.LowerAll. Code reload passes true so one bad method doesn't drop a whole file's reload. + public void Load(IScript script, bool lenient = false) + { + _parsed?.PdbProvider?.Dispose(); + _parsed?.Pe.Dispose(); + // Parse reads attacker-reachable PE/metadata directly (System.Reflection.Metadata). Convert + // a malformed-image fault into a clean load failure rather than letting a raw + // BadImageFormatException/ArgumentException escape Load. ScriptExceptions (our own validation, + // e.g. a cyclic base chain) pass through unchanged. + try { _parsed = Parse(script.Il.ToArray(), _binding, _logSink); } + catch (ScriptException) { throw; } + catch (Exception ex) { throw new ScriptValidationException($"Failed to parse script assembly: {ex.Message}"); } + var failures = IrLowerer.LowerAll(_parsed, lenient); + if (failures.Count > 0) + { + var sb = new System.Text.StringBuilder("Script could not be lowered:"); + foreach (var f in failures) sb.Append('\n').Append(" ").Append(f.Name).Append(": ").Append(f.Reason); + _parsed.PdbProvider?.Dispose(); + _parsed.Pe.Dispose(); + _parsed = null; + throw new ScriptValidationException(sb.ToString()); + } + IrLowerer.BuildIrBlob(_parsed); + _instance = MakeInstance(_parsed); + // Run script static constructors eagerly (CLR runs them lazily; eager keeps the VM free of + // per-access init checks). The load-bearing case is the compiler-generated `<>c..cctor` + // that creates the singleton behind non-capturing lambdas — without it, `ldsfld <>9` + // reads null and a delegate over an instance method loses its receiver. A user static + // initializer that throws fails the Load, like everything else it validates. + foreach (var m in _parsed.ByToken.Values) + if (m.Name == ".cctor" && m.Lowered != null) + { + try { (_vm ??= new Vm(this)).Invoke(m, Array.Empty()); } + catch (ScriptException ex) + { + // Name the type whose static initializer faulted — a bare "array index out of + // bounds at IL+0x…" gives no clue which of an assembly's many .cctors blew up. + var typeName = _parsed.TypeDefToType.TryGetValue(m.DeclaringTypeDef, out var td) + ? td.Name : $"typedef 0x{m.DeclaringTypeDef:X8}"; + throw new ScriptRuntimeException( + $"static initializer of type '{typeName}' failed at load: {ex.Message}", ex); + } + } + } + + // Release the loaded assembly. Invoke() will throw until Load() is called again. + public void Unload() + { + _parsed?.PdbProvider?.Dispose(); + _parsed?.Pe.Dispose(); + _parsed = null; + _instance = null; + } + + public void Dispose() + { + Unload(); + _vm?.Dispose(); + _vm = null; + } + + // True if the loaded script defines a method with this (simple) name. Lets callers + // probe for an optional entry point (e.g. a code-reload override) without catching the + // "method not found" exception that Invoke throws. + public bool HasMethod(string name) => _parsed != null && _parsed.ByName.ContainsKey(name); + + // Host member refs of the loaded script that resolved to throwing stubs (or missing host + // fields) — the script runs, but reaching one of these sites throws ScriptRuntimeException. + // Callers surface these as load-time warnings instead of exploding mid-play on first call. + public IReadOnlyList UnboundHostMembers => + (IReadOnlyList?)_parsed?.UnboundHostMembers ?? Array.Empty(); + + // A method that exists but was skipped at load — Lowered==null with a reason — because it + // couldn't be lowered under lenient loading (see Load). Its call sites throw the reason if + // reached, so the code-reload executor skips registering just that override instead of the file. + public bool TryGetLoweringSkip(string methodName, out string reason) + { + if (_parsed != null && _parsed.ByName.TryGetValue(methodName, out var m) + && m.Lowered == null && m.LoweringSkipReason != null) + { reason = m.LoweringSkipReason; return true; } + reason = null!; + return false; + } + + // Resolve a host-invocable method by simple name, rejecting stubs that were skipped at + // load (cold enumerator members whose lowering failed). + ParsedMethod ResolveInvocable(string methodName) + { + if (_parsed == null) throw new ScriptRuntimeException("No script loaded — call Load() first"); + if (!_parsed.ByName.TryGetValue(methodName, out var method)) + throw new ScriptRuntimeException($"Method '{methodName}' not found"); + if (method.Lowered == null) + throw new ScriptRuntimeException( + $"Method '{methodName}' was skipped at load: {method.LoweringSkipReason}"); + return method; + } + + // Invoke a named method. + // For instance methods: args[0] = script instance, args[1..] = extraArgs. + // For static methods: args[0..] = extraArgs. + public object? Invoke(string methodName, params object?[] extraArgs) + { + var method = ResolveInvocable(methodName); + + // Hot no-arg path (OnUpdate/Run): the Vm places the receiver and runs with no object?[] + // allocation. Only the primitive return is boxed (at the API boundary). + if (extraArgs.Length == 0) + return WrapForHost((_vm ??= new Vm(this)).InvokeTyped(method)); + + var args = new object?[method.ArgCount]; + int slot = 0; + if (!method.IsStatic && _instance != null) args[slot++] = _instance; + for (int i = 0; i < extraArgs.Length && slot < method.ArgCount; i++) + args[slot++] = extraArgs[i]; + + return WrapForHost((_vm ??= new Vm(this)).Invoke(method, args)); + } + + // Typed 1-arg overload: bypasses object?[] allocation for primitive args. + // RyuJIT specializes per value-type T1; typeof checks fold to constants in + // each specialization, so no boxing occurs for int/float/bool callers. + // Void-returning methods (the common Unity Update pattern) allocate zero bytes. + // Numeric-returning methods re-box the result once (24 B); ref-returning methods + // have no boxing on either side. + public object? Invoke(string methodName, T1 a1) + { + var method = ResolveInvocable(methodName); + return WrapForHost((_vm ??= new Vm(this)).InvokeTyped(method, a1)); + } + + public object? Invoke(string methodName, T1 a1, T2 a2) + { + var method = ResolveInvocable(methodName); + return WrapForHost((_vm ??= new Vm(this)).InvokeTyped(method, a1, a2)); + } + + public object? Invoke(string methodName, T1 a1, T2 a2, T3 a3) + { + var method = ResolveInvocable(methodName); + return WrapForHost((_vm ??= new Vm(this)).InvokeTyped(method, a1, a2, a3)); + } + + // A script enumerator (iterator state machine) crossing to the host gets wrapped in a + // bridge the host can drive (Unity's StartCoroutine, foreach, LINQ over IEnumerator). + // Anything else passes through. ScriptObject is internal, so an unwrapped state machine + // would be useless to the caller anyway. + internal object? WrapForHost(object? result) + { + return result is ScriptObject so && _parsed != null && + _parsed.EnumeratorTypes.TryGetValue(so.Type, out var em) + ? new ScriptEnumerator(this, so, em) + : result; + } + + // Re-enter the VM to run one member of a script enumerator with an explicit receiver + // (the state machine ScriptObject). Used by the ScriptEnumerator bridge; safe to call + // while another Invoke is live (the Vm's _base reentrancy handling covers it). + internal object? InvokeEnumeratorMember(ParsedMethod member, ScriptObject receiver) + { + if (_parsed == null) throw new ScriptRuntimeException("No script loaded — call Load() first"); + if (member.Lowered == null) + throw new ScriptRuntimeException( + $"Method '{member.Name}' was skipped at load: {member.LoweringSkipReason}"); + return WrapForHost((_vm ??= new Vm(this)).InvokeReceiver(member, receiver)); + } + + // Re-enter the VM from a delegate created over an interpreted method (button click, event + // callback). args includes the receiver at [0] for instance targets. Same reentrancy story + // as InvokeEnumeratorMember. The generation check makes staleness a CLEAN error: the target's + // IR offsets are blob-absolute into the assembly it was lowered with, so a delegate surviving + // a re-Load would execute arbitrary words of the NEW blob — silently wrong, not crashing. + internal object? InvokeDelegateTarget(ParsedMethod target, object?[] args, object generation) + { + if (_parsed == null) throw new ScriptRuntimeException("No script loaded — call Load() first"); + if (!ReferenceEquals(_parsed, generation)) + throw new ScriptRuntimeException( + $"Delegate over '{target.Name}' was created by a previous script load and is no " + + "longer valid — re-run the code that created it after a reload"); + return WrapForHost((_vm ??= new Vm(this)).Invoke(target, args)); + } + + // Per-receiver delegate cache: `b.clicked += Click; b.clicked -= Click` must observe delegate + // equality, and script-adapter delegates only compare equal as the SAME instance. + readonly System.Runtime.CompilerServices.ConditionalWeakTable> + _delegatesByReceiver = new(); + + internal Delegate GetOrCreateDelegate(DelegateSite site, object? receiver) + { + if (receiver == null) + return site.CachedStatic ??= CreateDelegateCore(site, null); + var perReceiver = _delegatesByReceiver.GetOrCreateValue(receiver); + if (!perReceiver.TryGetValue(site, out var d)) + perReceiver[site] = d = CreateDelegateCore(site, receiver); + return d; + } + + Delegate CreateDelegateCore(DelegateSite site, object? receiver) + { + if (site.HostMethod is { } hm) + { + if (hm.IsStatic) + return Delegate.CreateDelegate(site.DelegateType, hm); + if (receiver == null) + throw new ScriptRuntimeException( + $"Cannot create a {site.DelegateType.Name} over instance method '{hm.Name}' with a null receiver"); + return Delegate.CreateDelegate(site.DelegateType, receiver, hm); + } + return ScriptDelegateAdapter.Create(this, site.DelegateType, site.ScriptMethod!, receiver); + } + + public object? Execute(IScript script, string methodName = "Run") + { + Load(script); + return Invoke(methodName); + } + + internal sealed class ParsedMethod + { + // Name/IlBytes/ArgSTypes are always set by Parse before the instance escapes; + // `= null!` marks that invariant (same idiom as Lowered below). + public string Name = null!; + public int Token; + public int ArgCount; + public int LocalCount; + public byte[] IlBytes = null!; + public bool IsStatic; + public bool IsVoid; + // True when the declared return type is bool (which stores as I4). The method-return boxing + // uses this to hand back a boxed bool rather than a boxed int, so a host caller casting the + // Invoke result to bool doesn't fault. + public bool ReturnIsBool; + // Return and parameter types for IR slot classification (set during Parse) + public SType ReturnSType; + public SType[] ArgSTypes = null!; // length = explicit param count (no `this` slot) + // Per-local struct layout. Non-null when the local is a registered host + // value type and should be allocated as a Vt slot in numFrame. + public HostBinding.StructLayout?[]? LocalStructLayouts; + // Per-local DECLARED slot type, from the LocalVarSig — the authoritative, stable backing: + // an IL local is single-typed, so its declared type (not last-wins store inference) says + // whether its value lives unboxed in the numeric frame (I4/R4) or boxed in refStack (O). + // Only the unambiguous, VM-consistent cases are captured (bool/char/sub-int/int -> I4, + // float -> R4, string/object/class/array/generic -> O); null entries (valuetype/enum, long, + // double, nint, byref, …) fall back to the inference pass. Non-null entries are FROZEN: the + // lowering neither infers nor retypes them, so a fast op is never emitted over a slot whose + // value is actually boxed. Null array when the sig gives nothing to freeze. + public SType?[]? LocalSTypes; + // Per-local flag: true when the local is a SCRIPT-defined struct (ELEMENT_TYPE_VALUETYPE + // referencing a TypeDef in this assembly). Such locals are O ScriptObject references, so a + // value load (ldloc) must clone to preserve struct copy semantics. Null when none are. + // The flat-resolution post-pass CLEARS entries whose struct turned out blittable — those + // locals get a Vt layout in LocalStructLayouts instead. + public bool[]? LocalIsScriptStruct; + // Per-local TypeDef token when the local is a script-defined struct (0 otherwise). Lets the + // post-pass resolve the local to a descriptor once all types are built. Null when none. + public int[]? LocalScriptStructTypeDefs; + // Per-explicit-param / return TypeDef token for script-struct types (0 = not one). + public int[]? ArgScriptStructTypeDefs; + public int ReturnScriptStructTypeDef; + // Per-explicit-param flat layout (parallel to ArgSTypes; entry non-null ⇔ ArgSTypes[i]==Vt). + public HostBinding.StructLayout?[]? ArgStructLayouts; + // Flat layout of a Vt return (ReturnSType == Vt). Null otherwise. + public HostBinding.StructLayout? ReturnStructLayout; + // Declaring TypeDef token (0 when unknown). Set at parse; lets the post-pass flatten + // `this` for instance methods declared on a blittable script struct. + public int DeclaringTypeDef; + // Flat layout for `this` (arg slot 0) when the declaring type is a flat script struct. + public HostBinding.StructLayout? ThisStructLayout; + // Doc is the source file name (no directory), "" when the PDB document had no name. + public (int IlOffset, int Line, string Doc)[]? SeqPoints; + // Finally clauses from the method body's exception-region table (offsets are IL byte + // offsets, End = exclusive). Null when the method has none. Consumed by the lowerer's + // leave/endfinally handling. Only the NON-exceptional path runs handlers. + public (int TryStart, int TryEnd, int HandlerStart, int HandlerEnd)[]? FinallyRegions; + // Fault-handler entry IL offsets. Fault handlers never run (the interpreter has no + // exception dispatch — see the region parse in Parse), but their bodies still lower as + // unreachable code; the lowerer treats each entry as a basic-block boundary. + public int[]? FaultHandlerStarts; + // True when the body has catch/filter clauses — those need real exception + // dispatch, which the interpreter doesn't do; lowering fails with a clear reason. + public bool HasUnsupportedEhClauses; + // True for enumerator members the host never drives (Reset — its body is + // `throw new NotSupportedException()`, which the interpreter can't lower). A lowering + // failure on a cold member becomes a skipped stub (LoweringSkipReason) instead of + // failing the whole Load. + public bool IsColdEnumeratorMember; + // Set when lowering this method failed but was tolerated (cold enumerator member). + // Lowered stays null; invoking the method throws this reason. + public string? LoweringSkipReason; + // Non-nullable post-Load for every invocable method: Load throws + // ScriptValidationException listing every method that didn't lower. The one exception + // is a cold enumerator member (IsColdEnumeratorMember) whose lowering failed — it keeps + // Lowered null and carries LoweringSkipReason; the Vm entry points guard against it. + public LoweredMethod Lowered = null!; + } + + // Output of the lowering pass for one method. + internal sealed class LoweredMethod + { + // All non-nullable fields are set by the lowering pass before the instance escapes. + public uint[] Ir = null!; // flat instruction stream (variable-width words) + public int FrameSize; // number of logical slots (numFrame = FrameSize*4 bytes, refFrame = FrameSize refs) + public int[] IrToIlOffset = null!; // ir word index → IL offset (for source mapping) + public string[] Strings = null!; // string table, indexed by ldstr immediate + public int[] Tokens = null!; // token table (field/type/method tokens), indexed by tok_idx immediate + // Per-slot type: I4/R4 → numeric byte frame; O → reference frame; Vt → flat struct + // bytes occupying ceil(StructLayouts[s].Size/4) consecutive slots. Indexed by slot number. + public SType[] SlotTypes = null!; + // Parallel to SlotTypes; non-null entries describe the struct layout for Vt slots. + public HostBinding.StructLayout?[]? StructLayouts; + // Pre-resolved fast-delegate cache, parallel to Tokens. For each tokIdx whose + // entry has a Fast closure, HostFastByTokIdx[tokIdx] is that closure. Eliminates + // the per-call_host `asm.HostCalls[tok]` dictionary lookup on the hot path. + // Null entries fall through to the slow path (which still does the dict lookup). + public FastCallDelegate?[]? HostFastByTokIdx; + // Parallel to HostFastByTokIdx: true when the entry's Fast closure handles a FLAT + // (Vt) receiver itself (Entry.FastVtRecv) — the executor may keep the fast path + // instead of dropping to the boxing slow path. + public bool[]? HostFastVtRecvByTokIdx; + public bool[]? HostFastWideOkByTokIdx; // Fast closure handles I8/R8 slots itself + + // Pre-resolved script callee per tokIdx. Eliminates the asm.ByToken[tok] dict + // lookup on the call_script / newobj_script hot path. Null for tokens that don't + // resolve to a script method (host calls, host ctors, fields, etc.). + public ParsedMethod?[]? CalleeByTokIdx; + + // Highest O-typed slot index + 1 (0 when the frame is all-numeric). Frame setup only + // needs to null refFrame slots the method can actually read as references — clearing + // the numeric remainder is wasted work on every call_script into a math-y helper. + public int RefClearLen; + + // This method's base offset inside ParsedAssembly.IrBlob. Execution ips are + // blob-absolute; diagnostics index IrToIlOffset with (ip - IrStart). + public int IrStart; + + // Arg index → frame slot. Null when identity (no Vt args): args live at slots + // 0..ArgCount-1. Non-null when a flat-struct arg occupies multiple slots, shifting + // the args after it. + public int[]? ArgSlot; + + // Sparse per-tokIdx flat layout for newarr_vt element types (parallel to Tokens). + public HostBinding.StructLayout?[]? LayoutByTokIdx; + + // Sparse per-tokIdx element Type for primitive arrays that need a typed runtime backing + // (bool[]/char[]/byte[]/sbyte[]/short[]/ushort[]). Int32/Single use the dedicated + // newarr_i4/newarr_r4 ops; every other primitive was previously object?[]-backed, which + // lost the element type (element ToString/boxing-identity wrong). Null when no such array. + public Type?[]? PrimArrayElemTypeByTokIdx; + + // Sparse per-tokIdx delegate-creation site (parallel to Tokens, keyed by the delegate + // ctor's tokIdx). Null when the method creates no delegates. + public DelegateSite?[]? DelegateSiteByTokIdx; + } + + // One ldftn/ldvirtftn + newobj delegate-creation site, pre-resolved at lowering: the runtime + // delegate type plus the target — a host MethodInfo (bound via Delegate.CreateDelegate) or an + // interpreted method (bound via a ScriptDelegateAdapter that re-enters the VM). + internal sealed class DelegateSite + { + public Type DelegateType = null!; + public MethodInfo? HostMethod; // exactly one of HostMethod / ScriptMethod is set + public ParsedMethod? ScriptMethod; + // Receiverless creations (static target) share one delegate per site, so repeated + // event `+=`/`-=` over the same method group observe delegate equality. + public Delegate? CachedStatic; + } + + sealed class HostEntry + { + public HostBinding.Entry Binding = null!; // the registered entry (handles int→bool coercion); always set at construction + public bool IsVoid; // true when return type is void — don't push result + // Per-arg flat-struct layouts (length = explicit param count, no receiver). + // Non-null entries indicate the parameter type was registered via AllowTypeStruct + // and its arg should be marshalled from a Vt slot at call time. null = boxed object. + public HostBinding.StructLayout?[]? ArgStructs; + // Receiver struct layout: non-null when the receiver is a flat struct (rare). + public HostBinding.StructLayout? ReceiverStruct; + // Return struct layout: non-null when the return type was registered via AllowTypeStruct. + public HostBinding.StructLayout? ReturnStruct; + // The EXACT overload this token resolved to. Aliased operator pairs (Quaternion's + // op_Multiply(Q,Q)->Q and op_Multiply(Q,V3)->V3) share one Entry whose Method is the + // FIRST overload — typing a call site from Entry.Method then mislabels the return + // (a Quaternion Vt dst for a Vector3 result => InvalidCastException at UnboxVt). + // Null when resolution didn't go through an exact MethodInfo (string fallback). + public MethodInfo? ResolvedMethod; + // Return SType from the call site's METADATA signature. The lowering types the result slot + // from ResolvedMethod when available; for string-keyed entries (hand-wired Allow/AllowBcl + // shims) there is no MethodInfo, and without this the slot defaulted to O — boxing every + // numeric host return, which broke `a.Length != a.Length` (reference-compared boxes) and + // any op that writes its result flat. + public SType SigRetSType = SType.O; + // How to box an I4-slot receiver for an instance host call. char and bool are stored in I4 + // slots but their boxed identity differs from int: 'r' (114) boxed as int renders "114" not + // "r", and a bool boxed as int renders "1"/"0" not "True"/"False". char and bool are the only + // I4-mapped receivers whose boxed identity changes the result (byte/short/... render the same + // digits as int). None for every other receiver. + public ReceiverBoxKind ReceiverBox; + } + + enum ReceiverBoxKind : byte { None, Char, Bool } + + + // Driver members of a script enumerator type (iterator state machine): what the host + // bridge invokes to pump it. Dispose is null when the type has no (parsed) Dispose. + internal readonly struct EnumeratorMembers + { + public readonly ParsedMethod MoveNext; + public readonly ParsedMethod GetCurrent; + public readonly ParsedMethod? Dispose; + public EnumeratorMembers(ParsedMethod moveNext, ParsedMethod getCurrent, ParsedMethod? dispose) + { + MoveNext = moveNext; GetCurrent = getCurrent; Dispose = dispose; + } + } + + sealed class ParsedAssembly + { + // Every non-nullable field is set by Parse's object initializer before the instance escapes. + public Dictionary ByToken = null!; + public Dictionary ByName = null!; + public Dictionary HostCalls = null!; // MemberRef token → host method + public Dictionary HostCtors = null!; // MemberRef token → host constructor + public Dictionary HostFields = null!; // MemberRef token → host field + public Dictionary HostFieldDeclTypeTok = null!; // host field MemberRef token → declaring TypeRef token (ensure-init of boxed struct locals) + public Dictionary TokenNames = null!; // MemberRef token → "Type.Method/N" for diagnostics + public Dictionary FieldSlots = null!; // FieldDef token → (type, slot) + public Dictionary FieldSTypes = null!; // FieldDef token → numeric type (for IR lowering) + public HashSet FieldIsScriptStruct = null!; // FieldDef tokens whose type is a script-defined struct (O storage, needs clone on value-load) + public Dictionary FieldToTypeDef = null!; // FieldDef token → declaring TypeDef token + public Dictionary CtorToType = null!; // .ctor MethodDef token → type + public Dictionary TypeDefToCtorTok = null!; // TypeDef token → default .ctor MethodDef token + public Dictionary TypeDefToType = null!; // TypeDef token → script type (for initobj) + public Dictionary TypesByName = null!; + // Script enumerator types (iterator state machines) → driver members for the host bridge. + public Dictionary EnumeratorTypes = null!; + public Dictionary TokenTypes = null!; // TypeRef/TypeSpec token → host Type (for castclass/isinst/ldtoken) + public MetadataReader Reader = null!; + public PEReader Pe = null!; + public MetadataReaderProvider? PdbProvider; + // Host member refs that resolved to throwing stubs (or missing host fields) — the script + // loaded, but reaching one of these call sites throws ScriptRuntimeException. Surfaced by + // ScriptInterpreter.UnboundHostMembers so callers can warn at load time. + public List UnboundHostMembers = new(); + + // SCRIPT static fields (FieldDef tokens accessed by ldsfld/stsfld), stored boxed, lazily. + // Unwritten fields read as null — matching CLR zero-init for the one pattern that needs + // this: the C# 11+ method-group delegate cache (`ldsfld <>O.__M; dup; brtrue …`), + // which null-checks before every use. Static ctors never run (pre-existing semantics), + // so a numeric static read before any write still fails, as it did before. + public Dictionary ScriptStatics = new(); + // Primitive (I4/R4) script statics, UNBOXED as raw bits (written by stsfld_i4/r4, read by + // ldsfld_i4/r4) — avoids a heap box on every static write. Miss = 0 (CLR zero-init); + // reference/long/double statics stay in the boxed ScriptStatics above. + public Dictionary ScriptStaticsNum = new(); + + // The host binding this assembly was parsed against — lets the LOWERER resolve + // flat layouts for host struct types (e.g. Vector3[] element layouts) by TypeRef name. + public HostBinding? HostSurface; + + // All methods' lowered IR concatenated (branch targets blob-absolute, one ret_void + // sentinel after each method's region). Built by IrLowerer.BuildIrBlob after LowerAll. + // Lets the Vm run script-to-script calls iteratively under a single `fixed` — no + // recursive Run() invocation (measured ~120ns per nested call on Mono) and no per-call + // array pin. + public uint[]? IrBlob; + } + + // CLR full name for a TypeRef — namespace-qualified, nested types joined with '+' — matching + // what Assembly.GetType expects. A nested TypeRef's ResolutionScope is its declaring TypeRef. + static string TypeRefFullName(MetadataReader reader, TypeReferenceHandle handle) + { + var tr = reader.GetTypeReference(handle); + var name = reader.GetString(tr.Name); + if (tr.ResolutionScope.Kind == HandleKind.TypeReference) + return TypeRefFullName(reader, (TypeReferenceHandle)tr.ResolutionScope) + "+" + name; + var ns = reader.GetString(tr.Namespace); + return ns.Length == 0 ? name : ns + "." + name; + } + + static ParsedAssembly Parse(byte[] bytes, HostBinding? binding, Action logSink) + { + var pe = new PEReader(new MemoryStream(bytes), PEStreamOptions.PrefetchEntireImage); + MetadataReader reader = pe.GetMetadataReader(); + + // --- Demand-time auto-bind: register this script's referenced host types on first use --- + // When the binding carries an AutoBindResolver policy, resolve every TypeRef the script + // references and AllowType the ones the policy accepts, BEFORE the MemberRef resolution + // below — every resolution site then sees the type as if it had been curated. What binds + // is entirely the resolver's decision (which types exist, which assemblies are policy- + // skipped); this pass just walks the refs. Type args of closed generics appear as their + // own TypeRef rows, so MethodSpec instantiation (AddComponent) benefits too. + if (binding?.AutoBindResolver != null) + foreach (var trHandle in reader.TypeReferences) + binding.TryAutoBindType(TypeRefFullName(reader, trHandle)); + + // --- Script methods --- + var byToken = new Dictionary(); + var byName = new Dictionary(); + + foreach (var handle in reader.MethodDefinitions) + { + var def = reader.GetMethodDefinition(handle); + var name = reader.GetString(def.Name); + var token = MetadataTokens.GetToken(handle); + + if (def.RelativeVirtualAddress == 0) continue; + + var body = pe.GetMethodBody(def.RelativeVirtualAddress); + var ilBytes = body.GetILBytes() ?? Array.Empty(); + var localCount = ReadLocalCount(reader, body.LocalSignature); + var localStrs = ReadLocalStructLayouts(reader, body.LocalSignature, localCount, binding); + var localScSt = ReadLocalIsScriptStruct(reader, body.LocalSignature, localCount, out var localScTds); + var localSTps = ReadLocalSTypes(reader, body.LocalSignature, localCount); + var isStatic = (def.Attributes & MethodAttributes.Static) != 0; + var sigBytes = reader.GetBlobBytes(def.Signature); + var (isVoid, retSType, retIsBool) = SigReturn(sigBytes); + var argSTypes = SigParamSTypes(sigBytes); + // Use signature param count, not Param-table count: Roslyn omits Param table entries + // for synthetic parameters in compiler-generated methods (closures, state machines). + var argCount = (isStatic ? 0 : 1) + argSTypes.Length; + + // Exception-region table: finally clauses are lowered (leave/endfinally continuation + // chain); catch/filter need real exception dispatch and fail lowering instead. + // FAULT clauses are IGNORED: a fault handler runs only under exception dispatch, + // which the interpreter never does — a script exception propagates straight to the + // host and the frame is abandoned — so skipping the region keeps normal-path + // semantics exact (fault must NOT run on normal exit). C# can't write a fault + // clause; Roslyn emits them in iterator/async MoveNext bodies, so this is what lets + // `yield return` state machines with try/finally lower. The handler body still + // lowers (as unreachable code); its start is recorded so the lowerer treats it as a + // basic-block boundary. + (int, int, int, int)[]? finRegions = null; + int[]? faultHandlerStarts = null; + bool unsupportedEh = false; + if (!body.ExceptionRegions.IsEmpty) + { + var fins = new List<(int, int, int, int)>(); + List? faults = null; + foreach (var er in body.ExceptionRegions) + { + if (er.Kind == ExceptionRegionKind.Finally) + fins.Add((er.TryOffset, er.TryOffset + er.TryLength, + er.HandlerOffset, er.HandlerOffset + er.HandlerLength)); + else if (er.Kind == ExceptionRegionKind.Fault) + (faults ??= new List()).Add(er.HandlerOffset); + else + unsupportedEh = true; + } + if (fins.Count > 0) finRegions = fins.ToArray(); + if (faults != null) faultHandlerStarts = faults.ToArray(); + } + + var (retScTd, argScTds) = ReadSigScriptStructTokens(sigBytes); + int declTypeDef = 0; + try { declTypeDef = MetadataTokens.GetToken(def.GetDeclaringType()); } catch (Exception) { } + var m = new ParsedMethod { Name = name, Token = token, DeclaringTypeDef = declTypeDef, ArgCount = argCount, LocalCount = localCount, IlBytes = ilBytes, IsStatic = isStatic, IsVoid = isVoid, ReturnIsBool = retIsBool, ReturnSType = retSType, ArgSTypes = argSTypes, LocalStructLayouts = localStrs, LocalSTypes = localSTps, LocalIsScriptStruct = localScSt, LocalScriptStructTypeDefs = localScTds, ArgScriptStructTypeDefs = argScTds, ReturnScriptStructTypeDef = retScTd, FinallyRegions = finRegions, FaultHandlerStarts = faultHandlerStarts, HasUnsupportedEhClauses = unsupportedEh }; + byToken[token] = m; + byName[name] = m; + } + + // --- Script-defined types: field slots, .ctor → type mapping, and name → type --- + var fieldSlots = new Dictionary(); + var fieldSTypes = new Dictionary(); + var fieldIsScStruct = new HashSet(); + var fieldToTypeDef = new Dictionary(); // FieldDef token → declaring TypeDef token + var ctorToType = new Dictionary(); + var typeDefToCtorTok = new Dictionary(); // TypeDef token → default .ctor MethodDef token + var typeDefToType = new Dictionary(); // TypeDef token → type + var typesByName = new Dictionary(); + // Script types implementing System.Collections.IEnumerator → their driver members, + // so the host bridge (ScriptEnumerator) can pump an interpreted state machine. + var enumeratorTypes = new Dictionary(); + // (descriptor, per-field nested-struct TypeDef token) pending resolution once all types exist. + var pendingStructFields = new List<(ScriptTypeDescriptor desc, int[] typeDefs)>(); + + // This type's own INSTANCE fields, declaration order. Statics are excluded: an enum's + // members are static literals typed as the enum itself, which made the descriptor + // self-referential and recursed ScriptObject.Create forever. + List GetOwnInstanceFields(TypeDefinition td) + { + var handles = td.GetFields(); + var result = new List(handles.Count); + foreach (var fh in handles) + if ((reader.GetFieldDefinition(fh).Attributes & FieldAttributes.Static) == 0) + result.Add(fh); + return result; + } + + // Base-first field layout ([root base]..[own]), walking in-assembly BaseTypes. ownStart = + // first own field. Invariant this fix relies on: a base field lands at the identical + // slot/offset in every descriptor that inherits it. + List BuildBaseFirstFields(TypeDefinition td, out int ownStart, int depth = 0) + { + // Guard against a cyclic base chain (A : B, B : A) in hostile metadata — an unbounded + // recursion here is a StackOverflowException, which .NET cannot catch (whole-process + // kill). A real inheritance depth never approaches this. + if (depth > 256) + throw new ScriptValidationException( + "base-type chain is too deep or cyclic — malformed metadata"); + List fields; + var bt = td.BaseType; + if (bt.Kind == HandleKind.TypeDefinition) + { + var baseTd = reader.GetTypeDefinition((TypeDefinitionHandle)bt); + var baseName = reader.GetString(baseTd.Name); + fields = (baseName == "ValueType" || baseName == "Enum") + ? new List() + : BuildBaseFirstFields(baseTd, out _, depth + 1); + } + else + { + fields = new List(); + } + ownStart = fields.Count; + fields.AddRange(GetOwnInstanceFields(td)); + return fields; + } + + foreach (var typeHandle in reader.TypeDefinitions) + { + var typeDef = reader.GetTypeDefinition(typeHandle); + var typeName = reader.GetString(typeDef.Name); + if (typeName == "") continue; + + // Detect a script-defined struct (base type System.ValueType, excluding enums whose base is + // System.Enum). Used to zero-init `new T[n]` elements to usable structs rather than nulls. + bool isStructType = false; + { + var bt = typeDef.BaseType; + string baseName = ""; + if (bt.Kind == HandleKind.TypeReference) + baseName = reader.GetString(reader.GetTypeReference((TypeReferenceHandle)bt).Name); + else if (bt.Kind == HandleKind.TypeDefinition) + baseName = reader.GetString(reader.GetTypeDefinition((TypeDefinitionHandle)bt).Name); + isStructType = baseName == "ValueType"; // "Enum" (also a value type) intentionally excluded + } + + // Assign field slots in declaration order, BASE-FIRST: inherited fields occupy the front + // (same indices/offsets as in their own descriptor), then this type's own fields. See + // BuildBaseFirstFields for the invariant this depends on. + var typeFields = BuildBaseFirstFields(typeDef, out int ownFieldStart).ToArray(); + var fieldSlotMap = new Dictionary(typeFields.Length); + var fldTypes = new SType[typeFields.Length]; + var fldOffsets = new int[typeFields.Length]; + HostBinding.StructLayout?[]? fldVtLayouts = null; // allocated on demand + bool[]? fldIsScStruct = null; // allocated on demand (nested value-type fields) + int[]? fldScStructTypeDef = null; // nested script-struct field's TypeDef token (resolved to a descriptor after the loop) + int primByteOff = 0; + int refSlotOff = 0; + for (int i = 0; i < typeFields.Length; i++) + { + int fieldToken = MetadataTokens.GetToken(typeFields[i]); + bool isOwnField = i >= ownFieldStart; + fieldSlotMap[fieldToken] = i; + // Global field maps are keyed by declaring type only, so register OWN fields here; + // an inherited field is registered by its own type's pass (base-first makes the + // value identical anyway, and re-keying it here corrupts declaring-type bookkeeping). + if (isOwnField) + fieldSlots[fieldToken] = (null!, i); // type filled in below + var fDef = reader.GetFieldDefinition(typeFields[i]); + var fSig = reader.GetBlobBytes(fDef.Signature); + // Field sig: [calling_conv(0x06), type_byte, ...] + // VALUETYPE (0x11) followed by compressed TypeRef/TypeDef token — check + // if it's a registered host struct; if so, store inline in PrimBytes (SType.Vt). + SType fst = SType.O; + HostBinding.StructLayout? vtLayout = null; + if (fSig.Length >= 2) + { + byte tb = fSig[1]; + // 0x03 = ELEMENT_TYPE_CHAR: char is I4-typed in the VM; omitting it typed a + // hoisted char field O (a ref slot), the mirror of the SigReturn char fix + // (found by fuzzing: a foreach-over-string char hoisted across a yield). + // nint/nuint (0x18/0x19) are NOT here: an I4 slot would truncate 64-bit + // handles (AndroidJNI-style IntPtrs); they ride O slots as boxed IntPtr. + if (tb is 0x02 or 0x03 or 0x04 or 0x05 or 0x06 or 0x07 or 0x08 or 0x09) + fst = SType.I4; + else if (tb == 0x0C) + fst = SType.R4; + else if (tb == 0x11 && fSig.Length >= 3) + { + // VALUETYPE: try to decode the TypeRef name and look up a struct layout + int codedIdx = 0, n = 2; + while (n < fSig.Length) + { + byte b = fSig[n++]; + if ((b & 0x80) == 0) { codedIdx |= b; break; } + if ((b & 0x40) == 0) { codedIdx = ((b & 0x3F) << 8) | fSig[n++]; break; } + codedIdx = ((b & 0x1F) << 24) | (fSig[n] << 16) | (fSig[n+1] << 8) | fSig[n+2]; n += 3; break; + } + int table = codedIdx & 0x03; int row = codedIdx >> 2; + string vtName = ""; + if (table == 1 && row > 0) // TypeRef + { + try { vtName = reader.GetString(reader.GetTypeReference(MetadataTokens.TypeReferenceHandle(row)).Name); } + catch { } + } + else if (table == 0 && row > 0) // TypeDef + { + try { vtName = reader.GetString(reader.GetTypeDefinition(MetadataTokens.TypeDefinitionHandle(row)).Name); } + catch { } + } + if (vtName != "" && binding != null && binding.TryGetStructLayout(vtName, out vtLayout) && vtLayout != null) + fst = SType.Vt; + else if (table == 0 && IsEnumTypeDef(reader, row)) + { + // VALUETYPE that is a script-defined ENUM: the value is a plain int, + // not a nested struct — classify I4 so loads/stores/compares treat it + // as a number and Create never allocates a ScriptObject for it. + fst = SType.I4; + } + else if (table == 0) + { + // VALUETYPE referencing a TypeDef in this assembly that isn't a registered + // host struct = a script-defined struct. Stored as O (boxed ScriptObject), + // so a value-load of this field must clone to preserve copy semantics. + if (isOwnField) fieldIsScStruct.Add(fieldToken); + (fldIsScStruct ??= new bool[typeFields.Length])[i] = true; + // Stash the field's own TypeDef token (0x02 table) to resolve to a descriptor + // after all types are built, so Create can allocate the nested struct. + (fldScStructTypeDef ??= new int[typeFields.Length])[i] = + MetadataTokens.GetToken(MetadataTokens.TypeDefinitionHandle(row)); + } + } + } + if (isOwnField) fieldSTypes[fieldToken] = fst; + fldTypes[i] = fst; + if (fst == SType.I4 || fst == SType.R4) + { + fldOffsets[i] = primByteOff; + primByteOff += 4; + } + else if (fst == SType.Vt && vtLayout != null) + { + // Align to 4 bytes (structs are naturally aligned in Marshal layout) + int align = (4 - primByteOff % 4) % 4; + primByteOff += align; + fldOffsets[i] = primByteOff; + primByteOff += vtLayout.Size; + if (primByteOff % 4 != 0) primByteOff += 4 - primByteOff % 4; + if (fldVtLayouts == null) fldVtLayouts = new HostBinding.StructLayout?[typeFields.Length]; + fldVtLayouts[i] = vtLayout; + } + else + { + fldOffsets[i] = refSlotOff; + refSlotOff++; + } + } + + var desc = new ScriptTypeDescriptor + { + Name = typeName, + FieldCount = typeFields.Length, + FieldSlots = fieldSlotMap, + FieldTypes = fldTypes, + FieldOffsets = fldOffsets, + PrimByteSize = primByteOff, + RefSlotCount = refSlotOff, + VtFieldLayouts = fldVtLayouts, + FieldIsScriptStruct = fldIsScStruct, + IsScriptStructValue = isStructType, + }; + + // Patch back the type reference into fieldSlots — OWN fields only (see isOwnField above). + foreach (var ft in fieldSlotMap) + if (ft.Value >= ownFieldStart) + fieldSlots[ft.Key] = (desc, ft.Value); + + typesByName[typeName] = desc; + if (fldScStructTypeDef != null) pendingStructFields.Add((desc, fldScStructTypeDef)); + + // Map TypeDef token → type descriptor (used by initobj lowering). + int typeDefTok = MetadataTokens.GetToken(typeHandle); + typeDefToType[typeDefTok] = desc; + // Map each OWN field → declaring TypeDef token (used by auto-initobj in stfld lowering). + // An inherited field's declaring TypeDef is registered when its own type is processed. + foreach (var ft in fieldSlotMap) + if (ft.Value >= ownFieldStart) + fieldToTypeDef[ft.Key] = typeDefTok; + + // Map each .ctor to this type; also record TypeDef → default-ctor for initobj handling. + foreach (var methodHandle in typeDef.GetMethods()) + { + var mDef = reader.GetMethodDefinition(methodHandle); + var mName = reader.GetString(mDef.Name); + if (mName != ".ctor") continue; + int ctorTok = MetadataTokens.GetToken(methodHandle); + ctorToType[ctorTok] = desc; + // Record the first ctor as the default; prefer the 0-arg ctor (ArgCount==1 = just `this`) + if (!typeDefToCtorTok.TryGetValue(typeDefTok, out _) || + (byToken.TryGetValue(ctorTok, out var ctorM) && ctorM.ArgCount == 1)) + typeDefToCtorTok[typeDefTok] = ctorTok; + } + + // --- Enumerator (iterator state-machine) detection --- + // A script type implementing System.Collections.IEnumerator — in practice a Roslyn + // `yield return` state machine — gets its driver members resolved so the host + // bridge (ScriptEnumerator) can pump it from Unity's coroutine scheduler. Reset is + // marked cold: its body is `throw new NotSupportedException()`, which the + // interpreter can't lower, and no driver ever calls it. + bool isEnumerator = false; + foreach (var ih in typeDef.GetInterfaceImplementations()) + { + var ii = reader.GetInterfaceImplementation(ih); + if (ii.Interface.Kind == HandleKind.TypeReference && + TypeRefFullName(reader, (TypeReferenceHandle)ii.Interface) == "System.Collections.IEnumerator") + { + isEnumerator = true; + break; + } + } + if (isEnumerator) + { + ParsedMethod? moveNext = null, getCurrent = null, dispose = null; + foreach (var mh in typeDef.GetMethods()) + { + if (!byToken.TryGetValue(MetadataTokens.GetToken(mh), out var pm)) continue; + switch (pm.Name) + { + case "MoveNext": + case "System.Collections.IEnumerator.MoveNext": + moveNext = pm; break; + // The non-generic getter — present in every flavor (a generic + // IEnumerator state machine also implements it). Overwrites a plain + // get_Current seen earlier; the plain name only fills when nothing + // better exists (hand-written enumerator without explicit impls). + case "System.Collections.IEnumerator.get_Current": + getCurrent = pm; break; + case "get_Current": + getCurrent ??= pm; break; + case "Dispose": + case "System.IDisposable.Dispose": + dispose = pm; break; + case "Reset": + case "System.Collections.IEnumerator.Reset": + pm.IsColdEnumeratorMember = true; break; + } + } + if (moveNext != null && getCurrent != null) + enumeratorTypes[desc] = new EnumeratorMembers(moveNext, getCurrent, dispose); + } + } + + // Resolve each script-struct field's nested TypeDef token to a descriptor now that every type + // is built (declaration order isn't guaranteed to be dependency order). Enables Create() to + // recursively allocate nested value-type fields. + foreach (var (desc, typeDefs) in pendingStructFields) + { + ScriptTypeDescriptor?[]? arr = null; + for (int i = 0; i < typeDefs.Length; i++) + if (typeDefs[i] != 0 && typeDefToType.TryGetValue(typeDefs[i], out var sub)) + (arr ??= new ScriptTypeDescriptor?[typeDefs.Length])[i] = sub; + desc.FieldStructDescriptors = arr; + } + + // --- Flat script-struct resolution (zero-GC structs) --- + // Recursively classify blittable script structs (no reference-frame fields, transitively), + // inline nested blittable struct FIELDS into the parent's PrimBytes (they were RefSlot + // ScriptObjects), and synthesize a FlatLayout so blittable structs live in Vt frame slots. + // Runs after FieldStructDescriptors resolution (needs nested descs) and before methods' + // flat metadata below. + { + var flatVisited = new HashSet(); + foreach (var d in typesByName.Values) + ResolveFlatScriptStruct(d, flatVisited, fieldSTypes, fieldIsScStruct); + + // Method metadata post-pass: locals/args/returns whose script-struct type resolved + // flat switch from the heap (O + clone_sc) representation to Vt. + foreach (var m in byToken.Values) + { + var ltds = m.LocalScriptStructTypeDefs; + if (ltds != null) + for (int j = 0; j < m.LocalCount && j < ltds.Length; j++) + if (ltds[j] != 0 && typeDefToType.TryGetValue(ltds[j], out var ld) && ld.FlatLayout != null) + { + (m.LocalStructLayouts ??= new HostBinding.StructLayout?[m.LocalCount])[j] = ld.FlatLayout; + if (m.LocalIsScriptStruct != null) m.LocalIsScriptStruct[j] = false; + } + var atds = m.ArgScriptStructTypeDefs; + if (atds != null) + for (int j = 0; j < m.ArgSTypes.Length && j < atds.Length; j++) + if (atds[j] != 0 && typeDefToType.TryGetValue(atds[j], out var ad) && ad.FlatLayout != null) + { + m.ArgSTypes[j] = SType.Vt; + (m.ArgStructLayouts ??= new HostBinding.StructLayout?[m.ArgSTypes.Length])[j] = ad.FlatLayout; + } + if (m.ReturnScriptStructTypeDef != 0 + && typeDefToType.TryGetValue(m.ReturnScriptStructTypeDef, out var rd) && rd.FlatLayout != null) + { + m.ReturnSType = SType.Vt; + m.ReturnStructLayout = rd.FlatLayout; + } + if (!m.IsStatic && m.DeclaringTypeDef != 0 + && typeDefToType.TryGetValue(m.DeclaringTypeDef, out var dd) && dd.FlatLayout != null) + m.ThisStructLayout = dd.FlatLayout; + } + } + + // --- Host calls: resolve MemberRef tokens --- + var hostCalls = new Dictionary(); + var hostCtors = new Dictionary(); + var hostFields = new Dictionary(); + var hostFieldDeclTypeTok = new Dictionary(); + var tokenNames = new Dictionary(); + var unboundHostMembers = new List(); + + foreach (var memberRefHandle in reader.MemberReferences) + { + var memberRef = reader.GetMemberReference(memberRefHandle); + var memberName = reader.GetString(memberRef.Name); + int token = MetadataTokens.GetToken(memberRefHandle); + + string typeName2 = ""; + string typeNs2 = ""; + if (memberRef.Parent.Kind == HandleKind.TypeReference) + { + var typeRef = reader.GetTypeReference((TypeReferenceHandle)memberRef.Parent); + typeName2 = reader.GetString(typeRef.Name); + typeNs2 = reader.GetString(typeRef.Namespace); + } + else if (memberRef.Parent.Kind == HandleKind.TypeSpecification) + { + typeName2 = ResolveTypeSpecName(reader, (TypeSpecificationHandle)memberRef.Parent); + } + + // Field refs have signature byte 0x06 (FIELD calling convention) + var sigBytes = reader.GetBlobBytes(memberRef.Signature); + if (sigBytes.Length > 0 && sigBytes[0] == 0x06) + { + if (binding != null && binding.TryGetField($"{typeName2}.{memberName}", out var fe)) + { + hostFields[token] = fe!; + if (memberRef.Parent.Kind == HandleKind.TypeReference) + hostFieldDeclTypeTok[token] = MetadataTokens.GetToken(memberRef.Parent); + // Classify the field type so the IR lowerer can emit ldfld_i4/ldfld_r4. Prefer the + // binding's FieldSt (derived from the CLR field type, so it knows an enum is I4); the + // signature-byte guess below can't distinguish an enum (0x11 VALUETYPE) from a struct + // and would mis-type it O. Fall back to the sig guess only when FieldSt is unset (O). + if (fe!.FieldSt != SType.O) + fieldSTypes[token] = fe.FieldSt; + else if (sigBytes.Length >= 2) + fieldSTypes[token] = sigBytes[1] switch + { + // nint/nuint (0x18/0x19) stay O: boxed IntPtr, no 64-bit truncation + 0x02 or 0x04 or 0x05 or 0x06 or 0x07 or 0x08 or 0x09 => SType.I4, + 0x0C => SType.R4, + _ => SType.O, + }; + } + else if (binding != null && memberRef.Parent.Kind == HandleKind.TypeSpecification + && ResolveClosedSpecType(reader, (TypeSpecificationHandle)memberRef.Parent, binding) is { } fldDecl + && fldDecl.GetField(memberName) is { } specFi) + { + // Field reached through a closed generic instantiation (ValueTuple`2.Item1): + // synthesize per token — each instantiation's MemberRef row carries its own + // FieldInfo. Reads only on value types: writing through the boxed receiver + // would alias every copy of the struct (silent value-semantics break). + var specFe = HostBinding.BuildFieldEntry(specFi); + if (fldDecl.IsValueType) + { + var fname = $"{typeName2}.{memberName}"; + specFe.Set = (_, _2) => throw new ScriptRuntimeException( + $"assigning {fname} through a boxed struct is not supported — construct a new value instead"); + } + hostFields[token] = specFe; + if (specFe.FieldSt != SType.O) + fieldSTypes[token] = specFe.FieldSt; + } + else if (binding != null) + unboundHostMembers.Add($"{typeName2}.{memberName} (field)"); + continue; + } + + // Constructor — registered ones become host ctors; others are no-op base inits. + // System.Object::.ctor() is always the canonical base ctor (script chains to it + // via `ldarg.0; call .ctor`); force it to noop even if a name-only collision + // (e.g. UnityEngine.Object also being registered) would otherwise route it to + // hostCtors. Lookups in `_entries` are unqualified `TypeName..ctor/N`. + if (memberName == ".ctor") + { + int memberParamCount0 = DecompressInt(sigBytes, 1); + // new Nullable(v) — including the implicit T→T? conversion and `T? x = v;` local + // inits (ldloca + call .ctor, routed through the Call lowering's hostCtor pattern). + // The interpreter stores nullables the way CLR boxing does — as the boxed T, or + // null — so construction is the identity on the single argument. + if (memberParamCount0 == 1 && typeName2 == "Nullable`1" + && memberRef.Parent.Kind == HandleKind.TypeSpecification) + { + var nullableCtor = new HostBinding.Entry + { + Delegate = (_, args) => args[0], + ParamCount = 1, + HasThis = false, + }; + hostCtors[token] = new HostEntry { Binding = nullableCtor, IsVoid = true }; + continue; + } + // In-script `new List()` / Dictionary / HashSet / Queue / Stack: the shimmed + // collections dispatch every member on the receiver's runtime type, so construction + // was the only missing piece. Resolve the TypeSpec to its closed type and bind the + // ctor whose params decode concretely — the 0-arg and int-capacity overloads; a + // VAR-typed param (e.g. IEnumerable) fails the decode and stays loud-unsupported. + if (memberRef.Parent.Kind == HandleKind.TypeSpecification && binding != null + && (typeName2 is "List`1" or "Dictionary`2" or "HashSet`1" or "Queue`1" or "Stack`1" + || typeName2.StartsWith("ValueTuple`", StringComparison.Ordinal))) + { + var closedType = ResolveClosedSpecType(reader, (TypeSpecificationHandle)memberRef.Parent, binding); + ConstructorInfo? ci = null; + if (closedType != null) + { + if (memberParamCount0 == 0) + ci = closedType.GetConstructor(Type.EmptyTypes); + else if (DecodeMethodSigParamTypes(reader, sigBytes, binding) is { } ctorParams) + ci = closedType.GetConstructor(ctorParams); + // Tuple ctor params are VARs (!0, !1) — the sig decode can't name them, but + // for a CLOSED spec they are exactly the instantiation's type args. + if (ci == null && closedType.IsGenericType + && closedType.GetGenericArguments() is { } ga && ga.Length == memberParamCount0) + ci = closedType.GetConstructor(ga); + } + if (ci != null) + { + var ciLocal = ci; + var ctorParamTypes = Array.ConvertAll(ci.GetParameters(), p => p.ParameterType); + hostCtors[token] = new HostEntry + { + Binding = new HostBinding.Entry + { + // I4-slot identity erasure: a char/bool/enum arg arrives boxed as + // int; Invoke is strict, so coerce to the declared param type. + Delegate = (_, args) => + { + for (int ai = 0; ai < ctorParamTypes.Length; ai++) + { + var pt = ctorParamTypes[ai]; + if (args[ai] is int iv && pt != typeof(int) && pt != typeof(object)) + { + if (pt == typeof(char)) args[ai] = (char)iv; + else if (pt == typeof(bool)) args[ai] = iv != 0; + else if (pt.IsEnum) args[ai] = Enum.ToObject(pt, iv); + else if (pt == typeof(float)) args[ai] = (float)iv; + else args[ai] = HostBinding.ToFieldIntegral(iv, pt); + } + } + return ciLocal.Invoke(args); + }, + ParamCount = memberParamCount0, + HasThis = false, + }, + IsVoid = true, + }; + continue; + } + } + bool isSystemObjectCtor = typeNs2 == "System" && typeName2 == "Object" && memberParamCount0 == 0; + if (!isSystemObjectCtor && binding != null && binding.TryGet($"{typeName2}..ctor", memberParamCount0, out var ctorEntry)) + hostCtors[token] = new HostEntry { Binding = ctorEntry!, IsVoid = true }; + else + { + var noop = new HostBinding.Entry { Delegate = (_, _) => null, ParamCount = 0, HasThis = true }; + hostCalls[token] = new HostEntry { Binding = noop, IsVoid = true }; + } + continue; + } + + int memberParamCount = DecompressInt(sigBytes, 1); + var (sigIsVoid, sigRetSType, _) = SigReturn(sigBytes); + + tokenNames[token] = $"{typeName2}.{memberName}/{memberParamCount}"; + + // System.Object's members are intrinsics dispatched virtually on the receiver. + // Resolving them through the binding by SHORT name is wrong: "Object" collides with + // UnityEngine.Object in the standard surface, so a `constrained.` callvirt (e.g. + // `"impact" + quaternion` → Object.ToString on the boxed struct) invoked + // UnityEngine.Object.ToString with a foreign receiver — a TargetException the call + // site remapped to a misleading "called Object.ToString/0 on a null object". + // The namespace check mirrors the System.Object ctor special case above. + if (typeNs2 == "System" && typeName2 == "Object" + && memberRef.Parent.Kind == HandleKind.TypeReference) + { + bool objHasThis = sigBytes.Length > 0 && (sigBytes[0] & 0x20) != 0; + HostBinding.Entry objIntrinsic = memberName switch + { + "ToString" when objHasThis && memberParamCount == 0 => new HostBinding.Entry + { + Delegate = (recv, _) => recv != null ? recv.ToString() + : throw new ScriptRuntimeException("NullReferenceException: called Object.ToString on a null object"), + ParamCount = 0, HasThis = true, + }, + "GetHashCode" when objHasThis && memberParamCount == 0 => new HostBinding.Entry + { + Delegate = (recv, _) => recv != null ? recv.GetHashCode() + : throw new ScriptRuntimeException("NullReferenceException: called Object.GetHashCode on a null object"), + ParamCount = 0, HasThis = true, + }, + "GetType" when objHasThis && memberParamCount == 0 => new HostBinding.Entry + { + Delegate = (recv, _) => recv != null ? recv.GetType() + : throw new ScriptRuntimeException("NullReferenceException: called Object.GetType on a null object"), + ParamCount = 0, HasThis = true, + }, + "Equals" when objHasThis && memberParamCount == 1 => new HostBinding.Entry + { + Delegate = (recv, args) => recv != null ? recv.Equals(args![0]) + : throw new ScriptRuntimeException("NullReferenceException: called Object.Equals on a null object"), + ParamCount = 1, HasThis = true, + }, + "Equals" when !objHasThis && memberParamCount == 2 => new HostBinding.Entry + { Delegate = (_, args) => Equals(args![0], args[1]), ParamCount = 2, HasThis = false }, + "ReferenceEquals" when !objHasThis && memberParamCount == 2 => new HostBinding.Entry + { Delegate = (_, args) => ReferenceEquals(args![0], args[1]), ParamCount = 2, HasThis = false }, + _ => null!, + }; + if (objIntrinsic != null) + { + hostCalls[token] = new HostEntry + { Binding = objIntrinsic, IsVoid = false, SigRetSType = sigRetSType }; + continue; + } + } + + // VM intrinsic: IlInterpreter.Vm.Log → route to per-VM logSink. + if (typeNs2 == "IlInterpreter" && typeName2 == "Vm" && memberName == "Log" && memberParamCount == 1) + { + var entry = new HostBinding.Entry + { + Delegate = (_, args) => { logSink((string)args[0]!); return null; }, + ParamCount = 1, + HasThis = false, + }; + hostCalls[token] = new HostEntry { Binding = entry, IsVoid = true }; + continue; + } + + // System.Nullable members are intrinsics. The interpreter stores nullables the way + // CLR boxing does — as the boxed T, or null — so HasValue/Value/GetValueOrDefault + // reduce to null checks on the receiver slot's boxed value (the ldloca/ldflda receiver + // phantom resolves to that boxed value before the call). + if (typeName2 == "Nullable`1" && memberRef.Parent.Kind == HandleKind.TypeSpecification) + { + var (_, nullableArg) = DecodeGenericInstHeader(reader, (TypeSpecificationHandle)memberRef.Parent); + var nullableEntry = MakeNullableIntrinsic(memberName, memberParamCount, nullableArg, binding); + if (nullableEntry != null) + { + // Type the result slot from the RESOLVED type arg (not the open sig, which says + // bool for HasValue but generic T for Value/GetValueOrDefault -> O). get_HasValue + // -> I4 (bool); Value/GetValueOrDefault -> the type arg's SType. Without this the + // slot defaulted to O (boxed) and numeric ops on the result used object compares + // (found by fuzzing: `q != null` boxes, and `0 != q.GetValueOrDefault()` too). + hostCalls[token] = new HostEntry + { + Binding = nullableEntry, + IsVoid = false, + SigRetSType = NullableIntrinsicResultSType(memberName, nullableArg), + }; + continue; + } + // Unmodeled members (ToString, GetHashCode, Equals) fall through to the normal + // resolution/stub path. + } + + // TypeSpec: attempt MethodHandle-based resolution for generic types + if (memberRef.Parent.Kind == HandleKind.TypeSpecification && binding != null) + { + var (outerName, arg0Name) = DecodeGenericInstHeader( + reader, (TypeSpecificationHandle)memberRef.Parent); + if (outerName != "" && arg0Name != "" && + (binding.TryGetGenericType(outerName, arg0Name, out var genType) + || binding.TryMakeClosedGenericType(outerName, arg0Name, out genType))) + { + var mi = FindMethod(genType!, memberName, memberParamCount); + if (mi != null && binding.TryGetByHandle(mi.MethodHandle.Value, out var he)) + { + hostCalls[token] = new HostEntry { Binding = he!, IsVoid = sigIsVoid, ResolvedMethod = mi }; + continue; + } + // Mono: MethodHandle.Value for closed generic instantiations may differ + // between registration time and load time, causing TryGetByHandle to miss. + // Synthesize an Entry directly from the MethodInfo so the call still works. + if (mi != null) + { + hostCalls[token] = FallbackEntry(mi, sigIsVoid, + $"[IlInterpreter] TypeSpec handle fallback: {typeName2}.{memberName}/{memberParamCount}", logSink); + continue; + } + } + } + + // Signature-based resolution: TypeRef parent + registered type → decode full param types → GetMethod + if (memberRef.Parent.Kind == HandleKind.TypeReference && binding != null && + TryResolveReceiverTypeRef(reader, (TypeReferenceHandle)memberRef.Parent, typeName2, + binding, out var hostType2) && hostType2 != null) + { + var paramTypes = DecodeMethodSigParamTypes(reader, sigBytes, binding); + MethodInfo? mi2 = null; + if (paramTypes != null) + { + // Manual exact-signature scan rather than GetMethod: DefaultBinder ignores + // generic-ness, so a generic twin with the same parameter list (Go(string, + // object[]) vs Go(string, object[])) throws AmbiguousMatchException, and + // `new`-hiding across the hierarchy under Public|NonPublic does too. A plain + // MemberRef never targets a generic method definition (those calls arrive via + // MethodSpec), so skip them outright; GetMethods lists most-derived first, so + // a `new`-hidden pair resolves to the hiding member like a normal call would. + foreach (var m in hostType2.GetMethods( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance)) + { + if (m.Name != memberName || m.IsGenericMethodDefinition) continue; + var ps = m.GetParameters(); + if (ps.Length != paramTypes.Length) continue; + bool exact = true; + for (int i = 0; i < ps.Length; i++) + if (ps[i].ParameterType != paramTypes[i]) { exact = false; break; } + if (exact) { mi2 = m; break; } + } + } + // Fallback: if sig decode failed (e.g. unregistered VALUETYPE param) and EXACTLY ONE + // non-generic method with this name+arity exists on the registered type, use it + // unambiguously. Multiple candidates → bail; the string-based fallback or a runtime + // error is safer than a silent mispick. Generic method definitions never count: + // a plain MemberRef row can't be their call site (those go through MethodSpec). + if (mi2 == null) + { + MethodInfo? unique = null; + foreach (var m in hostType2.GetMethods( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance)) + { + if (m.Name != memberName || m.IsGenericMethodDefinition || + m.GetParameters().Length != memberParamCount) continue; + if (unique != null) { unique = null; break; } // ambiguous + unique = m; + } + mi2 = unique; + } + if (mi2 != null && binding.TryGetByHandle(mi2.MethodHandle.Value, out var he2)) + { + hostCalls[token] = new HostEntry { Binding = he2!, IsVoid = sigIsVoid, ResolvedMethod = mi2, ReceiverBox = ReceiverBoxFor(typeName2) }; + continue; + } + // Mono/IL2CPP: MethodHandle.Value may differ between registration and load time. + // When TryGetByHandle misses but the MethodInfo is unambiguous, synthesize an + // Entry directly — the string-keyed fallback only covers properties/operators. + if (mi2 != null) + { + hostCalls[token] = FallbackEntry(mi2, sigIsVoid, + $"[IlInterpreter] TypeRef handle fallback: {typeName2}.{memberName}/{memberParamCount}", logSink); + continue; + } + } + + // String-based fallback (properties, operators, AllowBcl, hand-wired Allow/AllowStatic) + if (binding != null && binding.TryGet($"{typeName2}.{memberName}", memberParamCount, out var entry2)) + { + // A TypeSpec member returning the generic VAR `!n` (Func`N.Invoke's TResult, + // List.get_Item, Enumerator.get_Current, KeyValuePair Key/Value, …) types O + // from the open signature — but `weights[0] + weights[2]` on a List then + // lowered add_i4 over boxed floats (sum 0), and `switch (s)` over a List + // element lowered brfalse_o on a boxed 0 (case silently missed). Recover the + // CLOSED return SType from the instantiation's n-th type argument — enums + // included (their values ride I4 like everywhere else). + var invRetSt = sigRetSType; + if (memberRef.Parent.Kind == HandleKind.TypeSpecification && invRetSt == SType.O) + { + var d = GenericVarReturnSType(reader, + (TypeSpecificationHandle)memberRef.Parent, sigBytes, binding); + if (d != SType.O) invRetSt = d; + } + hostCalls[token] = new HostEntry { Binding = entry2!, IsVoid = sigIsVoid, SigRetSType = invRetSt, ReceiverBox = ReceiverBoxFor(typeName2) }; + } + else + { + // Unregistered external call: stub that throws at runtime, so the method still + // lowers and the gap is reported in UnboundHostMembers at load time. + // + // EXCEPT open generic rows (GENERIC bit set on the calling convention): IL calls + // generic methods through MethodSpec tokens, which resolve — and report real gaps + // as 'Type.Name/N' — in the pass below. The open MemberRef row is only their + // metadata parent; reporting it too flagged every generic call as unbound + // ("Component.GetComponent/1") even when all instantiations resolved fine. + string msgKey = $"{typeName2}.{memberName}"; + bool isOpenGenericRow = sigBytes.Length > 0 && (sigBytes[0] & 0x10) != 0; + if (!isOpenGenericRow) + unboundHostMembers.Add($"{msgKey}/{memberParamCount}"); + var stub = new HostBinding.Entry + { + Delegate = (_, _) => throw new ScriptRuntimeException($"Host method '{msgKey}' is not registered in the binding"), + ParamCount = memberParamCount, + HasThis = sigBytes.Length > 0 && (sigBytes[0] & 0x20) != 0, + }; + hostCalls[token] = new HostEntry { Binding = stub, IsVoid = sigIsVoid }; + } + } + + // --- MethodSpec resolution: closed generic method calls (e.g. AddComponent) --- + // For each MethodSpec token, decode (open def, type args), instantiate via MakeGenericMethod, + // and register the resulting closed method under the methodspec token. + int msCount = reader.GetTableRowCount(TableIndex.MethodSpec); + for (int msRow = 1; msRow <= msCount; msRow++) + { + var msHandle = MetadataTokens.MethodSpecificationHandle(msRow); + var ms = reader.GetMethodSpecification(msHandle); + int token = MetadataTokens.GetToken(msHandle); + + // Parent for host generic methods is always a MemberRef (script-defined open generics + // are banned by the validator). + if (ms.Method.Kind != HandleKind.MemberReference) continue; + var parentRef = reader.GetMemberReference((MemberReferenceHandle)ms.Method); + if (parentRef.Parent.Kind != HandleKind.TypeReference) continue; + + var parentTypeRef = reader.GetTypeReference((TypeReferenceHandle)parentRef.Parent); + var parentType = reader.GetString(parentTypeRef.Name); + var parentName = reader.GetString(parentRef.Name); + + // Generic method signature: [conv-byte][genParamCount][paramCount]... + // The GENERIC bit (0x10) must be set on the calling-convention byte. + var parentSig = reader.GetBlobBytes(parentRef.Signature); + if (parentSig.Length < 3) continue; + int sigIdx = 0; + byte conv = parentSig[sigIdx++]; + if ((conv & 0x10) == 0) continue; + DecompressIntAdv(parentSig, ref sigIdx); // skip genParamCount + int paramCount = DecompressIntAdv(parentSig, ref sigIdx); + + if (binding == null) continue; + if (!binding.TryGetOpenGenerics(parentType, parentName, paramCount, out var openDefs)) + { + // Unregistered open generic: insert a runtime stub so the lowerer can still lower + // the method. The stub throws at runtime if the call is actually reached. + string stubKey = $"{parentType}.{parentName}/{paramCount}"; + unboundHostMembers.Add(stubKey); + bool hasThisStub = (conv & 0x20) != 0; + var stub2 = new HostBinding.Entry + { + Delegate = (_, _) => throw new ScriptRuntimeException($"Host generic method '{stubKey}' is not in the binding allowlist"), + ParamCount = paramCount, + HasThis = hasThisStub, + }; + hostCalls[token] = new HostEntry { Binding = stub2, IsVoid = false }; + continue; + } + + var typeArgs = DecodeMethodSpecArgs(reader, ms.Signature, binding, token); + // Several open overloads can share (type, name, arity) — Object.Instantiate has both + // (T, Transform, bool) and (T, Vector3, Quaternion). Pick by matching the MemberRef's + // parameter signature (sigIdx points at the return type here); a mispick invokes the + // wrong host method with type-mismatched args. + var openDef = openDefs!.Count == 1 + ? openDefs[0] + : SelectOpenGenericOverload(reader, openDefs!, parentSig, sigIdx, typeArgs); + if (openDef == null) + { + string stubKey = $"{parentType}.{parentName}/{paramCount}"; + unboundHostMembers.Add(stubKey + " (ambiguous overloads)"); + bool hasThisStub2 = (conv & 0x20) != 0; + var stub3 = new HostBinding.Entry + { + Delegate = (_, _) => throw new ScriptRuntimeException($"Host generic method '{stubKey}' has multiple overloads and none matched the call signature"), + ParamCount = paramCount, + HasThis = hasThisStub2, + }; + hostCalls[token] = new HostEntry { Binding = stub3, IsVoid = false }; + continue; + } + var closed = binding.GetOrMakeClosedMethod(openDef!, typeArgs); + bool hasThis = !openDef!.IsStatic; + var ps = closed.GetParameters(); + var msEntry = new HostBinding.Entry + { + Delegate = (recv, args) => closed.Invoke(recv, args), + ParamCount = ps.Length, + HasThis = hasThis, + Params = ps, + }; + // ResolvedMethod = the CLOSED generic method (e.g. Echo): its ReturnType is the + // concrete instantiation (int), so the call-site lowering types the result slot I4/R4 + // instead of defaulting to O. Without it a generic call returning int/float/bool boxed + // its result, and a numeric use (e.g. `Echo(x) != Echo(x)`) reference-compared + // two boxes (found by fuzzing the MethodSpec path). + hostCalls[token] = new HostEntry + { + Binding = msEntry, + IsVoid = closed.ReturnType == typeof(void), + ResolvedMethod = closed as MethodInfo, + }; + } + + // --- Token → Type map: resolve TypeRef/TypeSpec tokens for castclass/isinst/ldtoken --- + var tokenTypes = new Dictionary(); + if (binding != null) + { + foreach (var trHandle in reader.TypeReferences) + { + var tr = reader.GetTypeReference(trHandle); + var name = reader.GetString(tr.Name); + int tok = MetadataTokens.GetToken(trHandle); + // System primitives, so unbox.any is type-exact (a boxed char can't unbox to int), + // and isinst/castclass/typeof work on boxed primitives (box_prim gives them the real + // runtime type). The binding doesn't register these as host types. + Type? primType = reader.GetString(tr.Namespace) == "System" ? name switch + { + "Int32" => typeof(int), "Boolean" => typeof(bool), "Char" => typeof(char), + "Byte" => typeof(byte), "SByte" => typeof(sbyte), "Int16" => typeof(short), + "UInt16" => typeof(ushort), "UInt32" => typeof(uint), "Int64" => typeof(long), + "UInt64" => typeof(ulong), "Single" => typeof(float), "Double" => typeof(double), + _ => null, + } : null; + if (primType != null) + tokenTypes[tok] = primType; + else if (binding.TryGetTypeByName(name, out var t) && t != null) + tokenTypes[tok] = t; + } + int typeSpecCount = reader.GetTableRowCount(TableIndex.TypeSpec); + for (int tsRow = 1; tsRow <= typeSpecCount; tsRow++) + { + var tsHandle = MetadataTokens.TypeSpecificationHandle(tsRow); + int tok = MetadataTokens.GetToken(tsHandle); + var name = ResolveTypeSpecName(reader, tsHandle); + if (name != "" && binding.TryGetTypeByName(name, out var t) && t != null) + tokenTypes[tok] = t; + } + + // `new S { x = 1 }` / `default(S)` on a BOXED host struct local (AllowType, the + // auto-bind default) lowers to `ldloca; initobj S; ldloca; stfld x`. The O slot has + // no box yet, and stfld on null threw "Non-static field requires a target" (found + // live in CubeFlipr's Update). Give initobj a 0-arg "ctor" keyed by the TYPE token + // — disjoint from the MemberRef tokens real ctors use — so the lowerer can emit + // newobj_host to produce the default box for the following stores to mutate in + // place. Nullable keeps the ldnull path (null IS its representation here); + // primitives/enums live in numeric slots; flat structs never reach the O-slot path. + foreach (var kv in tokenTypes) + { + var vt = kv.Value; + if (!vt.IsValueType || vt.IsPrimitive || vt.IsEnum || Nullable.GetUnderlyingType(vt) != null) + continue; + if (hostCtors.ContainsKey(kv.Key)) continue; + var vtLocal = vt; + hostCtors[kv.Key] = new HostEntry + { + Binding = new HostBinding.Entry + { + Delegate = (_, _) => Activator.CreateInstance(vtLocal), + ParamCount = 0, HasThis = false, + }, + IsVoid = true, + }; + } + } + + // --- Embedded PDB: read sequence points for source-mapped errors --- + MetadataReaderProvider? pdbProvider = null; + foreach (var entry in pe.ReadDebugDirectory()) + { + if (entry.Type == DebugDirectoryEntryType.EmbeddedPortablePdb) + { + pdbProvider = pe.ReadEmbeddedPortablePdbDebugDirectoryData(entry); + break; + } + } + if (pdbProvider != null) + { + var pdbReader = pdbProvider.GetMetadataReader(); + // Document handle → short file name, resolved once per document. With #line directives + // in the compiled source (the code-reload transform emits them), documents are the USER's + // files, so the name makes error locations self-locating: " at line 394 (TankAI.cs)". + var docNames = new Dictionary(); + foreach (var handle in reader.MethodDefinitions) + { + int token = MetadataTokens.GetToken(handle); + if (!byToken.TryGetValue(token, out var pm)) continue; + int row = MetadataTokens.GetRowNumber(handle); + var debugInfo = pdbReader.GetMethodDebugInformation(MetadataTokens.MethodDebugInformationHandle(row)); + if (debugInfo.SequencePointsBlob.IsNil) continue; + var points = new List<(int, int, string)>(); + foreach (var sp in debugInfo.GetSequencePoints()) + { + if (sp.IsHidden) continue; + if (!docNames.TryGetValue(sp.Document, out var doc)) + { + var fullName = sp.Document.IsNil ? "" : pdbReader.GetString(pdbReader.GetDocument(sp.Document).Name); + doc = Path.GetFileName(fullName); + docNames[sp.Document] = doc; + } + points.Add((sp.Offset, sp.StartLine, doc)); + } + if (points.Count > 0) + pm.SeqPoints = points.ToArray(); + } + } + + // Resolve flat-struct (Vt) layouts for every host call / host ctor whose params, + // return, or receiver are registered struct types. Done in one sweep at the end + // so the per-site HostEntry construction code stays unchanged. + foreach (var he in hostCalls.Values) FillStructInfo(he, binding, isCtor: false); + foreach (var he in hostCtors.Values) FillStructInfo(he, binding, isCtor: true); + + return new ParsedAssembly + { + ByToken = byToken, + ByName = byName, + HostCalls = hostCalls, + HostCtors = hostCtors, + HostFieldDeclTypeTok = hostFieldDeclTypeTok, + HostFields = hostFields, + TokenNames = tokenNames, + FieldSlots = fieldSlots, + FieldSTypes = fieldSTypes, + FieldIsScriptStruct = fieldIsScStruct, + FieldToTypeDef = fieldToTypeDef, + CtorToType = ctorToType, + TypeDefToCtorTok = typeDefToCtorTok, + TypeDefToType = typeDefToType, + TypesByName = typesByName, + EnumeratorTypes = enumeratorTypes, + TokenTypes = tokenTypes, + Reader = reader, + Pe = pe, + PdbProvider = pdbProvider, + UnboundHostMembers = unboundHostMembers, + HostSurface = binding, + }; + } + + // Build an intrinsic Entry for a System.Nullable member. The receiver arrives as the slot's + // boxed value — boxed T or null, the CLR boxed representation of a nullable — so the semantics + // are plain null checks. Returns null for members that aren't modeled (ToString, GetHashCode, + // Equals), which then resolve or stub through the normal path. + static HostBinding.Entry? MakeNullableIntrinsic(string memberName, int paramCount, string typeArgName, HostBinding? binding) + { + switch (memberName) + { + case "get_HasValue" when paramCount == 0: + return new HostBinding.Entry + { Delegate = (recv, _) => recv != null, ParamCount = 0, HasThis = true }; + + case "get_Value" when paramCount == 0: + return new HostBinding.Entry + { + Delegate = (recv, _) => recv ?? throw new ScriptRuntimeException( + "InvalidOperationException: Nullable object must have a value"), + ParamCount = 0, + HasThis = true, + }; + + case "GetValueOrDefault" when paramCount == 0: + { + // default(T), boxed once at load time. Stays null when T can't be resolved — safe + // for the compiler-generated uses (`x ?? y`, `x?.M()` evaluate GetValueOrDefault + // only alongside a HasValue guard); a hand-written call on an unresolved T yields + // null, which downstream O-slot ops treat as default anyway. + object? boxedDefault = null; + var t = ResolveNullableTypeArg(typeArgName, binding); + if (t != null && t.IsValueType) + try { boxedDefault = HostBinding.NormalizeIntegralReturn(Activator.CreateInstance(t)); } catch { /* keep null */ } + return new HostBinding.Entry + { Delegate = (recv, _) => recv ?? boxedDefault, ParamCount = 0, HasThis = true }; + } + + case "GetValueOrDefault" when paramCount == 1: + return new HostBinding.Entry + { Delegate = (recv, args) => recv ?? args[0], ParamCount = 1, HasThis = true }; + + default: + return null; + } + } + + // Result SType for a Nullable intrinsic. get_HasValue returns bool (I4); get_Value and + // GetValueOrDefault return T, whose SType comes from the RESOLVED type arg — the open signature + // says T (which SigReturn can only classify as O), so without this the result lands in a boxed + // O slot and a numeric comparison like `0 != n.GetValueOrDefault()` lowers to an OBJECT compare + // (reference-unequal) instead of an int compare (found by fuzzing). + static SType NullableIntrinsicResultSType(string memberName, string typeArgName) + { + if (memberName == "get_HasValue") return SType.I4; // bool + switch (typeArgName) + { + case "Boolean": case "Char": case "SByte": case "Byte": + case "Int16": case "UInt16": case "Int32": case "UInt32": + return SType.I4; + case "Single": case "Double": + return SType.R4; + default: + return SType.O; // long/ulong/enums/ref types stay boxed + } + } + + // Resolve a Nullable type-arg name (as produced by DecodeGenericInstHeader) to a Type: + // CLR primitive names first, then any binding-registered type. + static Type? ResolveNullableTypeArg(string name, HostBinding? binding) + { + switch (name) + { + case "Boolean": return typeof(bool); + case "Char": return typeof(char); + case "SByte": return typeof(sbyte); + case "Byte": return typeof(byte); + case "Int16": return typeof(short); + case "UInt16": return typeof(ushort); + case "Int32": return typeof(int); + case "UInt32": return typeof(uint); + case "Int64": return typeof(long); + case "UInt64": return typeof(ulong); + case "Single": return typeof(float); + case "Double": return typeof(double); + } + return binding != null && binding.TryGetTypeByName(name, out var t) ? t : null; + } + + // Build a HostEntry from a MethodInfo that slipped through TryGetByHandle (Mono/IL2CPP handle instability). + static HostEntry FallbackEntry(MethodInfo mi, bool isVoid, string label, Action logSink) + { + logSink(label); + var captured = mi; + var mp = mi.GetParameters(); + var entry = new HostBinding.Entry + { + Delegate = mi.IsStatic + ? (_, args) => captured.Invoke(null, args) + : (recv, args) => captured.Invoke(recv, args), + ParamCount = mp.Length, + HasThis = !mi.IsStatic, + Params = mp, + }; + return new HostEntry { Binding = entry, IsVoid = isVoid }; + } + + // Create one instance of the script's "Script" class and run its default .ctor. + // The ctor runs on the Vm (the sole engine) via _parsed, which Load() has already set. + ScriptObject? MakeInstance(ParsedAssembly asm) + { + if (!asm.TypesByName.TryGetValue("Script", out var desc)) return null; + var obj = ScriptObject.Create(desc); + // Find the default .ctor (ArgCount == 1 means just 'this') + foreach (var (tok, type) in asm.CtorToType) + { + if (type == desc && asm.ByToken.TryGetValue(tok, out var ctor) && ctor.ArgCount == 1) + { + (_vm ??= new Vm(this)).Invoke(ctor, new object?[] { obj }); // result discarded (ctor) + break; + } + } + return obj; + } + + // Recursive flat classification for script-defined structs (see the Parse call site). + // Relayouts a type in place when nested blittable struct fields move inline, updates the + // assembly-level per-field-token maps, and synthesizes FlatLayout for blittable structs + // (Size = PrimByteSize; box form = a ScriptObject whose PrimBytes IS the flat image). + static unsafe void ResolveFlatScriptStruct(ScriptTypeDescriptor desc, + HashSet visited, + Dictionary fieldSTypes, HashSet fieldIsScStruct) + { + if (!visited.Add(desc)) return; + var nested = desc.FieldStructDescriptors; + if (nested != null) + foreach (var nd in nested) + if (nd != null) ResolveFlatScriptStruct(nd, visited, fieldSTypes, fieldIsScStruct); + + bool anyInline = false; + if (desc.FieldIsScriptStruct != null && nested != null) + for (int i = 0; i < desc.FieldCount; i++) + if (desc.FieldIsScriptStruct[i] && i < nested.Length && nested[i]?.FlatLayout != null) + { anyInline = true; break; } + + if (anyInline) + { + int prim = 0, refs = 0; + for (int i = 0; i < desc.FieldCount; i++) + { + bool inline = desc.FieldIsScriptStruct != null && desc.FieldIsScriptStruct[i] + && nested != null && i < nested.Length && nested[i]?.FlatLayout != null; + var ft = desc.FieldTypes[i]; + if (inline) + { + var flay = nested![i]!.FlatLayout!; + prim = (prim + 3) & ~3; + desc.FieldOffsets[i] = prim; + prim += (flay.Size + 3) & ~3; + desc.FieldTypes[i] = SType.Vt; + (desc.VtFieldLayouts ??= new HostBinding.StructLayout?[desc.FieldCount])[i] = flay; + desc.FieldIsScriptStruct![i] = false; + nested![i] = null; + } + else if (ft == SType.I4 || ft == SType.R4) + { + desc.FieldOffsets[i] = prim; prim += 4; + } + else if (ft == SType.Vt) + { + var lay = desc.VtFieldLayouts![i]!; + prim = (prim + 3) & ~3; + desc.FieldOffsets[i] = prim; + prim += (lay.Size + 3) & ~3; + } + else + { + desc.FieldOffsets[i] = refs++; + } + } + desc.PrimByteSize = prim; + desc.RefSlotCount = refs; + foreach (var kv in desc.FieldSlots) + { + fieldSTypes[kv.Key] = desc.FieldTypes[kv.Value]; + if (desc.FieldTypes[kv.Value] == SType.Vt) fieldIsScStruct.Remove(kv.Key); + } + } + + if (desc.IsScriptStructValue && desc.RefSlotCount == 0 && desc.FlatLayout == null + // A local function's captured environment is a display STRUCT the caller mutates + // in place and passes BYREF (`call Scale(x, ref V_0)`). Flattening it splits the + // representation — caller writes Vt frame bytes, callee reads a ScriptObject — + // so the environment must stay ScriptObject-backed and shared by reference. + && !desc.Name.StartsWith("<>c__DisplayClass", StringComparison.Ordinal)) + { + int copyLen = desc.PrimByteSize; + int size = copyLen == 0 ? 4 : copyLen; // empty struct still occupies one slot + var d = desc; + HostBinding.BoxFromPtrDelegate box = src => + { + var so = ScriptObject.Create(d); + if (copyLen > 0) Marshal.Copy((IntPtr)src, so.PrimBytes, 0, copyLen); + return so; + }; + HostBinding.CopyToPtrDelegate copy = (dst, boxed) => + { + var so = (ScriptObject)boxed; + int n = so.PrimBytes.Length < copyLen ? so.PrimBytes.Length : copyLen; + if (n > 0) Marshal.Copy(so.PrimBytes, 0, (IntPtr)dst, n); + }; + // Fields is scanned BY OFFSET (host-call receiver materialization for primitives + // inside flat struct slots — `v.f0.ToString()`); metadata field names aren't retained + // on the descriptor, so keys are synthetic. Nested flat structs contribute their + // primitives at composed offsets (their own layouts were built first, bottom-up). + var flatFields = new Dictionary(desc.FieldCount); + for (int i = 0; i < desc.FieldCount; i++) + { + if (desc.FieldTypes[i] is SType.I4 or SType.R4) + flatFields["#" + i] = (desc.FieldOffsets[i], desc.FieldTypes[i]); + else if (desc.FieldTypes[i] == SType.Vt && desc.VtFieldLayouts?[i] is { } nlay) + foreach (var kv in nlay.Fields) + flatFields[$"#{i}.{kv.Key}"] = (desc.FieldOffsets[i] + kv.Value.Offset, kv.Value.St); + } + desc.FlatLayout = new HostBinding.StructLayout + { + Type = typeof(ScriptObject), + Size = size, + TypeName = desc.Name, + Fields = flatFields, + BoxFromPtr = box, + CopyToPtr = copy, + }; + } + } + + static int ReadLocalCount(MetadataReader reader, StandaloneSignatureHandle sig) + { + if (sig.IsNil) return 0; + var blob = reader.GetBlobBytes(reader.GetStandaloneSignature(sig).Signature); + if (blob.Length < 2 || blob[0] != 0x07) return 0; // 0x07 = LOCAL_SIG marker + int count = DecompressInt(blob, 1); + // Every local needs at least one byte of type signature, so a well-formed LOCAL_SIG can't + // declare more locals than the blob has bytes. A larger count is a lying/hostile signature + // that would otherwise size a per-local array from it and OOM in Parse. + if ((uint)count > (uint)blob.Length) + throw new ScriptValidationException( + $"LOCAL_SIG declares {count} locals but the signature blob is only {blob.Length} bytes — malformed metadata"); + return count; + } + + // Decode each local's struct layout from the LOCAL_SIG blob. Returns null when + // none are structs registered with the binding (saves an array allocation per method). + // LOCAL_SIG = 0x07 [count] (type)*. Per-local: optional CMOD/PINNED/BYREF prefixes, + // then ELEMENT_TYPE_VALUETYPE (0x11) + coded TypeDefOrRef → look up via TypeRef name. + static HostBinding.StructLayout?[]? ReadLocalStructLayouts( + MetadataReader reader, StandaloneSignatureHandle sig, int localCount, HostBinding? binding) + { + if (sig.IsNil || binding == null || localCount == 0) return null; + var blob = reader.GetBlobBytes(reader.GetStandaloneSignature(sig).Signature); + if (blob.Length < 2 || blob[0] != 0x07) return null; + int idx = 1; + DecompressIntAdv(blob, ref idx); // count (already known) + var result = new HostBinding.StructLayout?[localCount]; + bool any = false; + for (int i = 0; i < localCount && idx < blob.Length; i++) + { + // Skip CMOD_REQD (0x1F), CMOD_OPT (0x20), PINNED (0x45), BYREF (0x10) + while (idx < blob.Length && (blob[idx] == 0x1F || blob[idx] == 0x20 || blob[idx] == 0x45 || blob[idx] == 0x10)) + { + byte tag = blob[idx++]; + if (tag == 0x1F || tag == 0x20) DecompressIntAdv(blob, ref idx); + } + if (idx >= blob.Length) break; + byte t = blob[idx++]; + if (t == 0x11) // VALUETYPE + { + int coded = DecompressIntAdv(blob, ref idx); + int table = coded & 0x03; int row = coded >> 2; + if (table == 1) // TypeRef + { + var name = reader.GetString(reader.GetTypeReference(MetadataTokens.TypeReferenceHandle(row)).Name); + if (binding.TryGetStructLayout(name, out var lay)) { result[i] = lay; any = true; } + } + } + else + { + // Skip nested types (SZARRAY 0x1D, GENERICINST 0x15) — none are structs we track. + // Primitive types (I4/R4/...) — just consume the single tag byte (already done). + if (t == 0x12) DecompressIntAdv(blob, ref idx); // CLASS — also consumes coded index + else if (t == 0x1D) SkipSigType(blob, ref idx); // SZARRAY → recurse + else if (t == 0x15) + { + // GENERICINST: 0x11/0x12, coded index, arity, args. Skip conservatively. + if (idx < blob.Length) idx++; // value/class flag + if (idx < blob.Length) DecompressIntAdv(blob, ref idx); + if (idx < blob.Length) + { + int n = DecompressIntAdv(blob, ref idx); + for (int j = 0; j < n; j++) SkipSigType(blob, ref idx); + } + } + } + } + return any ? result : null; + } + + // Per-local DECLARED SType from the LocalVarSig (see ParsedMethod.LocalSTypes). Walks the blob + // exactly like ReadLocalStructLayouts to stay byte-aligned, but classifies each local into the + // FROZEN set — only the unambiguous, VM-consistent declared types. Anything else (valuetype/enum, + // long/ulong, double, nint/nuint, byref/pinned locals) is left null to fall back to inference, + // so this can only make typing MORE stable, never change a type the fuzzer hasn't validated. + static SType?[]? ReadLocalSTypes(MetadataReader reader, StandaloneSignatureHandle sig, int localCount) + { + if (sig.IsNil || localCount == 0) return null; + var blob = reader.GetBlobBytes(reader.GetStandaloneSignature(sig).Signature); + if (blob.Length < 2 || blob[0] != 0x07) return null; + int idx = 1; + DecompressIntAdv(blob, ref idx); // count (already known) + var result = new SType?[localCount]; + bool any = false; + for (int i = 0; i < localCount && idx < blob.Length; i++) + { + bool byRef = false; + while (idx < blob.Length && (blob[idx] == 0x1F || blob[idx] == 0x20 || blob[idx] == 0x45 || blob[idx] == 0x10)) + { + byte tag = blob[idx++]; + if (tag == 0x10) byRef = true; // BYREF local — not a plain value; fall back + else if (tag == 0x1F || tag == 0x20) DecompressIntAdv(blob, ref idx); // CMOD token + } + if (idx >= blob.Length) break; + byte t = blob[idx++]; + SType? st; + if (t == 0x11 || t == 0x12) + { + DecompressIntAdv(blob, ref idx); // TypeDefOrRef token (also the alignment skip) + // CLASS (0x12) is a reference -> O. VALUETYPE (0x11) is left to inference: a host + // struct is Vt / a script struct is O (classified elsewhere), and an ENUM is NOT + // safely I4 here — an enum local zero-inited via `initobj En` takes the script-object + // path, so freezing it I4 regressed real fuzzed programs (oracle [False] vs interp []). + st = t == 0x12 ? SType.O : (SType?)null; + } + else + { + st = t switch + { + 0x02 or 0x03 or 0x04 or 0x05 or 0x06 or 0x07 or 0x08 or 0x09 => SType.I4, // bool,char,i1,u1,i2,u2,i4,u4 + 0x0C => SType.R4, // float + 0x0A or 0x0B => SType.I8, // long/ulong — wide slot + 0x0D => SType.R8, // double — wide slot + 0x0E or 0x1C or 0x1D or 0x15 => SType.O, // string,object,array,generic + _ => (SType?)null, // nint/… left to inference (rare or validator-rejected) + }; + // Keep idx aligned for the next local (mirror ReadLocalStructLayouts's skips). + if (t == 0x1D) SkipSigType(blob, ref idx); + else if (t == 0x15) + { + if (idx < blob.Length) idx++; // value/class flag + if (idx < blob.Length) DecompressIntAdv(blob, ref idx); + if (idx < blob.Length) { int n = DecompressIntAdv(blob, ref idx); for (int j = 0; j < n; j++) SkipSigType(blob, ref idx); } + } + } + if (byRef) st = null; + if (st is { }) { result[i] = st; any = true; } + } + return any ? result : null; + } + + // True when TypeDef row `row` in this assembly is an enum (base type System.Enum). Enum values + // are I4 ints, not nested script structs — the distinction matters wherever a VALUETYPE TypeDef + // is classified (fields, locals): misclassifying an enum as a struct gives it object identity + // and, through its own static literal members, a self-referential descriptor. + static bool IsEnumTypeDef(MetadataReader reader, int row) + { + try + { + var bt = reader.GetTypeDefinition(MetadataTokens.TypeDefinitionHandle(row)).BaseType; + return bt.Kind switch + { + HandleKind.TypeReference => reader.GetString(reader.GetTypeReference((TypeReferenceHandle)bt).Name) == "Enum", + HandleKind.TypeDefinition => reader.GetString(reader.GetTypeDefinition((TypeDefinitionHandle)bt).Name) == "Enum", + _ => false, + }; + } + catch { return false; } + } + + // Decode which locals are SCRIPT-defined structs: ELEMENT_TYPE_VALUETYPE (0x11) whose + // TypeDefOrRef coded token resolves to a TypeDef (table 0 — defined in THIS assembly), as opposed + // to a TypeRef (table 1 — a host struct, handled by ReadLocalStructLayouts). Such locals are stored + // as O ScriptObject references, so a value load must clone them. Returns null when none qualify. + static bool[]? ReadLocalIsScriptStruct( + MetadataReader reader, StandaloneSignatureHandle sig, int localCount, out int[]? typeDefs) + { + typeDefs = null; + if (sig.IsNil || localCount == 0) return null; + var blob = reader.GetBlobBytes(reader.GetStandaloneSignature(sig).Signature); + if (blob.Length < 2 || blob[0] != 0x07) return null; + int idx = 1; + DecompressIntAdv(blob, ref idx); // count (already known) + var result = new bool[localCount]; + bool any = false; + for (int i = 0; i < localCount && idx < blob.Length; i++) + { + // Skip CMOD_REQD (0x1F), CMOD_OPT (0x20), PINNED (0x45), BYREF (0x10) + while (idx < blob.Length && (blob[idx] == 0x1F || blob[idx] == 0x20 || blob[idx] == 0x45 || blob[idx] == 0x10)) + { + byte tag = blob[idx++]; + if (tag == 0x1F || tag == 0x20) DecompressIntAdv(blob, ref idx); + } + if (idx >= blob.Length) break; + byte t = blob[idx++]; + if (t == 0x11) // VALUETYPE + { + int coded = DecompressIntAdv(blob, ref idx); + if ((coded & 0x03) == 0) // table 0 == TypeDef + { + result[i] = true; any = true; + (typeDefs ??= new int[localCount])[i] = + MetadataTokens.GetToken(MetadataTokens.TypeDefinitionHandle(coded >> 2)); + } + } + else if (t == 0x12) DecompressIntAdv(blob, ref idx); // CLASS: consume coded index + else if (t == 0x1D) SkipSigType(blob, ref idx); // SZARRAY: recurse + else if (t == 0x15) // GENERICINST: skip + { + if (idx < blob.Length) idx++; + if (idx < blob.Length) DecompressIntAdv(blob, ref idx); + if (idx < blob.Length) + { + int n = DecompressIntAdv(blob, ref idx); + for (int j = 0; j < n; j++) SkipSigType(blob, ref idx); + } + } + } + return any ? result : null; + } + + // Decode a TypeSpec signature to extract the simple type name. + // TypeSpec for a generic instantiation: 0x15 (CLASS|VALUETYPE) coded-index arg-count args... + static string ResolveTypeSpecName(MetadataReader reader, TypeSpecificationHandle handle) + { + var blob = reader.GetBlobBytes(reader.GetTypeSpecification(handle).Signature); + if (blob.Length < 3 || blob[0] != 0x15) return ""; + int codedIndex = DecompressInt(blob, 2); + int table = codedIndex & 0x03; + int row = codedIndex >> 2; + if (table == 1) // TypeRef + return reader.GetString(reader.GetTypeReference(MetadataTokens.TypeReferenceHandle(row)).Name); + if (table == 0) // TypeDef + return reader.GetString(reader.GetTypeDefinition(MetadataTokens.TypeDefinitionHandle(row)).Name); + return ""; + } + + // Parses the return info from a method signature blob. + // Sig layout: [callconv (1 byte)] [paramCount (compressed)] [CMOD_REQD/OPT*] [returnType] ... + // How an I4-slot receiver of an instance host call on this type must be boxed (see ReceiverBox). + static ReceiverBoxKind ReceiverBoxFor(string typeName) => typeName switch + { + "Char" => ReceiverBoxKind.Char, + "Boolean" => ReceiverBoxKind.Bool, + _ => ReceiverBoxKind.None, + }; + + // Returns (isVoid, retSType, isBool): retSType is O when isVoid is true. isBool is true only for + // ELEMENT_TYPE_BOOLEAN — bool collapses to I4 for storage, but the method-return boxing must + // still hand back a boxed bool (not int), so callers casting the result to bool don't fault. + static (bool isVoid, SType retSType, bool isBool) SigReturn(byte[] sig) + { + if (sig.Length < 3) return (false, SType.O, false); + int idx = 1; // skip calling convention byte + DecompressIntAdv(sig, ref idx); // skip paramCount + // skip custom modifiers (CMOD_REQD=0x1F, CMOD_OPT=0x20) each followed by a compressed type ref + while (idx < sig.Length && (sig[idx] == 0x1F || sig[idx] == 0x20)) + { + idx++; // skip modifier tag + DecompressIntAdv(sig, ref idx); // skip encoded type reference + } + if (idx >= sig.Length) return (false, SType.O, false); + byte tb = sig[idx]; + if (tb == 0x01) return (true, SType.O, false); // ELEMENT_TYPE_VOID + SType st = tb switch + { + // 0x03 = ELEMENT_TYPE_CHAR: char is I4-typed in the VM; omitting it left a char return + // (e.g. String.get_Chars) boxed in an O slot, read back as 0. (found by fuzzing.) + // nint/nuint (0x18/0x19) stay O: boxed IntPtr, no 64-bit truncation. + 0x02 or 0x03 or 0x04 or 0x05 or 0x06 or 0x07 or 0x08 or 0x09 => SType.I4, + 0x0C => SType.R4, + 0x0A or 0x0B => SType.I8, // long/ulong — wide slot + 0x0D => SType.R8, // double — wide slot + _ => SType.O, + }; + return (false, st, tb == 0x02); // 0x02 = ELEMENT_TYPE_BOOLEAN + } + + // Parse explicit parameter types from a method signature blob. + // Returns SType[] of length = explicit param count (not counting implicit `this`). + static SType[] SigParamSTypes(byte[] sig) + { + if (sig.Length < 2) return Array.Empty(); + int idx = 1; // skip calling convention + int paramCount = DecompressIntAdv(sig, ref idx); + if (paramCount == 0) return Array.Empty(); + // skip return type (with optional modopt/modreq then the whole type) + while (idx < sig.Length && (sig[idx] == 0x1F || sig[idx] == 0x20)) + { idx++; DecompressIntAdv(sig, ref idx); } + // Skip the ENTIRE return type via SkipSigType. Consuming only the leading tag left a + // compound return's inner tag (SZARRAY/GENERICINST) to be read as the first parameter's + // type, desyncing every following param by one signature byte. + if (idx < sig.Length) SkipSigType(sig, ref idx); + var result = new SType[paramCount]; + for (int p = 0; p < paramCount && idx < sig.Length; p++) + { + // skip any modopt/modreq + while (idx < sig.Length && (sig[idx] == 0x1F || sig[idx] == 0x20)) + { idx++; DecompressIntAdv(sig, ref idx); } + if (idx >= sig.Length) break; + byte tb = sig[idx++]; + result[p] = tb switch + { + // 0x03 = ELEMENT_TYPE_CHAR — I4-typed in the VM (see SigReturn). + // nint/nuint (0x18/0x19) stay O: boxed IntPtr, no 64-bit truncation. + 0x02 or 0x03 or 0x04 or 0x05 or 0x06 or 0x07 or 0x08 or 0x09 => SType.I4, + 0x0C => SType.R4, + 0x0A or 0x0B => SType.I8, // long/ulong — wide slot + 0x0D => SType.R8, // double — wide slot + _ => SType.O, + }; + // VALUETYPE (0x11) and CLASS (0x12) are followed by a compressed type token — skip it. + if (tb == 0x11 || tb == 0x12) DecompressIntAdv(sig, ref idx); + // Array/generic params carry a nested type — skip it so the next param's tag is read + // from the right position (e.g. `(int[] a, MyStruct s)` would otherwise desync). + else if (tb == 0x1D || tb == 0x15) SkipSigType(sig, ref idx); + } + return result; + } + + // Walks a method signature like SigReturn/SigParamSTypes but captures the TypeDef token of + // every VALUETYPE (0x11, coded table 0) return/param — script-defined struct types. Returns + // (returnTypeDefTok, perParamTypeDefToks); 0 / null when not script structs. + static (int retTok, int[]? paramToks) ReadSigScriptStructTokens(byte[] sig) + { + if (sig.Length < 3) return (0, null); + int idx = 1; + int paramCount = DecompressIntAdv(sig, ref idx); + while (idx < sig.Length && (sig[idx] == 0x1F || sig[idx] == 0x20)) + { idx++; DecompressIntAdv(sig, ref idx); } + if (idx >= sig.Length) return (0, null); + + int retTok = 0; + byte retTb = sig[idx++]; + if (retTb == 0x11 || retTb == 0x12) + { + int coded = DecompressIntAdv(sig, ref idx); + if (retTb == 0x11 && (coded & 0x03) == 0) + retTok = MetadataTokens.GetToken(MetadataTokens.TypeDefinitionHandle(coded >> 2)); + } + else if (retTb == 0x1D) SkipSigType(sig, ref idx); // SZARRAY return + + int[]? paramToks = null; + for (int pi = 0; pi < paramCount && idx < sig.Length; pi++) + { + while (idx < sig.Length && (sig[idx] == 0x1F || sig[idx] == 0x20)) + { idx++; DecompressIntAdv(sig, ref idx); } + if (idx >= sig.Length) break; + byte tb = sig[idx++]; + if (tb == 0x11 || tb == 0x12) + { + int coded = DecompressIntAdv(sig, ref idx); + if (tb == 0x11 && (coded & 0x03) == 0) + (paramToks ??= new int[paramCount])[pi] = + MetadataTokens.GetToken(MetadataTokens.TypeDefinitionHandle(coded >> 2)); + } + else if (tb == 0x1D) SkipSigType(sig, ref idx); // SZARRAY param + else if (tb == 0x15) SkipSigType(sig, ref idx); // GENERICINST — conservative skip + } + return (retTok, paramToks); + } + + static int DecompressInt(byte[] b, int i) + { + if ((b[i] & 0x80) == 0) return b[i]; + if ((b[i] & 0xC0) == 0x80) return ((b[i] & 0x3F) << 8) | b[i + 1]; + return ((b[i] & 0x1F) << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]; + } + + // DecompressInt variant that advances the index past the compressed integer. + static int DecompressIntAdv(byte[] b, ref int idx) + { + byte first = b[idx]; + if ((first & 0x80) == 0) { idx += 1; return first; } + if ((first & 0xC0) == 0x80) { int v = ((first & 0x3F) << 8) | b[idx + 1]; idx += 2; return v; } + int v4 = ((first & 0x1F) << 24) | (b[idx + 1] << 16) | (b[idx + 2] << 8) | b[idx + 3]; + idx += 4; return v4; + } + + // Decode a TypeSpec GENERICINST blob: returns (outerTypeName, firstArgTypeName). + /// SType of a Func`N TypeSpec's LAST generic arg — its TResult. Func`N is + /// 0x15 (CLASS) coded-outer argCount T1..Tn TResult; the return is the final arg. + /// Skips the leading args, then maps the final arg's element-type tag to I4 (integral/bool/ + /// char) or R4 (Single); anything else (String, object, another generic) stays O so only + /// primitive delegate results get numeric-frame typing. Returns O on any decode failure. + // Closed return SType for a TypeSpec member whose signature returns the generic VAR `!n`: + // decode n from the member sig, then read the instantiation's n-th type argument. + // Primitives map to their slot type; enums (host TypeRef or script TypeDef) map to I4; + // everything else — and any decode wobble — stays O, the pre-existing behavior. + static SType GenericVarReturnSType( + MetadataReader reader, TypeSpecificationHandle spec, byte[] memberSig, HostBinding? binding) + { + try + { + int idx = 0; + byte conv = memberSig[idx++]; + if ((conv & 0x10) != 0) DecompressIntAdv(memberSig, ref idx); // genParamCount + DecompressIntAdv(memberSig, ref idx); // paramCount + if (idx >= memberSig.Length || memberSig[idx] != 0x13) return SType.O; // ELEMENT_TYPE_VAR + idx++; + int varIdx = DecompressIntAdv(memberSig, ref idx); + + var blob = reader.GetBlobBytes(reader.GetTypeSpecification(spec).Signature); + if (blob.Length < 4 || blob[0] != 0x15) return SType.O; // GENERICINST + int b = 2; // skip 0x15 and the CLASS/VALUETYPE tag + DecompressIntAdv(blob, ref b); // generic definition coded index + int argc = DecompressIntAdv(blob, ref b); + for (int i = 0; i < argc && b < blob.Length; i++) + { + if (i != varIdx) { if (!SkipSigType(blob, ref b)) return SType.O; continue; } + byte tag = blob[b]; + switch (tag) + { + case 0x02: case 0x03: case 0x04: case 0x05: + case 0x06: case 0x07: case 0x08: case 0x09: + return SType.I4; + case 0x0C: + return SType.R4; + case 0x11: // VALUETYPE coded index — enum type args ride I4 + { + int c = b + 1; + int coded = DecompressIntAdv(blob, ref c); + if ((coded & 0x03) == 0 && IsEnumTypeDef(reader, coded >> 2)) return SType.I4; + if ((coded & 0x03) == 1 && binding != null + && ResolveSigTypeRef(reader, MetadataTokens.TypeReferenceHandle(coded >> 2), binding) + is { IsEnum: true }) return SType.I4; + return SType.O; + } + default: + return SType.O; + } + } + return SType.O; + } + catch { return SType.O; } + } + + static SType FuncReturnSType(MetadataReader reader, TypeSpecificationHandle handle) + { + try + { + var blob = reader.GetBlobBytes(reader.GetTypeSpecification(handle).Signature); + if (blob.Length < 3 || blob[0] != 0x15) return SType.O; + int idx = 2; // skip 0x15 (GENERICINST) and 0x11/0x12 (CLASS/VALUETYPE) + DecompressIntAdv(blob, ref idx); // coded index of the open type (Func`N) + int argCount = DecompressIntAdv(blob, ref idx); + if (argCount < 1) return SType.O; + for (int i = 0; i < argCount - 1; i++) // skip T1..Tn, leaving TResult + if (!SkipSigType(blob, ref idx)) return SType.O; + if (idx >= blob.Length) return SType.O; + return blob[idx] switch + { + 0x02 or 0x03 or 0x04 or 0x05 or 0x06 or 0x07 or 0x08 or 0x09 => SType.I4, // bool/char/sub-int/int/uint + 0x0C => SType.R4, // Single + _ => SType.O, + }; + } + catch { return SType.O; } + } + + static (string, string) DecodeGenericInstHeader(MetadataReader reader, TypeSpecificationHandle handle) + { + var blob = reader.GetBlobBytes(reader.GetTypeSpecification(handle).Signature); + if (blob.Length < 3 || blob[0] != 0x15) return ("", ""); + + int idx = 2; // skip 0x15 and 0x11/0x12 + int codedIndex = DecompressIntAdv(blob, ref idx); + int table = codedIndex & 0x03; + int row = codedIndex >> 2; + + string outerName; + if (table == 1) + outerName = reader.GetString(reader.GetTypeReference(MetadataTokens.TypeReferenceHandle(row)).Name); + else if (table == 0) + outerName = reader.GetString(reader.GetTypeDefinition(MetadataTokens.TypeDefinitionHandle(row)).Name); + else return ("", ""); + + int argCount = DecompressIntAdv(blob, ref idx); + if (argCount < 1 || idx >= blob.Length) return (outerName, ""); + + byte argTag = blob[idx++]; + string arg0Name = ""; + switch (argTag) + { + case 0x02: arg0Name = "Boolean"; break; + case 0x03: arg0Name = "Char"; break; + case 0x04: arg0Name = "SByte"; break; + case 0x05: arg0Name = "Byte"; break; + case 0x06: arg0Name = "Int16"; break; + case 0x07: arg0Name = "UInt16"; break; + case 0x08: arg0Name = "Int32"; break; + case 0x09: arg0Name = "UInt32"; break; + case 0x0A: arg0Name = "Int64"; break; + case 0x0B: arg0Name = "UInt64"; break; + case 0x0C: arg0Name = "Single"; break; + case 0x0D: arg0Name = "Double"; break; + case 0x0E: arg0Name = "String"; break; + case 0x11: case 0x12: + { + int ac = DecompressIntAdv(blob, ref idx); + int at = ac & 0x03; int ar = ac >> 2; + if (at == 1) arg0Name = reader.GetString(reader.GetTypeReference(MetadataTokens.TypeReferenceHandle(ar)).Name); + else if (at == 0) arg0Name = reader.GetString(reader.GetTypeDefinition(MetadataTokens.TypeDefinitionHandle(ar)).Name); + break; + } + } + return (outerName, arg0Name); + } + + // Resolve a TypeSpecification handle to its closed runtime Type (List`1 → List). + // Null when any piece of the signature is unsupported or unresolved. + static Type? ResolveClosedSpecType(MetadataReader reader, TypeSpecificationHandle handle, HostBinding binding) + { + try + { + var blob = reader.GetBlobBytes(reader.GetTypeSpecification(handle).Signature); + int idx = 0; + return DecodeSigType(reader, blob, ref idx, binding); + } + catch { return null; } + } + + // Decode a MethodSpec signature blob (II.23.2.15) into resolved host Types. + // Format: 0x0A [GenArgCount] [Type signatures...] + // Throws ScriptValidationException for unsupported tags or unresolved type-args — + // the caller should let it propagate so the user sees a friendly load-time error. + static Type[] DecodeMethodSpecArgs(MetadataReader reader, BlobHandle sigHandle, HostBinding binding, int msToken) + { + var blob = reader.GetBlobBytes(sigHandle); + if (blob.Length < 2 || blob[0] != 0x0A) + throw new ScriptValidationException($"MethodSpec at 0x{msToken:X8}: malformed signature"); + int idx = 1; + int argCount = DecompressIntAdv(blob, ref idx); + var result = new Type[argCount]; + for (int i = 0; i < argCount; i++) + result[i] = DecodeMethodSpecArg(reader, blob, ref idx, binding, msToken); + return result; + } + + static Type DecodeMethodSpecArg(MetadataReader reader, byte[] blob, ref int idx, HostBinding binding, int msToken) + { + byte tag = blob[idx++]; + switch (tag) + { + case 0x02: return typeof(bool); + case 0x08: return typeof(int); + case 0x09: return typeof(uint); + case 0x0A: return typeof(long); + case 0x0C: return typeof(float); + case 0x0D: return typeof(double); + case 0x0E: return typeof(string); + case 0x18: return typeof(IntPtr); // ELEMENT_TYPE_I — native int + case 0x19: return typeof(UIntPtr); // ELEMENT_TYPE_U + case 0x1C: return typeof(object); + case 0x11: case 0x12: + { + int coded = DecompressIntAdv(blob, ref idx); + int table = coded & 0x03; + int row = coded >> 2; + if (table != 1) // only TypeRef supported (TypeDef / TypeSpec out of scope) + throw new ScriptValidationException( + $"MethodSpec at 0x{msToken:X8}: unsupported type-arg table {table}"); + var trh = reader.GetTypeReference(MetadataTokens.TypeReferenceHandle(row)); + var name = reader.GetString(trh.Name); + if (binding.TryGetTypeByName(name, out var t)) + return t!; + // Unregistered ENUM type-args resolve by full name (Enum.TryParse): + // an enum carries no callable surface, so it doesn't widen the allowlist. + var full = trh.Namespace.IsNil ? name : reader.GetString(trh.Namespace) + "." + name; + if (FindTypeByFullName(full) is { IsEnum: true } et) + return et; + throw new ScriptValidationException( + $"MethodSpec at 0x{msToken:X8}: type argument '{name}' is not in the host binding allowlist (call AllowType<{name}>())"); + } + default: + throw new ScriptValidationException( + $"MethodSpec at 0x{msToken:X8}: unsupported type-arg signature tag 0x{tag:X2}"); + } + } + + // Decode a standard method signature blob (II.23.2.1) into a param Type[]. + // Format: [conv-byte] [paramCount] [returnType] [paramType * paramCount] + // Returns null if the blob is malformed or contains an unsupported tag — caller falls through. + static Type[]? DecodeMethodSigParamTypes(MetadataReader reader, byte[] blob, HostBinding binding) + { + if (blob.Length < 3) return null; + int idx = 0; + byte conv = blob[idx++]; + if ((conv & 0x10) != 0) return null; // GENERIC — handled via MethodSpec path + int paramCount = DecompressIntAdv(blob, ref idx); + // skip return type + if (!SkipSigType(blob, ref idx)) return null; + var result = new Type[paramCount]; + for (int i = 0; i < paramCount; i++) + { + var t = DecodeSigType(reader, blob, ref idx, binding); + if (t == null) return null; + result[i] = t; + } + return result; + } + + // Decode one type from a signature blob at idx, advancing idx. Returns null on unsupported tag. + static Type? DecodeSigType(MetadataReader reader, byte[] blob, ref int idx, HostBinding binding) + { + if (idx >= blob.Length) return null; + byte tag = blob[idx++]; + switch (tag) + { + case 0x01: return typeof(void); + case 0x02: return typeof(bool); + case 0x03: return typeof(char); + case 0x04: return typeof(sbyte); + case 0x05: return typeof(byte); + case 0x06: return typeof(short); + case 0x07: return typeof(ushort); + case 0x08: return typeof(int); + case 0x09: return typeof(uint); + case 0x0A: return typeof(long); + case 0x0B: return typeof(ulong); + case 0x0C: return typeof(float); + case 0x0D: return typeof(double); + case 0x0E: return typeof(string); + case 0x18: return typeof(IntPtr); // ELEMENT_TYPE_I — native int + case 0x19: return typeof(UIntPtr); // ELEMENT_TYPE_U + case 0x1C: return typeof(object); + case 0x11: case 0x12: // VALUETYPE / CLASS + coded TypeDefOrRef + { + int coded = DecompressIntAdv(blob, ref idx); + int table = coded & 0x03; + int row = coded >> 2; + if (table != 1) return null; // only TypeRef supported + return ResolveSigTypeRef(reader, MetadataTokens.TypeReferenceHandle(row), binding); + } + case 0x10: // BYREF — recurse then MakeByRefType + { + var inner = DecodeSigType(reader, blob, ref idx, binding); + return inner?.MakeByRefType(); + } + case 0x15: // GENERICINST: (CLASS|VALUETYPE) coded-index argCount arg* — e.g. List + { + if (idx >= blob.Length) return null; + idx++; // CLASS or VALUETYPE tag of the generic definition + int gcoded = DecompressIntAdv(blob, ref idx); + Type? def = (gcoded & 0x03) == 1 + ? ResolveSigTypeRef(reader, MetadataTokens.TypeReferenceHandle(gcoded >> 2), binding) + : null; + // A registered CLOSED instantiation (e.g. an AllowType'd List) shares the + // metadata short name "List`1" — normalize to the open definition before re-closing. + if (def is { IsGenericType: true, IsGenericTypeDefinition: false }) + def = def.GetGenericTypeDefinition(); + int argc = DecompressIntAdv(blob, ref idx); + var args = new Type[argc]; + for (int i = 0; i < argc; i++) + { + var a = DecodeSigType(reader, blob, ref idx, binding); + if (a == null) return null; // caller aborts the whole decode; position is moot + args[i] = a; + } + if (def == null || !def.IsGenericTypeDefinition || + def.GetGenericArguments().Length != argc) return null; + try { return def.MakeGenericType(args); } catch { return null; } + } + case 0x1D: // SZARRAY — recurse then MakeArrayType + { + var inner = DecodeSigType(reader, blob, ref idx, binding); + return inner?.MakeArrayType(); + } + default: return null; // unsupported tag — fall through + } + } + + // Resolve the runtime Type a ctor MemberRef constructs — used by delegate-creation lowering, + // where the delegate type is only named by the newobj token's parent. TypeRef parents cover + // custom delegates and System.Action; TypeSpec parents cover generic instantiations + // (Func`2, EventCallback`1) via the full recursive sig decode. + static Type? ResolveCtorParentType(ParsedAssembly asm, int ctorTok) + { + if (asm.HostSurface == null) return null; + try + { + var h = MetadataTokens.EntityHandle(ctorTok); + if (h.Kind != HandleKind.MemberReference) return null; + var mr = asm.Reader.GetMemberReference((MemberReferenceHandle)h); + switch (mr.Parent.Kind) + { + case HandleKind.TypeReference: + return ResolveSigTypeRef(asm.Reader, (TypeReferenceHandle)mr.Parent, asm.HostSurface); + case HandleKind.TypeSpecification: + { + var blob = asm.Reader.GetBlobBytes( + asm.Reader.GetTypeSpecification((TypeSpecificationHandle)mr.Parent).Signature); + int idx = 0; + return DecodeSigType(asm.Reader, blob, ref idx, asm.HostSurface); + } + default: + return null; + } + } + catch { return null; } + } + + // Resolve the receiver type of a MemberRef whose parent is a TypeRef. The namespace-qualified + // name goes through the (policy-gated) auto-bind FIRST: simple names collide across namespaces + // (UnityEngine.Application vs UnityEngine.WSA/Device.Application), and resolving against the + // wrong twin either loses the member (unbound) or — worse — silently binds a same-named member + // of the wrong type. Nested TypeRefs (nil namespace) and names the auto-bind policy declines + // (or bindings without a resolver) keep the registered simple-name behavior unchanged. + static bool TryResolveReceiverTypeRef(MetadataReader reader, TypeReferenceHandle handle, + string simpleName, HostBinding binding, out Type? type) + { + var tr = reader.GetTypeReference(handle); + if (!tr.Namespace.IsNil) + { + var fullName = reader.GetString(tr.Namespace) + "." + simpleName; + if (binding.TryAutoBindType(fullName) && binding.TryGetTypeByFullName(fullName, out type)) + return true; + } + return binding.TryGetTypeByName(simpleName, out type); + } + + // Resolve a sig-blob TypeRef to a host Type for SIGNATURE MATCHING only (no registration + // implied): registered simple names first, then the namespace-qualified name — covering types + // the auto-bind policy deliberately never registers (List`1, System.Uri) and generic + // definitions living outside corelib (InputFeatureUsage`1) — without a Type for them, every + // same-arity overload set that differs only by such a parameter resolves ambiguous and lands + // in UnboundHostMembers. + static Type? ResolveSigTypeRef(MetadataReader reader, TypeReferenceHandle handle, HostBinding binding) + { + var tr = reader.GetTypeReference(handle); + var name = reader.GetString(tr.Name); + if (binding.TryGetTypeByName(name, out var t) && t != null) return t; + return FindTypeByFullName(tr.Namespace.IsNil + ? name : reader.GetString(tr.Namespace) + "." + name); + } + + // Full-name type lookup across corelib and every loaded assembly, for signature matching only. + // Deliberately NOT the auto-bind resolver: no policy applies (registering nothing, we only need + // Type identity to pick the right overload), and results — including misses — are cached for + // the process (a type loaded later than its first sig miss stays unresolved; overload matching + // then degrades to the name+arity fallback exactly as before this lookup existed). + static readonly System.Collections.Concurrent.ConcurrentDictionary _sigTypeByFullName = new(); + + static Type? FindTypeByFullName(string fullName) => + _sigTypeByFullName.GetOrAdd(fullName, static fn => + { + try { var t = Type.GetType(fn, throwOnError: false); if (t != null) return t; } catch { } + // UNITY_6000_5_OR_NEWER: AppDomain.GetAssemblies can return already-unloaded assemblies + // in the editor (UAC0005, an error on 6000.6+); the pure-dotnet builds keep AppDomain. +#if UNITY_6000_5_OR_NEWER + foreach (var asm in UnityEngine.Assemblies.CurrentAssemblies.GetLoadedAssemblies()) +#else + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) +#endif + { + try { var t = asm.GetType(fn, throwOnError: false); if (t != null) return t; } catch { } + } + return null; + }); + + // Advance idx past one type in a sig blob without returning a Type. Returns false on error. + static bool SkipSigType(byte[] blob, ref int idx) + { + if (idx >= blob.Length) return false; + byte tag = blob[idx++]; + switch (tag) + { + case 0x01: case 0x02: case 0x03: case 0x04: case 0x05: + case 0x06: case 0x07: case 0x08: case 0x09: case 0x0A: + case 0x0B: case 0x0C: case 0x0D: case 0x0E: case 0x18: + case 0x19: case 0x1C: + return true; // primitives/native int/string/object — no extra bytes + case 0x11: case 0x12: + DecompressIntAdv(blob, ref idx); // coded index + return true; + case 0x10: case 0x1D: + return SkipSigType(blob, ref idx); // wrapper: skip inner type + case 0x13: case 0x1E: // VAR / MVAR: generic parameter reference + index + DecompressIntAdv(blob, ref idx); + return true; + case 0x15: // GENERICINST: (CLASS|VALUETYPE) coded-index argCount arg* + { + // A generic RETURN type (e.g. AsyncOperationHandle) must be skippable + // even though DecodeSigType can't produce it — otherwise param decoding never runs + // and multi-overload members (Addressables.InstantiateAsync/5) resolve ambiguous. + if (idx >= blob.Length) return false; + idx++; // CLASS or VALUETYPE tag + DecompressIntAdv(blob, ref idx); // coded TypeDefOrRef + int n = DecompressIntAdv(blob, ref idx); + for (int i = 0; i < n; i++) + if (!SkipSigType(blob, ref idx)) return false; + return true; + } + default: return false; // unsupported + } + } + + // Pick the open generic overload whose MemberRef parameter signature matches the call. + // sigIdx points at the RETURN type inside the GENERIC-calling-convention MemberRef blob + // (conv byte, genParamCount and paramCount already consumed). Each candidate is closed with + // typeArgs and its parameters compared structurally against the blob. Null = none matched. + static MethodInfo? SelectOpenGenericOverload(MetadataReader reader, List candidates, + byte[] sig, int sigIdx, Type[] typeArgs) + { + foreach (var cand in candidates) + { + MethodInfo closed; + try { closed = cand.MakeGenericMethod(typeArgs); } + catch { continue; } + var ps = closed.GetParameters(); + int idx = sigIdx; + if (!SkipSigType(sig, ref idx)) continue; // return type + bool ok = true; + for (int i = 0; i < ps.Length && ok; i++) + ok = SigTypeMatches(reader, sig, ref idx, ps[i].ParameterType, typeArgs); + if (ok) return cand; + } + return null; + } + + // Structural comparison of one signature-blob type against a resolved CLR type, advancing idx. + // TypeRef comparisons use short names (good enough to split overloads; full-name resolution + // isn't available for unregistered refs). VAR/MVAR compare against the instantiation's args. + static bool SigTypeMatches(MetadataReader reader, byte[] blob, ref int idx, Type expected, Type[] typeArgs) + { + if (idx >= blob.Length) return false; + byte tag = blob[idx++]; + switch (tag) + { + case 0x02: return expected == typeof(bool); + case 0x03: return expected == typeof(char); + case 0x04: return expected == typeof(sbyte); + case 0x05: return expected == typeof(byte); + case 0x06: return expected == typeof(short); + case 0x07: return expected == typeof(ushort); + case 0x08: return expected == typeof(int); + case 0x09: return expected == typeof(uint); + case 0x0A: return expected == typeof(long); + case 0x0B: return expected == typeof(ulong); + case 0x0C: return expected == typeof(float); + case 0x0D: return expected == typeof(double); + case 0x0E: return expected == typeof(string); + case 0x1C: return expected == typeof(object); + case 0x11: case 0x12: // VALUETYPE / CLASS + coded TypeDefOrRef + { + int coded = DecompressIntAdv(blob, ref idx); + if ((coded & 0x03) != 1) return false; // only TypeRef comparable here + var name = reader.GetString(reader.GetTypeReference( + MetadataTokens.TypeReferenceHandle(coded >> 2)).Name); + return expected.Name == name; + } + case 0x13: case 0x1E: // VAR / MVAR n → n-th type argument of this instantiation + { + int n = DecompressIntAdv(blob, ref idx); + return n < typeArgs.Length && expected == typeArgs[n]; + } + case 0x10: // BYREF + return expected.IsByRef && SigTypeMatches(reader, blob, ref idx, expected.GetElementType()!, typeArgs); + case 0x1D: // SZARRAY + return expected.IsArray && SigTypeMatches(reader, blob, ref idx, expected.GetElementType()!, typeArgs); + case 0x15: // GENERICINST: outer definition name + each type arg + { + if (idx >= blob.Length) return false; + idx++; // CLASS|VALUETYPE tag + int coded = DecompressIntAdv(blob, ref idx); + string outer = (coded & 0x03) == 1 + ? reader.GetString(reader.GetTypeReference(MetadataTokens.TypeReferenceHandle(coded >> 2)).Name) + : ""; + int n = DecompressIntAdv(blob, ref idx); + if (!expected.IsGenericType || expected.Name != outer) return false; + var eargs = expected.GetGenericArguments(); + if (eargs.Length != n) return false; + for (int i = 0; i < n; i++) + if (!SigTypeMatches(reader, blob, ref idx, eargs[i], typeArgs)) return false; + return true; + } + default: return false; + } + } + + static MethodInfo? FindMethod(Type type, string name, int paramCount) + { + foreach (var m in type.GetMethods( + BindingFlags.Public | BindingFlags.Instance | + BindingFlags.Static | BindingFlags.DeclaredOnly)) + if (m.Name == name && m.GetParameters().Length == paramCount) + return m; + return null; + } + + // Resolve flat-struct (Vt) layouts for a HostEntry's params/return/receiver, given + // the binding. Called for hostCalls and hostCtors after construction in Parse. + static void FillStructInfo(HostEntry he, HostBinding? binding, bool isCtor) + { + if (binding == null) return; + var ps = he.Binding.Params; + if (ps != null && ps.Length > 0) + { + var arr = new HostBinding.StructLayout?[ps.Length]; + bool any = false; + for (int i = 0; i < ps.Length; i++) + { + var pt = ps[i].ParameterType; + if (pt.IsByRef) pt = pt.GetElementType()!; + if (pt.IsValueType && binding.TryGetStructLayout(pt, out var lay)) + { arr[i] = lay; any = true; } + } + if (any) he.ArgStructs = arr; + } + var m = (MethodBase?)he.ResolvedMethod ?? he.Binding.Method; + if (isCtor) + { + // Ctor's "return" type is its declaring type. + var declT = he.Binding.DeclaringType; + if (declT != null && declT.IsValueType && binding.TryGetStructLayout(declT, out var declLay)) + he.ReturnStruct = declLay; + } + else if (m is MethodInfo mi) + { + var rt = mi.ReturnType; + if (rt.IsValueType && rt != typeof(void) && binding.TryGetStructLayout(rt, out var retLay)) + he.ReturnStruct = retLay; + // Receiver: for a value-type instance method, the receiver is a struct + // (rarely useful in our subset but tracked for completeness). + if (he.Binding.HasThis && mi.DeclaringType != null && mi.DeclaringType.IsValueType + && binding.TryGetStructLayout(mi.DeclaringType, out var recvLay)) + he.ReceiverStruct = recvLay; + } + } + + + // Walks one ParsedMethod's IL, simulates the eval stack symbolically to determine + // operand types, allocates frame slots, and emits uint[] IR. All methods in the + // assembly are lowered at Load time and cached on ParsedMethod.Lowered. + static class IrLowerer + { + // Returns a list of (methodName, reason) for any method that failed to lower. + // Empty list means full success. Load throws when this is non-empty. + // lenient=true demotes EVERY per-method lowering failure to a skipped stub (as cold + // enumerator members already are) instead of failing the whole Load — a method that can't + // lower is left with Lowered==null + a LoweringSkipReason, and the rest load normally. Hot + // reload uses this so one unsupported method (e.g. SetupUI referencing a member missing on + // the running build) doesn't poison every other reloaded method in the same file. Strict + // (default) stays all-or-nothing for the fuzzer/tests, where a failure is a bug to surface. + public static List<(string Name, string Reason)> LowerAll(ParsedAssembly asm, bool lenient = false) + { + var failures = new List<(string, string)>(); + foreach (var m in asm.ByToken.Values) + { + try { m.Lowered = LowerMethod(m, asm); } + catch (NotSupportedException ex) + { + // A cold enumerator member (iterator Reset — body is `throw new + // NotSupportedException()`) becomes a skipped stub instead of failing the + // whole Load: no driver ever calls it. Invoking it anyway throws the reason. + if (m.IsColdEnumeratorMember || lenient) { m.LoweringSkipReason = ex.Message; continue; } + failures.Add((m.Name, ex.Message)); + } + catch (Exception ex) + { + // A lowering crash must still name the METHOD: a raw IndexOutOfRangeException + // escaping Load reads as "Index was outside the bounds of the array" with + // nothing to act on. The stack rides along so a bug report is actionable. + var reason = $"internal error while lowering (an unsupported construct — please report): {ex}"; + if (m.IsColdEnumeratorMember || lenient) { m.LoweringSkipReason = reason; continue; } + failures.Add((m.Name, reason)); + } + } + + // A lowered method whose call sites target a skipped stub would jump through a null + // Lowered at runtime — demote such callers to failures too (cascading; defensive: + // in practice cold members are leaf Reset bodies nothing calls). + bool changed = true; + while (changed) + { + changed = false; + foreach (var m in asm.ByToken.Values) + { + var callees = m.Lowered?.CalleeByTokIdx; + if (callees == null) continue; + foreach (var callee in callees) + { + if (callee == null || callee.Lowered != null) continue; + var reason = $"calls '{callee.Name}', which could not be lowered: {callee.LoweringSkipReason}"; + m.Lowered = null!; + if (m.IsColdEnumeratorMember || lenient) m.LoweringSkipReason = reason; + else failures.Add((m.Name, reason)); + changed = true; + break; + } + } + } + return failures; + } + + // Concatenates every lowered method's IR into ParsedAssembly.IrBlob with branch targets + // rewritten blob-absolute, so the Vm can execute script-to-script calls iteratively (frame + // push + ip jump) under one `fixed` instead of recursing into Run(). Each method's region + // gets a trailing ret_void sentinel: it preserves both the old "fell off the end" return + // (void methods) and the lowerer's past-end fall-through branch targets, and stops + // execution from running into the next method's code. + public static void BuildIrBlob(ParsedAssembly asm) + { + int total = 0; + foreach (var m in asm.ByToken.Values) + if (m.Lowered != null) total += m.Lowered.Ir.Length + 1; + var blob = new uint[total]; + int off = 0; + foreach (var m in asm.ByToken.Values) + { + var lm = m.Lowered; + if (lm == null) continue; // skipped stub (cold enumerator member) + var ir = lm.Ir; + int n = ir.Length; + lm.IrStart = off; + Array.Copy(ir, 0, blob, off, n); + + // PatchInsnBranchTargets computes newTarget = t - shift[t]; a constant -off shift + // relocates every target (including past-end t == n, which lands on the sentinel). + var shift = new int[n + 1]; + for (int i = 0; i <= n; i++) shift[i] = -off; + int pos = 0; + while (pos < n) + { + var op = (Op)ir[pos]; + int w = OpWidthForCoalesce(op, ir, pos); + if (w <= 0) + throw new NotSupportedException($"IR blob: unknown width for op {op} in '{m.Name}'"); + PatchInsnBranchTargets(blob, off + pos, op, shift, ir, pos); + pos += w; + } + blob[off + n] = (uint)Op.ret_void; + off += n + 1; + } + asm.IrBlob = blob; + } + + // Simple (unqualified) type name of a TypeRef/TypeDef token, for matching a + // `constrained.` prefix's type against a flat struct layout's TypeName. Null for + // TypeSpecs (generic instantiations — never a registered flat struct) or bad tokens. + static string TokenSimpleTypeName(ParsedAssembly asm, int token) + { + try + { + var handle = MetadataTokens.Handle(token); + switch (handle.Kind) + { + case HandleKind.TypeReference: + return asm.Reader.GetString( + asm.Reader.GetTypeReference((TypeReferenceHandle)handle).Name); + case HandleKind.TypeDefinition: + return asm.Reader.GetString( + asm.Reader.GetTypeDefinition((TypeDefinitionHandle)handle).Name); + default: + return null!; + } + } + catch (Exception) + { + return null!; + } + } + + public static LoweredMethod LowerMethod(ParsedMethod method, ParsedAssembly asm) + { + var il = method.IlBytes; + var ir = new List(il.Length * 2); + var irToIl = new List(il.Length * 2); + var strings = new List(); + var tokens = new List(); + + // Frame layout: [args (one slot each, 0..ArgCount-1), + // locals (variable slot count — struct locals occupy ceil(size/4) slots), + // eval-stack slots (above)] + int frameSize = method.ArgCount; + // Local slot index → first frame-slot index. Filler slots (struct continuation) are + // not directly addressable but exist in slotTypes/structLayouts to keep AllocSlot monotonic. + var localFrameSlot = new int[method.LocalCount]; + + // Per-slot type table — grows alongside frameSize. + // Arg slots: slot 0 is `this` for instance methods; then explicit params from ArgSTypes. + // A flat-struct arg (Vt) occupies ceil(size/4) slots, so args after it shift — + // argFrameSlot maps arg index → frame slot (identity when no Vt args). + var slotTypes = new List(frameSize + 32); + // Parallel list of per-Vt-slot struct layouts. null for non-Vt slots and filler slots. + var slotStructs = new List(frameSize + 32); + var argFrameSlot = new int[method.ArgCount]; + bool argIndirect = false; + { + int sigParamBase = method.IsStatic ? 0 : 1; // slot 0 is `this` for instance methods + for (int i = 0; i < method.ArgCount; i++) + { + int sigIdx = i - sigParamBase; + SType t = (sigIdx >= 0 && sigIdx < method.ArgSTypes.Length) ? method.ArgSTypes[sigIdx] : SType.O; + // `this` of a flat script struct is ALSO Vt (instance methods on flat structs). + HostBinding.StructLayout? alay = null; + if (sigIdx >= 0 && method.ArgStructLayouts != null && sigIdx < method.ArgStructLayouts.Length) + alay = method.ArgStructLayouts[sigIdx]; + else if (sigIdx < 0 && method.ThisStructLayout != null) { alay = method.ThisStructLayout; t = SType.Vt; } + argFrameSlot[i] = slotTypes.Count; + if (t == SType.Vt && alay != null) + { + int slotsNeeded = (alay.Size + 3) / 4; + slotTypes.Add(SType.Vt); + slotStructs.Add(alay); + for (int k = 1; k < slotsNeeded; k++) { slotTypes.Add(SType.O); slotStructs.Add(null); } + argIndirect = true; + } + else + { + var at = t == SType.Vt ? SType.O : t; // Vt without layout: defensive O + slotTypes.Add(at); + slotStructs.Add(null); + if (at is SType.I8 or SType.R8) + { + // Wide arg: continuation cell, and later args shift — argFrameSlot maps. + slotTypes.Add(SType.O); + slotStructs.Add(null); + argIndirect = true; + } + } + } + frameSize = slotTypes.Count; + } + // Allocate locals — struct locals get Vt + filler slots whenever a layout exists for + // the local's type. Vt-typed operator dispatchers chain through Vt slots without + // boxing; only the crossing into a boxed host API pays a single box_vt. + var localStrs = method.LocalStructLayouts; + // A local with a FROZEN declared type (LocalSTypes) is typed authoritatively and never + // re-inferred/retyped — this restores the "one slot ⇒ one type ⇒ one backing store" + // invariant that last-wins inference broke, so a fast op is never emitted over a slot + // whose value is actually boxed. Null entries fall through to inference as before. + var localDeclared = new bool[method.LocalCount]; + for (int i = 0; i < method.LocalCount; i++) + { + var lay = localStrs?[i]; + bool useVt = lay != null; + localFrameSlot[i] = frameSize; + if (useVt) + { + int slotsNeeded = (lay!.Size + 3) / 4; + slotTypes.Add(SType.Vt); + slotStructs.Add(lay); + for (int k = 1; k < slotsNeeded; k++) + { + slotTypes.Add(SType.O); // filler slot: never addressed directly + slotStructs.Add(null); + } + frameSize += slotsNeeded; + } + else if (method.LocalSTypes?[i] is { } declSt) + { + slotTypes.Add(declSt); // frozen: declared type, not inferred + slotStructs.Add(null); + localDeclared[i] = true; + frameSize++; + if (declSt is SType.I8 or SType.R8) + { + slotTypes.Add(SType.O); // wide local: continuation cell + slotStructs.Add(null); + frameSize++; + } + } + else + { + slotTypes.Add(SType.O); // patched by pre-scan + slotStructs.Add(null); + frameSize++; + } + } + // Pre-scan: determine local types from first stloc in each slot. + // Skips struct-typed and declared-typed locals (already classified above). + PreClassifyLocals(il, method, slotTypes, localFrameSlot, localStrs, localDeclared); + + // Exception clauses: finally is lowered via the push_cont/br_cont continuation chain; + // catch/filter would need real exception dispatch — fail with the reason instead + // of a bare opcode number. (Fault clauses were dropped at Parse: their handlers only + // run under exception dispatch, which the interpreter never does.) + if (method.HasUnsupportedEhClauses) + throw new NotSupportedException( + $"IR lowering: '{method.Name}' contains a try/catch (or filter) clause — " + + "only try/finally is supported by the interpreter"); + + // Pre-scan: collect all IL offsets that are branch targets. + // Used to clear ensuredScriptSlots at basic-block boundaries. + var ilBranchTargets = CollectIlBranchTargets(il); + // Finally handler entries are reached via the lowered leave's br — treat them as + // basic-block boundaries too. + if (method.FinallyRegions != null) + foreach (var r in method.FinallyRegions) + ilBranchTargets.Add(r.HandlerStart); + // Fault handlers are never entered (no exception dispatch) but their bodies still + // lower — mark each entry a basic-block boundary so the linear scan doesn't carry + // stale eval-stack slot state into the dead region. + if (method.FaultHandlerStarts != null) + foreach (var h in method.FaultHandlerStarts) + ilBranchTargets.Add(h); + + // Symbolic eval stack — each entry is (frameSlot, SType) + var evalStack = new (int slot, SType type)[64]; + int sp = 0; + + // Address tracking: indexed by frame slot number (slots are allocated monotonically). + // addrTagBySlot[s]: -2 = normal slot; >= 0 = "frame-slot address" (points to that frame slot); + // -1 = "field address" (use addrFldObjBySlot[s] + addrFldTokBySlot[s]); + // -3 = "array element address" (addrFldObjBySlot[s] = arr slot, addrFldTokBySlot[s] = idx slot). + // Size 512 is a generous upper bound; slots never exceed a few dozen in practice. + var addrTagBySlot = new int[512]; + var addrFldObjBySlot = new int[512]; + var addrFldTokBySlot = new int[512]; + // Byte-offset accumulation for addresses that point INSIDE flat struct bytes: + // tag >= 0 → addrByteOffBySlot = offset within the frame Vt slot (0 = whole slot); + // tag == -1 → addrFldOffBySlot >= 0 = composed byte offset into the parent + // ScriptObject's PrimBytes (-1 = classic token-addressed field phantom). + // Composition happens along INLINE segments (nested blittable structs); at a + // reference boundary (O field / array element) the chain REBASES onto the loaded ref. + var addrByteOffBySlot = new int[512]; + var addrFldOffBySlot = new int[512]; + // For -3 (array element) phantoms whose ARRAY has flat struct elements: the element + // layout (consumers materialize via ldelem_vt + write back via stelem_vt). + var addrElemLayoutBySlot = new HostBinding.StructLayout?[512]; + for (int i = 0; i < 512; i++) { addrTagBySlot[i] = -2; addrFldOffBySlot[i] = -1; } + // newarr_vt element layouts, resolved at lowering (sparse, keyed by tokIdx). + Dictionary? layoutByTokIdx = null; + // Primitive typed-array element types (bool/char/byte/…), resolved at lowering. + Dictionary? primArrayElemTypeByTokIdx = null; + // Delegate-creation sites (ldftn + newobj), resolved at lowering (sparse, by tokIdx). + Dictionary? delegateSiteByTokIdx = null; + + // Per-local and per-arg type tracking (updated on store, read on load) + var localTypes = new SType[method.LocalCount]; // synced from slotTypes + var argTypes = new SType[method.ArgCount]; // synced from slotTypes + + for (int i = 0; i < method.ArgCount; i++) argTypes[i] = slotTypes[argFrameSlot[i]]; + for (int i = 0; i < method.LocalCount; i++) localTypes[i] = slotTypes[localFrameSlot[i]]; + + // A script-defined struct local is always an O ScriptObject. PreClassifyLocals infers a + // local's type from the first value stored into it, and a generic `ldelem ` (from + // `s = arr[i]`) can be mis-inferred as I4 — which would then suppress clone-on-load and + // corrupt struct value semantics. Force these locals back to O. + if (method.LocalIsScriptStruct != null) + for (int i = 0; i < method.LocalCount; i++) + if (method.LocalIsScriptStruct[i]) + { + localTypes[i] = SType.O; + if (localFrameSlot[i] < slotTypes.Count) slotTypes[localFrameSlot[i]] = SType.O; + + // CLR localsinit: a struct local is usable without any initobj — Roslyn + // relies on it for local-function DISPLAY STRUCTS (`V_0.mult = 3` straight + // into the uninitialized local, then `call Scale(ref V_0)`). The O slot + // starts null here, so create the ScriptObject in the prologue. + if (method.LocalScriptStructTypeDefs != null + && method.LocalScriptStructTypeDefs[i] is var lsTok && lsTok != 0) + { + int lsTokIdx = tokens.Count; tokens.Add(lsTok); + ir.Add((uint)Op.initobj_script); irToIl.Add(0); + ir.Add((uint)localFrameSlot[i]); irToIl.Add(0); + ir.Add((uint)lsTokIdx); irToIl.Add(0); + } + } + + // IL offset → IR word index (for branch patching) + var ilToIrIp = new Dictionary(); + // Forward branches that need patching: (ir_word_index_of_target, il_target_offset) + var patchList = new List<(int irIdx, int ilTarget)>(); + // Branch-target → authoritative eval-stack depth delivered by its incoming edge(s). + // Recorded at every FORWARD branch (the delivered depth after the branch's own stack + // effect). In structured IL all edges into a join agree on depth, so when the linear + // walk reaches a target this value is authoritative: it drops any dead carry left on the + // stack after a preceding unconditional branch (e.g. a ternary then-arm result at the + // ELSE label) while preserving values that live *beneath* a short-circuit region (e.g. + // the `==` left operand while its right side is a `&&`/`||` chain). + var branchTargetDepth = new Dictionary(); + // Record a forward branch's delivered depth at its target (backward edges — loop headers — + // are already past in the linear walk, so they never need reconciling here). + void RecordJoinDepth(int src, int target, int depth) + { + if (target > src) branchTargetDepth[target] = depth; + } + // Ternary / short-circuit merge: END offset -> the fresh slot BOTH branches converge into. + // Each branch moves its result into this slot at its own tail; the join reads it. Using a + // fresh slot (never a branch's own value slot) is essential — the value slot may be a real + // local, or the two branches may leave their results in different slots. + var mergeSlotForEnd = new Dictionary(); + + // O slots known to hold a non-null ScriptObject in the current basic block. + // Cleared at branch targets and after any branch/call instruction. + // Allows the Stfld lowerer to skip redundant ensure_script ops. + var ensuredScriptSlots = new HashSet(); + + // Tracks whether the immediately preceding instruction was a `dup`. Roslyn emits + // `dup; brtrue`/`dup; brfalse` only for the null-coalescing / null-conditional idioms + // (`a ?? b`, `a?.b`), where the value left under the popped condition is a genuine + // forward-join carry. A plain conditional branch with no preceding dup (if/while/&&/||) + // leaves only base operands beneath, which must NOT be merged. + bool lastWasDup = false; + + // Type token of a pending `constrained.` prefix, consumed by the next call lowering. + // Disambiguates a Vt receiver: constrained to the slot's OWN struct type → box the + // whole struct; constrained to a primitive → extract the field at the byte offset. + int constrainedTok = 0; + + // Method token of a pending ldftn/ldvirtftn, consumed by the delegate-ctor Newobj that + // C# always emits immediately after (`ldnull|recv[; dup]; ld(virt)ftn M; newobj D`). + // Anything else between them is unsupported IL for this lowerer. + int pendingFtnTok = 0; + + // Byref locals carry an ADDRESS. Roslyn spills a managed pointer (&x.f from ldflda, + // &a[i] from ldelema, &local from ldloca) into a local when control flow sits between + // the address and its use — e.g. `x.f op= (cond switch {...})` or `a[i].Mut((cond) + // switch {...})`, where the switch-expr's branches force the spill. That address is a + // lowering-time PHANTOM (per-slot addr* metadata, no runtime value), so a plain + // stloc/ldloc dropped it and the later stind/call saw a non-address operand. Carry the + // phantom through the local by stashing its metadata ON THE LOCAL'S FRAME SLOT (the + // addr* arrays are per-slot and stable) and restoring a fresh phantom on load. + // (found by fuzzing: switch-expr in a compound-assign RHS / struct-array Mut arg.) + bool TryStoreLocalPhantom(int localIdx) + { + int src = evalStack[sp - 1].Item1; + if (addrTagBySlot[src] == -2) return false; // ordinary value → normal stloc + int dst = localFrameSlot[localIdx]; + addrTagBySlot[dst] = addrTagBySlot[src]; + addrFldObjBySlot[dst] = addrFldObjBySlot[src]; + addrFldTokBySlot[dst] = addrFldTokBySlot[src]; + addrByteOffBySlot[dst] = addrByteOffBySlot[src]; + addrFldOffBySlot[dst] = addrFldOffBySlot[src]; + addrElemLayoutBySlot[dst] = addrElemLayoutBySlot[src]; + sp--; // consume; the phantom is metadata-only, no runtime mov + return true; + } + bool TryLoadLocalPhantom(int localIdx) + { + int srcSlot = localFrameSlot[localIdx]; + if (addrTagBySlot[srcSlot] == -2) return false; + int ph = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + addrTagBySlot[ph] = addrTagBySlot[srcSlot]; + addrFldObjBySlot[ph] = addrFldObjBySlot[srcSlot]; + addrFldTokBySlot[ph] = addrFldTokBySlot[srcSlot]; + addrByteOffBySlot[ph] = addrByteOffBySlot[srcSlot]; + addrFldOffBySlot[ph] = addrFldOffBySlot[srcSlot]; + addrElemLayoutBySlot[ph] = addrElemLayoutBySlot[srcSlot]; + evalStack[sp++] = (ph, SType.O); + return true; + } + + int ip = 0; + while (ip < il.Length) + { + // The addr phantom tables are indexed by frame slot and evalStack by stack depth; + // both start at a size that covers ordinary methods, but generated/pathological + // code can allocate thousands of temp slots (found by fuzzing: IndexOutOfRange on + // addrTagBySlot). Grow here, once per instruction — no single instruction + // allocates anywhere near 256 slots, and the locals are re-read by every helper + // call below, so Array.Resize's new arrays propagate. + if (frameSize + 256 > addrTagBySlot.Length) + { + int oldCap = addrTagBySlot.Length; + int newCap = Math.Max(oldCap * 2, frameSize + 512); + Array.Resize(ref addrTagBySlot, newCap); + Array.Resize(ref addrFldObjBySlot, newCap); + Array.Resize(ref addrFldTokBySlot, newCap); + Array.Resize(ref addrByteOffBySlot, newCap); + Array.Resize(ref addrFldOffBySlot, newCap); + Array.Resize(ref addrElemLayoutBySlot, newCap); + // Same defaults as the initial fill: -2 = normal slot, -1 = token-addressed. + for (int i = oldCap; i < newCap; i++) { addrTagBySlot[i] = -2; addrFldOffBySlot[i] = -1; } + } + if (sp + 8 > evalStack.Length) + Array.Resize(ref evalStack, evalStack.Length * 2); + + int instrStart = ip; + // Basic-block boundary: clear ensuredScriptSlots when the current instruction + // is a branch target (we may have arrived via a backward edge or a jump). + if (ilBranchTargets.Contains(instrStart)) ensuredScriptSlots.Clear(); + // Reconcile the abstract eval-stack depth with the depth the branch edges deliver. + // The base slots are identical on every edge (they were computed before the diamond), + // so the current walk's evalStack entries remain valid — only the depth needs fixing. + if (branchTargetDepth.TryGetValue(instrStart, out var joinDepth)) + sp = joinDepth; + // Ternary/merge join: the second branch's result is on top; move it into the merge + // slot M so both paths converge on M. Done BEFORE recording ilToIrIp[ip] so the first + // branch's `br` (patched to this offset) lands AFTER the mov, not on top of it. + if (mergeSlotForEnd.TryGetValue(instrStart, out var mergeJoin)) + { + if (sp > 0) + { + var (joinTop, _) = evalStack[sp - 1]; + if (joinTop != mergeJoin.slot) + Emit3(ir, irToIl, mergeJoin.type == SType.Vt ? Op.mov_vt : Op.mov, + mergeJoin.slot, joinTop, instrStart); + evalStack[sp - 1] = (mergeJoin.slot, mergeJoin.type); + } + mergeSlotForEnd.Remove(instrStart); + } + // Record the mapping BEFORE emitting IR words + int irIp = ir.Count; + ilToIrIp[ip] = irIp; + + byte b = il[ip++]; + ILOpCode op = b == 0xFE ? (ILOpCode)(0xFE00 | il[ip++]) : (ILOpCode)b; + + // A loaded function pointer is only modeled as the immediate operand of a + // delegate ctor; any other consumer would see a placeholder slot. + if (pendingFtnTok != 0 && op != ILOpCode.Newobj) + throw new NotSupportedException( + $"IR lowering: ldftn in '{method.Name}' at IL+0x{instrStart:X4} is not part of a delegate construction"); + + bool afterDup = lastWasDup; + lastWasDup = false; + + switch (op) + { + case ILOpCode.Nop: break; + + case ILOpCode.Pop: + sp--; + break; + + case ILOpCode.Dup: + { + lastWasDup = true; + var (src, t) = evalStack[sp - 1]; + // Phantom-address slots aren't real values — duplicate by sharing the + // tag without emitting a mov. Required for `arr[i] += v` (ldelema; dup; + // ldind; add; stind) so both ldind and stind see the same array address. + if (addrTagBySlot[src] != -2) + { + int phDup = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + addrTagBySlot[phDup] = addrTagBySlot[src]; + addrFldObjBySlot[phDup] = addrFldObjBySlot[src]; + addrFldTokBySlot[phDup] = addrFldTokBySlot[src]; + addrByteOffBySlot[phDup] = addrByteOffBySlot[src]; + addrFldOffBySlot[phDup] = addrFldOffBySlot[src]; + addrElemLayoutBySlot[phDup] = addrElemLayoutBySlot[src]; + evalStack[sp++] = (phDup, SType.O); + break; + } + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, t, null); + evalStack[sp++] = (dst, t); + Emit3(ir, irToIl, Op.mov, dst, src, instrStart); + break; + } + + // --- Constants --- + case ILOpCode.Ldc_i4_m1: PushConst(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, -1, SType.I4, Op.ldc_i4, instrStart); break; + case ILOpCode.Ldc_i4_0: PushConst(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, 0, SType.I4, Op.ldc_i4, instrStart); break; + case ILOpCode.Ldc_i4_1: PushConst(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, 1, SType.I4, Op.ldc_i4, instrStart); break; + case ILOpCode.Ldc_i4_2: PushConst(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, 2, SType.I4, Op.ldc_i4, instrStart); break; + case ILOpCode.Ldc_i4_3: PushConst(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, 3, SType.I4, Op.ldc_i4, instrStart); break; + case ILOpCode.Ldc_i4_4: PushConst(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, 4, SType.I4, Op.ldc_i4, instrStart); break; + case ILOpCode.Ldc_i4_5: PushConst(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, 5, SType.I4, Op.ldc_i4, instrStart); break; + case ILOpCode.Ldc_i4_6: PushConst(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, 6, SType.I4, Op.ldc_i4, instrStart); break; + case ILOpCode.Ldc_i4_7: PushConst(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, 7, SType.I4, Op.ldc_i4, instrStart); break; + case ILOpCode.Ldc_i4_8: PushConst(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, 8, SType.I4, Op.ldc_i4, instrStart); break; + case ILOpCode.Ldc_i4_s: PushConst(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, (int)(sbyte)il[ip++], SType.I4, Op.ldc_i4, instrStart); break; + case ILOpCode.Ldc_i4: + { + int imm = BitConverter.ToInt32(il, ip); ip += 4; + PushConst(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, imm, SType.I4, Op.ldc_i4, instrStart); + break; + } + case ILOpCode.Ldc_r4: + { + // Store float bits as int (bit-identical, read back with UInt32BitsToSingle in executor) + int imm = (int)BitConverter.ToUInt32(il, ip); ip += 4; + PushConst(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, imm, SType.R4, Op.ldc_r4, instrStart); + break; + } + case ILOpCode.Ldc_r8: + { + // Real double constant into a wide R8 slot (bits split across two IR words). + ulong bits = BitConverter.ToUInt64(il, ip); ip += 8; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.R8, null); + evalStack[sp++] = (dst, SType.R8); + ir.Add((uint)Op.ldc_r8); irToIl.Add(instrStart); + ir.Add((uint)dst); irToIl.Add(instrStart); + ir.Add((uint)(bits & 0xFFFFFFFF)); irToIl.Add(instrStart); + ir.Add((uint)(bits >> 32)); irToIl.Add(instrStart); + break; + } + case ILOpCode.Ldc_i8: + { + ulong bits = BitConverter.ToUInt64(il, ip); ip += 8; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I8, null); + evalStack[sp++] = (dst, SType.I8); + ir.Add((uint)Op.ldc_i8); irToIl.Add(instrStart); + ir.Add((uint)dst); irToIl.Add(instrStart); + ir.Add((uint)(bits & 0xFFFFFFFF)); irToIl.Add(instrStart); + ir.Add((uint)(bits >> 32)); irToIl.Add(instrStart); + break; + } + case ILOpCode.Ldnull: + { + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + evalStack[sp++] = (dst, SType.O); + Emit2(ir, irToIl, Op.ldnull, dst, instrStart); + break; + } + case ILOpCode.Ldstr: + { + int tok = BitConverter.ToInt32(il, ip); ip += 4; + string s = asm.Reader.GetUserString(MetadataTokens.UserStringHandle(tok & 0x00FFFFFF)); + int strIdx = strings.Count; strings.Add(s); + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + evalStack[sp++] = (dst, SType.O); + Emit3(ir, irToIl, Op.ldstr, dst, strIdx, instrStart); + break; + } + + // --- Locals --- + case ILOpCode.Ldloc_0: if (!TryLoadLocalPhantom(0)) PushLocal(ir, irToIl, evalStack, ref sp, ref frameSize, 0, localFrameSlot, localTypes, slotTypes, slotStructs, method.LocalIsScriptStruct, instrStart); break; + case ILOpCode.Ldloc_1: if (!TryLoadLocalPhantom(1)) PushLocal(ir, irToIl, evalStack, ref sp, ref frameSize, 1, localFrameSlot, localTypes, slotTypes, slotStructs, method.LocalIsScriptStruct, instrStart); break; + case ILOpCode.Ldloc_2: if (!TryLoadLocalPhantom(2)) PushLocal(ir, irToIl, evalStack, ref sp, ref frameSize, 2, localFrameSlot, localTypes, slotTypes, slotStructs, method.LocalIsScriptStruct, instrStart); break; + case ILOpCode.Ldloc_3: if (!TryLoadLocalPhantom(3)) PushLocal(ir, irToIl, evalStack, ref sp, ref frameSize, 3, localFrameSlot, localTypes, slotTypes, slotStructs, method.LocalIsScriptStruct, instrStart); break; + case ILOpCode.Ldloc_s: { int li = il[ip++]; if (!TryLoadLocalPhantom(li)) PushLocal(ir, irToIl, evalStack, ref sp, ref frameSize, li, localFrameSlot, localTypes, slotTypes, slotStructs, method.LocalIsScriptStruct, instrStart); break; } + case ILOpCode.Ldloc: { int li = ReadU16(il, ref ip); if (!TryLoadLocalPhantom(li)) PushLocal(ir, irToIl, evalStack, ref sp, ref frameSize, li, localFrameSlot, localTypes, slotTypes, slotStructs, method.LocalIsScriptStruct, instrStart); break; } + case ILOpCode.Stloc_0: if (!TryStoreLocalPhantom(0)) { addrTagBySlot[localFrameSlot[0]] = -2; StoreLocal(ir, irToIl, evalStack, ref sp, 0, localFrameSlot, localTypes, slotTypes, slotStructs, localDeclared, ref frameSize, instrStart); } break; + case ILOpCode.Stloc_1: if (!TryStoreLocalPhantom(1)) { addrTagBySlot[localFrameSlot[1]] = -2; StoreLocal(ir, irToIl, evalStack, ref sp, 1, localFrameSlot, localTypes, slotTypes, slotStructs, localDeclared, ref frameSize, instrStart); } break; + case ILOpCode.Stloc_2: if (!TryStoreLocalPhantom(2)) { addrTagBySlot[localFrameSlot[2]] = -2; StoreLocal(ir, irToIl, evalStack, ref sp, 2, localFrameSlot, localTypes, slotTypes, slotStructs, localDeclared, ref frameSize, instrStart); } break; + case ILOpCode.Stloc_3: if (!TryStoreLocalPhantom(3)) { addrTagBySlot[localFrameSlot[3]] = -2; StoreLocal(ir, irToIl, evalStack, ref sp, 3, localFrameSlot, localTypes, slotTypes, slotStructs, localDeclared, ref frameSize, instrStart); } break; + case ILOpCode.Stloc_s: { int li = il[ip++]; if (!TryStoreLocalPhantom(li)) { addrTagBySlot[localFrameSlot[li]] = -2; StoreLocal(ir, irToIl, evalStack, ref sp, li, localFrameSlot, localTypes, slotTypes, slotStructs, localDeclared, ref frameSize, instrStart); } break; } + case ILOpCode.Stloc: { int li = ReadU16(il, ref ip); if (!TryStoreLocalPhantom(li)) { addrTagBySlot[localFrameSlot[li]] = -2; StoreLocal(ir, irToIl, evalStack, ref sp, li, localFrameSlot, localTypes, slotTypes, slotStructs, localDeclared, ref frameSize, instrStart); } break; } + + // ldloca / ldloca.s — push a phantom address of the local's frame slot. + // The consuming instruction (ldind.*, stind.*, call .ctor) resolves it. + case ILOpCode.Ldloca_s: + case ILOpCode.Ldloca: + { + int localIdx = op == ILOpCode.Ldloca_s ? il[ip++] : ReadU16(il, ref ip); + int frameSlot = localFrameSlot[localIdx]; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); // phantom O slot + addrTagBySlot[dst] = frameSlot; // frame-slot address + evalStack[sp++] = (dst, SType.O); + break; + } + // ldarga / ldarga.s — push a phantom address of the arg's frame slot. + case ILOpCode.Ldarga_s: + case ILOpCode.Ldarga: + { + int argIdx = op == ILOpCode.Ldarga_s ? il[ip++] : ReadU16(il, ref ip); + int frameSlot = argFrameSlot[argIdx]; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); // phantom O slot + addrTagBySlot[dst] = frameSlot; // frame-slot address + evalStack[sp++] = (dst, SType.O); + break; + } + + // --- Args --- + case ILOpCode.Ldarg_0: PushArg(evalStack, ref sp, 0, argTypes, argFrameSlot); break; + case ILOpCode.Ldarg_1: PushArg(evalStack, ref sp, 1, argTypes, argFrameSlot); break; + case ILOpCode.Ldarg_2: PushArg(evalStack, ref sp, 2, argTypes, argFrameSlot); break; + case ILOpCode.Ldarg_3: PushArg(evalStack, ref sp, 3, argTypes, argFrameSlot); break; + case ILOpCode.Ldarg_s: PushArg(evalStack, ref sp, il[ip++], argTypes, argFrameSlot); break; + case ILOpCode.Ldarg: PushArg(evalStack, ref sp, ReadU16(il, ref ip), argTypes, argFrameSlot); break; + case ILOpCode.Starg_s: StoreArg(ir, irToIl, evalStack, ref sp, il[ip++], argTypes, argFrameSlot, slotTypes, slotStructs, ref frameSize, instrStart); break; + case ILOpCode.Starg: StoreArg(ir, irToIl, evalStack, ref sp, ReadU16(il, ref ip), argTypes, argFrameSlot, slotTypes, slotStructs, ref frameSize, instrStart); break; + + // --- Arithmetic --- + case ILOpCode.Add: EmitBinop(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.add_i4, Op.add_r4, Op.add_i4_nn, Op.add_r4_nn, instrStart); break; + case ILOpCode.Sub: EmitBinop(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.sub_i4, Op.sub_r4, Op.sub_i4_nn, Op.sub_r4_nn, instrStart); break; + case ILOpCode.Mul: EmitBinop(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.mul_i4, Op.mul_r4, Op.mul_i4_nn, Op.mul_r4_nn, instrStart); break; + case ILOpCode.Div: EmitBinop(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.div_i4, Op.div_r4, Op.div_i4_nn, Op.div_r4_nn, instrStart); break; + case ILOpCode.Rem: EmitBinop(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.rem_i4, Op.rem_r4, Op.rem_i4_nn, Op.rem_r4_nn, instrStart); break; + case ILOpCode.Div_un: EmitBinopI4(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.div_un_i4, Op.div_un_i4_nn, instrStart); break; + case ILOpCode.Rem_un: EmitBinopI4(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.rem_un_i4, Op.rem_un_i4_nn, instrStart); break; + case ILOpCode.Neg: EmitUnop(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.neg_i4, Op.neg_r4, Op.neg_i4_n, Op.neg_r4_n, instrStart); break; + case ILOpCode.And: EmitBinopI4(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.and_i4, Op.and_i4_nn, instrStart); break; + case ILOpCode.Or: EmitBinopI4(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.or_i4, Op.or_i4_nn, instrStart); break; + case ILOpCode.Xor: EmitBinopI4(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.xor_i4, Op.xor_i4_nn, instrStart); break; + case ILOpCode.Not: EmitUnopI4(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.not_i4, Op.not_i4_n, instrStart); break; + case ILOpCode.Shl: EmitBinopI4(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.shl_i4, Op.shl_i4_nn, instrStart); break; + case ILOpCode.Shr: EmitBinopI4(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.shr_i4, Op.shr_i4_nn, instrStart); break; + case ILOpCode.Shr_un: EmitBinopI4(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.shr_un_i4, Op.shr_un_i4_nn, instrStart); break; + + // --- Comparisons --- + case ILOpCode.Ceq: + { + var (s2, t2) = evalStack[--sp]; + var (s1, t1) = evalStack[sp - 1]; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I4, null); + Op cop; + if (t1 == SType.R8 || t2 == SType.R8) + cop = Op.ceq_r8; + else if (t1 == SType.I8 || t2 == SType.I8) + cop = Op.ceq_i8; + else if (t1 == SType.R4 || t2 == SType.R4) + cop = (t1 == SType.R4 && t2 == SType.R4 && slotTypes[s1] == SType.R4 && slotTypes[s2] == SType.R4) ? Op.ceq_r4_nn : Op.ceq_r4; + else if (t1 == SType.I4 || t2 == SType.I4) + // ceq_i4 reads each operand from numStack or refStack based on slotT at runtime, + // so it handles mixed O/I4 cases (e.g. boxed int from a non-flat host call vs. ldc.i4). + cop = (slotTypes[s1] == SType.I4 && slotTypes[s2] == SType.I4) ? Op.ceq_i4_nn : Op.ceq_i4; + else + cop = Op.ceq_o; + evalStack[sp - 1] = (dst, SType.I4); + Emit4(ir, irToIl, cop, dst, s1, s2, instrStart); + break; + } + case ILOpCode.Cgt: + { + var (s2, t2) = evalStack[--sp]; + var (s1, t1) = evalStack[sp - 1]; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I4, null); + Op cop; + if (t1 == SType.R8 || t2 == SType.R8) + cop = Op.cgt_r8; + else if (t1 == SType.I8 || t2 == SType.I8) + cop = Op.cgt_i8; + else if (t1 == SType.R4 || t2 == SType.R4) + cop = (t1 == SType.R4 && t2 == SType.R4 && slotTypes[s1] == SType.R4 && slotTypes[s2] == SType.R4) ? Op.cgt_r4_nn : Op.cgt_r4; + else + cop = (slotTypes[s1] == SType.I4 && slotTypes[s2] == SType.I4) ? Op.cgt_i4_nn : Op.cgt_i4; + evalStack[sp - 1] = (dst, SType.I4); + Emit4(ir, irToIl, cop, dst, s1, s2, instrStart); + break; + } + case ILOpCode.Cgt_un: + { + var (s2, t2) = evalStack[--sp]; + var (s1, t1) = evalStack[sp - 1]; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I4, null); + Op cop; + if (t1 == SType.R8 || t2 == SType.R8) + cop = Op.cgt_un_r8; + else if (t1 == SType.I8 || t2 == SType.I8) + cop = Op.cgt_un_i8; + else if (t1 == SType.R4 || t2 == SType.R4) + cop = Op.cgt_un_r4; // float `<=` lowers to `cgt.un` — unordered greater-than + else if (t1 == SType.O) + cop = Op.cgt_un_o; + else + cop = (slotTypes[s1] == SType.I4 && slotTypes[s2] == SType.I4) ? Op.cgt_un_i4_nn : Op.cgt_un_i4; + evalStack[sp - 1] = (dst, SType.I4); + Emit4(ir, irToIl, cop, dst, s1, s2, instrStart); + break; + } + case ILOpCode.Clt: + { + var (s2, t2) = evalStack[--sp]; + var (s1, t1) = evalStack[sp - 1]; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I4, null); + Op cop; + if (t1 == SType.R8 || t2 == SType.R8) + cop = Op.clt_r8; + else if (t1 == SType.I8 || t2 == SType.I8) + cop = Op.clt_i8; + else if (t1 == SType.R4 || t2 == SType.R4) + cop = (t1 == SType.R4 && t2 == SType.R4 && slotTypes[s1] == SType.R4 && slotTypes[s2] == SType.R4) ? Op.clt_r4_nn : Op.clt_r4; + else + cop = (slotTypes[s1] == SType.I4 && slotTypes[s2] == SType.I4) ? Op.clt_i4_nn : Op.clt_i4; + evalStack[sp - 1] = (dst, SType.I4); + Emit4(ir, irToIl, cop, dst, s1, s2, instrStart); + break; + } + case ILOpCode.Clt_un: + { + var (s2, t2) = evalStack[--sp]; + var (s1, t1) = evalStack[sp - 1]; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I4, null); + evalStack[sp - 1] = (dst, SType.I4); + if (t1 == SType.R8 || t2 == SType.R8) + { + Emit4(ir, irToIl, Op.clt_un_r8, dst, s1, s2, instrStart); + break; + } + if (t1 == SType.I8 || t2 == SType.I8) + { + Emit4(ir, irToIl, Op.clt_un_i8, dst, s1, s2, instrStart); + break; + } + if (t1 == SType.R4 || t2 == SType.R4) + { + Emit4(ir, irToIl, Op.clt_un_r4, dst, s1, s2, instrStart); // float `>=` lowers to `clt.un` + break; + } + bool both_i4 = t1 == SType.I4 && t2 == SType.I4 && slotTypes[s1] == SType.I4 && slotTypes[s2] == SType.I4; + Emit4(ir, irToIl, both_i4 ? Op.clt_un_i4_nn : Op.clt_un_i4, dst, s1, s2, instrStart); + break; + } + + // Box is a no-op: values already live unboxed in the frame; boxing + // happens at host-call boundaries. + case ILOpCode.Box: + { + int tok = BitConverter.ToInt32(il, ip); ip += 4; + // Materialize a REAL, correctly-typed boxed object for primitive value types. + // Leaving box a no-op kept the value in its I4/R4 slot: the target `object` + // local was then classified I4 (so `object a=5; object b=5; a==b` compared by + // value, not reference) and bool/char boxed as int (Debug.Log(true) -> "1"). + // Reference types are already O (box is identity); flat structs box on store. + int tc = BoxPrimTypeCode(asm, tok); + if (tc >= 0) + { + var (src, _) = evalStack[sp - 1]; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + evalStack[sp - 1] = (dst, SType.O); + Emit4(ir, irToIl, Op.box_prim, dst, src, tc, instrStart); + } + // HOST enum: box as the real enum type, not the underlying int — + // `$"state={m}"` / Debug.Log(m) must render the member name. Script- + // declared enums (TypeDefs) have no runtime Type and keep the int form. + else if (ResolveHostEnumType(asm, tok) is { } enumType) + { + int tokIdx = tokens.Count; tokens.Add(tok); + asm.TokenTypes[tok] = enumType; // executor resolves through TokenTypes + var (src, _) = evalStack[sp - 1]; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + evalStack[sp - 1] = (dst, SType.O); + Emit4(ir, irToIl, Op.box_enum, dst, src, tokIdx, instrStart); + } + break; + } + + // --- Castclass / isinst / unbox.any / ldtoken --- + case ILOpCode.Castclass: + { + int tok = BitConverter.ToInt32(il, ip); ip += 4; + int tokIdx = tokens.Count; tokens.Add(tok); + var (src, _) = evalStack[sp - 1]; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + evalStack[sp - 1] = (dst, SType.O); + Emit4(ir, irToIl, Op.castclass, dst, src, tokIdx, instrStart); + break; + } + case ILOpCode.Isinst: + { + int tok = BitConverter.ToInt32(il, ip); ip += 4; + int tokIdx = tokens.Count; tokens.Add(tok); + var (src, _) = evalStack[sp - 1]; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + evalStack[sp - 1] = (dst, SType.O); + Emit4(ir, irToIl, Op.isinst, dst, src, tokIdx, instrStart); + break; + } + case ILOpCode.Unbox_any: + { + int tok = BitConverter.ToInt32(il, ip); ip += 4; + int tokIdx = tokens.Count; tokens.Add(tok); + var (src, _) = evalStack[sp - 1]; + // Type the result by the TARGET, not the boxed source: `(int)(object)x` must + // yield an I4 value, not an O. Keeping the source's O tag reclassified the + // destination local as a ref slot (e.g. `int acc2 = (int)(object)k` made acc2 + // O-typed, so later int reads saw 0). Primitives -> I4/R4; ref/struct/enum -> O. + var targetSt = PrimitiveSTypeForTypeToken(asm, tok) ?? SType.O; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, targetSt, null); + evalStack[sp - 1] = (dst, targetSt); + Emit4(ir, irToIl, Op.unbox_any, dst, src, tokIdx, instrStart); + break; + } + case ILOpCode.Ldtoken: + { + int tok = BitConverter.ToInt32(il, ip); ip += 4; + int tokIdx = tokens.Count; tokens.Add(tok); + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + evalStack[sp++] = (dst, SType.O); + Emit3(ir, irToIl, Op.ldtoken, dst, tokIdx, instrStart); + break; + } + + // --- Conversions --- + case ILOpCode.Conv_i4: + case ILOpCode.Conv_u4: + { + var (src, st) = evalStack[sp - 1]; + if (st == SType.I4) break; // already int + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I4, null); + evalStack[sp - 1] = (dst, SType.I4); + var cvOp = st == SType.I8 ? Op.conv_i4_i8 + : st == SType.R8 ? Op.conv_i4_r8 + : Op.conv_r4_i4; + Emit3(ir, irToIl, cvOp, dst, src, instrStart); + break; + } + case ILOpCode.Conv_i8: + case ILOpCode.Conv_u8: + { + var (src, st) = evalStack[sp - 1]; + if (st == SType.I8) break; // already 64-bit + // conv.i8 sign-extends an i4; conv.u8 zero-extends it. From float sources + // only the SIGNED form is supported — (ulong)someFloat stays loud. + bool zext = op == ILOpCode.Conv_u8; + Op cvOp; + if (st == SType.R4) { if (zext) goto notSupported; cvOp = Op.conv_i8_r4; } + else if (st == SType.R8) { if (zext) goto notSupported; cvOp = Op.conv_i8_r8; } + else cvOp = zext ? Op.conv_i8_u4 : Op.conv_i8_i4; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I8, null); + evalStack[sp - 1] = (dst, SType.I8); + Emit3(ir, irToIl, cvOp, dst, src, instrStart); + break; + } + case ILOpCode.Conv_r_un: + { + // Unsigned int → floating: ulong/uint reinterpreted unsigned, result R8 + // (a following conv.r4 narrows it). + var (src, st) = evalStack[sp - 1]; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.R8, null); + evalStack[sp - 1] = (dst, SType.R8); + if (st == SType.I8) + Emit3(ir, irToIl, Op.conv_r8_u8, dst, src, instrStart); + else + { + // u4 source: zero-extend to I8 first, then unsigned-convert. + int wide = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I8, null); + Emit3(ir, irToIl, Op.conv_i8_u4, wide, src, instrStart); + Emit3(ir, irToIl, Op.conv_r8_u8, dst, wide, instrStart); + } + break; + } + case ILOpCode.Conv_i1: + { + var (src2, _) = evalStack[sp - 1]; + int dst2 = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I4, null); + evalStack[sp - 1] = (dst2, SType.I4); + Emit3(ir, irToIl, Op.conv_i4_i1, dst2, src2, instrStart); + break; + } + case ILOpCode.Conv_u1: + { + var (src2, _) = evalStack[sp - 1]; + int dst2 = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I4, null); + evalStack[sp - 1] = (dst2, SType.I4); + Emit3(ir, irToIl, Op.conv_i4_u1, dst2, src2, instrStart); + break; + } + case ILOpCode.Conv_i2: + { + var (src2, _) = evalStack[sp - 1]; + int dst2 = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I4, null); + evalStack[sp - 1] = (dst2, SType.I4); + Emit3(ir, irToIl, Op.conv_i4_i2, dst2, src2, instrStart); + break; + } + case ILOpCode.Conv_u2: + { + var (src2, _) = evalStack[sp - 1]; + int dst2 = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I4, null); + evalStack[sp - 1] = (dst2, SType.I4); + Emit3(ir, irToIl, Op.conv_i4_u2, dst2, src2, instrStart); + break; + } + case ILOpCode.Conv_r4: + { + var (src, st) = evalStack[sp - 1]; + if (st == SType.R4) break; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.R4, null); + evalStack[sp - 1] = (dst, SType.R4); + var cvOp = st == SType.I8 ? Op.conv_r4_i8 + : st == SType.R8 ? Op.conv_r4_r8 + : Op.conv_i4_r4; + Emit3(ir, irToIl, cvOp, dst, src, instrStart); + break; + } + case ILOpCode.Conv_r8: + { + // Honest double now that R8 slots exist (this retires the double-as-float + // divergence the analyzer guarded with MS002). + var (src, st) = evalStack[sp - 1]; + if (st == SType.R8) break; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.R8, null); + evalStack[sp - 1] = (dst, SType.R8); + var cvOp = st == SType.I8 ? Op.conv_r8_i8 + : st == SType.R4 ? Op.conv_r8_r4 + : Op.conv_r8_i4; + Emit3(ir, irToIl, cvOp, dst, src, instrStart); + break; + } + + // --- Branches --- + case ILOpCode.Br_s: + case ILOpCode.Br: + { + int target = ReadBranchTarget(il, ref ip, op == ILOpCode.Br_s); + // Ternary / short-circuit value pattern: a FORWARD br that is the tail of a + // value-producing arm — `; br END; ELSE: ; END:`. The top-of-stack + // is this arm's result; move it into a fresh merge slot M so the other arm can + // converge on the same M at END. + // + // The arm is genuine when the label that follows the br (`ip`) is the ELSE + // label — i.e. an earlier branch already delivered depth `sp-1` there, so ELSE + // drops exactly this one result. When the recorded depth is `sp` instead, the + // value on top is NOT an arm result but a live operand carried beneath a + // short-circuit region (the `v0 == (… || …)` case): merging/dropping it would + // corrupt the stack, so we leave it alone and let RecordJoinDepth carry it. + // + // It is ALSO genuine — regardless of the ELSE-adjacency heuristic above — when + // `target` is already a known merge join (an earlier sibling arm already + // converged there): every further forward edge into an established merge point + // must converge its own top-of-stack into the same slot too. This matters for a + // degenerate zero-displacement `br` (target == ip, e.g. when Roslyn collapses two + // constant-valued ternary arms into one shared tail): such a br still carries a + // value that MUST be merged, but its "label right after it" is the merge point + // itself rather than a distinct ELSE label, so the ELSE-adjacency check alone + // (which additionally requires target > ip) never fires for it. Without this, + // the arm's value is silently dropped and the merge slot keeps a stale/default + // value when that arm is the one actually taken at runtime. + if (target >= ip && sp > 0 && + (mergeSlotForEnd.ContainsKey(target) || + (branchTargetDepth.TryGetValue(ip, out var elseDepth) && elseDepth == sp - 1))) + { + MergeTopIntoJoinSlot(ir, irToIl, evalStack, sp, ref frameSize, slotTypes, slotStructs, mergeSlotForEnd, target, instrStart); + } + RecordJoinDepth(instrStart, target, sp); // br leaves the stack unchanged + int patchIdx = ir.Count + 1; + Emit2(ir, irToIl, Op.br, -1, instrStart); + patchList.Add((patchIdx, target)); + ensuredScriptSlots.Clear(); + break; + } + // leave empties the eval stack, then runs every enclosing finally whose try + // region contains this instruction but not the target (innermost first), + // then continues at the target. Lowered as a continuation chain: push the + // final target and the outer handlers' entries, branch to the innermost + // handler; each handler's endfinally (br_cont) pops the next address. + case ILOpCode.Leave_s: + case ILOpCode.Leave: + { + int target = ReadBranchTarget(il, ref ip, op == ILOpCode.Leave_s); + sp = 0; // leave semantics: the eval stack is emptied + var chain = FinallyChain(method.FinallyRegions, instrStart, target); + RecordJoinDepth(instrStart, target, 0); + if (chain.Count == 0) + { + int patchIdx = ir.Count + 1; + Emit2(ir, irToIl, Op.br, -1, instrStart); + patchList.Add((patchIdx, target)); + } + else + { + // Push order: final target first, then outer→inner-but-one handlers, + // so pops run inner handler → … → outer handler → target. + int pi = ir.Count + 1; + Emit2(ir, irToIl, Op.push_cont, -1, instrStart); + patchList.Add((pi, target)); + for (int k = chain.Count - 1; k >= 1; k--) + { + pi = ir.Count + 1; + Emit2(ir, irToIl, Op.push_cont, -1, instrStart); + patchList.Add((pi, chain[k])); + } + RecordJoinDepth(instrStart, chain[0], 0); + pi = ir.Count + 1; + Emit2(ir, irToIl, Op.br, -1, instrStart); + patchList.Add((pi, chain[0])); + } + ensuredScriptSlots.Clear(); + break; + } + case ILOpCode.Endfinally: // also ends the handler when reached by fall-through + { + ir.Add((uint)Op.br_cont); irToIl.Add(instrStart); + sp = 0; + ensuredScriptSlots.Clear(); + break; + } + + case ILOpCode.Brtrue_s: + case ILOpCode.Brtrue: + { + int target = ReadBranchTarget(il, ref ip, op == ILOpCode.Brtrue_s); + var (cond, ct) = evalStack[--sp]; + // Value left on the stack after the condition is a forward-join carry ONLY in + // the `dup; brtrue L` idiom (`a ?? b`, `a?.b`): converge it with the + // fall-through's value into one slot; the join reconciles the other edge. With + // no preceding dup the remaining top is just a base operand (e.g. the `==` left + // operand under a `&&`/`||` chain) and must be left untouched. + if (afterDup && sp > 0 && target > ip) + MergeTopIntoJoinSlot(ir, irToIl, evalStack, sp, ref frameSize, slotTypes, slotStructs, mergeSlotForEnd, target, instrStart); + RecordJoinDepth(instrStart, target, sp); // condition already popped + var bop = ct == SType.O ? Op.brtrue_o : ct == SType.I8 ? Op.brtrue_i8 : Op.brtrue_i4; + if (ct == SType.R8) goto notSupported; // no IL brtrue on floats + int patchIdx = ir.Count + 2; + Emit3(ir, irToIl, bop, cond, -1, instrStart); + patchList.Add((patchIdx, target)); + ensuredScriptSlots.Clear(); + break; + } + case ILOpCode.Brfalse_s: + case ILOpCode.Brfalse: + { + int target = ReadBranchTarget(il, ref ip, op == ILOpCode.Brfalse_s); + var (cond, ct) = evalStack[--sp]; + if (afterDup && sp > 0 && target > ip) + MergeTopIntoJoinSlot(ir, irToIl, evalStack, sp, ref frameSize, slotTypes, slotStructs, mergeSlotForEnd, target, instrStart); + RecordJoinDepth(instrStart, target, sp); // condition already popped + var bop = ct == SType.O ? Op.brfalse_o : ct == SType.I8 ? Op.brfalse_i8 : Op.brfalse_i4; + if (ct == SType.R8) goto notSupported; + int patchIdx = ir.Count + 2; + Emit3(ir, irToIl, bop, cond, -1, instrStart); + patchList.Add((patchIdx, target)); + ensuredScriptSlots.Clear(); + break; + } + + // Compare-and-branch (lower to ceq+brtrue pattern) + case ILOpCode.Beq_s: case ILOpCode.Beq: + { + int target = ReadBranchTarget(il, ref ip, op == ILOpCode.Beq_s); + EmitCmpBranch(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.ceq_i4, Op.ceq_r4, Op.ceq_o, Op.ceq_i4_nn, Op.ceq_r4_nn, Op.brtrue_i4, patchList, target, instrStart, branchTargetDepth); + ensuredScriptSlots.Clear(); + break; + } + case ILOpCode.Bne_un_s: case ILOpCode.Bne_un: + { + int target = ReadBranchTarget(il, ref ip, op == ILOpCode.Bne_un_s); + EmitCmpBranch(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.ceq_i4, Op.ceq_r4, Op.ceq_o, Op.ceq_i4_nn, Op.ceq_r4_nn, Op.brfalse_i4, patchList, target, instrStart, branchTargetDepth); + ensuredScriptSlots.Clear(); + break; + } + case ILOpCode.Blt_s: case ILOpCode.Blt: + { + int target = ReadBranchTarget(il, ref ip, op == ILOpCode.Blt_s); + EmitCmpBranch(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.clt_i4, Op.clt_r4, Op.clt_i4, Op.clt_i4_nn, Op.clt_r4_nn, Op.brtrue_i4, patchList, target, instrStart, branchTargetDepth); + ensuredScriptSlots.Clear(); + break; + } + case ILOpCode.Blt_un_s: case ILOpCode.Blt_un: + { + int target = ReadBranchTarget(il, ref ip, op == ILOpCode.Blt_un_s); + // Unordered: branch when a < b OR either is NaN, so the float compare must be the + // unordered clt_un_r4 (used for both the typed and _nn slots — it reads operand types itself). + EmitCmpBranch(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.clt_un_i4, Op.clt_un_r4, Op.clt_un_i4, Op.clt_un_i4_nn, Op.clt_un_r4, Op.brtrue_i4, patchList, target, instrStart, branchTargetDepth); + ensuredScriptSlots.Clear(); + break; + } + case ILOpCode.Bgt_s: case ILOpCode.Bgt: + { + int target = ReadBranchTarget(il, ref ip, op == ILOpCode.Bgt_s); + EmitCmpBranch(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.cgt_i4, Op.cgt_r4, Op.cgt_i4, Op.cgt_i4_nn, Op.cgt_r4_nn, Op.brtrue_i4, patchList, target, instrStart, branchTargetDepth); + ensuredScriptSlots.Clear(); + break; + } + case ILOpCode.Bgt_un_s: case ILOpCode.Bgt_un: + { + int target = ReadBranchTarget(il, ref ip, op == ILOpCode.Bgt_un_s); + // Unordered: branch when a > b OR either is NaN, so the float compare must be the + // unordered cgt_un_r4 (used for both the typed and _nn slots — it reads operand types itself). + EmitCmpBranch(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.cgt_un_i4, Op.cgt_un_r4, Op.cgt_un_o, Op.cgt_un_i4_nn, Op.cgt_un_r4, Op.brtrue_i4, patchList, target, instrStart, branchTargetDepth); + ensuredScriptSlots.Clear(); + break; + } + case ILOpCode.Ble_s: case ILOpCode.Ble: + { + int target = ReadBranchTarget(il, ref ip, op == ILOpCode.Ble_s); + // !(a > b) — negating through brfalse swaps ordered/unordered, so the ORDERED ble + // (no branch on NaN) needs the UNORDERED cgt_un_r4: !(a > b OR NaN) is false for NaN. + // (cgt_un_r4 serves both the typed and _nn slots — it reads operand types itself.) + EmitCmpBranch(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.cgt_i4, Op.cgt_un_r4, Op.cgt_i4, Op.cgt_i4_nn, Op.cgt_un_r4, Op.brfalse_i4, patchList, target, instrStart, branchTargetDepth); + ensuredScriptSlots.Clear(); + break; + } + case ILOpCode.Ble_un_s: case ILOpCode.Ble_un: + { + int target = ReadBranchTarget(il, ref ip, op == ILOpCode.Ble_un_s); + EmitCmpBranch(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.cgt_un_i4, Op.cgt_r4, Op.cgt_un_o, Op.cgt_un_i4_nn, Op.cgt_r4_nn, Op.brfalse_i4, patchList, target, instrStart, branchTargetDepth); + ensuredScriptSlots.Clear(); + break; + } + case ILOpCode.Bge_s: case ILOpCode.Bge: + { + int target = ReadBranchTarget(il, ref ip, op == ILOpCode.Bge_s); + // !(a < b) — negating through brfalse swaps ordered/unordered, so the ORDERED bge + // (no branch on NaN) needs the UNORDERED clt_un_r4: !(a < b OR NaN) is false for NaN. + // (clt_un_r4 serves both the typed and _nn slots — it reads operand types itself.) + EmitCmpBranch(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.clt_i4, Op.clt_un_r4, Op.clt_i4, Op.clt_i4_nn, Op.clt_un_r4, Op.brfalse_i4, patchList, target, instrStart, branchTargetDepth); + ensuredScriptSlots.Clear(); + break; + } + case ILOpCode.Bge_un_s: case ILOpCode.Bge_un: + { + int target = ReadBranchTarget(il, ref ip, op == ILOpCode.Bge_un_s); + EmitCmpBranch(ir, irToIl, evalStack, ref sp, ref frameSize, slotTypes, slotStructs, Op.clt_un_i4, Op.clt_r4, Op.clt_un_i4, Op.clt_un_i4_nn, Op.clt_r4_nn, Op.brfalse_i4, patchList, target, instrStart, branchTargetDepth); + ensuredScriptSlots.Clear(); + break; + } + + // --- Switch --- + case ILOpCode.Switch: + { + int n = BitConverter.ToInt32(il, ip); ip += 4; + int tableStart = ip; + ip += 4 * n; // skip offset table + int afterSwitch = ip; // base for relative offsets + + var (valSlot, _) = evalStack[--sp]; + // Emit: [switch_i4, val, n, default_patch_idx, ip0, ip1, ...]; targets patched afterwards. + int irSwitchStart = ir.Count; + ir.Add((uint)Op.switch_i4); + irToIl.Add(instrStart); + ir.Add((uint)valSlot); + irToIl.Add(instrStart); + ir.Add((uint)n); + irToIl.Add(instrStart); + // placeholder for default (fall-through = afterSwitch → patched via patchList) + int defaultPatchIdx = ir.Count; + ir.Add(unchecked((uint)-1)); + irToIl.Add(instrStart); + patchList.Add((defaultPatchIdx, afterSwitch)); + // per-case targets — the switch value is already popped, so every edge + // (cases + default fall-through) delivers the current base depth. + RecordJoinDepth(instrStart, afterSwitch, sp); + for (int ci = 0; ci < n; ci++) + { + int off = BitConverter.ToInt32(il, tableStart + ci * 4); + int target = afterSwitch + off; + int patchIdx2 = ir.Count; + ir.Add(unchecked((uint)-1)); + irToIl.Add(instrStart); + patchList.Add((patchIdx2, target)); + RecordJoinDepth(instrStart, target, sp); + } + ensuredScriptSlots.Clear(); + break; + } + + // --- Ret --- + case ILOpCode.Throw: + { + var (thrSrc, thrSt) = evalStack[--sp]; + if (thrSt != SType.O) goto notSupported; // exceptions are always O refs + Emit2(ir, irToIl, Op.throw_o, thrSrc, instrStart); + // Unreachable code follows until the next branch target; the linear walk's + // join-depth bookkeeping handles it like the code after an unconditional br. + sp = 0; + ensuredScriptSlots.Clear(); + break; + } + + case ILOpCode.Ret: + { + if (sp > 0) + { + var (src, st) = evalStack[--sp]; + if (method.ReturnSType == SType.Vt && method.ReturnStructLayout != null) + { + // Flat-struct return. A boxed (O) value — e.g. from a path the + // lowerer left boxed — is unboxed into a Vt temp first so ret_vt + // always reads real frame bytes. + if (st != SType.Vt) + { + int vtmp = AllocStructSlot(ref frameSize, slotTypes, slotStructs, method.ReturnStructLayout); + Emit3(ir, irToIl, Op.unbox_vt, vtmp, src, instrStart); + src = vtmp; + } + Emit2(ir, irToIl, Op.ret_vt, src, instrStart); + } + else + { + // A Vt value returned through a non-Vt signature (defensive): box it. + if (st == SType.Vt) + { + int boxed = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + Emit3(ir, irToIl, Op.box_vt, boxed, src, instrStart); + src = boxed; + } + var retOp = st == SType.R4 ? Op.ret_r4 : st == SType.I4 ? Op.ret_i4 + : st == SType.I8 ? Op.ret_i8 : st == SType.R8 ? Op.ret_r8 : Op.ret_o; + Emit2(ir, irToIl, retOp, src, instrStart); + } + } + else + { + ir.Add((uint)Op.ret_void); + irToIl.Add(instrStart); + } + ensuredScriptSlots.Clear(); + break; + } + + // --- Method calls --- + // `constrained. T` prefixes a callvirt on a byref receiver. Boxed (O-slot) + // struct receivers already give constrained semantics through plain virtual + // dispatch. FLAT (Vt-slot) receivers need the type token: constrained to the + // slot's own struct type, the whole struct must box before the call — the + // receiver resolution below consumes the remembered token for that. + case ILOpCode.Constrained: + constrainedTok = BitConverter.ToInt32(il, ip); + ip += 4; + break; + + case ILOpCode.Call: + case ILOpCode.Callvirt: + { + int tok = BitConverter.ToInt32(il, ip); ip += 4; + int tokIdx = tokens.Count; tokens.Add(tok); + int ctok = constrainedTok; constrainedTok = 0; // consume the prefix, if any + + if (asm.ByToken.TryGetValue(tok, out var callee)) + { + // Detect ldloca.s + call .ctor (script struct ctor) pattern: + // if this is a .ctor and the first arg slot is a frame-addr phantom, + // emit newobj_script writing into the existing local slot. + int argc = callee.ArgCount; + int spBase = sp - argc; + if (callee.Name == ".ctor" && argc >= 1 && addrTagBySlot[evalStack[spBase].slot] >= 0) + { + int destSlot = addrTagBySlot[evalStack[spBase].slot]; + int destOff = addrByteOffBySlot[evalStack[spBase].slot]; + addrTagBySlot[evalStack[spBase].slot] = -2; // consume the addr tag + int explicitArgc = argc - 1; // exclude `this` + if (slotTypes[destSlot] == SType.Vt && destOff == 0) + { + // FLAT struct ctor: zero the destination and run the ctor body + // with `this` = the Vt slot itself. The VM copies the bytes into + // the callee's Vt arg0 and copies them back on ret (VtThisWb). + Emit2(ir, irToIl, Op.initobj, destSlot, instrStart); + ir.Add((uint)Op.call_script); irToIl.Add(instrStart); + ir.Add(unchecked((uint)-1)); irToIl.Add(instrStart); + ir.Add((uint)tokIdx); irToIl.Add(instrStart); + ir.Add((uint)argc); irToIl.Add(instrStart); + ir.Add((uint)destSlot); irToIl.Add(instrStart); // `this` + for (int k = 0; k < explicitArgc; k++) + { ir.Add((uint)evalStack[spBase + 1 + k].slot); irToIl.Add(instrStart); } + sp = spBase; // ctor is void, no push + ensuredScriptSlots.Clear(); + break; + } + ir.Add((uint)Op.newobj_script); irToIl.Add(instrStart); + ir.Add((uint)destSlot); irToIl.Add(instrStart); + ir.Add((uint)tokIdx); irToIl.Add(instrStart); + ir.Add((uint)explicitArgc); irToIl.Add(instrStart); + for (int k = 0; k < explicitArgc; k++) + { ir.Add((uint)evalStack[spBase + 1 + k].slot); irToIl.Add(instrStart); } + sp = spBase; // ctor is void, no push + } + else + { + // Instance callee: resolve a phantom receiver (ldloca/ldflda/ldelema) + // to a real slot — otherwise `this` arrives null. Flat receivers with + // a composed offset (or field/element receivers) are materialized into + // a temp with a write-back after the call (copy-in/copy-out byref + // semantics). + int wbKind = 0; // 0=none, 1=stfld_vt_vt, 2=stfld_sc_vt + int wbA = 0, wbB = 0, wbTemp = 0; + if (!callee.IsStatic && argc >= 1) + { + var (rSlot, _) = evalStack[spBase]; + int rTag = addrTagBySlot[rSlot]; + if (rTag >= 0) + { + int rOff = addrByteOffBySlot[rSlot]; + addrTagBySlot[rSlot] = -2; + if (rOff == 0 || slotTypes[rTag] != SType.Vt) + { + evalStack[spBase] = (rTag, slotTypes[rTag]); + } + else if (callee.ThisStructLayout != null) + { + int tmp = AllocStructSlot(ref frameSize, slotTypes, slotStructs, callee.ThisStructLayout); + Emit4(ir, irToIl, Op.ldfld_vt_vt, tmp, rTag, rOff, instrStart); + evalStack[spBase] = (tmp, SType.Vt); + wbKind = 1; wbA = rTag; wbB = rOff; wbTemp = tmp; + } + } + else if (rTag == -1 && addrFldOffBySlot[rSlot] >= 0 && callee.ThisStructLayout != null) + { + int parentR = addrFldObjBySlot[rSlot]; + int offR = addrFldOffBySlot[rSlot]; + addrTagBySlot[rSlot] = -2; + int tmp = AllocStructSlot(ref frameSize, slotTypes, slotStructs, callee.ThisStructLayout); + Emit4(ir, irToIl, Op.ldfld_sc_vt, tmp, parentR, offR, instrStart); + evalStack[spBase] = (tmp, SType.Vt); + wbKind = 2; wbA = parentR; wbB = offR; wbTemp = tmp; + } + else if (rTag == -1) + { + // Heap script-struct field receiver: load the ScriptObject + // reference — mutations through it propagate, no write-back. + int parentR = addrFldObjBySlot[rSlot]; + int fTokIdx = addrFldTokBySlot[rSlot]; + int fTok = tokens[fTokIdx]; + addrTagBySlot[rSlot] = -2; + int tmp = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + if (asm.FieldSlots.TryGetValue(fTok, out var rfs)) + Emit4(ir, irToIl, Op.ldfld_sc_o, tmp, parentR, rfs.Item1.FieldOffsets[rfs.Item2], instrStart); + else + Emit4(ir, irToIl, Op.ldfld_o, tmp, parentR, fTokIdx, instrStart); + evalStack[spBase] = (tmp, SType.O); + } + else if (rTag == -3 && addrElemLayoutBySlot[rSlot] != null + && callee.ThisStructLayout != null) + { + // arr[i].Method(): materialize the flat element, call with + // `this` = the temp, memcpy the element back after (wbKind 4). + int arrS = addrFldObjBySlot[rSlot]; + int idxS = addrFldTokBySlot[rSlot]; + addrTagBySlot[rSlot] = -2; + int tmp = AllocStructSlot(ref frameSize, slotTypes, slotStructs, addrElemLayoutBySlot[rSlot]!); + Emit4(ir, irToIl, Op.ldelem_vt, tmp, arrS, idxS, instrStart); + evalStack[spBase] = (tmp, SType.Vt); + wbKind = 4; wbA = arrS; wbB = idxS; wbTemp = tmp; + } + else if (rTag == -3) + { + int arrS = addrFldObjBySlot[rSlot]; + int idxS = addrFldTokBySlot[rSlot]; + addrTagBySlot[rSlot] = -2; + int tmp = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + Emit4(ir, irToIl, Op.ldelem_o, tmp, arrS, idxS, instrStart); + evalStack[spBase] = (tmp, SType.O); + } + } + // Normal script → script call; return type comes from callee's signature + bool isVoid = callee.IsVoid; + SType retSType = callee.ReturnSType; + int dst = isVoid ? -1 + : retSType == SType.Vt && callee.ReturnStructLayout != null + ? AllocStructSlot(ref frameSize, slotTypes, slotStructs, callee.ReturnStructLayout) + : AllocSlot(ref frameSize, slotTypes, slotStructs, retSType == SType.Vt ? SType.O : retSType, null); + // Resolve ldloca-phantom args BEFORE emitting: a local function's + // captured environment arrives as `ref <>c__DisplayClass` — pass the + // ScriptObject reference itself, so callee field writes propagate. + // Any other byref arg shape stays loud (emitting the phantom slot + // raw would read null/garbage at runtime — silent wrong). + for (int k = 0; k < argc; k++) + { + int aSlot = evalStack[spBase + k].slot; + if (aSlot < 0 || addrTagBySlot[aSlot] == -2) continue; + int aTag = addrTagBySlot[aSlot]; + if (aTag >= 0) + { + // Frame-local environment (ldloca in a plain method). + if (slotTypes[aTag] != SType.O || addrByteOffBySlot[aSlot] != 0) + goto notSupported; + addrTagBySlot[aSlot] = -2; + evalStack[spBase + k] = (aTag, SType.O); + } + else if (aTag == -1 && addrFldOffBySlot[aSlot] < 0) + { + // Token mode — the addr names a whole O field; a composed + // byte offset (>= 0) would point INSIDE a nested struct, + // which falls through to the loud path below. + // State-machine-hoisted environment (ldflda in MoveNext): + // load the ScriptObject field — same reference, mutations + // propagate — exactly like the heap receiver path above. + int aParent = addrFldObjBySlot[aSlot]; + int aTokIdx = addrFldTokBySlot[aSlot]; + int aTok = tokens[aTokIdx]; + addrTagBySlot[aSlot] = -2; + int tmp = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + if (asm.FieldSlots.TryGetValue(aTok, out var afs)) + Emit4(ir, irToIl, Op.ldfld_sc_o, tmp, aParent, afs.Item1.FieldOffsets[afs.Item2], instrStart); + else + Emit4(ir, irToIl, Op.ldfld_o, tmp, aParent, aTokIdx, instrStart); + evalStack[spBase + k] = (tmp, SType.O); + } + else + goto notSupported; + } + // [call_script, dst, tok_idx, argc, arg0, arg1, ...] + ir.Add((uint)Op.call_script); irToIl.Add(instrStart); + ir.Add((uint)(isVoid ? -1 : dst)); irToIl.Add(instrStart); + ir.Add((uint)tokIdx); irToIl.Add(instrStart); + ir.Add((uint)argc); irToIl.Add(instrStart); + for (int k = 0; k < argc; k++) + { ir.Add((uint)evalStack[spBase + k].slot); irToIl.Add(instrStart); } + if (wbKind == 1) Emit4(ir, irToIl, Op.stfld_vt_vt, wbA, wbB, wbTemp, instrStart); + else if (wbKind == 2) Emit4(ir, irToIl, Op.stfld_sc_vt, wbA, wbB, wbTemp, instrStart); + else if (wbKind == 4) Emit4(ir, irToIl, Op.stelem_vt, wbA, wbB, wbTemp, instrStart); + sp = spBase; + if (!isVoid) evalStack[sp++] = (dst, retSType); + } + } + else if (asm.HostCalls.TryGetValue(tok, out var hostEntry)) + { + // Detect byref params — these go through the slow boxed path with + // post-call write-back. The Fast / FastFlat shortcuts are bypassed + // (none of the registered Fast templates take byref args today). + var ps = hostEntry.Binding.Params; + int byRefArgCount = 0; + int explicitArgc = hostEntry.Binding.ParamCount; + if (ps != null) + { + int nByref = ps.Length; + if (nByref > explicitArgc) nByref = explicitArgc; + for (int k = 0; k < nByref; k++) + if (ps[k].ParameterType.IsByRef) byRefArgCount++; + } + if (byRefArgCount > 0) + { + EmitCallHostByref(ir, irToIl, evalStack, ref sp, ref frameSize, + slotTypes, slotStructs, addrTagBySlot, addrFldObjBySlot, addrFldTokBySlot, addrByteOffBySlot, + asm, tokens, hostEntry, tokIdx, ps!, instrStart); + break; + } + + int argc = hostEntry.Binding.ParamCount; + bool isVoid2 = hostEntry.IsVoid; + // Dst-slot picker. Three flat paths (all gated on + // Binding.FastIsFlat — when true the entry's Fast closure writes + // results to numFrame, so dst must be a numFrame-resident SType): + // - Vt return: AllocStructSlot, Fast writes struct bytes + // - primitive (R4/I4) return: AllocSlot of that numeric type, Fast + // writes the primitive bytes (no float/int box) + // - everything else: O slot, boxed Fast / Invoke path + // Flat dst whenever the return type HAS a layout — even on the slow + // Invoke path the executor unboxes the boxed return straight into the + // Vt slot (one boundary box), so downstream consumers (field reads, + // operator args, further calls) stay flat instead of re-boxing. + bool useFlatVt = hostEntry.ReturnStruct != null; + SType retSt = SType.O; + if (!isVoid2 && (hostEntry.ResolvedMethod ?? hostEntry.Binding.Method) is MethodInfo mi5) + { + var rt5 = mi5.ReturnType; + if (rt5 == typeof(float)) retSt = SType.R4; + else if (rt5 == typeof(int) || rt5 == typeof(bool) || rt5 == typeof(byte) + || rt5 == typeof(sbyte) || rt5 == typeof(short) || rt5 == typeof(ushort)) retSt = SType.I4; + } + else if (!isVoid2) + { + // String-keyed shim (no MethodInfo): type the result slot from the + // call site's metadata signature. Leaving numeric returns O boxed + // them, so `a.Length != a.Length` reference-compared two boxes. + retSt = hostEntry.SigRetSType; + } + // Optional explicit override (e.g. AllowBcl AttachFlatFloat sets it to R4 + // because Math.Sqrt is C# (double)→double but we narrow to float at the closure). + // default(SType) == SType.O means "unset" — leaves the Method-derived retSt alone. + if (!isVoid2 && hostEntry.Binding.FlatReturnSType != SType.O) + retSt = hostEntry.Binding.FlatReturnSType; + // Numeric returns get a NUMERIC dst even on the slow Invoke path — the + // executor unboxes into it (WrObj), and Entry.Invoke normalizes sub-int + // returns to int boxes. Leaving them O made downstream arithmetic pick + // the int fallback over boxed floats: `r.width + r.height` returned 0. + bool useFlatNum = !useFlatVt && (retSt == SType.R4 || retSt == SType.I4 + || retSt == SType.I8 || retSt == SType.R8); + int dst; + if (isVoid2) dst = -1; + else if (useFlatVt) dst = AllocStructSlot(ref frameSize, slotTypes, slotStructs, hostEntry.ReturnStruct!); + else if (useFlatNum) dst = AllocSlot(ref frameSize, slotTypes, slotStructs, retSt, null); + else dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + // willCallFlat: when true, the executor's Fast closure expects Vt args + // to be flat (no box_vt boundary). Void calls with FastIsFlat also need + // this — their closure unboxes args directly from numFrame. + bool willCallFlat = hostEntry.Binding.FastIsFlat && (useFlatVt || useFlatNum || isVoid2); + int recvSlotRaw = hostEntry.Binding.HasThis ? (int)evalStack[sp - argc - 1].slot : -1; + // Resolve receiver address phantoms to the actual value slot: + // • frame-slot address (addrTagBySlot >= 0): the local slot IS the value + // • field address (addrTagBySlot == -1): emit ldfld to load the field value + int recvSlot = recvSlotRaw; + // Write-back for a mutating value-type instance method called through a field + // address (`h.P.Scale()`): the receiver is materialised into a boxed slot, the + // call mutates that box in place, and we must store it back into the field + // afterwards — otherwise the mutation is lost. Only for value-type receivers. + bool recvWriteBack = false; + int wbFldObj = 0, wbFldToki = 0, wbFldOff = 0; bool wbSc = false; SType wbFldSt = SType.O; + int recvVtOff = 0; // composed byte offset when the receiver address points inside a Vt slot + if (recvSlotRaw >= 0) + { + int addrTag = addrTagBySlot[recvSlotRaw]; + if (addrTag >= 0) + { + // Local address → use actual local slot (value is in numFrame or refFrame) + recvSlot = addrTag; + recvVtOff = addrByteOffBySlot[recvSlotRaw]; + } + else if (addrTag == -3) + { + // Array-element receiver (`a[i].ToString()`): mirror the script-call + // resolution — materialize the element. Flat struct elements load + // into a Vt temp; scalar/ref elements box through ldelem_o (its + // executor dispatches on the array's runtime type). Previously this + // tag was unhandled here and the executor read a never-written O + // phantom slot: null receiver (found by fuzzing). + int arrS2 = addrFldObjBySlot[recvSlotRaw]; + int idxS2 = addrFldTokBySlot[recvSlotRaw]; + addrTagBySlot[recvSlotRaw] = -2; + if (addrElemLayoutBySlot[recvSlotRaw] is { } elay) + { + int tmp2 = AllocStructSlot(ref frameSize, slotTypes, slotStructs, elay); + Emit4(ir, irToIl, Op.ldelem_vt, tmp2, arrS2, idxS2, instrStart); + recvSlot = tmp2; + // a[i].fK: the receiver is a primitive field at a byte offset + // inside the materialized element — extracted below. + recvVtOff = addrByteOffBySlot[recvSlotRaw]; + } + else + { + int tmp2 = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + Emit4(ir, irToIl, Op.ldelem_o, tmp2, arrS2, idxS2, instrStart); + recvSlot = tmp2; + } + } + else if (addrTag == -1) + { + // Field address → emit ldfld to materialise the value into a new slot + int fldObj = addrFldObjBySlot[recvSlotRaw]; + int fldToki = addrFldTokBySlot[recvSlotRaw]; + int fldTok = tokens[fldToki]; + asm.FieldSTypes.TryGetValue(fldTok, out var fldSt); + bool isScField = asm.FieldSlots.TryGetValue(fldTok, out var fldFs); + // A Vt field (a flat HOST struct stored inline in PrimBytes — + // e.g. Roslyn SPILLS a struct receiver into an iterator + // state-machine field around a switch expression's branches) + // needs a layout-carrying struct slot and a byte-range copy; + // the ldfld_sc_o fallback read RefSlots at a PrimBytes offset + // and threw IndexOutOfRange (found by fuzzing). + var fldVtLay = isScField && fldSt == SType.Vt + ? fldFs.Item1.VtFieldLayouts?[fldFs.Item2] : null; + recvSlot = fldVtLay != null + ? AllocStructSlot(ref frameSize, slotTypes, slotStructs, fldVtLay) + : AllocSlot(ref frameSize, slotTypes, slotStructs, fldSt, null); + if (isScField) + { + // Prefer the COMPOSED offset when the phantom accumulated one + // (`o.sf.f1`: sf's base + f1's offset) — recomputing from the + // token alone gives f1's offset within sf, dropping sf's base + // and reading the wrong PrimBytes (found by fuzzing). + int composed = addrFldOffBySlot[recvSlotRaw]; + int fldOff = composed >= 0 ? composed : fldFs.Item1.FieldOffsets[fldFs.Item2]; + var fldOp2 = fldVtLay != null ? Op.ldfld_sc_vt + : fldSt == SType.I4 ? Op.ldfld_sc_i4 : fldSt == SType.R4 ? Op.ldfld_sc_r4 : Op.ldfld_sc_o; + Emit4(ir, irToIl, fldOp2, recvSlot, fldObj, fldOff, instrStart); + wbFldOff = fldOff; + } + else + { + var fldLoadOp = fldSt == SType.I4 ? Op.ldfld_i4 : fldSt == SType.R4 ? Op.ldfld_r4 : Op.ldfld_o; + Emit4(ir, irToIl, fldLoadOp, recvSlot, fldObj, fldToki, instrStart); + } + // A mutating instance method on a boxed value-type receiver needs its + // result written back to the field. Reference-type receivers mutate in + // place; flat (Vt) receivers take a different path and are left as-is. + if (fldSt == SType.O && hostEntry.Binding.HasThis + && hostEntry.Binding.Method?.DeclaringType?.IsValueType == true) + { + recvWriteBack = true; wbFldObj = fldObj; wbFldToki = fldToki; wbSc = isScField; wbFldSt = fldSt; + } + } + } + + // `constrained. ` on an I4-slot receiver — an enum + // local/field calling ToString/GetHashCode/HasFlag. RdObj would box + // the receiver as INT and ToString would render "1", not the member + // name; box as the real enum type (the box_enum op covers explicit + // `box` IL, this is the callvirt-receiver path). Script enums keep + // the int form — no runtime Type exists. + if (ctok != 0 && hostEntry.Binding.HasThis + && recvSlot >= 0 && recvSlot < slotTypes.Count && slotTypes[recvSlot] == SType.I4 + && ResolveHostEnumType(asm, ctok) is { } cEnumType) + { + int ctokIdx = tokens.Count; tokens.Add(ctok); + asm.TokenTypes[ctok] = cEnumType; + int boxedEnumRecv = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + Emit4(ir, irToIl, Op.box_enum, boxedEnumRecv, recvSlot, ctokIdx, instrStart); + recvSlot = boxedEnumRecv; + } + + // Receiver inside a FLAT struct slot with no Vt-aware entry (no + // ReceiverStruct, no AccessorOffset — typically an inherited virtual + // like Object.ToString reached via `constrained.`). RdObj on a Vt slot + // yields null, so the value must be materialized first. Two shapes: + // • constrained to the slot's OWN struct type (`"impact" + quaternion` + // → constrained callvirt Object.ToString when the struct overrides + // it): the receiver is the WHOLE struct — box it (box_vt) and + // dispatch virtually on the box. Without the constrained-type check + // the field extraction below misread the struct's first field as + // the receiver (Quaternion printed as its x component). + // • constrained to a PRIMITIVE field (`v.f0.ToString()`, or a struct + // array element's field): extract the primitive at its byte offset + // into a typed slot (found by fuzzing: ToString returned ""). + if (hostEntry.ReceiverStruct == null && hostEntry.Binding.AccessorOffset < 0 + && recvSlot >= 0 && recvSlot < slotTypes.Count + && slotTypes[recvSlot] == SType.Vt && slotStructs[recvSlot] is { } lay0) + { + if (recvVtOff == 0 && ctok != 0 + && TokenSimpleTypeName(asm, ctok) == lay0.TypeName) + { + int boxedRecv = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + Emit3(ir, irToIl, Op.box_vt, boxedRecv, recvSlot, instrStart); + recvSlot = boxedRecv; + } + else + { + foreach (var fld in lay0.Fields.Values) + { + if (fld.Offset != recvVtOff || fld.St is not (SType.I4 or SType.R4)) continue; + int prim = AllocSlot(ref frameSize, slotTypes, slotStructs, fld.St, null); + Emit4(ir, irToIl, fld.St == SType.I4 ? Op.ldfld_vt_i4 : Op.ldfld_vt_r4, + prim, recvSlot, recvVtOff, instrStart); + recvSlot = prim; + recvVtOff = 0; + break; + } + } + } + int spBase = sp - argc - (hostEntry.Binding.HasThis ? 1 : 0); + + // Verified trivial accessor on a FLAT receiver: `r.width` / `r.width = v` + // becomes a direct byte read/write of the backing field — no host call, + // no receiver box. (The accessor IL was verified at registration.) + if (hostEntry.Binding.AccessorOffset >= 0 && recvSlot >= 0 + && recvSlot < slotTypes.Count && slotTypes[recvSlot] == SType.Vt) + { + int accOff = recvVtOff + hostEntry.Binding.AccessorOffset; + if (argc == 0 && !isVoid2) + { + int accDst; + if (hostEntry.Binding.AccessorSt == SType.Vt) + { + // Struct-typed backing field (Bounds.center → Vector3): + // byte-range copy into a Vt slot sized by the field layout. + accDst = AllocStructSlot(ref frameSize, slotTypes, slotStructs, + hostEntry.Binding.AccessorVtLayout!); + Emit4(ir, irToIl, Op.ldfld_vt_vt, accDst, recvSlot, accOff, instrStart); + } + else + { + accDst = AllocSlot(ref frameSize, slotTypes, slotStructs, hostEntry.Binding.AccessorSt, null); + Emit4(ir, irToIl, + hostEntry.Binding.AccessorSt == SType.I4 ? Op.ldfld_vt_i4 : Op.ldfld_vt_r4, + accDst, recvSlot, accOff, instrStart); + } + sp = spBase; + evalStack[sp++] = (accDst, hostEntry.Binding.AccessorSt); + ensuredScriptSlots.Clear(); + break; + } + if (argc == 1 && isVoid2) + { + int accSrc = evalStack[sp - 1].slot; + if (hostEntry.Binding.AccessorSt == SType.Vt) + { + // Struct value into a struct-typed backing field. A boxed + // source (O slot from a host return that wasn't flattened) + // unboxes into a Vt temp first, so stfld_vt_vt always + // copies real frame bytes at the field layout's size. + if (evalStack[sp - 1].type != SType.Vt) + { + int vtmp = AllocStructSlot(ref frameSize, slotTypes, slotStructs, + hostEntry.Binding.AccessorVtLayout!); + Emit3(ir, irToIl, Op.unbox_vt, vtmp, accSrc, instrStart); + accSrc = vtmp; + } + Emit4(ir, irToIl, Op.stfld_vt_vt, recvSlot, accOff, accSrc, instrStart); + } + else + Emit4(ir, irToIl, + hostEntry.Binding.AccessorSt == SType.I4 ? Op.stfld_vt_i4 : Op.stfld_vt_r4, + recvSlot, accOff, accSrc, instrStart); + sp = spBase; + ensuredScriptSlots.Clear(); + break; + } + } + + // Vt receiver addressed at a NONZERO byte offset — a method call on a + // flat struct nested inside another flat struct (`w.p.Scale(3)` where p + // sits at offset 4 of w): call_host encodes only a slot, so the executor + // would box the WRONG bytes (from the slot start). Materialize the + // sub-struct into its own Vt temp and copy it back after the call (the + // callee may mutate the receiver). + bool recvVtWb = false; int recvVtWbBase = -1; + if (hostEntry.Binding.HasThis && recvSlot >= 0 && recvVtOff != 0 + && recvSlot < slotTypes.Count && slotTypes[recvSlot] == SType.Vt + && hostEntry.ReceiverStruct != null) + { + int rTmp = AllocStructSlot(ref frameSize, slotTypes, slotStructs, hostEntry.ReceiverStruct); + Emit4(ir, irToIl, Op.ldfld_vt_vt, rTmp, recvSlot, recvVtOff, instrStart); + recvVtWb = true; recvVtWbBase = recvSlot; + recvSlot = rTmp; + } + + // The operator-inlining decision must come BEFORE the boundary boxing: + // EmitInlinedOp synthesizes per-field arithmetic over the FLAT arg + // slots, so its args must not be boxed away first. + bool inlineOp = hostEntry.Binding.IsInlineableOp && useFlatVt + && hostEntry.ReturnStruct != null + && CanInlineOp(hostEntry, argc, hostEntry.ReturnStruct, isCtor: false); + + // Boundary box: any Vt-slot arg gets converted to a fresh O slot via + // box_vt before the call — UNLESS we'll dispatch to FastFlat, which + // reads Vt arg bytes directly from numFrame (or the op inlines). + int argBase = sp - argc; + if (!willCallFlat && !inlineOp) + { + for (int k = 0; k < argc; k++) + { + if (evalStack[argBase + k].type == SType.Vt) + { + int boxed = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + Emit3(ir, irToIl, Op.box_vt, boxed, evalStack[argBase + k].slot, instrStart); + evalStack[argBase + k] = (boxed, SType.O); + } + } + } + + // Pure operator returning Vt: synthesize per-field arithmetic instead + // of calling through call_host. + if (inlineOp) + { + EmitInlinedOp(ir, irToIl, evalStack, argBase, argc, dst, + hostEntry.ReturnStruct!, hostEntry, instrStart, isCtor: false); + sp = spBase; + evalStack[sp++] = (dst, SType.Vt); + break; + } + + ir.Add((uint)Op.call_host); irToIl.Add(instrStart); + ir.Add((uint)(isVoid2 ? -1 : dst)); irToIl.Add(instrStart); + ir.Add((uint)(hostEntry.Binding.HasThis ? recvSlot : -1)); irToIl.Add(instrStart); + ir.Add((uint)tokIdx); irToIl.Add(instrStart); + ir.Add((uint)argc); irToIl.Add(instrStart); + for (int k = 0; k < argc; k++) + { + ir.Add((uint)evalStack[argBase + k].slot); irToIl.Add(instrStart); + } + // Nested-offset Vt receiver: copy the (possibly mutated) temp back into + // its byte range of the outer struct. + if (recvVtWb) + { + ir.Add((uint)Op.stfld_vt_vt); irToIl.Add(instrStart); + ir.Add((uint)recvVtWbBase); irToIl.Add(instrStart); + ir.Add((uint)recvVtOff); irToIl.Add(instrStart); + ir.Add((uint)recvSlot); irToIl.Add(instrStart); + } + // Mutating value-type method via field address: store the mutated receiver box + // back into the field (see recvWriteBack above). + if (recvWriteBack) + { + if (wbSc) + { + var wbOp = wbFldSt == SType.I4 ? Op.stfld_sc_i4 : wbFldSt == SType.R4 ? Op.stfld_sc_r4 : Op.stfld_sc_o; + ir.Add((uint)wbOp); irToIl.Add(instrStart); + ir.Add((uint)wbFldObj); irToIl.Add(instrStart); + ir.Add((uint)wbFldOff); irToIl.Add(instrStart); + ir.Add((uint)recvSlot); irToIl.Add(instrStart); + } + else + { + var wbOp = wbFldSt == SType.I4 ? Op.stfld_i4 : wbFldSt == SType.R4 ? Op.stfld_r4 : Op.stfld_o; + ir.Add((uint)wbOp); irToIl.Add(instrStart); + ir.Add((uint)wbFldObj); irToIl.Add(instrStart); + ir.Add((uint)wbFldToki); irToIl.Add(instrStart); + ir.Add((uint)recvSlot); irToIl.Add(instrStart); + } + } + sp = spBase; + if (!isVoid2) evalStack[sp++] = (dst, useFlatVt ? SType.Vt : useFlatNum ? retSt : SType.O); + } + else if (asm.HostCtors.TryGetValue(tok, out var callCtor)) + { + // ldloca.s + call hostCtor pattern: create host object, store into local slot + int callCtorArgc = callCtor.Binding.ParamCount; // explicit params only + int spBase2 = sp - callCtorArgc - 1; // -1 for the this-addr + int thisAddrSlot = evalStack[spBase2].slot; + if (addrTagBySlot[thisAddrSlot] >= 0) + { + int destSlot = addrTagBySlot[thisAddrSlot]; + addrTagBySlot[thisAddrSlot] = -2; // consume addr tag + ir.Add((uint)Op.newobj_host); irToIl.Add(instrStart); + ir.Add((uint)destSlot); irToIl.Add(instrStart); + ir.Add((uint)tokIdx); irToIl.Add(instrStart); + ir.Add((uint)callCtorArgc); irToIl.Add(instrStart); + int argBase2 = sp - callCtorArgc; + for (int k = 0; k < callCtorArgc; k++) + { ir.Add((uint)evalStack[argBase2 + k].slot); irToIl.Add(instrStart); } + sp = spBase2; + } + else + goto notSupported; + } + else + { + // unresolved — treat as not-supported so IL path handles it + goto notSupported; + } + ensuredScriptSlots.Clear(); + break; + } + + // --- Newobj --- + case ILOpCode.Ldftn: + { + pendingFtnTok = BitConverter.ToInt32(il, ip); ip += 4; + // Placeholder for the native fn pointer; the delegate newobj pops it. + evalStack[sp++] = (-1, SType.I4); + break; + } + case ILOpCode.Ldvirtftn: + { + pendingFtnTok = BitConverter.ToInt32(il, ip); ip += 4; + // Pops the dup'd receiver copy, pushes the fn pointer placeholder — the + // original receiver stays beneath for the delegate newobj. + evalStack[sp - 1] = (-1, SType.I4); + break; + } + + case ILOpCode.Newobj: + { + int tok = BitConverter.ToInt32(il, ip); ip += 4; + int tokIdx = tokens.Count; tokens.Add(tok); + + if (pendingFtnTok != 0) + { + // Delegate construction: `recv|null; ftn; newobj D::.ctor(object, IntPtr)`. + int ftnTok = pendingFtnTok; pendingFtnTok = 0; + var delegateType = ResolveCtorParentType(asm, tok); + if (delegateType == null || !typeof(Delegate).IsAssignableFrom(delegateType)) + throw new NotSupportedException( + $"IR lowering: delegate construction in '{method.Name}' at IL+0x{instrStart:X4}" + + $" over an unresolvable delegate type (token 0x{tok:X8})"); + var site = new DelegateSite { DelegateType = delegateType }; + if (asm.ByToken.TryGetValue(ftnTok, out var scriptTarget)) + site.ScriptMethod = scriptTarget; + else if (asm.HostCalls.TryGetValue(ftnTok, out var hostTarget) + && (hostTarget.ResolvedMethod ?? hostTarget.Binding.Method as MethodInfo) is { } mi) + site.HostMethod = mi; + else + { + asm.TokenNames.TryGetValue(ftnTok, out var ftnName); + throw new NotSupportedException( + $"IR lowering: method group '{ftnName ?? $"token 0x{ftnTok:X8}"}' in " + + $"'{method.Name}' at IL+0x{instrStart:X4} did not resolve to a host or script method"); + } + (delegateSiteByTokIdx ??= new Dictionary())[tokIdx] = site; + + int recvSlot = evalStack[sp - 2].slot; + if (recvSlot < 0 || addrTagBySlot[recvSlot] != -2) + goto notSupported; // phantom/placeholder receiver — not a plain O value + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + ir.Add((uint)Op.new_delegate); irToIl.Add(instrStart); + ir.Add((uint)dst); irToIl.Add(instrStart); + ir.Add((uint)tokIdx); irToIl.Add(instrStart); + ir.Add((uint)recvSlot); irToIl.Add(instrStart); + sp -= 2; + evalStack[sp++] = (dst, SType.O); + ensuredScriptSlots.Clear(); + break; + } + + if (asm.CtorToType.TryGetValue(tok, out var newObjDesc)) + { + // Script-defined type + int argc = asm.ByToken.TryGetValue(tok, out var ctorM) ? ctorM.ArgCount - 1 : 0; + if (newObjDesc.FlatLayout != null) + { + // FLAT struct `new S(...)` as an expression: zeroed Vt temp + + // ctor call with `this` = the temp (VtThisWb copies mutations back). + int vdst = AllocStructSlot(ref frameSize, slotTypes, slotStructs, newObjDesc.FlatLayout); + Emit2(ir, irToIl, Op.initobj, vdst, instrStart); + int argBaseF = sp - argc; + ir.Add((uint)Op.call_script); irToIl.Add(instrStart); + ir.Add(unchecked((uint)-1)); irToIl.Add(instrStart); + ir.Add((uint)tokIdx); irToIl.Add(instrStart); + ir.Add((uint)(argc + 1)); irToIl.Add(instrStart); + ir.Add((uint)vdst); irToIl.Add(instrStart); // `this` + for (int k = 0; k < argc; k++) + { ir.Add((uint)evalStack[argBaseF + k].slot); irToIl.Add(instrStart); } + sp = argBaseF; + evalStack[sp++] = (vdst, SType.Vt); + ensuredScriptSlots.Clear(); + break; + } + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + ir.Add((uint)Op.newobj_script); irToIl.Add(instrStart); + ir.Add((uint)dst); irToIl.Add(instrStart); + ir.Add((uint)tokIdx); irToIl.Add(instrStart); + ir.Add((uint)argc); irToIl.Add(instrStart); + int argBase = sp - argc; + for (int k = 0; k < argc; k++) + { + ir.Add((uint)evalStack[argBase + k].slot); irToIl.Add(instrStart); + } + sp = argBase; + evalStack[sp++] = (dst, SType.O); + } + else if (asm.HostCtors.TryGetValue(tok, out var hostCtor)) + { + int argc = hostCtor.Binding.ParamCount; + // A host ctor whose declaring type is a flat-registered struct and that + // has a flat-write Fast → allocate dst as Vt and let the executor write + // the struct bytes directly. Otherwise stay on O. + // Same slow-path-flat reasoning as call_host's useFlatVt above. + bool useFlat = hostCtor.ReturnStruct != null; + int dst = useFlat + ? AllocStructSlot(ref frameSize, slotTypes, slotStructs, hostCtor.ReturnStruct!) + : AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + int argBase = sp - argc; + // Inline the ctor when field order is verified and the dst is flat Vt. + if (hostCtor.Binding.IsInlineableOp && useFlat && hostCtor.ReturnStruct != null + && CanInlineOp(hostCtor, argc, hostCtor.ReturnStruct, isCtor: true)) + { + EmitInlinedOp(ir, irToIl, evalStack, argBase, argc, dst, + hostCtor.ReturnStruct, hostCtor, instrStart, isCtor: true); + sp = argBase; + evalStack[sp++] = (dst, SType.Vt); + break; + } + ir.Add((uint)Op.newobj_host); irToIl.Add(instrStart); + ir.Add((uint)dst); irToIl.Add(instrStart); + ir.Add((uint)tokIdx); irToIl.Add(instrStart); + ir.Add((uint)argc); irToIl.Add(instrStart); + for (int k = 0; k < argc; k++) + { + ir.Add((uint)evalStack[argBase + k].slot); irToIl.Add(instrStart); + } + sp = argBase; + evalStack[sp++] = (dst, useFlat ? SType.Vt : SType.O); + } + else + { + goto notSupported; + } + ensuredScriptSlots.Clear(); + break; + } + + // --- Field access --- + case ILOpCode.Ldfld: + { + int tok = BitConverter.ToInt32(il, ip); ip += 4; + int tokIdx = tokens.Count; tokens.Add(tok); + var (obj, _) = evalStack[--sp]; + // Resolve address phantoms to actual object slots + int actualObj; + int recvOff = 0; // composed byte offset within a Vt receiver slot + int heapOff = -1; // composed PrimBytes offset for a heap receiver (-1 = token mode) + if (addrTagBySlot[obj] >= 0) + { + // Frame-slot address (ldloca.s / composed inline chain) + actualObj = addrTagBySlot[obj]; + recvOff = addrByteOffBySlot[obj]; + } + else if (addrTagBySlot[obj] == -1 && addrFldOffBySlot[obj] >= 0) + { + // Composed inline address on a heap ScriptObject: parent + byte offset. + actualObj = addrFldObjBySlot[obj]; + heapOff = addrFldOffBySlot[obj]; + } + else if (addrTagBySlot[obj] == -1) + { + // Field-address phantom (ldflda): materialise the intermediate field first + int midFldObj = addrFldObjBySlot[obj]; + int midFldToki = addrFldTokBySlot[obj]; + int midFldTok = tokens[midFldToki]; + asm.FieldSTypes.TryGetValue(midFldTok, out var midFst); + if (midFst == SType.Vt && asm.FieldSlots.TryGetValue(midFldTok, out var midFsVt)) + { + // Vt field on a script class — allocate a Vt slot and emit ldfld_sc_vt. + var midVtLay = midFsVt.Item1.VtFieldLayouts?[midFsVt.Item2]; + int midSlotVt = midVtLay != null + ? AllocStructSlot(ref frameSize, slotTypes, slotStructs, midVtLay) + : AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + int midOffVt = midFsVt.Item1.FieldOffsets[midFsVt.Item2]; + Emit4(ir, irToIl, Op.ldfld_sc_vt, midSlotVt, midFldObj, midOffVt, instrStart); + actualObj = midSlotVt; + } + else + { + int midSlot = AllocSlot(ref frameSize, slotTypes, slotStructs, midFst, null); + if (asm.FieldSlots.TryGetValue(midFldTok, out var midFs)) + { + int midOff = midFs.Item1.FieldOffsets[midFs.Item2]; + var midOp2 = midFst == SType.I4 ? Op.ldfld_sc_i4 : midFst == SType.R4 ? Op.ldfld_sc_r4 : Op.ldfld_sc_o; + Emit4(ir, irToIl, midOp2, midSlot, midFldObj, midOff, instrStart); + } + else + { + var midOp = midFst == SType.I4 ? Op.ldfld_i4 : midFst == SType.R4 ? Op.ldfld_r4 : Op.ldfld_o; + Emit4(ir, irToIl, midOp, midSlot, midFldObj, midFldToki, instrStart); + } + actualObj = midSlot; + } + } + else if (addrTagBySlot[obj] == -3 && addrElemLayoutBySlot[obj] != null) + { + // Flat array element: memcpy it into a Vt temp — the read flows through + // the Vt-receiver field branch below (no write-back needed for loads). + int arrSlot = addrFldObjBySlot[obj]; + int idxSlot = addrFldTokBySlot[obj]; + int midVt = AllocStructSlot(ref frameSize, slotTypes, slotStructs, addrElemLayoutBySlot[obj]!); + Emit4(ir, irToIl, Op.ldelem_vt, midVt, arrSlot, idxSlot, instrStart); + actualObj = midVt; + recvOff = addrByteOffBySlot[obj]; + } + else if (addrTagBySlot[obj] == -3) + { + // Array-element address (ldelema): materialise the element via ldelem. + int arrSlot = addrFldObjBySlot[obj]; + int idxSlot = addrFldTokBySlot[obj]; + int midSlot = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + Emit4(ir, irToIl, Op.ldelem_o, midSlot, arrSlot, idxSlot, instrStart); + actualObj = midSlot; + } + else + { + actualObj = obj; + } + // HOST-struct field read through a composed heap offset: the flat struct + // lives inline in a ScriptObject's PrimBytes — e.g. a Vector3 hoisted into + // an iterator state-machine field (`target.y` after a yield). Without this + // the read fell through to the boxed host-field path with the ScriptObject + // as receiver ("Field y ... is not a field on ScriptObject"). + if (heapOff >= 0 && asm.HostFields.TryGetValue(tok, out var hfheap) + && hfheap.DeclaringStruct != null) + { + if (hfheap.PrimitiveSt == SType.I4 || hfheap.PrimitiveSt == SType.R4) + { + // Primitive field: materialize the declaring struct into a Vt temp + // so the flat fast path below applies (widening kinds included). + int midVtH = AllocStructSlot(ref frameSize, slotTypes, slotStructs, hfheap.DeclaringStruct); + Emit4(ir, irToIl, Op.ldfld_sc_vt, midVtH, actualObj, heapOff, instrStart); + actualObj = midVtH; + recvOff = 0; + heapOff = -1; + } + else if (hfheap.FieldTypeName != null && asm.HostSurface != null + && asm.HostSurface.TryGetStructLayout(hfheap.FieldTypeName, out var nLayH) + && nLayH != null) + { + // NESTED struct field (`pose.P` value read): copy its byte range + // straight out at the summed offset. + int dstNH = AllocStructSlot(ref frameSize, slotTypes, slotStructs, nLayH); + evalStack[sp++] = (dstNH, SType.Vt); + Emit4(ir, irToIl, Op.ldfld_sc_vt, dstNH, actualObj, heapOff + hfheap.ByteOffset, instrStart); + break; + } + } + // Flat-struct fast path: receiver is a Vt slot AND the field is a registered + // primitive on that struct → emit ldfld_vt_* with the byte offset, bypassing + // the boxed Get delegate entirely. + if (heapOff < 0 && slotTypes[actualObj] == SType.Vt + && asm.HostFields.TryGetValue(tok, out var hfvt) + && hfvt.DeclaringStruct != null + && (hfvt.PrimitiveSt == SType.I4 || hfvt.PrimitiveSt == SType.R4)) + { + int dstv = AllocSlot(ref frameSize, slotTypes, slotStructs, hfvt.PrimitiveSt, null); + evalStack[sp++] = (dstv, hfvt.PrimitiveSt); + Emit4(ir, irToIl, LdFldVtOpFor(hfvt), dstv, actualObj, recvOff + hfvt.ByteOffset, instrStart); + break; + } + // Flat script-struct receiver (Vt frame slot): field reads are direct + // byte-offset loads; nested blittable struct fields memcpy a sub-range. + if (heapOff < 0 && slotTypes[actualObj] == SType.Vt + && asm.FieldSlots.TryGetValue(tok, out var vfs)) + { + var vfst = vfs.Item1.FieldTypes[vfs.Item2]; + int vOff = recvOff + vfs.Item1.FieldOffsets[vfs.Item2]; + if (vfst == SType.Vt) + { + var nLay = vfs.Item1.VtFieldLayouts?[vfs.Item2]; + int dstn = nLay != null + ? AllocStructSlot(ref frameSize, slotTypes, slotStructs, nLay) + : AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + evalStack[sp++] = (dstn, SType.Vt); + Emit4(ir, irToIl, Op.ldfld_vt_vt, dstn, actualObj, vOff, instrStart); + } + else + { + var pst = vfst == SType.R4 ? SType.R4 : SType.I4; + int dstn = AllocSlot(ref frameSize, slotTypes, slotStructs, pst, null); + evalStack[sp++] = (dstn, pst); + Emit4(ir, irToIl, pst == SType.I4 ? Op.ldfld_vt_i4 : Op.ldfld_vt_r4, + dstn, actualObj, vOff, instrStart); + } + break; + } + // Heap receiver with a COMPOSED inline offset (nested blittable struct + // inside a heap ScriptObject): direct PrimBytes access at the summed offset. + if (heapOff >= 0 && asm.FieldSlots.TryGetValue(tok, out var cfs)) + { + var cfst = cfs.Item1.FieldTypes[cfs.Item2]; + int cOff = heapOff + cfs.Item1.FieldOffsets[cfs.Item2]; + if (cfst == SType.Vt) + { + var nLay = cfs.Item1.VtFieldLayouts?[cfs.Item2]; + int dstn = nLay != null + ? AllocStructSlot(ref frameSize, slotTypes, slotStructs, nLay) + : AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + evalStack[sp++] = (dstn, SType.Vt); + Emit4(ir, irToIl, Op.ldfld_sc_vt, dstn, actualObj, cOff, instrStart); + } + else + { + var pst = cfst == SType.R4 ? SType.R4 : SType.I4; + int dstn = AllocSlot(ref frameSize, slotTypes, slotStructs, pst, null); + evalStack[sp++] = (dstn, pst); + Emit4(ir, irToIl, pst == SType.I4 ? Op.ldfld_sc_i4 : Op.ldfld_sc_r4, + dstn, actualObj, cOff, instrStart); + } + break; + } + asm.FieldSTypes.TryGetValue(tok, out var fst); + if (asm.FieldSlots.TryGetValue(tok, out var fsLo)) + { + int off = fsLo.Item1.FieldOffsets[fsLo.Item2]; + if (fst == SType.Vt) + { + // Host-struct Vt field stored inline in PrimBytes; copy bytes out. + var vtLay = fsLo.Item1.VtFieldLayouts?[fsLo.Item2]; + int dst2 = vtLay != null + ? AllocStructSlot(ref frameSize, slotTypes, slotStructs, vtLay) + : AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + evalStack[sp++] = (dst2, fst); + Emit4(ir, irToIl, Op.ldfld_sc_vt, dst2, actualObj, off, instrStart); + } + else + { + int dst2 = AllocSlot(ref frameSize, slotTypes, slotStructs, fst, null); + evalStack[sp++] = (dst2, fst); + var op2 = fst == SType.I4 ? Op.ldfld_sc_i4 : fst == SType.R4 ? Op.ldfld_sc_r4 : Op.ldfld_sc_o; + Emit4(ir, irToIl, op2, dst2, actualObj, off, instrStart); + } + } + else + { + int dst2 = AllocSlot(ref frameSize, slotTypes, slotStructs, fst, null); + evalStack[sp++] = (dst2, fst); + var ldfldOp = fst == SType.I4 ? Op.ldfld_i4 : fst == SType.R4 ? Op.ldfld_r4 : Op.ldfld_o; + Emit4(ir, irToIl, ldfldOp, dst2, actualObj, tokIdx, instrStart); + } + // Value-load of a script-struct field: clone so the pushed value is an + // independent copy (struct copy semantics). Field mutation uses ldflda, which + // takes a different path and is unaffected. + if (fst == SType.O && asm.FieldIsScriptStruct.Contains(tok)) + { + var (fldSlot, _) = evalStack[sp - 1]; + int cl = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + Emit3(ir, irToIl, Op.clone_sc, cl, fldSlot, instrStart); + evalStack[sp - 1] = (cl, SType.O); + } + break; + } + case ILOpCode.Ldflda: + { + int tok = BitConverter.ToInt32(il, ip); ip += 4; + int tokIdx = tokens.Count; tokens.Add(tok); + var (obj, _) = evalStack[--sp]; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); // phantom slot + asm.FieldSTypes.TryGetValue(tok, out var fldaFst); + bool isScFldA = asm.FieldSlots.TryGetValue(tok, out var fldaFs); + int inTag = addrTagBySlot[obj]; + + // Address of a field INSIDE flat struct bytes: accumulate the byte offset + // instead of materializing — this is what makes nested chains like + // `a.inner.x = v` (ldloca a; ldflda inner; stfld x) work. HOST flat-struct + // fields (Pose.P where Pose is a registered flat struct) compose the same + // way via the FieldEntry byte offset — without this a chain rooted at a + // hoisted iterator field fell to the token-mode phantom and consumers + // reflected on a null or ScriptObject receiver. + HostBinding.FieldEntry? hfA = null; + bool isHostFlatA = !isScFldA + && asm.HostFields.TryGetValue(tok, out hfA) && hfA!.DeclaringStruct != null; + if ((isScFldA && (fldaFst == SType.Vt || fldaFst == SType.I4 || fldaFst == SType.R4)) + || isHostFlatA) + { + int fOff = isScFldA + ? fldaFs.Item1.FieldOffsets[fldaFs.Item2] + : hfA!.ByteOffset; + // "Is the addressed field itself a struct?" — gates the heap-receiver + // branch below. For host fields, anything non-primitive is a nested struct. + bool fldaIsVt = isScFldA + ? fldaFst == SType.Vt + : hfA!.PrimitiveSt != SType.I4 && hfA.PrimitiveSt != SType.R4; + if (inTag >= 0 && slotTypes[inTag] == SType.Vt) + { + // base = frame Vt slot (+ prior offset) + addrTagBySlot[dst] = inTag; + addrByteOffBySlot[dst] = addrByteOffBySlot[obj] + fOff; + addrFldTokBySlot[dst] = tokIdx; + evalStack[sp++] = (dst, SType.O); + break; + } + if (inTag == -1 && addrFldOffBySlot[obj] >= 0) + { + // base = heap parent + prior PrimBytes offset + addrTagBySlot[dst] = -1; + addrFldObjBySlot[dst] = addrFldObjBySlot[obj]; + addrFldOffBySlot[dst] = addrFldOffBySlot[obj] + fOff; + addrFldTokBySlot[dst] = tokIdx; + evalStack[sp++] = (dst, SType.O); + break; + } + if (inTag == -1 && addrFldOffBySlot[obj] < 0) + { + // base = token-addressed field phantom. Composition is only valid + // when the OUTER field is itself inline (sc-Vt); resolve its offset. + int outTok = tokens[addrFldTokBySlot[obj]]; + asm.FieldSTypes.TryGetValue(outTok, out var outFst); + if (outFst == SType.Vt && asm.FieldSlots.TryGetValue(outTok, out var outFs)) + { + addrTagBySlot[dst] = -1; + addrFldObjBySlot[dst] = addrFldObjBySlot[obj]; + addrFldOffBySlot[dst] = outFs.Item1.FieldOffsets[outFs.Item2] + fOff; + addrFldTokBySlot[dst] = tokIdx; + evalStack[sp++] = (dst, SType.O); + break; + } + } + if (inTag == -2 && slotTypes[obj] == SType.Vt) + { + // base = a Vt VALUE slot on the stack (r-value receiver) + addrTagBySlot[dst] = obj; + addrByteOffBySlot[dst] = fOff; + addrFldTokBySlot[dst] = tokIdx; + evalStack[sp++] = (dst, SType.O); + break; + } + if (inTag == -3 && addrElemLayoutBySlot[obj] != null) + { + // Address of a field inside a FLAT ARRAY ELEMENT: keep the element + // address, accumulate the byte offset (consumers materialize the + // element and write it back). + addrTagBySlot[dst] = -3; + addrFldObjBySlot[dst] = addrFldObjBySlot[obj]; + addrFldTokBySlot[dst] = addrFldTokBySlot[obj]; + addrElemLayoutBySlot[dst] = addrElemLayoutBySlot[obj]; + addrByteOffBySlot[dst] = addrByteOffBySlot[obj] + fOff; + evalStack[sp++] = (dst, SType.O); + break; + } + if (fldaIsVt) + { + // Address of an INLINE (Vt) struct field on a HEAP receiver (class + // instance or heap-struct local): offset-mode phantom rooted at the + // OBJECT slot, so consumers read/write PrimBytes in place — + // materializing would copy and lose mutations (h.pos.x = 3). + int baseObj = inTag >= 0 ? inTag : obj; + addrTagBySlot[dst] = -1; + addrFldObjBySlot[dst] = baseObj; + addrFldOffBySlot[dst] = fOff; + addrFldTokBySlot[dst] = tokIdx; + evalStack[sp++] = (dst, SType.O); + break; + } + } + + // Classic path: address of a field on a (heap) object — resolve ldloca + // bases to the actual slot; consumers materialize via the token. + int actualObj = inTag >= 0 ? inTag : obj; + addrTagBySlot[dst] = -1; // field address + addrFldObjBySlot[dst] = actualObj; + addrFldTokBySlot[dst] = tokIdx; + evalStack[sp++] = (dst, SType.O); + break; + } + + case ILOpCode.Stfld: + { + int tok = BitConverter.ToInt32(il, ip); ip += 4; + int tokIdx = tokens.Count; tokens.Add(tok); + var (src, srcT) = evalStack[--sp]; + var (obj, _) = evalStack[--sp]; + int tag = addrTagBySlot[obj]; + int recvOffS = tag >= 0 ? addrByteOffBySlot[obj] : 0; + int heapOffS = tag == -1 ? addrFldOffBySlot[obj] : -1; + // If obj is a frame-slot addr (from ldloca.s), use the actual local slot as receiver. + // If obj is a field-address phantom (ldflda on a struct class-field), materialise the + // field into a temp slot. ScriptObject is a reference type, so writing through the + // reference propagates to the parent's field slot automatically — no write-back needed. + // If obj is an array-element phantom (ldelema on a struct array), materialise the + // element the same way. + int actualObj; + if (tag >= 0) + { + actualObj = tag; // ldloca.s → resolve to actual local + } + else if (tag == -1 && heapOffS >= 0) + { + // Composed inline address on a heap ScriptObject: use the parent directly. + actualObj = addrFldObjBySlot[obj]; + } + else if (tag == -1) + { + // ldflda phantom: load the struct reference stored in the parent class field + int midFldObj = addrFldObjBySlot[obj]; + int midFldToki = addrFldTokBySlot[obj]; + int midTok = tokens[midFldToki]; + asm.FieldSTypes.TryGetValue(midTok, out var midFst); + if (midFst == SType.Vt && asm.FieldSlots.TryGetValue(midTok, out var midFs2Vt)) + { + // Vt field on a script class — allocate Vt slot and emit ldfld_sc_vt. + var midVtLay2 = midFs2Vt.Item1.VtFieldLayouts?[midFs2Vt.Item2]; + int midSlotVt2 = midVtLay2 != null + ? AllocStructSlot(ref frameSize, slotTypes, slotStructs, midVtLay2) + : AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + int midOff2Vt = midFs2Vt.Item1.FieldOffsets[midFs2Vt.Item2]; + Emit4(ir, irToIl, Op.ldfld_sc_vt, midSlotVt2, midFldObj, midOff2Vt, instrStart); + actualObj = midSlotVt2; + } + else + { + int midSlot = AllocSlot(ref frameSize, slotTypes, slotStructs, midFst, null); + if (asm.FieldSlots.TryGetValue(midTok, out var midFs2)) + { + int midOff2 = midFs2.Item1.FieldOffsets[midFs2.Item2]; + var midOp2 = midFst == SType.I4 ? Op.ldfld_sc_i4 : midFst == SType.R4 ? Op.ldfld_sc_r4 : Op.ldfld_sc_o; + Emit4(ir, irToIl, midOp2, midSlot, midFldObj, midOff2, instrStart); + } + else + { + var midOp = midFst == SType.I4 ? Op.ldfld_i4 : midFst == SType.R4 ? Op.ldfld_r4 : Op.ldfld_o; + Emit4(ir, irToIl, midOp, midSlot, midFldObj, midFldToki, instrStart); + } + actualObj = midSlot; + } + } + else if (tag == -3 && addrElemLayoutBySlot[obj] != null) + { + // Flat array element write (`arr[i].f = v`): materialize → mutate the + // Vt temp → memcpy the whole element back. Handled inline because the + // write-back must follow the field store. + int arrSlotW = addrFldObjBySlot[obj]; + int idxSlotW = addrFldTokBySlot[obj]; + var elemLayW = addrElemLayoutBySlot[obj]!; + int elemTmp = AllocStructSlot(ref frameSize, slotTypes, slotStructs, elemLayW); + Emit4(ir, irToIl, Op.ldelem_vt, elemTmp, arrSlotW, idxSlotW, instrStart); + int baseOffW = addrByteOffBySlot[obj]; + // Host-struct element field (arr[i].x on Vector3[]): FieldEntry byte offset. + if (asm.HostFields.TryGetValue(tok, out var whf) && whf.DeclaringStruct != null + && (whf.PrimitiveSt == SType.I4 || whf.PrimitiveSt == SType.R4)) + { + Emit4(ir, irToIl, StFldVtOpFor(whf), + elemTmp, baseOffW + whf.ByteOffset, src, instrStart); + Emit4(ir, irToIl, Op.stelem_vt, arrSlotW, idxSlotW, elemTmp, instrStart); + break; + } + if (asm.FieldSlots.TryGetValue(tok, out var wfs)) + { + var wfst = wfs.Item1.FieldTypes[wfs.Item2]; + int wOff = baseOffW + wfs.Item1.FieldOffsets[wfs.Item2]; + if (wfst == SType.Vt) + { + if (srcT != SType.Vt) + { + var nLayW = wfs.Item1.VtFieldLayouts?[wfs.Item2]; + if (nLayW != null) + { + int vtmpW = AllocStructSlot(ref frameSize, slotTypes, slotStructs, nLayW); + Emit3(ir, irToIl, Op.unbox_vt, vtmpW, src, instrStart); + src = vtmpW; + } + } + Emit4(ir, irToIl, Op.stfld_vt_vt, elemTmp, wOff, src, instrStart); + } + else + { + Emit4(ir, irToIl, wfst == SType.R4 ? Op.stfld_vt_r4 : Op.stfld_vt_i4, + elemTmp, wOff, src, instrStart); + } + } + Emit4(ir, irToIl, Op.stelem_vt, arrSlotW, idxSlotW, elemTmp, instrStart); + break; + } + else if (tag == -3) + { + // ldelema phantom: load the struct reference stored in the array element + int arrSlot = addrFldObjBySlot[obj]; + int idxSlot = addrFldTokBySlot[obj]; + int midSlot = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + Emit4(ir, irToIl, Op.ldelem_o, midSlot, arrSlot, idxSlot, instrStart); + actualObj = midSlot; + } + else + { + actualObj = obj; + } + // Auto-init: if this stfld writes into an O slot (script-defined struct local + // addressed via ldloca.s), emit ensure_script before the first field write. + // Roslyn omits initobj when the struct is "definitely assigned" by consecutive + // field stores, but our runtime O slot starts null — so the first stfld on + // any execution path would NRE without this guard. + // Optimization: skip if the slot is already known initialized in this basic block + // (tracked in ensuredScriptSlots). Cleared at block boundaries and after calls/branches. + // Same for a BOXED host struct local (`S s; s.x = 1; s.y = 2;` with no + // initobj): the field's declaring TypeRef token has a synthetic default + // ctor (see the parse-time hostCtors registration), and ensure_script + // creates the box through it when the slot is still null. + if (tag >= 0 && slotTypes[actualObj] == SType.O + && (asm.FieldToTypeDef.TryGetValue(tok, out int fieldTypeDef) + || (asm.HostFieldDeclTypeTok.TryGetValue(tok, out fieldTypeDef) + && asm.HostCtors.ContainsKey(fieldTypeDef)))) + { + if (!ensuredScriptSlots.Contains(actualObj)) + { + int typeDefTokIdx2 = tokens.Count; tokens.Add(fieldTypeDef); + ir.Add((uint)Op.ensure_script); irToIl.Add(instrStart); + ir.Add((uint)actualObj); irToIl.Add(instrStart); + ir.Add((uint)typeDefTokIdx2); irToIl.Add(instrStart); + } + ensuredScriptSlots.Add(actualObj); + } + // HOST-struct field write through a composed heap offset (the mirror of the + // Ldfld handling above — a flat host struct inlined in a ScriptObject's + // PrimBytes, e.g. `target.y = v` on a hoisted iterator local). + if (heapOffS >= 0 && asm.HostFields.TryGetValue(tok, out var hfheapS) + && hfheapS.DeclaringStruct != null) + { + if (hfheapS.PrimitiveSt == SType.I4 || hfheapS.PrimitiveSt == SType.R4) + { + // Primitive field: the temp is a copy, so read-modify-WRITE-BACK — + // copy the struct out, store the field into the temp (widening/ + // truncating kinds included), copy the whole struct back. + int midVtS = AllocStructSlot(ref frameSize, slotTypes, slotStructs, hfheapS.DeclaringStruct); + Emit4(ir, irToIl, Op.ldfld_sc_vt, midVtS, actualObj, heapOffS, instrStart); + Emit4(ir, irToIl, StFldVtOpFor(hfheapS), midVtS, hfheapS.ByteOffset, src, instrStart); + Emit4(ir, irToIl, Op.stfld_sc_vt, actualObj, heapOffS, midVtS, instrStart); + break; + } + if (srcT == SType.Vt) + { + // NESTED struct field (`pose.P = v`): write the source's byte range + // (stfld_sc_vt sizes from the SRC slot's layout) at the summed offset. + Emit4(ir, irToIl, Op.stfld_sc_vt, actualObj, heapOffS + hfheapS.ByteOffset, src, instrStart); + break; + } + } + // Flat-struct fast path: stfld of a primitive into a Vt slot. + if (heapOffS < 0 && slotTypes[actualObj] == SType.Vt + && asm.HostFields.TryGetValue(tok, out var hfvtS) + && hfvtS.DeclaringStruct != null + && (hfvtS.PrimitiveSt == SType.I4 || hfvtS.PrimitiveSt == SType.R4)) + { + Emit4(ir, irToIl, StFldVtOpFor(hfvtS), actualObj, recvOffS + hfvtS.ByteOffset, src, instrStart); + break; + } + // Flat script-struct receiver (Vt frame slot). + if (heapOffS < 0 && slotTypes[actualObj] == SType.Vt + && asm.FieldSlots.TryGetValue(tok, out var vfsS)) + { + var vfst = vfsS.Item1.FieldTypes[vfsS.Item2]; + int vOff = recvOffS + vfsS.Item1.FieldOffsets[vfsS.Item2]; + if (vfst == SType.Vt) + { + if (srcT != SType.Vt) + { + var nLay = vfsS.Item1.VtFieldLayouts?[vfsS.Item2]; + if (nLay != null) + { + int vtmp = AllocStructSlot(ref frameSize, slotTypes, slotStructs, nLay); + Emit3(ir, irToIl, Op.unbox_vt, vtmp, src, instrStart); + src = vtmp; + } + } + Emit4(ir, irToIl, Op.stfld_vt_vt, actualObj, vOff, src, instrStart); + } + else + { + Emit4(ir, irToIl, vfst == SType.R4 ? Op.stfld_vt_r4 : Op.stfld_vt_i4, + actualObj, vOff, src, instrStart); + } + break; + } + // Heap receiver with a COMPOSED inline offset. + if (heapOffS >= 0 && asm.FieldSlots.TryGetValue(tok, out var cfsS)) + { + var cfst = cfsS.Item1.FieldTypes[cfsS.Item2]; + int cOff = heapOffS + cfsS.Item1.FieldOffsets[cfsS.Item2]; + if (cfst == SType.Vt) + { + if (srcT != SType.Vt) + { + var nLay = cfsS.Item1.VtFieldLayouts?[cfsS.Item2]; + if (nLay != null) + { + int vtmp = AllocStructSlot(ref frameSize, slotTypes, slotStructs, nLay); + Emit3(ir, irToIl, Op.unbox_vt, vtmp, src, instrStart); + src = vtmp; + } + } + Emit4(ir, irToIl, Op.stfld_sc_vt, actualObj, cOff, src, instrStart); + } + else + { + Emit4(ir, irToIl, cfst == SType.R4 ? Op.stfld_sc_r4 : Op.stfld_sc_i4, + actualObj, cOff, src, instrStart); + } + break; + } + asm.FieldSTypes.TryGetValue(tok, out var sfSType); + if (asm.FieldSlots.TryGetValue(tok, out var sfScFs)) + { + int sfOff = sfScFs.Item1.FieldOffsets[sfScFs.Item2]; + // Vt-typed destination field (inline in PrimBytes): emit stfld_sc_vt. + if (sfSType == SType.Vt) + { + // The runtime stfld_sc_vt copies the struct bytes out of the source's + // Vt frame slot. If the value instead arrived boxed in an O slot (e.g. + // a host call/ctor whose return wasn't flattened — common when a struct + // type is registered after its consumers), unbox it into a Vt temp using + // the field's layout first, so the store always reads real frame bytes. + if (srcT != SType.Vt) + { + var fieldLay = sfScFs.Item1.VtFieldLayouts?[sfScFs.Item2]; + if (fieldLay != null) + { + int vtmp = AllocStructSlot(ref frameSize, slotTypes, slotStructs, fieldLay); + Emit3(ir, irToIl, Op.unbox_vt, vtmp, src, instrStart); + src = vtmp; + } + } + ir.Add((uint)Op.stfld_sc_vt); irToIl.Add(instrStart); + ir.Add((uint)actualObj); irToIl.Add(instrStart); + ir.Add((uint)sfOff); irToIl.Add(instrStart); + ir.Add((uint)src); irToIl.Add(instrStart); + break; + } + // Vt source into a non-Vt receiver field (script struct field boxed in + // RefSlots): box it first. + if (srcT == SType.Vt) + { + int boxed = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + Emit3(ir, irToIl, Op.box_vt, boxed, src, instrStart); + src = boxed; + } + var scOp = sfSType == SType.I4 ? Op.stfld_sc_i4 : sfSType == SType.R4 ? Op.stfld_sc_r4 : Op.stfld_sc_o; + ir.Add((uint)scOp); irToIl.Add(instrStart); + ir.Add((uint)actualObj); irToIl.Add(instrStart); + ir.Add((uint)sfOff); irToIl.Add(instrStart); + ir.Add((uint)src); irToIl.Add(instrStart); + } + else + { + // Non-script-class field (host field). If src is Vt, box it first. + if (srcT == SType.Vt) + { + int boxed = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + Emit3(ir, irToIl, Op.box_vt, boxed, src, instrStart); + src = boxed; + } + var sfldOp = sfSType == SType.I4 ? Op.stfld_i4 : sfSType == SType.R4 ? Op.stfld_r4 : Op.stfld_o; + ir.Add((uint)sfldOp); irToIl.Add(instrStart); + ir.Add((uint)actualObj); irToIl.Add(instrStart); + ir.Add((uint)tokIdx); irToIl.Add(instrStart); + ir.Add((uint)src); irToIl.Add(instrStart); + } + break; + } + case ILOpCode.Ldsfld: + { + int tok = BitConverter.ToInt32(il, ip); ip += 4; + int tokIdx = tokens.Count; tokens.Add(tok); + // Host STRUCT field (the Vector3.one shape): unbox the Get() result into + // a flat Vt slot. The O-slot form handed a BOXED struct to flat consumers + // (inlined operators, Vt args, component reads), which read the numeric + // frame and saw zeros (found by fuzzing: HVec.One + v summed only v). + if (asm.HostFields.TryGetValue(tok, out var sfe) && sfe.FieldTypeName != null + && asm.HostSurface != null + && asm.HostSurface.TryGetStructLayout(sfe.FieldTypeName, out var sLay) && sLay != null) + { + int dstv = AllocStructSlot(ref frameSize, slotTypes, slotStructs, sLay); + evalStack[sp++] = (dstv, SType.Vt); + Emit3(ir, irToIl, Op.ldsfld_struct, dstv, tokIdx, instrStart); + break; + } + // SCRIPT static of primitive type: load into a typed numeric slot, not O. + // The boxed O form broke every consumer that keys off the slot type — + // `if (s_hp == 0 && …)` compiles to brtrue on the load, and brtrue_o + // tested the BOX for null (boxed 0 is non-null → wrong branch). + if (ScriptStaticPrimitiveSType(asm, tok) is { } sst) + { + int dstn = AllocSlot(ref frameSize, slotTypes, slotStructs, sst, null); + evalStack[sp++] = (dstn, sst); + Emit3(ir, irToIl, sst == SType.I4 ? Op.ldsfld_i4 : Op.ldsfld_r4, + dstn, tokIdx, instrStart); + break; + } + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + evalStack[sp++] = (dst, SType.O); + Emit3(ir, irToIl, Op.ldsfld_o, dst, tokIdx, instrStart); + break; + } + case ILOpCode.Ldsflda: + { + int tok = BitConverter.ToInt32(il, ip); ip += 4; + int tokIdx = tokens.Count; tokens.Add(tok); + // Address of a static field. C# emits this for member calls THROUGH the + // address — `str + s_count` becomes `ldsflda s_count; call int.ToString()`. + // For IMMUTABLE field types (primitives, enums, string) the address is + // equivalent to the value. Script statics load into TYPED slots like + // Ldsfld does (an enum static then hits the constrained-enum boxing on + // the call); host prims/strings load boxed. Mutable struct statics keep + // failing — a value copy would silently drop their mutations. + if (ScriptStaticPrimitiveSType(asm, tok) is { } ast) + { + int tdst = AllocSlot(ref frameSize, slotTypes, slotStructs, ast, null); + evalStack[sp++] = (tdst, ast); + Emit3(ir, irToIl, ast == SType.I4 ? Op.ldsfld_i4 : Op.ldsfld_r4, + tdst, tokIdx, instrStart); + break; + } + if (!StaticFieldIsImmutableValue(asm, tok)) + goto notSupported; + int adst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + evalStack[sp++] = (adst, SType.O); + Emit3(ir, irToIl, Op.ldsfld_o, adst, tokIdx, instrStart); + break; + } + case ILOpCode.Stsfld: + { + int tok = BitConverter.ToInt32(il, ip); ip += 4; + int tokIdx = tokens.Count; tokens.Add(tok); + var (src, srcT) = evalStack[--sp]; + // Primitive script static: emit an unboxed typed store (mirrors Ldsfld). The old + // unconditional stsfld_o boxed every primitive static write. + if (srcT != SType.Vt && ScriptStaticPrimitiveSType(asm, tok) is { } sst) + { + Emit3(ir, irToIl, sst == SType.I4 ? Op.stsfld_i4 : Op.stsfld_r4, tokIdx, src, instrStart); + break; + } + // Host static fields take a boxed value (FieldEntry.Set); box a flat struct first. + if (srcT == SType.Vt) + { + int boxed = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + Emit3(ir, irToIl, Op.box_vt, boxed, src, instrStart); + src = boxed; + } + Emit3(ir, irToIl, Op.stsfld_o, tokIdx, src, instrStart); + break; + } + // --- Arrays --- + case ILOpCode.Newarr: + { + int elemTok = BitConverter.ToInt32(il, ip); ip += 4; + int elemTokIdx = tokens.Count; tokens.Add(elemTok); + var (lenSlot, _) = evalStack[--sp]; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + evalStack[sp++] = (dst, SType.O); + + // Typed backing when the element type is statically int/float: the executor + // allocates int[]/float[] and element accesses skip boxing entirely. + // Flat script-struct elements get a ScriptVtArray (single byte[] backing, + // no per-element ScriptObject). Host flat structs (Vector3[]) still use the + // boxed object?[] path — the lowerer has no binding access here; follow-up. + var arrOp = Op.newarr; + var elemHandle2 = MetadataTokens.EntityHandle(elemTok); + if (elemHandle2.Kind == HandleKind.TypeReference) + { + var elemRef2 = asm.Reader.GetTypeReference((TypeReferenceHandle)elemHandle2); + if (asm.Reader.GetString(elemRef2.Namespace) == "System") + { + var elemName2 = asm.Reader.GetString(elemRef2.Name); + if (elemName2 == "Int32") arrOp = Op.newarr_i4; + else if (elemName2 == "Single") arrOp = Op.newarr_r4; + // Other primitives keep Op.newarr but get a real typed runtime + // array (bool[]/char[]/…) via the side table, so element reads box + // with the correct type (element ToString/identity were wrong when + // object?[]-backed: bool boxed as int, defaults null). + else if (PrimArrayElemType(elemName2) is { } pt) + (primArrayElemTypeByTokIdx ??= new Dictionary())[elemTokIdx] = pt; + } + } + // Host ENUM elements need a real typed array too: Roslyn skips storing + // zero-valued members into `{ ... }` initializers (newarr zeroes them), + // so an object?[] backing left NULLS where C# guarantees the zero member. + if (arrOp == Op.newarr + && (primArrayElemTypeByTokIdx == null || !primArrayElemTypeByTokIdx.ContainsKey(elemTokIdx)) + && ResolveHostEnumType(asm, elemTok) is { } enumElem2) + (primArrayElemTypeByTokIdx ??= new Dictionary())[elemTokIdx] = enumElem2; + if (arrOp == Op.newarr && FlatElemLayout(asm, elemTok) is { } elemFlat2) + { + // Flat elements — script structs AND flat host structs (new Vector3[n]). + // Also fixes uninitialized-element access: object?[] held nulls where C# + // guarantees zeroed structs; the byte backing is zeroed by construction. + arrOp = Op.newarr_vt; + (layoutByTokIdx ??= new Dictionary())[elemTokIdx] = elemFlat2; + } + + // Check for eager InitializeArray pattern (dup; ldtoken; call InitializeArray) + // If present, we can't lower it cleanly — fall through to not-supported + if (ip + 11 <= il.Length && il[ip] == 0x25 && il[ip + 1] == 0xD0) + { + if (il[ip + 6] == 0x28) + { + int callTok2 = BitConverter.ToInt32(il, ip + 7); + if (asm.TokenNames.TryGetValue(callTok2, out var calleeName2) + && calleeName2 == "RuntimeHelpers.InitializeArray/2") + { + // Skip the dup+ldtoken+call pattern and emit with init flag + int fieldTok = BitConverter.ToInt32(il, ip + 2); + int fieldTokIdx = tokens.Count; tokens.Add(fieldTok); + ip += 11; + // Emit: [newarr*, dst, lenSlot, elemTokIdx, fieldTokIdx] (5 words) + ir.Add((uint)arrOp); irToIl.Add(instrStart); + ir.Add((uint)dst); irToIl.Add(instrStart); + ir.Add((uint)lenSlot); irToIl.Add(instrStart); + ir.Add((uint)elemTokIdx); irToIl.Add(instrStart); + ir.Add((uint)fieldTokIdx); irToIl.Add(instrStart); + break; + } + } + } + + // Normal newarr (no initializer): fieldTokIdx = 0xFFFFFFFF + ir.Add((uint)arrOp); irToIl.Add(instrStart); + ir.Add((uint)dst); irToIl.Add(instrStart); + ir.Add((uint)lenSlot); irToIl.Add(instrStart); + ir.Add((uint)elemTokIdx); irToIl.Add(instrStart); + ir.Add(unchecked((uint)-1)); irToIl.Add(instrStart); + break; + } + case ILOpCode.Ldlen: + { + var (src, _) = evalStack[sp - 1]; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I4, null); + evalStack[sp - 1] = (dst, SType.I4); + Emit3(ir, irToIl, Op.ldlen, dst, src, instrStart); + break; + } + // Generic `ldelem ` — emitted by Roslyn only for value-type elements (reference + // types use ldelem.ref). In this representation a struct element is an O + // ScriptObject, so the destination is O, and the loaded value must be cloned for + // struct copy semantics. clone_sc is a safe pass-through for any non-ScriptObject. + case ILOpCode.Ldelem: + { + int ldeTok = BitConverter.ToInt32(il, ip); ip += 4; + var (idxSlot, _) = evalStack[--sp]; + var (arrSlot, _) = evalStack[sp - 1]; + if (FlatElemLayout(asm, ldeTok) is { } ldeFlat) + { + // Flat element: memcpy out of the ScriptVtArray backing — value + // semantics without a clone allocation. + int vdst = AllocStructSlot(ref frameSize, slotTypes, slotStructs, ldeFlat); + evalStack[sp - 1] = (vdst, SType.Vt); + Emit4(ir, irToIl, Op.ldelem_vt, vdst, arrSlot, idxSlot, instrStart); + break; + } + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + evalStack[sp - 1] = (dst, SType.O); + Emit4(ir, irToIl, Op.ldelem_o, dst, arrSlot, idxSlot, instrStart); + int cl = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + Emit3(ir, irToIl, Op.clone_sc, cl, dst, instrStart); + evalStack[sp - 1] = (cl, SType.O); + break; + } + case ILOpCode.Ldelem_i: + case ILOpCode.Ldelem_i1: + case ILOpCode.Ldelem_u1: + case ILOpCode.Ldelem_i2: + case ILOpCode.Ldelem_u2: + case ILOpCode.Ldelem_i4: + case ILOpCode.Ldelem_u4: + case ILOpCode.Ldelem_i8: + case ILOpCode.Ldelem_r4: + case ILOpCode.Ldelem_r8: + case ILOpCode.Ldelem_ref: + { + var (idxSlot, _) = evalStack[--sp]; + var (arrSlot, _) = evalStack[sp - 1]; + SType et = op == ILOpCode.Ldelem_r4 ? SType.R4 + : op == ILOpCode.Ldelem_r8 ? SType.R8 + : op == ILOpCode.Ldelem_i8 ? SType.I8 + : op == ILOpCode.Ldelem_ref ? SType.O : SType.I4; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, et, null); + evalStack[sp - 1] = (dst, et); + Emit4(ir, irToIl, Op.ldelem_o, dst, arrSlot, idxSlot, instrStart); + break; + } + case ILOpCode.Stelem: + case ILOpCode.Stelem_i: + case ILOpCode.Stelem_i1: + case ILOpCode.Stelem_i2: + case ILOpCode.Stelem_i4: + case ILOpCode.Stelem_i8: + case ILOpCode.Stelem_r4: + case ILOpCode.Stelem_r8: + case ILOpCode.Stelem_ref: + { + int steTok = 0; + if (op == ILOpCode.Stelem) { steTok = BitConverter.ToInt32(il, ip); ip += 4; } + var (src, srcTE) = evalStack[--sp]; + var (idxSlot, _) = evalStack[--sp]; + var (arrSlot, _) = evalStack[--sp]; + if (steTok != 0 && FlatElemLayout(asm, steTok) is { } steFlat) + { + // Flat element: memcpy into the ScriptVtArray backing. A boxed (O) + // source is unboxed into a Vt temp first. + if (srcTE != SType.Vt) + { + int vtmp = AllocStructSlot(ref frameSize, slotTypes, slotStructs, steFlat); + Emit3(ir, irToIl, Op.unbox_vt, vtmp, src, instrStart); + src = vtmp; + } + Emit4(ir, irToIl, Op.stelem_vt, arrSlot, idxSlot, src, instrStart); + break; + } + // [stelem_o, arr, idx, src] + ir.Add((uint)Op.stelem_o); irToIl.Add(instrStart); + ir.Add((uint)arrSlot); irToIl.Add(instrStart); + ir.Add((uint)idxSlot); irToIl.Add(instrStart); + ir.Add((uint)src); irToIl.Add(instrStart); + break; + } + + // ldelema — push a phantom array-element address. Consumed in-place by + // ldind/stind (read or write the element directly), or as a byref call arg + // (then call_host_byref reads/writes through the array slot post-call). + case ILOpCode.Ldelema: + { + int lmaTok = BitConverter.ToInt32(il, ip); ip += 4; + var (idxSlot, _) = evalStack[--sp]; + var (arrSlot, _) = evalStack[--sp]; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); // phantom + addrTagBySlot[dst] = -3; // array-element address + addrFldObjBySlot[dst] = arrSlot; + addrFldTokBySlot[dst] = idxSlot; // reuse slot — points to index slot, not a token + if (FlatElemLayout(asm, lmaTok) is { } lmaFlat) + addrElemLayoutBySlot[dst] = lmaFlat; // flat elem: consumers ldelem_vt/stelem_vt + evalStack[sp++] = (dst, SType.O); + break; + } + + // initobj — zero-initialize the value-type at the address. For our model: + // - phantom frame-slot address pointing to a Vt slot: zero the slot's bytes. + // - phantom frame-slot address pointing to an O slot (script struct): emit newobj_script + // with 0 explicit args to create a zero-initialized ScriptObject. + // - phantom field address: stfld of null/zero. + // - phantom array-element address: stelem of null/zero. + case ILOpCode.Initobj: + { + int typeDefTok = BitConverter.ToInt32(il, ip); + ip += 4; // skip type token + var (addrSlot, _) = evalStack[--sp]; + int tag = addrTagBySlot[addrSlot]; + if (tag >= 0) + { + int targetSlot = tag; + SType ts = slotTypes[targetSlot]; + // Roslyn REUSES one temp local across consecutive struct initializers + // (`F(new V{...}, new V{...})`): the first arg's eval-stack entry still + // points at the slot this initobj is about to zero. CLR stack semantics + // say that entry is a COPY — materialize it before overwriting. + ProtectStackAliases(ir, irToIl, evalStack, sp, ref frameSize, + slotTypes, slotStructs, targetSlot, instrStart); + if (ts == SType.Vt) + { + ir.Add((uint)Op.initobj); irToIl.Add(instrStart); + ir.Add((uint)targetSlot); irToIl.Add(instrStart); + } + else if (ts == SType.I4 || ts == SType.R4) + { + Emit3(ir, irToIl, Op.ldc_i4, targetSlot, 0, instrStart); + } + else if (asm.TypeDefToType.ContainsKey(typeDefTok)) + { + // Script-defined struct/class O slot. + // If there is an explicit 0-arg ctor, call it via newobj_script. + // Otherwise (default struct ctor with RVA=0), zero-init without a call. + if (asm.TypeDefToCtorTok.TryGetValue(typeDefTok, out int ctorTok)) + { + int ctorTokIdx = tokens.Count; tokens.Add(ctorTok); + ir.Add((uint)Op.newobj_script); irToIl.Add(instrStart); + ir.Add((uint)targetSlot); irToIl.Add(instrStart); + ir.Add((uint)ctorTokIdx); irToIl.Add(instrStart); + ir.Add(0u); irToIl.Add(instrStart); // argc = 0 explicit args + } + else + { + // No body ctor — emit initobj_script with TypeDef token. + int typeDefTokIdx = tokens.Count; tokens.Add(typeDefTok); + ir.Add((uint)Op.initobj_script); irToIl.Add(instrStart); + ir.Add((uint)targetSlot); irToIl.Add(instrStart); + ir.Add((uint)typeDefTokIdx); irToIl.Add(instrStart); + } + // Slot is now initialized: subsequent stfld in this block won't need ensure_script. + ensuredScriptSlots.Add(targetSlot); + } + else if (PrimitiveSTypeForTypeToken(asm, typeDefTok) is { } pst) + { + // `initobj System.Int32` on a local PreClassifyLocals never saw a + // store to (Roslyn rewrites `stloc ` into `ldloca; initobj` + // when the constant is default(T)) — the slot defaulted to O. Retype + // it numeric and zero it; otherwise the value read null (found by + // fuzzing: `(byte)(p*0)` folds to 0, so `(...).ToString()` saw null). + slotTypes[targetSlot] = pst; + Emit3(ir, irToIl, pst == SType.R4 ? Op.ldc_r4 : Op.ldc_i4, targetSlot, 0, instrStart); + } + else if (asm.HostCtors.ContainsKey(typeDefTok)) + { + // Boxed host struct local: materialize the default box so the + // initializer's stfld sequence has a target (registered at parse + // time, keyed by the type token). + int hostTypeTokIdx = tokens.Count; tokens.Add(typeDefTok); + ir.Add((uint)Op.newobj_host); irToIl.Add(instrStart); + ir.Add((uint)targetSlot); irToIl.Add(instrStart); + ir.Add((uint)hostTypeTokIdx); irToIl.Add(instrStart); + ir.Add(0u); irToIl.Add(instrStart); // argc = 0 + ensuredScriptSlots.Add(targetSlot); + } + else + { + Emit2(ir, irToIl, Op.ldnull, targetSlot, instrStart); + } + } + else if (tag == -1) + { + // field-address initobj: store null / 0 / zero-Vt into the field. + int objSlot = addrFldObjBySlot[addrSlot]; + int tokIdx2 = addrFldTokBySlot[addrSlot]; + int tok2 = tokens[tokIdx2]; + asm.FieldSTypes.TryGetValue(tok2, out var fst2); + // Vt (flat host struct) field inline in the ScriptObject's PrimBytes — + // Roslyn zero-inits hoisted iterator locals through their field address + // (`ldarg.0; ldflda '5__N'; initobj Vec2`). Zero a Vt temp and copy + // its bytes in. The reference path below would emit stfld_sc_o with the + // field's BYTE offset as a RefSlots INDEX (found live: RefSlots[24] out + // of bounds on an iterator state machine). + if (fst2 == SType.Vt && asm.FieldSlots.TryGetValue(tok2, out var vtFsI) + && vtFsI.Item1.VtFieldLayouts?[vtFsI.Item2] is { } vtLayI) + { + int zeroVt = AllocStructSlot(ref frameSize, slotTypes, slotStructs, vtLayI); + ir.Add((uint)Op.initobj); irToIl.Add(instrStart); + ir.Add((uint)zeroVt); irToIl.Add(instrStart); + // A composed phantom carries the summed PrimBytes offset; a plain + // field phantom resolves it from the descriptor. + int vtOffI = addrFldOffBySlot[addrSlot] >= 0 + ? addrFldOffBySlot[addrSlot] + : vtFsI.Item1.FieldOffsets[vtFsI.Item2]; + Emit4(ir, irToIl, Op.stfld_sc_vt, objSlot, vtOffI, zeroVt, instrStart); + break; + } + // Same shape one level deeper: initobj on a HOST struct member of a + // hoisted flat struct (`pose.P = default` lowers to + // `ldflda ''; ldflda Pose::P; initobj Vec2`). The composed + // phantom's offset already sums the whole chain; zero the nested + // struct's byte range there. + if (addrFldOffBySlot[addrSlot] >= 0 + && asm.HostFields.TryGetValue(tok2, out var hfI) && hfI.DeclaringStruct != null + && hfI.PrimitiveSt != SType.I4 && hfI.PrimitiveSt != SType.R4 + && hfI.FieldTypeName != null && asm.HostSurface != null + && asm.HostSurface.TryGetStructLayout(hfI.FieldTypeName, out var nLayI) + && nLayI != null) + { + int zeroVtH = AllocStructSlot(ref frameSize, slotTypes, slotStructs, nLayI); + ir.Add((uint)Op.initobj); irToIl.Add(instrStart); + ir.Add((uint)zeroVtH); irToIl.Add(instrStart); + Emit4(ir, irToIl, Op.stfld_sc_vt, objSlot, addrFldOffBySlot[addrSlot], zeroVtH, instrStart); + break; + } + int zeroSlot = AllocSlot(ref frameSize, slotTypes, slotStructs, fst2, null); + if (fst2 == SType.I4 || fst2 == SType.R4) + Emit3(ir, irToIl, Op.ldc_i4, zeroSlot, 0, instrStart); + else + Emit2(ir, irToIl, Op.ldnull, zeroSlot, instrStart); + if (asm.FieldSlots.TryGetValue(tok2, out var sfScFsI)) + { + int sfOffI = sfScFsI.Item1.FieldOffsets[sfScFsI.Item2]; + var scOpI = fst2 == SType.I4 ? Op.stfld_sc_i4 : fst2 == SType.R4 ? Op.stfld_sc_r4 : Op.stfld_sc_o; + ir.Add((uint)scOpI); irToIl.Add(instrStart); + ir.Add((uint)objSlot); irToIl.Add(instrStart); + ir.Add((uint)sfOffI); irToIl.Add(instrStart); + ir.Add((uint)zeroSlot); irToIl.Add(instrStart); + } + else + { + var sfldOp = fst2 == SType.I4 ? Op.stfld_i4 : fst2 == SType.R4 ? Op.stfld_r4 : Op.stfld_o; + ir.Add((uint)sfldOp); irToIl.Add(instrStart); + ir.Add((uint)objSlot); irToIl.Add(instrStart); + ir.Add((uint)tokIdx2); irToIl.Add(instrStart); + ir.Add((uint)zeroSlot); irToIl.Add(instrStart); + } + } + else if (tag == -3) + { + // array-element initobj: stelem of null/0. + int arrSlot = addrFldObjBySlot[addrSlot]; + int idxSlot = addrFldTokBySlot[addrSlot]; + int zeroSlot = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + Emit2(ir, irToIl, Op.ldnull, zeroSlot, instrStart); + ir.Add((uint)Op.stelem_o); irToIl.Add(instrStart); + ir.Add((uint)arrSlot); irToIl.Add(instrStart); + ir.Add((uint)idxSlot); irToIl.Add(instrStart); + ir.Add((uint)zeroSlot); irToIl.Add(instrStart); + } + else + goto notSupported; + break; + } + + // ldind.* — dereference an address. The address may be: + // • a frame-slot address (addrTagBySlot[s] >= 0): read from that slot + // • a field address (addrTagBySlot[s] == -1): emit ldfld + // • an array-element address (addrTagBySlot[s] == -3): emit ldelem + case ILOpCode.Ldind_i1: case ILOpCode.Ldind_u1: case ILOpCode.Ldind_i2: + case ILOpCode.Ldind_u2: case ILOpCode.Ldind_i4: case ILOpCode.Ldind_u4: + case ILOpCode.Ldind_i: case ILOpCode.Ldind_r4: case ILOpCode.Ldind_r8: + case ILOpCode.Ldind_ref: + { + var (addrSlot, _) = evalStack[--sp]; + int tag = addrTagBySlot[addrSlot]; + SType ldindT = (op == ILOpCode.Ldind_r4 || op == ILOpCode.Ldind_r8) ? SType.R4 + : (op == ILOpCode.Ldind_ref) ? SType.O : SType.I4; + if (tag == -1 && addrFldOffBySlot[addrSlot] >= 0) + { + // Composed inline address on a heap ScriptObject: direct PrimBytes access. + int objSlot = addrFldObjBySlot[addrSlot]; + int cOff = addrFldOffBySlot[addrSlot]; + var pst = ldindT == SType.R4 ? SType.R4 : SType.I4; + int dst2 = AllocSlot(ref frameSize, slotTypes, slotStructs, pst, null); + evalStack[sp++] = (dst2, pst); + Emit4(ir, irToIl, pst == SType.I4 ? Op.ldfld_sc_i4 : Op.ldfld_sc_r4, dst2, objSlot, cOff, instrStart); + } + else if (tag == -1) + { + // field address: emit ldfld + int objSlot = addrFldObjBySlot[addrSlot]; + int tokIdx2 = addrFldTokBySlot[addrSlot]; + int tok2 = tokens[tokIdx2]; + asm.FieldSTypes.TryGetValue(tok2, out var fst2); + int dst2 = AllocSlot(ref frameSize, slotTypes, slotStructs, fst2, null); + evalStack[sp++] = (dst2, fst2); + if (asm.FieldSlots.TryGetValue(tok2, out var fsLo2)) + { + int off2 = fsLo2.Item1.FieldOffsets[fsLo2.Item2]; + var ldfldOp2 = fst2 == SType.I4 ? Op.ldfld_sc_i4 : fst2 == SType.R4 ? Op.ldfld_sc_r4 : Op.ldfld_sc_o; + Emit4(ir, irToIl, ldfldOp2, dst2, objSlot, off2, instrStart); + } + else + { + var ldfldOp2 = fst2 == SType.I4 ? Op.ldfld_i4 : fst2 == SType.R4 ? Op.ldfld_r4 : Op.ldfld_o; + Emit4(ir, irToIl, ldfldOp2, dst2, objSlot, tokIdx2, instrStart); + } + } + else if (tag == -3 && addrElemLayoutBySlot[addrSlot] != null) + { + // Flat array element field read via address: materialize the element + // and read the field at the composed offset. + int arrSlot = addrFldObjBySlot[addrSlot]; + int idxSlot = addrFldTokBySlot[addrSlot]; + int elemTmp = AllocStructSlot(ref frameSize, slotTypes, slotStructs, addrElemLayoutBySlot[addrSlot]!); + Emit4(ir, irToIl, Op.ldelem_vt, elemTmp, arrSlot, idxSlot, instrStart); + var pst = ldindT == SType.R4 ? SType.R4 : SType.I4; + int dst2 = AllocSlot(ref frameSize, slotTypes, slotStructs, pst, null); + evalStack[sp++] = (dst2, pst); + Emit4(ir, irToIl, pst == SType.I4 ? Op.ldfld_vt_i4 : Op.ldfld_vt_r4, + dst2, elemTmp, addrByteOffBySlot[addrSlot], instrStart); + } + else if (tag == -3) + { + // array element address: emit ldelem (chooses dst type from ldind suffix) + int arrSlot = addrFldObjBySlot[addrSlot]; + int idxSlot = addrFldTokBySlot[addrSlot]; + int dst2 = AllocSlot(ref frameSize, slotTypes, slotStructs, ldindT, null); + evalStack[sp++] = (dst2, ldindT); + Emit4(ir, irToIl, Op.ldelem_o, dst2, arrSlot, idxSlot, instrStart); + } + else if (tag >= 0) + { + int targetSlot = tag; + SType tgt = slotTypes[targetSlot]; + if (tgt == SType.Vt) + { + // Address inside a flat struct's bytes: read the field at the + // composed byte offset — a plain mov reads word 0 regardless of + // the addressed field and silently misreads offsets > 0. + int vOff2 = addrByteOffBySlot[addrSlot]; + var pst = ldindT == SType.R4 ? SType.R4 : SType.I4; + int dst2 = AllocSlot(ref frameSize, slotTypes, slotStructs, pst, null); + evalStack[sp++] = (dst2, pst); + Emit4(ir, irToIl, pst == SType.I4 ? Op.ldfld_vt_i4 : Op.ldfld_vt_r4, dst2, targetSlot, vOff2, instrStart); + } + else + { + int dst2 = AllocSlot(ref frameSize, slotTypes, slotStructs, tgt, null); + evalStack[sp++] = (dst2, tgt); + Emit3(ir, irToIl, Op.mov, dst2, targetSlot, instrStart); + } + } + else + { + // normal slot (e.g. ldind after non-phantom): copy it + int dst2 = AllocSlot(ref frameSize, slotTypes, slotStructs, slotTypes[addrSlot], null); + evalStack[sp++] = (dst2, slotTypes[addrSlot]); + Emit3(ir, irToIl, Op.mov, dst2, addrSlot, instrStart); + } + break; + } + + // stind.* — write through an address. The address may be: + // • a frame-slot address (addrTagBySlot[s] >= 0): mov val → that slot + // • a field address (addrTagBySlot[s] == -1): emit stfld + // • an array-element address (addrTagBySlot[s] == -3): emit stelem + case ILOpCode.Stind_ref: case ILOpCode.Stind_i1: case ILOpCode.Stind_i2: + case ILOpCode.Stind_i4: case ILOpCode.Stind_r4: case ILOpCode.Stind_r8: + { + var (val, _) = evalStack[--sp]; + var (addrSlot, _) = evalStack[--sp]; + int tag = addrTagBySlot[addrSlot]; + + if (tag == -1 && addrFldOffBySlot[addrSlot] >= 0) + { + // Composed inline address on a heap ScriptObject: write PrimBytes direct. + int objSlot = addrFldObjBySlot[addrSlot]; + int cOff = addrFldOffBySlot[addrSlot]; + bool isR4s = op == ILOpCode.Stind_r4 || op == ILOpCode.Stind_r8; + Emit4(ir, irToIl, isR4s ? Op.stfld_sc_r4 : Op.stfld_sc_i4, objSlot, cOff, val, instrStart); + } + else if (tag == -1) + { + // field address: emit stfld + int objSlot = addrFldObjBySlot[addrSlot]; + int tokIdx2 = addrFldTokBySlot[addrSlot]; + int tok2 = tokens[tokIdx2]; + asm.FieldSTypes.TryGetValue(tok2, out var fst2); + if (asm.FieldSlots.TryGetValue(tok2, out var sfScFs2)) + { + int sfOff2 = sfScFs2.Item1.FieldOffsets[sfScFs2.Item2]; + var scOp2 = fst2 == SType.I4 ? Op.stfld_sc_i4 : fst2 == SType.R4 ? Op.stfld_sc_r4 : Op.stfld_sc_o; + ir.Add((uint)scOp2); irToIl.Add(instrStart); + ir.Add((uint)objSlot); irToIl.Add(instrStart); + ir.Add((uint)sfOff2); irToIl.Add(instrStart); + ir.Add((uint)val); irToIl.Add(instrStart); + } + else + { + var sfldOp = fst2 == SType.I4 ? Op.stfld_i4 : fst2 == SType.R4 ? Op.stfld_r4 : Op.stfld_o; + ir.Add((uint)sfldOp); irToIl.Add(instrStart); + ir.Add((uint)objSlot); irToIl.Add(instrStart); + ir.Add((uint)tokIdx2); irToIl.Add(instrStart); + ir.Add((uint)val); irToIl.Add(instrStart); + } + } + else if (tag == -3 && addrElemLayoutBySlot[addrSlot] != null) + { + // Flat array element field write via address: read-modify-write the + // whole element (materialize, patch the field, memcpy back). + int arrSlot = addrFldObjBySlot[addrSlot]; + int idxSlot = addrFldTokBySlot[addrSlot]; + int elemTmp = AllocStructSlot(ref frameSize, slotTypes, slotStructs, addrElemLayoutBySlot[addrSlot]!); + Emit4(ir, irToIl, Op.ldelem_vt, elemTmp, arrSlot, idxSlot, instrStart); + bool isR4st = op == ILOpCode.Stind_r4 || op == ILOpCode.Stind_r8; + Emit4(ir, irToIl, isR4st ? Op.stfld_vt_r4 : Op.stfld_vt_i4, + elemTmp, addrByteOffBySlot[addrSlot], val, instrStart); + Emit4(ir, irToIl, Op.stelem_vt, arrSlot, idxSlot, elemTmp, instrStart); + } + else if (tag == -3) + { + int arrSlot = addrFldObjBySlot[addrSlot]; + int idxSlot = addrFldTokBySlot[addrSlot]; + ir.Add((uint)Op.stelem_o); irToIl.Add(instrStart); + ir.Add((uint)arrSlot); irToIl.Add(instrStart); + ir.Add((uint)idxSlot); irToIl.Add(instrStart); + ir.Add((uint)val); irToIl.Add(instrStart); + } + else if (tag >= 0) + { + if (slotTypes[tag] == SType.Vt) + { + // Write the field at the composed byte offset within the flat + // struct's bytes (a plain mov would clobber word 0 AND retarget + // nothing at offsets > 0). + bool isR4s2 = op == ILOpCode.Stind_r4 || op == ILOpCode.Stind_r8; + Emit4(ir, irToIl, isR4s2 ? Op.stfld_vt_r4 : Op.stfld_vt_i4, tag, addrByteOffBySlot[addrSlot], val, instrStart); + } + else + { + // frame-slot address: mov val → target slot + Emit3(ir, irToIl, Op.mov, tag, val, instrStart); + } + } + else + { + // Shouldn't happen — no valid stind target + throw new NotSupportedException( + $"IR lowering: stind with non-address operand in '{method.Name}' at IL+0x{instrStart:X4}"); + } + break; + } + + // ldobj — load a value-type from an address. Like ldind but for whole structs. + case ILOpCode.Ldobj: + { + ip += 4; // skip type token + var (addrSlot2, _) = evalStack[--sp]; + int tag = addrTagBySlot[addrSlot2]; + if (tag == -1) + { + // field address: emit ldfld + int objSlot = addrFldObjBySlot[addrSlot2]; + int tokIdx2 = addrFldTokBySlot[addrSlot2]; + int tok2 = tokens[tokIdx2]; + asm.FieldSTypes.TryGetValue(tok2, out var fst2); + int dst2 = AllocSlot(ref frameSize, slotTypes, slotStructs, fst2, null); + evalStack[sp++] = (dst2, fst2); + if (asm.FieldSlots.TryGetValue(tok2, out var fsLo3)) + { + int off3 = fsLo3.Item1.FieldOffsets[fsLo3.Item2]; + var ldfldOp2 = fst2 == SType.I4 ? Op.ldfld_sc_i4 : fst2 == SType.R4 ? Op.ldfld_sc_r4 : Op.ldfld_sc_o; + Emit4(ir, irToIl, ldfldOp2, dst2, objSlot, off3, instrStart); + } + else + { + var ldfldOp2 = fst2 == SType.I4 ? Op.ldfld_i4 : fst2 == SType.R4 ? Op.ldfld_r4 : Op.ldfld_o; + Emit4(ir, irToIl, ldfldOp2, dst2, objSlot, tokIdx2, instrStart); + } + } + else if (tag == -3 && addrElemLayoutBySlot[addrSlot2] != null) + { + int arrSlot = addrFldObjBySlot[addrSlot2]; + int idxSlot = addrFldTokBySlot[addrSlot2]; + int dst2 = AllocStructSlot(ref frameSize, slotTypes, slotStructs, addrElemLayoutBySlot[addrSlot2]!); + evalStack[sp++] = (dst2, SType.Vt); + Emit4(ir, irToIl, Op.ldelem_vt, dst2, arrSlot, idxSlot, instrStart); + } + else if (tag == -3) + { + int arrSlot = addrFldObjBySlot[addrSlot2]; + int idxSlot = addrFldTokBySlot[addrSlot2]; + int dst2 = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + evalStack[sp++] = (dst2, SType.O); + Emit4(ir, irToIl, Op.ldelem_o, dst2, arrSlot, idxSlot, instrStart); + } + else if (tag >= 0) + { + // frame-slot address: mov / mov_vt from that slot (ldfld_vt_vt when the + // address points INSIDE the flat struct at a composed offset). + SType tgt = slotTypes[tag]; + if (tgt == SType.Vt) + { + int vOff3 = addrByteOffBySlot[addrSlot2]; + if (vOff3 > 0) + { + int lTok = tokens[addrFldTokBySlot[addrSlot2]]; + HostBinding.StructLayout? nLay = null; + if (asm.FieldSlots.TryGetValue(lTok, out var lfs)) + nLay = lfs.Item1.VtFieldLayouts?[lfs.Item2]; + int dst3 = nLay != null + ? AllocStructSlot(ref frameSize, slotTypes, slotStructs, nLay) + : AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + evalStack[sp++] = (dst3, SType.Vt); + Emit4(ir, irToIl, Op.ldfld_vt_vt, dst3, tag, vOff3, instrStart); + } + else + { + var lay = slotStructs[tag]; + int dst2 = AllocStructSlot(ref frameSize, slotTypes, slotStructs, lay!); + evalStack[sp++] = (dst2, SType.Vt); + Emit3(ir, irToIl, Op.mov_vt, dst2, tag, instrStart); + } + } + else + { + int dst2 = AllocSlot(ref frameSize, slotTypes, slotStructs, tgt, null); + evalStack[sp++] = (dst2, tgt); + Emit3(ir, irToIl, Op.mov, dst2, tag, instrStart); + } + } + else + goto notSupported; + break; + } + + // stobj — store a value-type to an address. Like stind but for whole structs. + case ILOpCode.Stobj: + { + ip += 4; // skip type token + var (val, _) = evalStack[--sp]; + var (addrSlot2, _) = evalStack[--sp]; + int tag = addrTagBySlot[addrSlot2]; + if (tag == -1) + { + int objSlot = addrFldObjBySlot[addrSlot2]; + int tokIdx2 = addrFldTokBySlot[addrSlot2]; + int tok2 = tokens[tokIdx2]; + asm.FieldSTypes.TryGetValue(tok2, out var fst2); + var sfldOp = fst2 == SType.I4 ? Op.stfld_i4 : fst2 == SType.R4 ? Op.stfld_r4 : Op.stfld_o; + ir.Add((uint)sfldOp); irToIl.Add(instrStart); + ir.Add((uint)objSlot); irToIl.Add(instrStart); + ir.Add((uint)tokIdx2); irToIl.Add(instrStart); + ir.Add((uint)val); irToIl.Add(instrStart); + } + else if (tag == -3 && addrElemLayoutBySlot[addrSlot2] != null) + { + int arrSlot = addrFldObjBySlot[addrSlot2]; + int idxSlot = addrFldTokBySlot[addrSlot2]; + int vSrc = val; + if (slotTypes[val] != SType.Vt) + { + int vtmpO = AllocStructSlot(ref frameSize, slotTypes, slotStructs, addrElemLayoutBySlot[addrSlot2]!); + Emit3(ir, irToIl, Op.unbox_vt, vtmpO, val, instrStart); + vSrc = vtmpO; + } + Emit4(ir, irToIl, Op.stelem_vt, arrSlot, idxSlot, vSrc, instrStart); + } + else if (tag == -3) + { + int arrSlot = addrFldObjBySlot[addrSlot2]; + int idxSlot = addrFldTokBySlot[addrSlot2]; + ir.Add((uint)Op.stelem_o); irToIl.Add(instrStart); + ir.Add((uint)arrSlot); irToIl.Add(instrStart); + ir.Add((uint)idxSlot); irToIl.Add(instrStart); + ir.Add((uint)val); irToIl.Add(instrStart); + } + else if (tag >= 0) + { + // frame-slot address: mov / mov_vt val → target slot (sub-range write + // when the address carries a composed offset into the flat bytes). + int vOffS = addrByteOffBySlot[addrSlot2]; + if (slotTypes[tag] == SType.Vt && slotTypes[val] == SType.Vt) + { + if (vOffS > 0) Emit4(ir, irToIl, Op.stfld_vt_vt, tag, vOffS, val, instrStart); + else Emit3(ir, irToIl, Op.mov_vt, tag, val, instrStart); + } + else + Emit3(ir, irToIl, Op.mov, tag, val, instrStart); + } + else + goto notSupported; + break; + } + + // cpobj is in Rejected (validator bans it as unsafe), so we never see it + // here. If a valid script ever needs struct-to-struct copy, Roslyn emits + // ldobj + stobj instead, both of which are handled above. + + default: + goto notSupported; + } + continue; + + notSupported: + // Name the opcode: "ldftn" tells the user it's a method group / delegate + // creation; "0xFE06" tells them nothing. + throw new NotSupportedException( + $"IR lowering: opcode {op.ToString().ToLowerInvariant()} (0x{(int)op:X4}) in '{method.Name}' at IL+0x{instrStart:X4} not yet supported"); + } + + // Patch forward branch targets + var irArray = ir.ToArray(); + foreach (var (pIdx, ilTarget) in patchList) + { + if (ilToIrIp.TryGetValue(ilTarget, out int irTarget)) + irArray[pIdx] = (uint)irTarget; + else if (ilTarget == il.Length) + irArray[pIdx] = (uint)irArray.Length; // legit branch to method end -> ret sentinel + else + // Out of range, or mid-instruction (not an IL boundary). Rejecting instead of the + // old silent "jump past end = return" rewrite, which turned corrupt/hostile control + // flow into silently-wrong control flow with no diagnostic. + throw new NotSupportedException( + $"branch target IL+0x{ilTarget:X4} in '{method.Name}' is out of range or not at an instruction boundary"); + } + + var slotTypesArr = slotTypes.ToArray(); + var irToIlArr = irToIl.ToArray(); + var tokensArr = tokens.ToArray(); + // Wide (I8/R8) slots span two cells under one index; the pass tables (rename, + // coalesce, retarget) reason per-cell and would merge another value into a wide + // slot's continuation cell. Until the tables learn wide widths, a method whose + // frame contains any wide slot runs unoptimized — correctness first. + bool hasWideSlots = false; + for (int wi = 0; wi < slotTypesArr.Length; wi++) + if (slotTypesArr[wi] is SType.I8 or SType.R8) { hasWideSlots = true; break; } + if (!hasWideSlots && Environment.GetEnvironmentVariable("ILINTERPRETER_NOOPT") == null) { + var skip = Environment.GetEnvironmentVariable("ILINTERPRETER_SKIP_PASS") ?? ""; + if (!skip.Contains("frl")) ForwardRedundantLoads(ref irArray, ref irToIlArr, slotTypesArr, tokensArr); + if (!skip.Contains("fci")) FoldConstantImmediates(ref irArray, ref irToIlArr, slotTypesArr); + if (!skip.Contains("cdm")) CoalesceDestMovs(ref irArray, ref irToIlArr, slotTypesArr); + if (!skip.Contains("fcb")) FoldCompareBranch(ref irArray, ref irToIlArr, slotTypesArr); + if (!skip.Contains("ffl")) FoldForIntLoop(ref irArray, ref irToIlArr, slotTypesArr); + if (!skip.Contains("hlc")) HoistLoopConsts(ref irArray, ref irToIlArr, slotTypesArr, method.ArgCount); + } + + // Pre-resolve fast delegates per tokIdx so call_host can skip the dict + // lookup. Setup-time cost; eliminates ~37 ns/iter from mixed bench + // (call_host is dispatched 3+ times per inner step there). + FastCallDelegate?[]? hostFastByTokIdx = null; + bool[]? hostFastVtRecvByTokIdx = null; + bool[]? hostFastWideOkByTokIdx = null; + ParsedMethod?[]? calleeByTokIdx = null; + for (int t = 0; t < tokensArr.Length; t++) + { + int tok = tokensArr[t]; + if (asm.HostCalls.TryGetValue(tok, out var he) && he.Binding.Fast != null) + { + if (hostFastByTokIdx == null) hostFastByTokIdx = new FastCallDelegate?[tokensArr.Length]; + hostFastByTokIdx[t] = he.Binding.Fast; + if (he.Binding.FastVtRecv) + { + if (hostFastVtRecvByTokIdx == null) hostFastVtRecvByTokIdx = new bool[tokensArr.Length]; + hostFastVtRecvByTokIdx[t] = true; + } + if (he.Binding.FastWideOk) + { + if (hostFastWideOkByTokIdx == null) hostFastWideOkByTokIdx = new bool[tokensArr.Length]; + hostFastWideOkByTokIdx[t] = true; + } + } + if (asm.ByToken.TryGetValue(tok, out var callee)) + { + if (calleeByTokIdx == null) calleeByTokIdx = new ParsedMethod?[tokensArr.Length]; + calleeByTokIdx[t] = callee; + } + } + + int refClearLen = 0; + for (int s = slotTypesArr.Length - 1; s >= 0; s--) + if (slotTypesArr[s] == SType.O) { refClearLen = s + 1; break; } + + // A frame larger than either VM arena would write past its backing block on the first + // slot store. Reject at lowering (a clean load failure) instead of corrupting memory at + // invoke. Both arenas are indexed by the same slot number, but the reference arena is + // smaller, so a ref-heavy frame can exceed it while the numeric count still fits. + // Reachable from a pathological method body or a crafted LOCAL_SIG. + if (frameSize > Vm.NumSlots || refClearLen > Vm.RefSlots) + throw new NotSupportedException( + $"method '{method.Name}' needs {frameSize} frame slots and {refClearLen} reference slots, " + + $"over the {Vm.NumSlots}/{Vm.RefSlots}-slot arena limits"); + + return new LoweredMethod + { + Ir = irArray, + FrameSize = frameSize, + IrToIlOffset = irToIlArr, + Strings = strings.ToArray(), + Tokens = tokensArr, + SlotTypes = slotTypesArr, + StructLayouts = slotStructs.ToArray(), + HostFastByTokIdx = hostFastByTokIdx, + HostFastVtRecvByTokIdx = hostFastVtRecvByTokIdx, + HostFastWideOkByTokIdx = hostFastWideOkByTokIdx, + CalleeByTokIdx = calleeByTokIdx, + RefClearLen = refClearLen, + ArgSlot = argIndirect ? argFrameSlot : null, + LayoutByTokIdx = BuildLayoutByTokIdx(layoutByTokIdx, tokensArr.Length), + PrimArrayElemTypeByTokIdx = BuildPrimArrayTypes(primArrayElemTypeByTokIdx, tokensArr.Length), + DelegateSiteByTokIdx = BuildDelegateSites(delegateSiteByTokIdx, tokensArr.Length), + }; + } + + // Op selection for flat host-struct field access: sub-4-byte integer fields + // (Color32.r-class) need widening loads / truncating stores; everything else uses + // the plain 4-byte cell ops. + static Op LdFldVtOpFor(HostBinding.FieldEntry fe) + { + if (fe.PrimitiveSt == SType.R4) return Op.ldfld_vt_r4; + switch (fe.PrimitiveKind) + { + case 1: return Op.ldfld_vt_u1; + case 2: return Op.ldfld_vt_i1; + case 3: return Op.ldfld_vt_u2; + case 4: return Op.ldfld_vt_i2; + default: return Op.ldfld_vt_i4; + } + } + + static Op StFldVtOpFor(HostBinding.FieldEntry fe) + { + if (fe.PrimitiveSt == SType.R4) return Op.stfld_vt_r4; + switch (fe.PrimitiveKind) + { + case 1: case 2: return Op.stfld_vt_b1; + case 3: case 4: return Op.stfld_vt_b2; + default: return Op.stfld_vt_i4; + } + } + + // Runtime Type for a `box` token that names a HOST enum (TypeRef parent), null otherwise. + // Script-declared enums are TypeDefs — no runtime Type exists, so they keep the int form. + static Type? ResolveHostEnumType(ParsedAssembly asm, int tok) + { + if (asm.HostSurface == null) return null; + try + { + var h = MetadataTokens.EntityHandle(tok); + if (h.Kind != HandleKind.TypeReference) return null; + var t = ResolveSigTypeRef(asm.Reader, (TypeReferenceHandle)h, asm.HostSurface); + return t is { IsEnum: true } ? t : null; + } + catch { return null; } + } + + // Slot type for a SCRIPT static field (FieldDef) whose signature is an I4/R4-mapped + // primitive; null for host fields, reference/struct/wide types (those stay boxed O). + // Enums included: a script enum static's underlying value rides the I4 slot. + static SType? ScriptStaticPrimitiveSType(ParsedAssembly asm, int tok) + { + var h = MetadataTokens.EntityHandle(tok); + if (h.Kind != HandleKind.FieldDefinition) return null; + try + { + var blob = asm.Reader.GetBlobBytes( + asm.Reader.GetFieldDefinition((FieldDefinitionHandle)h).Signature); + if (blob.Length < 2 || blob[0] != 0x06) return null; + switch (blob[1]) + { + case 0x02: case 0x03: case 0x04: case 0x05: case 0x06: + case 0x07: case 0x08: case 0x09: // bool..uint + return SType.I4; + case 0x0C: // float + return SType.R4; + case 0x11: // VALUETYPE coded-index — a script ENUM static maps to I4 (its + { // value flows as the underlying int everywhere else already) + int idx = 2; + int coded = DecompressIntAdv(blob, ref idx); + if ((coded & 0x03) == 0 && IsEnumTypeDef(asm.Reader, coded >> 2)) + return SType.I4; + return null; + } + default: + return null; + } + } + catch { return null; } + } + + // True when a static field's type is immutable-by-value (primitive or string), so its + // address can be lowered as a value load (see the Ldsflda case). Script FieldDefs decode + // the field signature blob; host MemberRefs consult the registered FieldEntry. + static bool StaticFieldIsImmutableValue(ParsedAssembly asm, int tok) + { + var h = MetadataTokens.EntityHandle(tok); + if (h.Kind == HandleKind.FieldDefinition) + { + try + { + var blob = asm.Reader.GetBlobBytes( + asm.Reader.GetFieldDefinition((FieldDefinitionHandle)h).Signature); + // Field sig: FIELD (0x06) then the type. 0x02..0x0D = bool..double, 0x0E = string. + return blob.Length >= 2 && blob[0] == 0x06 && blob[1] >= 0x02 && blob[1] <= 0x0E; + } + catch { return false; } + } + if (asm.HostFields.TryGetValue(tok, out var fe)) + return fe.PrimitiveSt is SType.I4 or SType.R4 || fe.FieldTypeName == "String"; + return false; + } + + // Flat layout for an array-element type token: script TypeDefs use the descriptor's + // synthesized FlatLayout; host TypeRefs resolve by simple name through the binding. + static HostBinding.StructLayout? FlatElemLayout(ParsedAssembly asm, int tok) + { + if (asm.TypeDefToType.TryGetValue(tok, out var d)) return d.FlatLayout; + var h = MetadataTokens.EntityHandle(tok); + if (h.Kind == HandleKind.TypeReference && asm.HostSurface != null) + { + string name; + try { name = asm.Reader.GetString(asm.Reader.GetTypeReference((TypeReferenceHandle)h).Name); } + catch (Exception) { return null; } + if (asm.HostSurface.TryGetStructLayout(name, out var lay)) return lay; + } + return null; + } + + static HostBinding.StructLayout?[]? BuildLayoutByTokIdx( + Dictionary? sparse, int tokenCount) + { + if (sparse == null) return null; + var arr = new HostBinding.StructLayout?[tokenCount]; + foreach (var kv in sparse) arr[kv.Key] = kv.Value; + return arr; + } + + static Type?[]? BuildPrimArrayTypes(Dictionary? sparse, int tokenCount) + { + if (sparse == null) return null; + var arr = new Type?[tokenCount]; + foreach (var kv in sparse) arr[kv.Key] = kv.Value; + return arr; + } + + static DelegateSite?[]? BuildDelegateSites(Dictionary? sparse, int tokenCount) + { + if (sparse == null) return null; + var arr = new DelegateSite?[tokenCount]; + foreach (var kv in sparse) arr[kv.Key] = kv.Value; + return arr; + } + + // Primitive element types that need a real typed runtime array (I4-mapped but whose boxed + // identity differs from int, or that C# zero-inits differently than object?[] null). Int32 + // and Single are handled by newarr_i4/newarr_r4 and are intentionally not here. + static Type? PrimArrayElemType(string elemName) => elemName switch + { + "Boolean" => typeof(bool), + "Char" => typeof(char), + "Byte" => typeof(byte), + "SByte" => typeof(sbyte), + "Int16" => typeof(short), + "UInt16" => typeof(ushort), + "Int64" => typeof(long), + "UInt64" => typeof(ulong), + "Double" => typeof(double), + _ => null, + }; + + // Constant-immediate fold: `ldc_X tmp; binop_X_nn dst, src, tmp` with a single-use + // tmp folds into `binop_X_nk dst, src, K` (K inline in the IR word) — one dispatch + // less per pair. RHS-constant only (no _kn opcodes exist); commutative ops also + // accept tmp on the LEFT via operand swap. Skipped when a branch target lands on + // the binop (the ldc would be lost on that edge). Runs BEFORE CoalesceDestMovs so + // the trailing `mov local <- tmp_dst` is still available for coalescing. + static void FoldConstantImmediates(ref uint[] ir, ref int[] irToIl, SType[] slotTypes) + { + int n = ir.Length; + if (n == 0) return; + + // Walk IR, record (ip, op, width). + var ips = new List(n / 3); + var ops = new List(n / 3); + var widths = new List(n / 3); + int pos = 0; + while (pos < n) + { + var op = (Op)ir[pos]; + int w = OpWidthForCoalesce(op, ir, pos); + if (w <= 0) return; + ips.Add(pos); ops.Add(op); widths.Add(w); + pos += w; + } + int insnCount = ips.Count; + + int slotCount = slotTypes.Length; + var reads = new int[slotCount]; + var writes = new int[slotCount]; + for (int i = 0; i < insnCount; i++) + AccumulateSlotUses(ir, ips[i], ops[i], widths[i], reads, writes, slotCount); + + var isBranchTarget = new bool[n + 1]; + for (int i = 0; i < insnCount; i++) + MarkInsnBranchTargets(ir, ips[i], ops[i], isBranchTarget); + + var deleted = new bool[insnCount]; + int deletedWords = 0; + for (int i = 0; i < insnCount - 1; i++) + { + if (deleted[i]) continue; + var ldcOp = ops[i]; + if (ldcOp != Op.ldc_i4 && ldcOp != Op.ldc_r4) continue; + + int ldcIp = ips[i]; + int tmpSlot = (int)ir[ldcIp + 1]; + uint kBits = ir[ldcIp + 2]; + + if (writes[tmpSlot] != 1 || reads[tmpSlot] != 1) continue; + + // Find the next *non-deleted* instruction. + int j = i + 1; + while (j < insnCount && deleted[j]) j++; + if (j >= insnCount) continue; + + var binOp = ops[j]; + int binIp = ips[j]; + if (widths[j] != 4) continue; + if (isBranchTarget[binIp]) continue; + + int s1 = (int)ir[binIp + 2]; + int s2 = (int)ir[binIp + 3]; + bool tmpRight = (s2 == tmpSlot); + bool tmpLeft = (s1 == tmpSlot); + if (!tmpRight && !tmpLeft) continue; + + if (!TryFoldNN(ldcOp, binOp, tmpRight, out Op nkOp)) continue; + + // Apply: rewrite binop in place, mark ldc deleted. + ir[binIp] = (uint)nkOp; + if (tmpLeft) + { + // Commutative swap: move src1 into the operand-slot position + // (word 2), put k in word 3. Original encoding was [op, dst, + // tmp_left, src_right] — we want [nk, dst, src_right, k]. + ir[binIp + 2] = (uint)s2; + } + ir[binIp + 3] = kBits; + + deleted[i] = true; + deletedWords += widths[i]; + + // Maintain reads/writes so a future pass over the same IR sees + // the correct counts (we don't iterate to fixpoint, but defensive). + reads[tmpSlot] = 0; writes[tmpSlot] = 0; + } + + if (deletedWords == 0) return; + + // Compact (same shift-table machinery as CoalesceDestMovs). + var deletedWord = new bool[n]; + for (int i = 0; i < insnCount; i++) + { + if (!deleted[i]) continue; + for (int k = 0; k < widths[i]; k++) deletedWord[ips[i] + k] = true; + } + var shift = new int[n + 1]; + int run = 0; + for (int oldIp = 0; oldIp <= n; oldIp++) + { + shift[oldIp] = run; + if (oldIp < n && deletedWord[oldIp]) run++; + } + + int newLen = n - deletedWords; + var newIr = new uint[newLen]; + var newToIl = new int[newLen]; + int dstIdx = 0; + for (int i = 0; i < insnCount; i++) + { + if (deleted[i]) continue; + int oldIp = ips[i]; + int w = widths[i]; + for (int k = 0; k < w; k++) + { + newIr[dstIdx + k] = ir[oldIp + k]; + newToIl[dstIdx + k] = irToIl[oldIp + k]; + } + PatchInsnBranchTargets(newIr, dstIdx, ops[i], shift, ir, oldIp); + dstIdx += w; + } + + ir = newIr; + irToIl = newToIl; + } + + // Map an (ldc, binop_nn) pair to the matching binop_nk. Returns false if + // we don't have an _nk variant for this combination — no fold. + // tmpRight=true means the constant is on the right (no swap needed); for + // commutative ops we may also fold tmpLeft via operand swap. + static bool TryFoldNN(Op ldcOp, Op binOp, bool tmpRight, out Op nkOp) + { + nkOp = default; + // Type compatibility: ldc_i4 with i4 binop, ldc_r4 with r4 binop. + bool isI4Ldc = ldcOp == Op.ldc_i4; + switch (binOp) + { + case Op.add_i4_nn: if (!isI4Ldc) return false; nkOp = Op.add_i4_nk; return true; // commutative + case Op.add_r4_nn: if (isI4Ldc) return false; nkOp = Op.add_r4_nk; return true; // commutative + case Op.mul_r4_nn: if (isI4Ldc) return false; nkOp = Op.mul_r4_nk; return true; // commutative + case Op.ceq_i4_nn: if (!isI4Ldc) return false; nkOp = Op.ceq_i4_nk; return true; // commutative + case Op.mul_i4_nn: if (!isI4Ldc) return false; nkOp = Op.mul_i4_nk; return true; // commutative + case Op.and_i4_nn: if (!isI4Ldc) return false; nkOp = Op.and_i4_nk; return true; // commutative + case Op.or_i4_nn: if (!isI4Ldc) return false; nkOp = Op.or_i4_nk; return true; // commutative + case Op.xor_i4_nn: if (!isI4Ldc) return false; nkOp = Op.xor_i4_nk; return true; // commutative + case Op.ceq_r4_nn: if (isI4Ldc) return false; nkOp = Op.ceq_r4_nk; return true; // commutative + // Non-commutative: tmp must be on the right. + case Op.sub_i4_nn: if (!tmpRight || !isI4Ldc) return false; nkOp = Op.sub_i4_nk; return true; + case Op.sub_r4_nn: if (!tmpRight || isI4Ldc) return false; nkOp = Op.sub_r4_nk; return true; + case Op.clt_i4_nn: if (!tmpRight || !isI4Ldc) return false; nkOp = Op.clt_i4_nk; return true; + case Op.clt_r4_nn: if (!tmpRight || isI4Ldc) return false; nkOp = Op.clt_r4_nk; return true; + case Op.cgt_i4_nn: if (!tmpRight || !isI4Ldc) return false; nkOp = Op.cgt_i4_nk; return true; + case Op.cgt_r4_nn: if (!tmpRight || isI4Ldc) return false; nkOp = Op.cgt_r4_nk; return true; + case Op.div_i4_nn: if (!tmpRight || !isI4Ldc) return false; nkOp = Op.div_i4_nk; return true; + case Op.rem_i4_nn: if (!tmpRight || !isI4Ldc) return false; nkOp = Op.rem_i4_nk; return true; + case Op.shl_i4_nn: if (!tmpRight || !isI4Ldc) return false; nkOp = Op.shl_i4_nk; return true; + case Op.shr_i4_nn: if (!tmpRight || !isI4Ldc) return false; nkOp = Op.shr_i4_nk; return true; + case Op.shr_un_i4_nn: if (!tmpRight || !isI4Ldc) return false; nkOp = Op.shr_un_i4_nk; return true; + case Op.div_r4_nn: if (!tmpRight || isI4Ldc) return false; nkOp = Op.div_r4_nk; return true; + default: return false; + } + } + + // --- Redundant-load forwarding --- + // + // Within each basic block, tracks for each (objSlot, resolvedFieldToken) + // pair which slot currently holds the field's value: + // ldfld_X dst, obj, tok → cache[(obj, tok)] = dst + // stfld_X obj, tok, src → cache[(obj, tok)] = src + // + // On a subsequent matching ldfld_X with cache hit (and matching SType), + // the second load is redundant: rewrite all reads of its dst slot to + // the cached value slot, delete the load. Slot rename is global, + // applied via a slotMap[] table; safe because the deleted load was the + // sole writer of its dst within the live region (we're conservative + // with cache invalidation). + // + // Cache invalidation events (cleared aggressively): + // - Any call (host/script/byref) — could mutate any field. + // - Any newobj — constructor side effects. + // - Any stsfld_*, stelem_* — written field/elem could alias. + // - Any branch — basic-block boundary. + // - A target landing on the next instruction also resets the block. + // + // Subset is corpus-driven: ldfld_o, ldfld_r4, ldfld_i4 (with matching + // stfld variants for store-to-load forwarding). Other field-bearing + // ops (ldelem, ldfld_struct, ldsfld) deferred — no bench evidence. + /// Removes cached (obj, field) forwards for held under + /// any object slot other than — a store through one slot must not + /// leave stale forwards alive for other slots that may alias the same object. + static void DropAliasedField(Dictionary<(int, int), int> cache, int objS, int fieldKey) + { + List<(int, int)>? stale = null; + foreach (var kv in cache) + if (kv.Key.Item2 == fieldKey && kv.Key.Item1 != objS) (stale ??= new()).Add(kv.Key); + if (stale != null) foreach (var k in stale) cache.Remove(k); + } + + static void ForwardRedundantLoads(ref uint[] ir, ref int[] irToIl, + SType[] slotTypes, int[] tokens) + { + int n = ir.Length; + if (n == 0) return; + + var ips = new List(n / 3); + var ops = new List(n / 3); + var widths = new List(n / 3); + int pos = 0; + while (pos < n) + { + var op = (Op)ir[pos]; + int w = OpWidthForCoalesce(op, ir, pos); + if (w <= 0) return; + ips.Add(pos); ops.Add(op); widths.Add(w); + pos += w; + } + int insnCount = ips.Count; + + var isBranchTarget = new bool[n + 1]; + for (int i = 0; i < insnCount; i++) + MarkInsnBranchTargets(ir, ips[i], ops[i], isBranchTarget); + + int slotCount = slotTypes.Length; + var slotMap = new int[slotCount]; + for (int i = 0; i < slotCount; i++) slotMap[i] = i; + + var deleted = new bool[insnCount]; + int deletedWords = 0; + + // (objSlot, resolvedTok) → valueSlot. Cleared at block boundaries + // and on any side effect. + var cache = new Dictionary<(int, int), int>(); + + for (int i = 0; i < insnCount; i++) + { + int ip = ips[i]; + var op = ops[i]; + + // Block boundary: an instruction that's a branch target starts + // a new block (we may have entered via either fall-through or + // jump — conservative). + if (i > 0 && isBranchTarget[ip]) cache.Clear(); + + // Invalidate cache entries whose object slot OR cached value slot is overwritten by + // this instruction. Without this, store-to-load forwarding (stfld caches the SOURCE + // slot) or load forwarding (keyed by the OBJECT slot) survives a later reassignment of + // that slot and forwards a stale value — e.g. `bx.p = s1; s1 = other; read bx.p`. + if (WritesFrameDstAt1(op)) + { + int wdst = (int)ir[ip + 1]; + if (wdst >= 0) + { + List<(int, int)>? drop = null; + foreach (var kv in cache) + if (kv.Value == wdst || kv.Key.Item1 == wdst) (drop ??= new()).Add(kv.Key); + if (drop != null) foreach (var k in drop) cache.Remove(k); + } + } + + switch (op) + { + case Op.ldfld_o: + case Op.ldfld_r4: + case Op.ldfld_i4: + { + int dst = (int)ir[ip + 1]; + int objS = (int)ir[ip + 2]; + int tokIdx = (int)ir[ip + 3]; + if ((uint)tokIdx >= (uint)tokens.Length) break; + int resolvedTok = tokens[tokIdx]; + var key = (objS, resolvedTok); + if (cache.TryGetValue(key, out int valueSlot) + && (uint)valueSlot < (uint)slotCount + && slotTypes[valueSlot] == slotTypes[dst]) + { + // Forward: subsequent reads of dst become reads of + // valueSlot; this load is redundant. + slotMap[dst] = valueSlot; + deleted[i] = true; + deletedWords += widths[i]; + } + else + { + cache[key] = dst; + } + break; + } + + case Op.ldfld_sc_o: + case Op.ldfld_sc_r4: + case Op.ldfld_sc_i4: + { + int dst = (int)ir[ip + 1]; + int objS = (int)ir[ip + 2]; + int off = (int)ir[ip + 3]; + // Negated keys avoid collision with raw token values; the ref/prim split + // (~(2*off) vs ~(2*off+1)) keeps a stfld_sc_o REF-SLOT index from aliasing + // a stfld_sc_i4 PRIM BYTE offset of the same number — that collision + // forwarded a field read to an unrelated O field's source slot (found by + // fuzzing: a hoisted char at ref slot 4 hijacked the int at prim byte 4). + var key = (objS, op == Op.ldfld_sc_o ? ~(2 * off + 1) : ~(2 * off)); + if (cache.TryGetValue(key, out int valueSlot) + && (uint)valueSlot < (uint)slotCount + && slotTypes[valueSlot] == slotTypes[dst]) + { + slotMap[dst] = valueSlot; + deleted[i] = true; + deletedWords += widths[i]; + } + else + { + cache[key] = dst; + } + break; + } + + case Op.stfld_o: + case Op.stfld_r4: + case Op.stfld_i4: + { + int objS = (int)ir[ip + 1]; + int tokIdx = (int)ir[ip + 2]; + int srcS = (int)ir[ip + 3]; + if ((uint)tokIdx >= (uint)tokens.Length) break; + int resolvedTok = tokens[tokIdx]; + // Alias hazard: another slot may reference the SAME object (`C0 b = a;`), + // so a store through one slot must drop every cached entry for the same + // field under any OTHER object slot — else `a.f0 = 5; C0 b = a; + // b.f0 = 9; read a.f0` forwards the stale 5 (found by fuzzing). + DropAliasedField(cache, objS, resolvedTok); + // The stored value lives in srcS; subsequent loads of + // (objS, tok) can forward to it. + cache[(objS, resolvedTok)] = srcS; + break; + } + + case Op.stfld_sc_o: + case Op.stfld_sc_r4: + case Op.stfld_sc_i4: + { + int objS = (int)ir[ip + 1]; + int off = (int)ir[ip + 2]; + int srcS = (int)ir[ip + 3]; + int koff = op == Op.stfld_sc_o ? ~(2 * off + 1) : ~(2 * off); // see ldfld_sc key split + DropAliasedField(cache, objS, koff); // same alias hazard as stfld_o above + cache[(objS, koff)] = srcS; + break; + } + + case Op.call_script: + case Op.call_host: + case Op.call_host_byref: + case Op.newobj_script: + case Op.newobj_host: + case Op.stsfld_i4: case Op.stsfld_r4: + case Op.stsfld_o: case Op.stsfld_struct: + case Op.stelem_i4: case Op.stelem_r4: + case Op.stelem_o: case Op.stelem_struct: + case Op.stfld_struct: + case Op.box_vt: case Op.unbox_vt: case Op.mov_vt: + case Op.stfld_vt_i4: case Op.stfld_vt_r4: + case Op.stfld_vt_b1: case Op.stfld_vt_b2: + // Multi-byte flat-struct stores overwrite RANGES that can cover any cached + // (obj, offset) entry of the same object — e.g. `bx.p = s1` (stfld_sc_vt) + // rewrites every field of p, so a cached forward of bx.p.n.v goes stale. + case Op.stfld_sc_vt: case Op.stfld_vt_vt: case Op.stelem_vt: + cache.Clear(); + break; + + // stfld_sc_* only update the cache (handled above); no full clear needed. + + case Op.br: + case Op.brtrue_i4: case Op.brfalse_i4: + case Op.brtrue_o: case Op.brfalse_o: + case Op.blt_i4_nn: case Op.blt_i4_nk: + case Op.bgt_r4_nn: case Op.bgt_r4_nk: + case Op.beq_i4_nn: case Op.beq_i4_nk: + case Op.bne_i4_nn: case Op.bne_i4_nk: + case Op.for_i4_nk: + case Op.switch_i4: + case Op.push_cont: case Op.br_cont: + case Op.ret_void: case Op.ret_i4: case Op.ret_r4: case Op.ret_o: case Op.ret_vt: + case Op.throw_o: + // End-of-block: anything past here gets a fresh cache. + cache.Clear(); + break; + } + } + + if (deletedWords == 0) return; + + // Apply slotMap globally to all slot operands. + int Resolve(int s) { while (slotMap[s] != s) s = slotMap[s]; return s; } + + for (int i = 0; i < insnCount; i++) + { + if (deleted[i]) continue; + RenameSlots(ir, ips[i], ops[i], Resolve); + } + + // Compact + branch-target patch. + var deletedWord = new bool[n]; + for (int i = 0; i < insnCount; i++) + { + if (!deleted[i]) continue; + for (int k = 0; k < widths[i]; k++) deletedWord[ips[i] + k] = true; + } + var shift = new int[n + 1]; + int run = 0; + for (int oldIp = 0; oldIp <= n; oldIp++) + { + shift[oldIp] = run; + if (oldIp < n && deletedWord[oldIp]) run++; + } + + int newLen = n - deletedWords; + var newIr = new uint[newLen]; + var newToIl = new int[newLen]; + int dstIdx = 0; + for (int i = 0; i < insnCount; i++) + { + if (deleted[i]) continue; + int oldIp = ips[i]; + int w = widths[i]; + for (int k = 0; k < w; k++) + { + newIr[dstIdx + k] = ir[oldIp + k]; + newToIl[dstIdx + k] = irToIl[oldIp + k]; + } + PatchInsnBranchTargets(newIr, dstIdx, ops[i], shift, ir, oldIp); + dstIdx += w; + } + + ir = newIr; + irToIl = newToIl; + } + + // For each slot operand of `op` at `ip` in `ir`, apply `resolve` and write + // back. Mirrors AccumulateSlotUses' positions; keep both in sync. + static void RenameSlots(uint[] ir, int ip, Op op, Func resolve) + { + void RW(int wOff) + { + int s = (int)ir[ip + wOff]; + if (s < 0) return; // sentinel (-1 for void-call dst, no-receiver, etc.) + ir[ip + wOff] = (uint)resolve(s); + } + + if (Is4WordArithForCoalesce(op)) + { + RW(1); RW(2); + if (!IsNkForm(op)) RW(3); + return; + } + if (IsFusedCmpBranch(op)) + { + RW(1); + if (!IsFusedCmpBranchNk(op)) RW(2); + return; + } + if (op == Op.for_i4_nk) { RW(1); return; } + + switch (op) + { + case Op.nop: case Op.ret_void: case Op.br: + case Op.push_cont: case Op.br_cont: return; + + case Op.mov: case Op.box: case Op.mov_vt: case Op.box_vt: case Op.unbox_vt: + case Op.clone_sc: + case Op.ldlen: + case Op.neg_i4: case Op.neg_i4_n: case Op.neg_r4: case Op.neg_r4_n: + case Op.not_i4: case Op.not_i4_n: + case Op.conv_i4_r4: case Op.conv_r4_i4: + case Op.conv_i4_i1: case Op.conv_i4_u1: + case Op.conv_i4_i2: case Op.conv_i4_u2: + RW(1); RW(2); return; + + case Op.ldc_i4: case Op.ldc_r4: case Op.ldstr: + case Op.ldsfld_i4: case Op.ldsfld_r4: case Op.ldsfld_o: case Op.ldsfld_struct: + case Op.ldtoken: + case Op.ldnull: case Op.initobj: case Op.initobj_script: case Op.ensure_script: + RW(1); return; + + case Op.stsfld_i4: case Op.stsfld_r4: case Op.stsfld_o: case Op.stsfld_struct: + RW(2); return; + + case Op.ret_i4: case Op.ret_r4: case Op.ret_o: case Op.ret_vt: + case Op.throw_o: + RW(1); return; + + case Op.brtrue_i4: case Op.brfalse_i4: case Op.brtrue_o: case Op.brfalse_o: + RW(1); return; + + case Op.ldfld_i4: case Op.ldfld_r4: case Op.ldfld_o: + case Op.ldfld_struct: case Op.ldflda: + case Op.ldfld_vt_i4: case Op.ldfld_vt_r4: case Op.ldfld_vt_vt: + case Op.ldfld_vt_u1: case Op.ldfld_vt_i1: case Op.ldfld_vt_u2: case Op.ldfld_vt_i2: + case Op.ldfld_sc_i4: case Op.ldfld_sc_r4: case Op.ldfld_sc_o: case Op.ldfld_sc_vt: + RW(1); RW(2); return; + + case Op.stfld_i4: case Op.stfld_r4: case Op.stfld_o: case Op.stfld_struct: + case Op.stfld_vt_i4: case Op.stfld_vt_r4: case Op.stfld_vt_vt: + case Op.stfld_vt_b1: case Op.stfld_vt_b2: + case Op.stfld_sc_i4: case Op.stfld_sc_r4: case Op.stfld_sc_o: case Op.stfld_sc_vt: + RW(1); RW(3); return; + + case Op.ldelem_i4: case Op.ldelem_r4: case Op.ldelem_o: case Op.ldelem_struct: + case Op.ldelem_vt: + RW(1); RW(2); RW(3); return; + + case Op.stelem_i4: case Op.stelem_r4: case Op.stelem_o: case Op.stelem_struct: + case Op.stelem_vt: + RW(1); RW(2); RW(3); return; + + case Op.castclass: case Op.unbox_any: case Op.isinst: case Op.box_prim: + case Op.box_enum: + RW(1); RW(2); return; + + case Op.newarr: case Op.newarr_i4: case Op.newarr_r4: case Op.newarr_vt: + RW(1); RW(2); return; + + case Op.new_delegate: + RW(1); RW(3); return; // dst, receiver slot (word 2 is a tokIdx) + + case Op.call_script: + case Op.newobj_script: + case Op.newobj_host: + { + RW(1); + int argc = (int)ir[ip + 3]; + for (int k = 0; k < argc; k++) RW(4 + k); + return; + } + + case Op.call_host: + { + RW(1); RW(2); + int argc = (int)ir[ip + 4]; + for (int k = 0; k < argc; k++) RW(5 + k); + return; + } + + case Op.call_host_byref: + { + RW(1); RW(2); + int argc = (int)ir[ip + 4]; + for (int k = 0; k < argc; k++) RW(5 + k); + int wbBase = ip + 5 + argc; + int wbCount = (int)ir[wbBase]; + for (int k = 0; k < wbCount; k++) + { + int kind = (int)ir[wbBase + 1 + k * 4 + 1]; + // word offsets relative to ip: + int t1Off = wbBase + 1 + k * 4 + 2 - ip; + int t2Off = wbBase + 1 + k * 4 + 3 - ip; + if (kind == 0) { RW(t1Off); } + else if (kind == 1) { RW(t1Off); } + else { RW(t1Off); RW(t2Off); } + } + return; + } + + case Op.switch_i4: + RW(1); return; + } + } + + // --- Numeric for-loop super-instruction --- + // + // Detects the canonical Roslyn-emitted for-loop tail + // add_i4_nk slot[N], slot[N], 1 + // blt_i4_nk slot[N], K_limit, body_top + // plus the matching "test-at-bottom" loop entry + // ldc_i4 slot[N] = K_init + // br -> blt_ip + // and fuses the add+blt into one `for_i4_nk slot[N], K_limit, body_top` + // dispatch. Redirects the entry br from blt_ip to body_top so the first + // body iteration isn't skipped (the executor's increment-then-test + // would otherwise consume the first slot value). + // + // Safety: + // - Step is hardcoded to 1 (the add's K must be exactly 1). + // - K_init < K_limit must hold statically — without the entry test we + // can't guarantee zero-iter loops behave correctly. + // - The induction slot is single-write across the loop entry pattern + // (the ldc + the add). This rules out odd cases where the body + // re-initializes the counter. + // + // Not handled: + // - Reverse loops (`i--`). + // - Step != 1. + // - r4 induction (rare in idiomatic C#, accumulates error). + // - Slot-valued limit (`for (int i=0; i(n / 3); + var ops = new List(n / 3); + var widths = new List(n / 3); + int pos = 0; + while (pos < n) + { + var op = (Op)ir[pos]; + int w = OpWidthForCoalesce(op, ir, pos); + if (w <= 0) return; + ips.Add(pos); ops.Add(op); widths.Add(w); + pos += w; + } + int insnCount = ips.Count; + + // Map IP → instruction index for lookup. + var ipToIdx = new Dictionary(insnCount); + for (int i = 0; i < insnCount; i++) ipToIdx[ips[i]] = i; + + // Branch-target set: which IPs are jumped to by some branch? + var isBranchTarget = new bool[n + 1]; + for (int i = 0; i < insnCount; i++) + MarkInsnBranchTargets(ir, ips[i], ops[i], isBranchTarget); + + var deleted = new bool[insnCount]; + int deletedWords = 0; + + for (int i = 1; i < insnCount; i++) + { + if (deleted[i]) continue; + if (ops[i] != Op.blt_i4_nk) continue; + int bltIp = ips[i]; + int slotN = (int)ir[bltIp + 1]; + int kLimit = (int)ir[bltIp + 2]; + int bodyTop = (int)ir[bltIp + 3]; + if (bodyTop >= bltIp) continue; // must be a backward branch + if (!ipToIdx.ContainsKey(bodyTop)) continue; + + // Preceding instruction must be add_i4_nk slot[N], slot[N], 1. + int j = i - 1; + if (deleted[j] || ops[j] != Op.add_i4_nk) continue; + int addIp = ips[j]; + if ((int)ir[addIp + 1] != slotN) continue; // dst == N + if ((int)ir[addIp + 2] != slotN) continue; // src == N (in-place) + if ((int)ir[addIp + 3] != 1) continue; // step == 1 + + // Two cases: + // (a) Test-at-bottom loop: an entry `br -> bltIp` exists. We must + // redirect it to body_top so the for_i4_nk doesn't consume the + // first body iteration. Requires statically provable + // K_init < K_limit (otherwise we'd execute the body once when + // the original loop wouldn't have run at all). + // (b) Test-at-top / do-while loop: nothing branches to the blt. The + // loop is entered by fall-through and exited by fall-through past + // the blt. for_i4_nk has identical semantics to add+blt at the + // loop tail, so the fuse is unconditionally safe. + bool needRedirect = isBranchTarget[bltIp]; + if (needRedirect) + { + if (!ipToIdx.TryGetValue(bodyTop, out int topIdx)) continue; + if (topIdx < 2) continue; + int brIdx = topIdx - 1; + if (deleted[brIdx] || ops[brIdx] != Op.br) continue; + int brIp = ips[brIdx]; + if ((int)ir[brIp + 1] != bltIp) continue; // br must target the blt + + int initIdx = brIdx - 1; + if (initIdx < 0 || deleted[initIdx]) continue; + if (ops[initIdx] != Op.ldc_i4) continue; + int initIp = ips[initIdx]; + if ((int)ir[initIp + 1] != slotN) continue; + int kInit = (int)ir[initIp + 2]; + if (kInit >= kLimit) continue; // entry-check guard + + // Redirect entry br to body_top. + ir[brIp + 1] = (uint)bodyTop; + } + + // Apply: rewrite add → for_i4_nk in place; mark blt for deletion. + ir[addIp + 0] = (uint)Op.for_i4_nk; + ir[addIp + 1] = (uint)slotN; + ir[addIp + 2] = (uint)kLimit; + ir[addIp + 3] = (uint)bodyTop; + ops[j] = Op.for_i4_nk; + + deleted[i] = true; + deletedWords += widths[i]; + } + + if (deletedWords == 0) return; + + var deletedWord = new bool[n]; + for (int i = 0; i < insnCount; i++) + { + if (!deleted[i]) continue; + for (int k = 0; k < widths[i]; k++) deletedWord[ips[i] + k] = true; + } + var shift = new int[n + 1]; + int run = 0; + for (int oldIp = 0; oldIp <= n; oldIp++) + { + shift[oldIp] = run; + if (oldIp < n && deletedWord[oldIp]) run++; + } + + int newLen = n - deletedWords; + var newIr = new uint[newLen]; + var newToIl = new int[newLen]; + int dstIdx = 0; + for (int i = 0; i < insnCount; i++) + { + if (deleted[i]) continue; + int oldIp = ips[i]; + int w = widths[i]; + for (int k = 0; k < w; k++) + { + newIr[dstIdx + k] = ir[oldIp + k]; + newToIl[dstIdx + k] = irToIl[oldIp + k]; + } + PatchInsnBranchTargets(newIr, dstIdx, ops[i], shift, ir, oldIp); + dstIdx += w; + } + + ir = newIr; + irToIl = newToIl; + } + + // --- Loop-invariant constant hoisting --- + // + // A `ldc_i4 / ldc_r4 / ldstr` inside a loop re-executes every iteration even though it + // always produces the same value into the same slot. Profiling showed constant reloads at + // 10–38% of all dispatches on loop-heavy bodies (`i & 255`, `Translate(0.01f, 0f, -0.01f)`). + // This pass moves qualifying ldcs into a prologue emitted once at IR entry. + // + // A candidate ldc must satisfy: + // - writes[dst] == 1: this ldc is the slot's only writer in the whole method. C# definite + // assignment then guarantees the write dominates every read, so "execute once up front" + // observes the same values (re-executions were idempotent). + // - dst >= argCount: arg slots are populated by the CALLER before Run enters the IR; + // a prologue write would clobber the incoming argument. + // - the ldc sits inside a loop body — between a backward branch and its target. Hoisting + // straight-line or cold-branch constants would ADD a dispatch per call, not remove one. + // + // Branch targets all shift by the prologue length; the shared shift-table mechanism handles + // that with negative entries (newTarget = old - shift[old]). + static void HoistLoopConsts(ref uint[] ir, ref int[] irToIl, SType[] slotTypes, int argCount) + { + int n = ir.Length; + if (n == 0) return; + + var ips = new List(n / 3); + var ops = new List(n / 3); + var widths = new List(n / 3); + int pos = 0; + while (pos < n) + { + var op = (Op)ir[pos]; + int w = OpWidthForCoalesce(op, ir, pos); + if (w <= 0) return; + ips.Add(pos); ops.Add(op); widths.Add(w); + pos += w; + } + int insnCount = ips.Count; + + int slotCount = slotTypes.Length; + var reads = new int[slotCount]; + var writes = new int[slotCount]; + for (int i = 0; i < insnCount; i++) + AccumulateSlotUses(ir, ips[i], ops[i], widths[i], reads, writes, slotCount); + + // Mark loop-body words: for every backward branch at ip with target t <= ip, the + // whole [t, ip] range is (over-approximately) a loop body. Difference array over words. + var loopDelta = new int[n + 2]; + void MarkLoop(int from, int to) { if (to <= from) { loopDelta[to]++; loopDelta[from + 1]--; } } + for (int i = 0; i < insnCount; i++) + { + int ip = ips[i]; + switch (ops[i]) + { + case Op.br: case Op.push_cont: MarkLoop(ip, (int)ir[ip + 1]); break; + case Op.brtrue_i4: case Op.brfalse_i4: + case Op.brtrue_o: case Op.brfalse_o: MarkLoop(ip, (int)ir[ip + 2]); break; + case Op.blt_i4_nn: case Op.blt_i4_nk: + case Op.bgt_r4_nn: case Op.bgt_r4_nk: + case Op.beq_i4_nn: case Op.beq_i4_nk: + case Op.bne_i4_nn: case Op.bne_i4_nk: + case Op.for_i4_nk: MarkLoop(ip, (int)ir[ip + 3]); break; + case Op.switch_i4: + { + int cnt = (int)ir[ip + 2]; + MarkLoop(ip, (int)ir[ip + 3]); + for (int k = 0; k < cnt; k++) MarkLoop(ip, (int)ir[ip + 4 + k]); + break; + } + } + } + var inLoop = new bool[n]; + int depth = 0; + for (int w2 = 0; w2 < n; w2++) { depth += loopDelta[w2]; inLoop[w2] = depth > 0; } + + var hoisted = new List(); // instruction indexes to move + var deleted = new bool[insnCount]; + int hoistWords = 0; + for (int i = 0; i < insnCount; i++) + { + var op = ops[i]; + if (op != Op.ldc_i4 && op != Op.ldc_r4 && op != Op.ldstr) continue; + int ip = ips[i]; + if (!inLoop[ip]) continue; + int dst = (int)ir[ip + 1]; + if (dst < argCount) continue; + if ((uint)dst >= (uint)slotCount) continue; + if (writes[dst] != 1 || reads[dst] == 0) continue; + hoisted.Add(i); + deleted[i] = true; + hoistWords += widths[i]; + } + if (hoisted.Count == 0) return; + + // shift[t] = (deleted words strictly before t) - prologueLen, so + // newTarget = t - shift[t] lands on the surviving instruction, offset by the prologue. + var deletedWord = new bool[n]; + foreach (var i in hoisted) + for (int k = 0; k < widths[i]; k++) deletedWord[ips[i] + k] = true; + var shift = new int[n + 1]; + int run = 0; + for (int oldIp = 0; oldIp <= n; oldIp++) + { + shift[oldIp] = run - hoistWords; + if (oldIp < n && deletedWord[oldIp]) run++; + } + + int newLen = n; // deleted words re-emitted verbatim in the prologue + var newIr = new uint[newLen]; + var newToIl = new int[newLen]; + int dstIdx = 0; + foreach (var i in hoisted) + { + int oldIp = ips[i]; + for (int k = 0; k < widths[i]; k++) + { + newIr[dstIdx + k] = ir[oldIp + k]; + newToIl[dstIdx + k] = irToIl[oldIp + k]; + } + dstIdx += widths[i]; + } + for (int i = 0; i < insnCount; i++) + { + if (deleted[i]) continue; + int oldIp = ips[i]; + int w = widths[i]; + for (int k = 0; k < w; k++) + { + newIr[dstIdx + k] = ir[oldIp + k]; + newToIl[dstIdx + k] = irToIl[oldIp + k]; + } + PatchInsnBranchTargets(newIr, dstIdx, ops[i], shift, ir, oldIp); + dstIdx += w; + } + + ir = newIr; + irToIl = newToIl; + } + + // --- Fused compare-and-branch --- + // + // Detects `compare_X_form dst, ...; brtrue/brfalse dst, target` where + // dst is single-use (the brtrue/brfalse is its only reader) and folds + // into one fused `bX_form ...args, target`. Runs AFTER CoalesceDestMovs + // so that the compare's dst is already the local slot the branch + // reads — pattern detection becomes a clean two-instruction window. + // + // Mappings (only ones with bench-corpus evidence): + // clt_i4_nn + brtrue → blt_i4_nn ceq_i4_nn + brtrue → beq_i4_nn + // clt_i4_nk + brtrue → blt_i4_nk ceq_i4_nk + brtrue → beq_i4_nk + // cgt_r4_nn + brtrue → bgt_r4_nn ceq_i4_nn + brfalse → bne_i4_nn + // cgt_r4_nk + brtrue → bgt_r4_nk ceq_i4_nk + brfalse → bne_i4_nk + // + // Other polarity combinations (clt+brfalse, cgt+brfalse, etc.) are + // left as two ops — adding "bge"/"ble" etc. would double the opcode + // count for patterns we don't have. Add on demand. + static void FoldCompareBranch(ref uint[] ir, ref int[] irToIl, SType[] slotTypes) + { + int n = ir.Length; + if (n == 0) return; + + var ips = new List(n / 3); + var ops = new List(n / 3); + var widths = new List(n / 3); + int pos = 0; + while (pos < n) + { + var op = (Op)ir[pos]; + int w = OpWidthForCoalesce(op, ir, pos); + if (w <= 0) return; + ips.Add(pos); ops.Add(op); widths.Add(w); + pos += w; + } + int insnCount = ips.Count; + + int slotCount = slotTypes.Length; + var reads = new int[slotCount]; + var writes = new int[slotCount]; + for (int i = 0; i < insnCount; i++) + AccumulateSlotUses(ir, ips[i], ops[i], widths[i], reads, writes, slotCount); + + var isBranchTarget = new bool[n + 1]; + for (int i = 0; i < insnCount; i++) + MarkInsnBranchTargets(ir, ips[i], ops[i], isBranchTarget); + + var deleted = new bool[insnCount]; + int deletedWords = 0; + for (int i = 0; i < insnCount - 1; i++) + { + if (deleted[i]) continue; + int cmpIp = ips[i]; + if (widths[i] != 4) continue; + int dstSlot = (int)ir[cmpIp + 1]; + if ((uint)dstSlot >= (uint)slotCount) continue; // -1 sentinel (e.g. zero-arg void call_script) + if (writes[dstSlot] != 1 || reads[dstSlot] != 1) continue; + + int j = i + 1; + if (j >= insnCount || deleted[j]) continue; + var brOp = ops[j]; + if (brOp != Op.brtrue_i4 && brOp != Op.brfalse_i4) continue; + int brIp = ips[j]; + if (isBranchTarget[brIp]) continue; + if ((int)ir[brIp + 1] != dstSlot) continue; + + bool brIfTrue = (brOp == Op.brtrue_i4); + if (!TryFoldCompareBranch(ops[i], brIfTrue, out Op fusedOp)) continue; + + uint target = ir[brIp + 2]; + + // Rewrite cmp in place: [op, s1, s2_or_k, target_ip] + ir[cmpIp] = (uint)fusedOp; + ir[cmpIp + 1] = ir[cmpIp + 2]; // slide src1 from word 2 → word 1 + ir[cmpIp + 2] = ir[cmpIp + 3]; // slide src2/k from word 3 → word 2 + ir[cmpIp + 3] = target; // target at word 3 + + // Update cached op so PatchInsnBranchTargets sees the new opcode and + // applies the shift to word 3 (not the original cmp's word 3, which + // was an operand and shouldn't be shifted). + ops[i] = fusedOp; + + deleted[j] = true; + deletedWords += widths[j]; + + reads[dstSlot] = 0; writes[dstSlot] = 0; + } + + if (deletedWords == 0) return; + + // Compact + branch-target patch. + var deletedWord = new bool[n]; + for (int i = 0; i < insnCount; i++) + { + if (!deleted[i]) continue; + for (int k = 0; k < widths[i]; k++) deletedWord[ips[i] + k] = true; + } + var shift = new int[n + 1]; + int run = 0; + for (int oldIp = 0; oldIp <= n; oldIp++) + { + shift[oldIp] = run; + if (oldIp < n && deletedWord[oldIp]) run++; + } + + int newLen = n - deletedWords; + var newIr = new uint[newLen]; + var newToIl = new int[newLen]; + int dstIdx = 0; + for (int i = 0; i < insnCount; i++) + { + if (deleted[i]) continue; + int oldIp = ips[i]; + int w = widths[i]; + for (int k = 0; k < w; k++) + { + newIr[dstIdx + k] = ir[oldIp + k]; + newToIl[dstIdx + k] = irToIl[oldIp + k]; + } + PatchInsnBranchTargets(newIr, dstIdx, ops[i], shift, ir, oldIp); + dstIdx += w; + } + + ir = newIr; + irToIl = newToIl; + } + + // True when the op writes a frame slot at ir[ip+1] (its dst). False for control flow, stores + // (which write memory, not a frame slot), and returns. Used by ForwardRedundantLoads to know + // which cached slots a reassignment invalidates. Calls/newobj also write a dst but clear the + // whole cache separately, so treating them as writers here is harmless. + static bool WritesFrameDstAt1(Op op) => op switch + { + Op.nop or Op.br + or Op.brtrue_i4 or Op.brfalse_i4 or Op.brtrue_o or Op.brfalse_o + or Op.ret_void or Op.ret_i4 or Op.ret_r4 or Op.ret_o or Op.throw_o + or Op.stfld_i4 or Op.stfld_r4 or Op.stfld_o or Op.stfld_struct + or Op.stfld_sc_i4 or Op.stfld_sc_r4 or Op.stfld_sc_o or Op.stfld_sc_vt + or Op.stfld_vt_i4 or Op.stfld_vt_r4 or Op.stfld_vt_b1 or Op.stfld_vt_b2 + or Op.stelem_i4 or Op.stelem_r4 or Op.stelem_o or Op.stelem_struct + or Op.stsfld_i4 or Op.stsfld_r4 or Op.stsfld_o or Op.stsfld_struct + or Op.switch_i4 + or Op.blt_i4_nn or Op.blt_i4_nk or Op.bgt_r4_nn or Op.bgt_r4_nk + or Op.beq_i4_nn or Op.beq_i4_nk or Op.bne_i4_nn or Op.bne_i4_nk + or Op.push_cont or Op.br_cont => false, + _ => true, + }; + + static bool TryFoldCompareBranch(Op cmpOp, bool brIfTrue, out Op fused) + { + fused = default; + switch (cmpOp) + { + case Op.clt_i4_nn: if (brIfTrue) { fused = Op.blt_i4_nn; return true; } return false; + case Op.clt_i4_nk: if (brIfTrue) { fused = Op.blt_i4_nk; return true; } return false; + case Op.cgt_r4_nn: if (brIfTrue) { fused = Op.bgt_r4_nn; return true; } return false; + case Op.cgt_r4_nk: if (brIfTrue) { fused = Op.bgt_r4_nk; return true; } return false; + case Op.ceq_i4_nn: fused = brIfTrue ? Op.beq_i4_nn : Op.bne_i4_nn; return true; + case Op.ceq_i4_nk: fused = brIfTrue ? Op.beq_i4_nk : Op.bne_i4_nk; return true; + default: return false; + } + } + + // Helpers shared by all three peephole passes. + static void MarkInsnBranchTargets(uint[] ir, int ip, Op op, bool[] isBranchTarget) + { + switch (op) + { + case Op.br: + case Op.push_cont: + MarkTarget(isBranchTarget, ir[ip + 1]); break; + case Op.brtrue_i4: case Op.brfalse_i4: + case Op.brtrue_o: case Op.brfalse_o: + MarkTarget(isBranchTarget, ir[ip + 2]); break; + case Op.blt_i4_nn: case Op.blt_i4_nk: + case Op.bgt_r4_nn: case Op.bgt_r4_nk: + case Op.beq_i4_nn: case Op.beq_i4_nk: + case Op.bne_i4_nn: case Op.bne_i4_nk: + case Op.for_i4_nk: + MarkTarget(isBranchTarget, ir[ip + 3]); break; + case Op.switch_i4: + { + int cnt = (int)ir[ip + 2]; + MarkTarget(isBranchTarget, ir[ip + 3]); + for (int k = 0; k < cnt; k++) + MarkTarget(isBranchTarget, ir[ip + 4 + k]); + break; + } + } + } + + static void PatchInsnBranchTargets(uint[] newIr, int dstIdx, Op op, int[] shift, + uint[] oldIr, int oldIp) + { + switch (op) + { + case Op.br: + case Op.push_cont: + newIr[dstIdx + 1] = (uint)((int)oldIr[oldIp + 1] - shift[(int)oldIr[oldIp + 1]]); + break; + case Op.brtrue_i4: case Op.brfalse_i4: + case Op.brtrue_o: case Op.brfalse_o: + case Op.brtrue_i8: case Op.brfalse_i8: + newIr[dstIdx + 2] = (uint)((int)oldIr[oldIp + 2] - shift[(int)oldIr[oldIp + 2]]); + break; + case Op.blt_i4_nn: case Op.blt_i4_nk: + case Op.bgt_r4_nn: case Op.bgt_r4_nk: + case Op.beq_i4_nn: case Op.beq_i4_nk: + case Op.bne_i4_nn: case Op.bne_i4_nk: + case Op.for_i4_nk: + newIr[dstIdx + 3] = (uint)((int)oldIr[oldIp + 3] - shift[(int)oldIr[oldIp + 3]]); + break; + case Op.switch_i4: + { + int cnt = (int)oldIr[oldIp + 2]; + int defT = (int)oldIr[oldIp + 3]; + newIr[dstIdx + 3] = (uint)(defT - shift[defT]); + for (int k = 0; k < cnt; k++) + { + int t = (int)oldIr[oldIp + 4 + k]; + newIr[dstIdx + 4 + k] = (uint)(t - shift[t]); + } + break; + } + } + } + + // --- Dest-coalescing peephole --- + // + // Eliminates `mov dst=X, src=tmp` where `tmp` is a single-use temp produced by + // the immediately-preceding instruction. Rewrites that instruction's dst to X + // and deletes the mov. Run after branch-target patching so target IPs are + // word-indexed; we re-patch them after compaction. + // + // Safety: + // - tmp has exactly 1 read (the candidate mov) and 1 write (prev op) globally. + // - slotTypes[tmp] == slotTypes[X], and neither is Vt — guarantees the prev + // op's frame write (numFrame vs refFrame, byte vs ref) is valid for X. + // - The mov's IP is not a branch target. + // - Prev op is in a conservative whitelist (IsRetargetable) of ops that write + // their dst at words[1] with no other side effects on dst. + static void CoalesceDestMovs(ref uint[] ir, ref int[] irToIl, SType[] slotTypes) + { + int n = ir.Length; + if (n == 0) return; + + // Walk IR once: record (ip, op, width) per instruction. Bail out if any + // op has unknown width — leaves IR untouched. + var ips = new List(n / 3); + var ops = new List(n / 3); + var widths = new List(n / 3); + int pos = 0; + while (pos < n) + { + var op = (Op)ir[pos]; + int w = OpWidthForCoalesce(op, ir, pos); + if (w <= 0) return; + ips.Add(pos); ops.Add(op); widths.Add(w); + pos += w; + } + int insnCount = ips.Count; + + // Per-slot read/write counts. Conservative: any value in a "slot position" + // is counted, even if -1 (sentinel for void/static); -1 just bumps a phantom + // entry that nothing else references. + int slotCount = slotTypes.Length; + var reads = new int[slotCount]; + var writes = new int[slotCount]; + for (int i = 0; i < insnCount; i++) + AccumulateSlotUses(ir, ips[i], ops[i], widths[i], reads, writes, slotCount); + + // Set of branch-target word IPs. Branches target alive-instruction IPs; we + // refuse to delete a mov that anything jumps to. + var isBranchTarget = new bool[n + 1]; + for (int i = 0; i < insnCount; i++) + MarkInsnBranchTargets(ir, ips[i], ops[i], isBranchTarget); + + // Identify mov instructions to coalesce. + var deleted = new bool[insnCount]; + int deletedWords = 0; + for (int i = 1; i < insnCount; i++) + { + if (ops[i] != Op.mov) continue; + int movIp = ips[i]; + if (isBranchTarget[movIp]) continue; + + int dstSlot = (int)ir[movIp + 1]; + int srcSlot = (int)ir[movIp + 2]; + if ((uint)dstSlot >= (uint)slotCount || (uint)srcSlot >= (uint)slotCount) continue; + + var stSrc = slotTypes[srcSlot]; + var stDst = slotTypes[dstSlot]; + if (stSrc == SType.Vt || stDst == SType.Vt) continue; + if (stSrc != stDst) continue; + + if (writes[srcSlot] != 1 || reads[srcSlot] != 1) continue; + + // Prev instruction must define srcSlot in retargetable position. + var prevOp = ops[i - 1]; + if (deleted[i - 1]) continue; + if (!IsRetargetable(prevOp)) continue; + int prevDstWord = ips[i - 1] + 1; + if ((int)ir[prevDstWord] != srcSlot) continue; + + // Apply rewrite + mark deletion. + ir[prevDstWord] = (uint)dstSlot; + deleted[i] = true; + deletedWords += widths[i]; + + // Update counts so a chained mov (X <- Y where Y was just rewritten + // away) sees the new state. srcSlot loses its lone read+write; dstSlot + // gains one of each (the rewritten prev op now reads/writes dst). + reads[srcSlot] = 0; writes[srcSlot] = 0; + writes[dstSlot]++; + } + + if (deletedWords == 0) return; + + // Build old-IP → new-IP shift table for branch-target patching. + // shift[oldIp] = total deleted words at positions strictly less than oldIp. + var deletedWord = new bool[n]; + for (int i = 0; i < insnCount; i++) + { + if (!deleted[i]) continue; + for (int k = 0; k < widths[i]; k++) deletedWord[ips[i] + k] = true; + } + var shift = new int[n + 1]; + int run = 0; + for (int oldIp = 0; oldIp <= n; oldIp++) + { + shift[oldIp] = run; + if (oldIp < n && deletedWord[oldIp]) run++; + } + + // Compact: copy alive instructions, patching branch targets via shift. + int newLen = n - deletedWords; + var newIr = new uint[newLen]; + var newToIl = new int[newLen]; + int dstIdx = 0; + for (int i = 0; i < insnCount; i++) + { + if (deleted[i]) continue; + int oldIp = ips[i]; + int w = widths[i]; + for (int k = 0; k < w; k++) + { + newIr[dstIdx + k] = ir[oldIp + k]; + newToIl[dstIdx + k] = irToIl[oldIp + k]; + } + switch (ops[i]) + { + case Op.br: + case Op.push_cont: + newIr[dstIdx + 1] = (uint)((int)ir[oldIp + 1] - shift[(int)ir[oldIp + 1]]); + break; + case Op.brtrue_i4: case Op.brfalse_i4: + case Op.brtrue_o: case Op.brfalse_o: + newIr[dstIdx + 2] = (uint)((int)ir[oldIp + 2] - shift[(int)ir[oldIp + 2]]); + break; + case Op.switch_i4: + { + int cnt = (int)ir[oldIp + 2]; + int defT = (int)ir[oldIp + 3]; + newIr[dstIdx + 3] = (uint)(defT - shift[defT]); + for (int k = 0; k < cnt; k++) + { + int t = (int)ir[oldIp + 4 + k]; + newIr[dstIdx + 4 + k] = (uint)(t - shift[t]); + } + break; + } + } + dstIdx += w; + } + + ir = newIr; + irToIl = newToIl; + } + + static void MarkTarget(bool[] arr, uint target) + { + int t = (int)target; + if (t >= 0 && t < arr.Length) arr[t] = true; + } + + static bool IsNkForm(Op op) => op switch + { + Op.add_i4_nk or Op.add_r4_nk or Op.sub_i4_nk or Op.sub_r4_nk + or Op.mul_r4_nk + or Op.clt_i4_nk or Op.clt_r4_nk or Op.cgt_i4_nk or Op.cgt_r4_nk + or Op.ceq_i4_nk + or Op.mul_i4_nk or Op.div_i4_nk or Op.rem_i4_nk + or Op.and_i4_nk or Op.or_i4_nk or Op.xor_i4_nk + or Op.shl_i4_nk or Op.shr_i4_nk or Op.shr_un_i4_nk + or Op.div_r4_nk or Op.ceq_r4_nk => true, + _ => false, + }; + + static bool IsFusedCmpBranch(Op op) => op switch + { + Op.blt_i4_nn or Op.blt_i4_nk + or Op.bgt_r4_nn or Op.bgt_r4_nk + or Op.beq_i4_nn or Op.beq_i4_nk + or Op.bne_i4_nn or Op.bne_i4_nk => true, + _ => false, + }; + + // True when the second operand at word 2 is an inline immediate, not a slot. + static bool IsFusedCmpBranchNk(Op op) => op switch + { + Op.blt_i4_nk or Op.bgt_r4_nk or Op.beq_i4_nk or Op.bne_i4_nk => true, + _ => false, + }; + + // Whitelist of ops whose only effect (on the frame) is writing words[1] as a slot index. + // call_host_byref and Vt-dst ops are excluded conservatively (byref write-back touches + // slots beyond dst; Vt dsts span multiple numeric words — the coalesce pass's "types + // equal, neither Vt" check would reject them anyway). + // Calls and newobjs ARE retargetable: their dst is written exactly once, type-routed by + // slotT (numeric or ref frame), after all argument reads — the profile showed 12-25% of + // dispatches were `call tmp; mov local <- tmp` pairs this unlocks. + static bool IsRetargetable(Op op) + { + if (Is4WordArithForCoalesce(op)) return true; + return op switch + { + Op.call_host or Op.call_script or Op.newobj_host or Op.newobj_script + or Op.new_delegate => true, + Op.ldc_i4 or Op.ldc_r4 or Op.ldnull or Op.ldstr => true, + Op.neg_i4 or Op.neg_i4_n or Op.neg_r4 or Op.neg_r4_n + or Op.not_i4 or Op.not_i4_n => true, + Op.conv_i4_r4 or Op.conv_r4_i4 or Op.conv_i4_i1 or Op.conv_i4_u1 + or Op.conv_i4_i2 or Op.conv_i4_u2 => true, + Op.ldfld_i4 or Op.ldfld_r4 or Op.ldfld_o or Op.ldflda => true, + Op.ldfld_sc_i4 or Op.ldfld_sc_r4 or Op.ldfld_sc_o or Op.ldfld_sc_vt => true, + Op.ldsfld_i4 or Op.ldsfld_r4 or Op.ldsfld_o => true, + Op.ldlen => true, + Op.ldelem_i4 or Op.ldelem_r4 or Op.ldelem_o => true, + Op.ldtoken => true, + Op.castclass or Op.unbox_any or Op.isinst => true, + Op.box or Op.box_prim or Op.box_enum => true, + Op.ldfld_vt_i4 or Op.ldfld_vt_r4 => true, // dst is numeric, not Vt + Op.ldfld_vt_u1 or Op.ldfld_vt_i1 or Op.ldfld_vt_u2 or Op.ldfld_vt_i2 => true, + Op.box_vt => true, // dst is O + _ => false, + }; + } + + // Mirrors the executor's per-op ip-advance. Returns -1 for ops we don't + // recognise (causes the pass to bail out, leaving IR untouched). + internal static int OpWidthForCoalesce(Op op, uint[] ir, int ip) + { + if (Is4WordArithForCoalesce(op)) return 4; + if (IsFusedCmpBranch(op)) return 4; + if (op == Op.for_i4_nk) return 4; + return op switch + { + Op.nop or Op.ret_void or Op.br_cont => 1, + Op.br or Op.push_cont or Op.ret_i4 or Op.ret_r4 or Op.ret_o or Op.ret_vt or Op.throw_o or Op.ldnull or Op.initobj + or Op.ret_i8 or Op.ret_r8 => 2, + + // 64-bit family — 4-word binops/constants, 3-word convs/unaries/branches. These + // never reach the optimizer passes (wide frames skip them) but the blob relocator + // widths must be exact. + Op.ldc_i8 or Op.ldc_r8 + or Op.add_i8 or Op.sub_i8 or Op.mul_i8 or Op.div_i8 or Op.rem_i8 + or Op.div_un_i8 or Op.rem_un_i8 + or Op.and_i8 or Op.or_i8 or Op.xor_i8 + or Op.shl_i8 or Op.shr_i8 or Op.shr_un_i8 + or Op.add_r8 or Op.sub_r8 or Op.mul_r8 or Op.div_r8 or Op.rem_r8 + or Op.ceq_i8 or Op.cgt_i8 or Op.clt_i8 or Op.cgt_un_i8 or Op.clt_un_i8 + or Op.ceq_r8 or Op.cgt_r8 or Op.clt_r8 or Op.cgt_un_r8 or Op.clt_un_r8 => 4, + + Op.neg_i8 or Op.not_i8 or Op.neg_r8 + or Op.conv_i8_i4 or Op.conv_i8_u4 or Op.conv_i4_i8 + or Op.conv_i8_r4 or Op.conv_r4_i8 or Op.conv_i8_r8 or Op.conv_r8_i8 + or Op.conv_r8_r4 or Op.conv_r4_r8 or Op.conv_r8_i4 or Op.conv_i4_r8 + or Op.conv_r8_u8 + or Op.brtrue_i8 or Op.brfalse_i8 => 3, + + Op.mov or Op.box or Op.mov_vt or Op.box_vt or Op.unbox_vt or Op.clone_sc + or Op.ldlen or Op.neg_i4 or Op.neg_i4_n or Op.neg_r4 or Op.neg_r4_n + or Op.not_i4 or Op.not_i4_n + or Op.conv_i4_r4 or Op.conv_r4_i4 or Op.conv_i4_i1 or Op.conv_i4_u1 + or Op.conv_i4_i2 or Op.conv_i4_u2 + or Op.initobj_script or Op.ensure_script or Op.ldtoken + or Op.ldc_i4 or Op.ldc_r4 or Op.ldstr + or Op.ldsfld_i4 or Op.ldsfld_r4 or Op.ldsfld_o or Op.ldsfld_struct + or Op.stsfld_i4 or Op.stsfld_r4 or Op.stsfld_o or Op.stsfld_struct + or Op.brtrue_i4 or Op.brfalse_i4 or Op.brtrue_o or Op.brfalse_o => 3, + + Op.ldfld_i4 or Op.ldfld_r4 or Op.ldfld_o or Op.ldfld_struct or Op.ldflda + or Op.stfld_i4 or Op.stfld_r4 or Op.stfld_o or Op.stfld_struct + or Op.ldelem_i4 or Op.ldelem_r4 or Op.ldelem_o or Op.ldelem_struct or Op.ldelem_vt + or Op.stelem_i4 or Op.stelem_r4 or Op.stelem_o or Op.stelem_struct or Op.stelem_vt + or Op.ldfld_vt_i4 or Op.ldfld_vt_r4 or Op.stfld_vt_i4 or Op.stfld_vt_r4 + or Op.ldfld_vt_u1 or Op.ldfld_vt_i1 or Op.ldfld_vt_u2 or Op.ldfld_vt_i2 + or Op.stfld_vt_b1 or Op.stfld_vt_b2 + or Op.ldfld_vt_vt or Op.stfld_vt_vt + or Op.ldfld_sc_i4 or Op.ldfld_sc_r4 or Op.ldfld_sc_o or Op.ldfld_sc_vt + or Op.stfld_sc_i4 or Op.stfld_sc_r4 or Op.stfld_sc_o or Op.stfld_sc_vt + or Op.castclass or Op.unbox_any or Op.isinst or Op.box_prim or Op.box_enum + or Op.new_delegate => 4, + + Op.newarr or Op.newarr_i4 or Op.newarr_r4 or Op.newarr_vt => 5, + + Op.call_script or Op.newobj_script or Op.newobj_host + => ip + 3 < ir.Length ? 4 + (int)ir[ip + 3] : -1, + Op.call_host + => ip + 4 < ir.Length ? 5 + (int)ir[ip + 4] : -1, + Op.call_host_byref => CallHostByrefWidthForCoalesce(ir, ip), + Op.switch_i4 => ip + 2 < ir.Length ? 4 + (int)ir[ip + 2] : -1, + + _ => -1, + }; + } + + static bool Is4WordArithForCoalesce(Op op) => op switch + { + Op.add_i4 or Op.add_i4_nn or Op.sub_i4 or Op.sub_i4_nn + or Op.mul_i4 or Op.mul_i4_nn or Op.div_i4 or Op.div_i4_nn + or Op.rem_i4 or Op.rem_i4_nn + or Op.add_r4 or Op.add_r4_nn or Op.sub_r4 or Op.sub_r4_nn + or Op.mul_r4 or Op.mul_r4_nn or Op.div_r4 or Op.div_r4_nn + or Op.rem_r4 or Op.rem_r4_nn + or Op.and_i4 or Op.and_i4_nn or Op.or_i4 or Op.or_i4_nn + or Op.xor_i4 or Op.xor_i4_nn + or Op.shl_i4 or Op.shl_i4_nn or Op.shr_i4 or Op.shr_i4_nn + or Op.shr_un_i4 or Op.shr_un_i4_nn + or Op.div_un_i4 or Op.div_un_i4_nn or Op.rem_un_i4 or Op.rem_un_i4_nn + or Op.ceq_i4 or Op.ceq_i4_nn or Op.cgt_i4 or Op.cgt_i4_nn + or Op.clt_i4 or Op.clt_i4_nn or Op.cgt_un_i4 or Op.cgt_un_i4_nn + or Op.clt_un_i4 or Op.clt_un_i4_nn + or Op.ceq_r4 or Op.ceq_r4_nn or Op.cgt_r4 or Op.cgt_r4_nn + or Op.clt_r4 or Op.clt_r4_nn or Op.cgt_un_r4 or Op.clt_un_r4 + or Op.ceq_o or Op.cgt_un_o + // _nk forms also encode as [op, dst, src, k] — 4 words. dst at words[1]. + or Op.add_i4_nk or Op.add_r4_nk or Op.sub_i4_nk or Op.sub_r4_nk + or Op.mul_r4_nk + or Op.clt_i4_nk or Op.clt_r4_nk or Op.cgt_i4_nk or Op.cgt_r4_nk + or Op.ceq_i4_nk + or Op.mul_i4_nk or Op.div_i4_nk or Op.rem_i4_nk + or Op.and_i4_nk or Op.or_i4_nk or Op.xor_i4_nk + or Op.shl_i4_nk or Op.shr_i4_nk or Op.shr_un_i4_nk + or Op.div_r4_nk or Op.ceq_r4_nk => true, + _ => false, + }; + + static int CallHostByrefWidthForCoalesce(uint[] ir, int ip) + { + if (ip + 4 >= ir.Length) return -1; + int argc = (int)ir[ip + 4]; + int wbBase = ip + 5 + argc; + if (wbBase >= ir.Length) return -1; + int wbCount = (int)ir[wbBase]; + return (wbBase + 1 + wbCount * 4) - ip; + } + + // Conservative slot read/write accumulation. Counts every value at a known + // slot position. Tokens, immediates, and IP targets are skipped. + static void AccumulateSlotUses(uint[] ir, int ip, Op op, int width, + int[] reads, int[] writes, int slotCount) + { + void R(int s) { if ((uint)s < (uint)slotCount) reads[s]++; } + void W(int s) { if ((uint)s < (uint)slotCount) writes[s]++; } + + if (Is4WordArithForCoalesce(op)) + { + W((int)ir[ip + 1]); R((int)ir[ip + 2]); + // _nk forms have an immediate at word 3; everything else has a 2nd slot. + if (!IsNkForm(op)) R((int)ir[ip + 3]); + return; + } + if (IsFusedCmpBranch(op)) + { + R((int)ir[ip + 1]); + if (!IsFusedCmpBranchNk(op)) R((int)ir[ip + 2]); + // word 3 is target_ip, not a slot + return; + } + if (op == Op.for_i4_nk) + { + // induction slot is read AND written; words 2 and 3 are limit + target. + int s = (int)ir[ip + 1]; + R(s); W(s); + return; + } + switch (op) + { + case Op.nop: case Op.ret_void: case Op.br: + case Op.push_cont: case Op.br_cont: return; + + case Op.mov: case Op.box: case Op.mov_vt: case Op.box_vt: case Op.unbox_vt: + case Op.clone_sc: + case Op.ldlen: + case Op.neg_i4: case Op.neg_i4_n: case Op.neg_r4: case Op.neg_r4_n: + case Op.not_i4: case Op.not_i4_n: + case Op.conv_i4_r4: case Op.conv_r4_i4: + case Op.conv_i4_i1: case Op.conv_i4_u1: + case Op.conv_i4_i2: case Op.conv_i4_u2: + W((int)ir[ip + 1]); R((int)ir[ip + 2]); return; + + case Op.ldc_i4: case Op.ldc_r4: case Op.ldstr: + case Op.ldsfld_i4: case Op.ldsfld_r4: case Op.ldsfld_o: case Op.ldsfld_struct: + case Op.ldtoken: + case Op.ldnull: case Op.initobj: case Op.initobj_script: case Op.ensure_script: + W((int)ir[ip + 1]); return; + + case Op.stsfld_i4: case Op.stsfld_r4: case Op.stsfld_o: case Op.stsfld_struct: + R((int)ir[ip + 2]); return; + + case Op.ret_i4: case Op.ret_r4: case Op.ret_o: case Op.ret_vt: + case Op.throw_o: + R((int)ir[ip + 1]); return; + + case Op.brtrue_i4: case Op.brfalse_i4: case Op.brtrue_o: case Op.brfalse_o: + R((int)ir[ip + 1]); return; + + case Op.ldfld_i4: case Op.ldfld_r4: case Op.ldfld_o: + case Op.ldfld_struct: case Op.ldflda: + case Op.ldfld_vt_i4: case Op.ldfld_vt_r4: case Op.ldfld_vt_vt: + case Op.ldfld_vt_u1: case Op.ldfld_vt_i1: case Op.ldfld_vt_u2: case Op.ldfld_vt_i2: + case Op.ldfld_sc_i4: case Op.ldfld_sc_r4: case Op.ldfld_sc_o: case Op.ldfld_sc_vt: + W((int)ir[ip + 1]); R((int)ir[ip + 2]); return; + + case Op.stfld_i4: case Op.stfld_r4: case Op.stfld_o: case Op.stfld_struct: + case Op.stfld_vt_i4: case Op.stfld_vt_r4: case Op.stfld_vt_vt: + case Op.stfld_vt_b1: case Op.stfld_vt_b2: + case Op.stfld_sc_i4: case Op.stfld_sc_r4: case Op.stfld_sc_o: case Op.stfld_sc_vt: + R((int)ir[ip + 1]); R((int)ir[ip + 3]); return; + + case Op.ldelem_i4: case Op.ldelem_r4: case Op.ldelem_o: case Op.ldelem_struct: + case Op.ldelem_vt: + W((int)ir[ip + 1]); R((int)ir[ip + 2]); R((int)ir[ip + 3]); return; + + case Op.stelem_i4: case Op.stelem_r4: case Op.stelem_o: case Op.stelem_struct: + case Op.stelem_vt: + R((int)ir[ip + 1]); R((int)ir[ip + 2]); R((int)ir[ip + 3]); return; + + case Op.castclass: case Op.unbox_any: case Op.isinst: case Op.box_prim: + case Op.box_enum: + W((int)ir[ip + 1]); R((int)ir[ip + 2]); return; + + case Op.newarr: case Op.newarr_i4: case Op.newarr_r4: case Op.newarr_vt: + W((int)ir[ip + 1]); R((int)ir[ip + 2]); return; + + case Op.new_delegate: + W((int)ir[ip + 1]); R((int)ir[ip + 3]); return; // word 2 is a tokIdx + + case Op.call_script: + case Op.newobj_script: + case Op.newobj_host: + { + W((int)ir[ip + 1]); + int argc = (int)ir[ip + 3]; + for (int k = 0; k < argc; k++) R((int)ir[ip + 4 + k]); + return; + } + + case Op.call_host: + { + W((int)ir[ip + 1]); R((int)ir[ip + 2]); + int argc = (int)ir[ip + 4]; + for (int k = 0; k < argc; k++) R((int)ir[ip + 5 + k]); + return; + } + + case Op.call_host_byref: + { + W((int)ir[ip + 1]); R((int)ir[ip + 2]); + int argc = (int)ir[ip + 4]; + for (int k = 0; k < argc; k++) R((int)ir[ip + 5 + k]); + int wbBase = ip + 5 + argc; + int wbCount = (int)ir[wbBase]; + for (int k = 0; k < wbCount; k++) + { + int kind = (int)ir[wbBase + 1 + k * 4 + 1]; + int t1 = (int)ir[wbBase + 1 + k * 4 + 2]; + int t2 = (int)ir[wbBase + 1 + k * 4 + 3]; + if (kind == 0) { W(t1); } // frame slot writeback + else if (kind == 1) { R(t1); } // field on object + else { R(t1); R(t2); } // array[idx] + } + return; + } + + case Op.switch_i4: + R((int)ir[ip + 1]); return; + } + } + + // --- Pre-scan helpers --- + + // Scan the IL bytes and collect all branch target offsets into a HashSet. + // Used by LowerMethod to detect basic-block boundaries and clear ensuredScriptSlots. + static HashSet CollectIlBranchTargets(byte[] il) + { + var targets = new HashSet(); + int ip = 0; + while (ip < il.Length) + { + byte b = il[ip++]; + ILOpCode op = b == 0xFE ? (ILOpCode)(0xFE00 | il[ip++]) : (ILOpCode)b; + switch (op) + { + case ILOpCode.Br_s: + case ILOpCode.Brtrue_s: case ILOpCode.Brfalse_s: + case ILOpCode.Beq_s: case ILOpCode.Bne_un_s: + case ILOpCode.Blt_s: case ILOpCode.Blt_un_s: + case ILOpCode.Bgt_s: case ILOpCode.Bgt_un_s: + case ILOpCode.Ble_s: case ILOpCode.Ble_un_s: + case ILOpCode.Bge_s: case ILOpCode.Bge_un_s: + case ILOpCode.Leave_s: + { + int off = (sbyte)il[ip++]; + targets.Add(ip + off); + break; + } + case ILOpCode.Br: + case ILOpCode.Brtrue: case ILOpCode.Brfalse: + case ILOpCode.Beq: case ILOpCode.Bne_un: + case ILOpCode.Blt: case ILOpCode.Blt_un: + case ILOpCode.Bgt: case ILOpCode.Bgt_un: + case ILOpCode.Ble: case ILOpCode.Ble_un: + case ILOpCode.Bge: case ILOpCode.Bge_un: + case ILOpCode.Leave: + { + int off = BitConverter.ToInt32(il, ip); ip += 4; + targets.Add(ip + off); + break; + } + case ILOpCode.Switch: + { + int n = BitConverter.ToInt32(il, ip); ip += 4; + int tableStart = ip; + ip += 4 * n; + for (int ci = 0; ci < n; ci++) + { + int off = BitConverter.ToInt32(il, tableStart + ci * 4); + targets.Add(ip + off); + } + break; + } + default: + ip += ILOperandSize(op, il, ip); + break; + } + } + return targets; + } + + // Handler entry points of every finally region a `leave` at leaveOffset must run before + // reaching `target`: try region contains the leave but not the target. Innermost first + // (smallest try region), matching the order the CLR runs them on the non-exceptional path. + static List FinallyChain( + (int TryStart, int TryEnd, int HandlerStart, int HandlerEnd)[]? regions, + int leaveOffset, int target) + { + var chain = new List(); + if (regions == null) return chain; + // Sort by try-region size so nesting order is explicit (metadata usually orders + // inner clauses first, but don't rely on it). + var hits = new List<(int size, int handlerStart)>(); + foreach (var r in regions) + { + if (r.TryStart <= leaveOffset && leaveOffset < r.TryEnd + && !(r.TryStart <= target && target < r.TryEnd)) + hits.Add((r.TryEnd - r.TryStart, r.HandlerStart)); + } + hits.Sort((a, b) => a.size.CompareTo(b.size)); + foreach (var h in hits) chain.Add(h.handlerStart); + return chain; + } + + // Forward-type-flow pre-pass classifying every local's SType before IR emission: + // a simplified run of the main lowering pass that only tracks types (no IR output), + // pre-populating slotTypes[] for args+locals so the main pass allocates correctly + // from the start. + /// Maps a metadata type token to the interpreter SType for a system PRIMITIVE + /// (Int32/Boolean/Byte/…/Char → I4, Single/Double → R4); null for anything else (script + /// types, reference types, unresolvable). Used by initobj to classify a target the + /// local-type inference couldn't see a store to. + // Box typecode for a primitive value type (else -1). Codes: 0=Int32 1=Boolean 2=Char + // 3=Byte 4=SByte 5=Int16 6=UInt16 7=Single. Others (UInt32/Int64/Double/enum/struct/ref) + // return -1 and keep box a no-op (their value-uses already round-trip correctly). + static int BoxPrimTypeCode(ParsedAssembly asm, int token) + { + try + { + var h = MetadataTokens.EntityHandle(token); + string ns, name; + if (h.Kind == HandleKind.TypeReference) + { var tr = asm.Reader.GetTypeReference((TypeReferenceHandle)h); ns = asm.Reader.GetString(tr.Namespace); name = asm.Reader.GetString(tr.Name); } + else if (h.Kind == HandleKind.TypeDefinition) + { var td = asm.Reader.GetTypeDefinition((TypeDefinitionHandle)h); ns = asm.Reader.GetString(td.Namespace); name = asm.Reader.GetString(td.Name); } + else return -1; + if (ns != "System") return -1; + return name switch + { + "Int32" => 0, "Boolean" => 1, "Char" => 2, "Byte" => 3, + "SByte" => 4, "Int16" => 5, "UInt16" => 6, "Single" => 7, + "UInt32" => 8, // uint is analyzer-legal; boxing as int flips big values negative + "Int64" => 9, "UInt64" => 10, "Double" => 11, // wide slots + _ => -1, + }; + } + catch { return -1; } + } + + static SType? PrimitiveSTypeForTypeToken(ParsedAssembly asm, int token) + { + try + { + var h = MetadataTokens.EntityHandle(token); + string ns, name; + if (h.Kind == HandleKind.TypeReference) + { + var tr = asm.Reader.GetTypeReference((TypeReferenceHandle)h); + ns = asm.Reader.GetString(tr.Namespace); name = asm.Reader.GetString(tr.Name); + } + else if (h.Kind == HandleKind.TypeDefinition) + { + var td = asm.Reader.GetTypeDefinition((TypeDefinitionHandle)h); + ns = asm.Reader.GetString(td.Namespace); name = asm.Reader.GetString(td.Name); + } + else return null; + if (ns != "System") return null; + return name switch + { + "Int32" or "Boolean" or "Byte" or "SByte" or "Int16" or "UInt16" + or "UInt32" or "Char" => SType.I4, + "Single" => SType.R4, + "Int64" or "UInt64" => SType.I8, + "Double" => SType.R8, + _ => null, + }; + } + catch { return null; } + } + + static void PreClassifyLocals(byte[] il, ParsedMethod m, List slotTypes, + int[] localFrameSlot, HostBinding.StructLayout?[]? localStrs, bool[] localDeclared) + { + // Simulated eval stack (just types, no slot indices) + var stack = new SType[64]; + int sp = 0; + var localTypes = new SType[m.LocalCount]; // O by default; Vt locals seeded below + var argTypes = new SType[m.ArgCount]; // O by default + // Seed Vt locals so a later ldloc returns Vt instead of O. + if (localStrs != null) + for (int i = 0; i < m.LocalCount; i++) + if (localStrs[i] != null) localTypes[i] = SType.Vt; + // Seed declared-typed locals from their frozen type so the abstract walk sees them + // correctly and never re-infers them (PreStoreLocal skips them below). + for (int i = 0; i < m.LocalCount; i++) + if (localDeclared[i]) localTypes[i] = slotTypes[localFrameSlot[i]]; + + int ip = 0; + while (ip < il.Length) + { + byte b = il[ip++]; + ILOpCode op = b == 0xFE ? (ILOpCode)(0xFE00 | il[ip++]) : (ILOpCode)b; + switch (op) + { + // Constants + case ILOpCode.Ldc_i4_m1: case ILOpCode.Ldc_i4_0: case ILOpCode.Ldc_i4_1: + case ILOpCode.Ldc_i4_2: case ILOpCode.Ldc_i4_3: case ILOpCode.Ldc_i4_4: + case ILOpCode.Ldc_i4_5: case ILOpCode.Ldc_i4_6: case ILOpCode.Ldc_i4_7: + case ILOpCode.Ldc_i4_8: if(sp<64)stack[sp++]=SType.I4; break; + case ILOpCode.Ldc_i4_s: ip++; if(sp<64)stack[sp++]=SType.I4; break; + case ILOpCode.Ldc_i4: ip+=4; if(sp<64)stack[sp++]=SType.I4; break; + case ILOpCode.Ldc_r4: ip+=4; if(sp<64)stack[sp++]=SType.R4; break; + case ILOpCode.Ldc_r8: ip+=8; if(sp<64)stack[sp++]=SType.R8; break; + case ILOpCode.Ldc_i8: ip+=8; if(sp<64)stack[sp++]=SType.I8; break; + case ILOpCode.Ldnull: if(sp<64)stack[sp++]=SType.O; break; + case ILOpCode.Ldstr: ip+=4; if(sp<64)stack[sp++]=SType.O; break; + + // Locals + // Bounds-guarded like Ldloc_s below: valid IL never underflows, but this pass + // walks unknown opcodes by table — a size mistake there must degrade, not throw. + case ILOpCode.Ldloc_0: if(sp<64)stack[sp++]=m.LocalCount>0?localTypes[0]:SType.O; break; + case ILOpCode.Ldloc_1: if(sp<64)stack[sp++]=m.LocalCount>1?localTypes[1]:SType.O; break; + case ILOpCode.Ldloc_2: if(sp<64)stack[sp++]=m.LocalCount>2?localTypes[2]:SType.O; break; + case ILOpCode.Ldloc_3: if(sp<64)stack[sp++]=m.LocalCount>3?localTypes[3]:SType.O; break; + case ILOpCode.Ldloc_s: { int i=il[ip++]; if(sp<64)stack[sp++]=i0?argTypes[0]:SType.O; break; + case ILOpCode.Ldarg_1: if(sp<64)stack[sp++]=argTypes.Length>1?argTypes[1]:SType.O; break; + case ILOpCode.Ldarg_2: if(sp<64)stack[sp++]=argTypes.Length>2?argTypes[2]:SType.O; break; + case ILOpCode.Ldarg_3: if(sp<64)stack[sp++]=argTypes.Length>3?argTypes[3]:SType.O; break; + case ILOpCode.Ldarg_s: { ip++; if(sp<64)stack[sp++]=SType.O; break; } + case ILOpCode.Ldarg: { ip+=2; if(sp<64)stack[sp++]=SType.O; break; } + case ILOpCode.Starg_s: ip++; if(sp>0)sp--; break; + case ILOpCode.Starg: ip+=2; if(sp>0)sp--; break; + + // Arithmetic (consume 2, push 1 of same type) + case ILOpCode.Add: case ILOpCode.Sub: case ILOpCode.Mul: case ILOpCode.Div: case ILOpCode.Rem: + { + if(sp>=2){SType t2=stack[--sp];SType t1=stack[sp-1]; + stack[sp-1]=t1==SType.R8||t2==SType.R8?SType.R8 + :t1==SType.R4||t2==SType.R4?SType.R4 + :t1==SType.I8||t2==SType.I8?SType.I8:SType.I4;} + break; + } + case ILOpCode.Neg: + if(sp>0){SType t=stack[sp-1];stack[sp-1]=t is SType.R4 or SType.R8 or SType.I8?t:SType.I4;} break; + case ILOpCode.And: case ILOpCode.Or: case ILOpCode.Xor: case ILOpCode.Shl: case ILOpCode.Shr: case ILOpCode.Shr_un: + if(sp>=2){SType t2=stack[--sp];SType t1=stack[sp-1];stack[sp-1]=t1==SType.I8||t2==SType.I8?SType.I8:SType.I4;} break; + case ILOpCode.Not: + if(sp>0)stack[sp-1]=SType.I4; break; + + // Comparisons → I4 + case ILOpCode.Ceq: case ILOpCode.Cgt: case ILOpCode.Clt: case ILOpCode.Cgt_un: case ILOpCode.Clt_un: + if(sp>=2){sp--;stack[sp-1]=SType.I4;} break; + + // Conversions + case ILOpCode.Conv_i4: case ILOpCode.Conv_u4: + case ILOpCode.Conv_i1: case ILOpCode.Conv_u1: + case ILOpCode.Conv_i2: case ILOpCode.Conv_u2: + if(sp>0)stack[sp-1]=SType.I4; break; + case ILOpCode.Conv_r4: + if(sp>0)stack[sp-1]=SType.R4; break; + case ILOpCode.Conv_r8: case ILOpCode.Conv_r_un: + if(sp>0)stack[sp-1]=SType.R8; break; + case ILOpCode.Conv_i8: case ILOpCode.Conv_u8: + if(sp>0)stack[sp-1]=SType.I8; break; + + // Box, dup, pop + case ILOpCode.Box: ip+=4; break; + case ILOpCode.Dup: if(sp>0&&sp<64){stack[sp]=stack[sp-1];sp++;} break; + case ILOpCode.Pop: if(sp>0)sp--; break; + + // Branches (pop 0, 1, or 2) + case ILOpCode.Br_s: ip+=1; sp=0; break; + case ILOpCode.Br: ip+=4; sp=0; break; + case ILOpCode.Leave_s: ip+=1; sp=0; break; + case ILOpCode.Leave: ip+=4; sp=0; break; + case ILOpCode.Endfinally: sp=0; break; + case ILOpCode.Brtrue_s: case ILOpCode.Brfalse_s: ip+=1; if(sp>0)sp--; break; + case ILOpCode.Brtrue: case ILOpCode.Brfalse: ip+=4; if(sp>0)sp--; break; + case ILOpCode.Beq_s: case ILOpCode.Bne_un_s: case ILOpCode.Blt_s: case ILOpCode.Bgt_s: + case ILOpCode.Ble_s: case ILOpCode.Bge_s: case ILOpCode.Blt_un_s: case ILOpCode.Bgt_un_s: + case ILOpCode.Ble_un_s: case ILOpCode.Bge_un_s: ip+=1; if(sp>=2)sp-=2; break; + case ILOpCode.Beq: case ILOpCode.Bne_un: case ILOpCode.Blt: case ILOpCode.Bgt: + case ILOpCode.Ble: case ILOpCode.Bge: case ILOpCode.Blt_un: case ILOpCode.Bgt_un: + case ILOpCode.Ble_un: case ILOpCode.Bge_un: ip+=4; if(sp>=2)sp-=2; break; + + // Everything else: skip operands, reset stack conservatively (don't corrupt) + default: + { + int sz = ILOperandSize(op, il, ip); + ip += sz; + // For calls, field access, etc. — conservatively push/pop O values + // We don't need perfect accuracy here; only stloc tracking matters. + if (op == ILOpCode.Call || op == ILOpCode.Callvirt || op == ILOpCode.Newobj) + { + // Just clear the stack to be safe — we can't know arg counts without parsing sigs + sp = 0; + } + else if (op == ILOpCode.Ldfld || op == ILOpCode.Ldflda || op == ILOpCode.Ldsfld) + { + if(sp>0)sp--; + if(sp<64)stack[sp++]=SType.O; + } + else if (op == ILOpCode.Stfld) + { + if(sp>=2)sp-=2; + } + else if (op == ILOpCode.Stsfld) + { + if(sp>0)sp--; + } + else if (op == ILOpCode.Ldlen || op == ILOpCode.Newarr || op == ILOpCode.Ldtoken) + { + if(sp>0)sp--; + if(sp<64)stack[sp++]=SType.O; + } + break; + } + } + } + } + + // On conflicting stores, keep the most recent type — in practice well-typed C# + // stores only one type into each local. + static void UpdateSlotType(List slotTypes, int slot, SType t, string context = "") + { + if (slot < slotTypes.Count && slotTypes[slot] != SType.Vt) // never downgrade a Vt slot + slotTypes[slot] = t; + } + + // Stloc handler for the pre-classify pass: writes localTypes[i] = src type, propagates to + // slotTypes at the local's frame slot. Vt locals are seeded ahead of time and never overwritten. + static void PreStoreLocal(SType[] stack, ref int sp, int i, ParsedMethod m, + SType[] localTypes, List slotTypes, int[] localFrameSlot, HostBinding.StructLayout?[]? localStrs, + bool[] localDeclared) + { + if (sp == 0 || i >= m.LocalCount) { if (sp > 0) sp--; return; } + SType t = stack[--sp]; + if (i < localDeclared.Length && localDeclared[i]) return; // declared type is frozen — never infer + if (localStrs != null && localStrs[i] != null) return; // Vt local — keep type + // A Vt VALUE flowing into a local with no struct layout (e.g. `object o = someStruct;`) + // BOXES at the store — the local is O. Marking it Vt would make StoreLocal alias the + // struct bytes (mov_vt with a null layout) and lose box-snapshot semantics. + if (t == SType.Vt) t = SType.O; + localTypes[i] = t; + UpdateSlotType(slotTypes, localFrameSlot[i], t); + } + + // Returns the operand size in bytes for an IL opcode at position ip (for skipping in pre-scan). + static int ILOperandSize(ILOpCode op, byte[] il, int ip) + { + switch (op) + { + case ILOpCode.Ldarg_s: case ILOpCode.Ldloc_s: case ILOpCode.Stloc_s: + case ILOpCode.Ldarga_s: case ILOpCode.Ldloca_s: case ILOpCode.Starg_s: + case ILOpCode.Ldc_i4_s: case ILOpCode.Brfalse_s: case ILOpCode.Brtrue_s: + case ILOpCode.Br_s: case ILOpCode.Beq_s: case ILOpCode.Bge_s: case ILOpCode.Bgt_s: + case ILOpCode.Ble_s: case ILOpCode.Blt_s: case ILOpCode.Bne_un_s: + case ILOpCode.Bge_un_s: case ILOpCode.Bgt_un_s: case ILOpCode.Ble_un_s: + case ILOpCode.Blt_un_s: case ILOpCode.Leave_s: + case (ILOpCode)0xFE12: // Unaligned prefix (banned opcode) + return 1; + case ILOpCode.Ldarg: case ILOpCode.Ldloc: case ILOpCode.Stloc: case ILOpCode.Ldarga: + case ILOpCode.Ldloca: case ILOpCode.Starg: + return 2; + case ILOpCode.Ldc_i4: case ILOpCode.Ldc_r4: case ILOpCode.Ldstr: case ILOpCode.Newobj: + case ILOpCode.Ldfld: case ILOpCode.Ldflda: case ILOpCode.Stfld: case ILOpCode.Ldsfld: + case ILOpCode.Stsfld: case ILOpCode.Leave: case ILOpCode.Constrained: + case ILOpCode.Call: case ILOpCode.Callvirt: case ILOpCode.Newarr: + case ILOpCode.Ldelem: case ILOpCode.Stelem: case ILOpCode.Ldelema: case ILOpCode.Unbox_any: + case ILOpCode.Box: case ILOpCode.Castclass: case ILOpCode.Isinst: case ILOpCode.Ldtoken: + case ILOpCode.Br: case ILOpCode.Beq: case ILOpCode.Bge: case ILOpCode.Bgt: + case ILOpCode.Ble: case ILOpCode.Blt: case ILOpCode.Bne_un: case ILOpCode.Bge_un: + case ILOpCode.Bgt_un: case ILOpCode.Ble_un: case ILOpCode.Blt_un: + case ILOpCode.Brfalse: case ILOpCode.Brtrue: case ILOpCode.Initobj: + // Token-operand opcodes the lowerer rejects later — the size must still be right + // here, or the walk reads the token bytes as opcodes and desyncs (a misread + // ldloc.N then indexes past the locals array — the ldftn/delegate-creation crash). + case ILOpCode.Ldftn: case ILOpCode.Ldvirtftn: case ILOpCode.Ldsflda: + case ILOpCode.Jmp: case ILOpCode.Calli: case ILOpCode.Cpobj: case ILOpCode.Ldobj: + case ILOpCode.Stobj: case ILOpCode.Unbox: case ILOpCode.Refanyval: + case ILOpCode.Mkrefany: case ILOpCode.Sizeof: + return 4; + case ILOpCode.Ldc_r8: case (ILOpCode)0x21: // 0x21=Ldc_i8 (banned, 8-byte operand) + return 8; + case ILOpCode.Switch: + if (ip < il.Length) { int n = BitConverter.ToInt32(il, ip); return 4 + n * 4; } + return 0; + default: + return 0; // no operand + } + } + + // --- Slot allocation helpers --- + + // AllocSlot ALSO grows slotStructs (with null) in lockstep so the two lists stay aligned. + // For Vt slots, use AllocStructSlot which reserves ceil(size/4) consecutive slots. + static int AllocSlot(ref int frameSize, List slotTypes, List slotStructs, SType t, HostBinding.StructLayout? lay) + { + int s = frameSize++; + slotTypes.Add(t); + slotStructs.Add(lay); + if (t is SType.I8 or SType.R8) + { + // Wide value: reserve the continuation cell, filler-typed like Vt continuation + // cells so nothing else is allocated over the value's second half. + slotTypes.Add(SType.O); + slotStructs.Add(null); + frameSize++; + } + return s; + } + + static int AllocStructSlot(ref int frameSize, List slotTypes, List slotStructs, HostBinding.StructLayout lay) + { + int s = frameSize++; + slotTypes.Add(SType.Vt); + slotStructs.Add(lay); + int filler = (lay.Size + 3) / 4 - 1; + for (int k = 0; k < filler; k++) + { + slotTypes.Add(SType.O); + slotStructs.Add(null); + frameSize++; + } + return s; + } + + // Converge the value currently on top of the eval stack into a single "merge slot" for a forward + // join, so every forward edge into `target` leaves its result in the same frame slot. Used by the + // ternary/short-circuit `br` path and by conditional branches that carry a value to a forward join + // — e.g. `a ?? b`, whose `dup; brtrue L` leaves the left operand on the stack on the taken edge. + // Emits the mov (unconditionally, before the branch) and repoints the eval-stack top at the merge + // slot; the join reconciles the other edge's value into the same slot via mergeSlotForEnd. + static void MergeTopIntoJoinSlot( + List ir, List irToIl, (int slot, SType type)[] evalStack, int sp, + ref int frameSize, List slotTypes, List slotStructs, + Dictionary mergeSlotForEnd, int target, int instrStart) + { + var (firstSlot, firstType) = evalStack[sp - 1]; + int mSlot; + if (mergeSlotForEnd.TryGetValue(target, out var existing)) + mSlot = existing.slot; // multiple edges merging into one join + else if (firstType == SType.Vt) + { + mSlot = AllocStructSlot(ref frameSize, slotTypes, slotStructs, slotStructs[firstSlot]!); + mergeSlotForEnd[target] = (mSlot, firstType); + } + else + { + mSlot = AllocSlot(ref frameSize, slotTypes, slotStructs, firstType, null); + mergeSlotForEnd[target] = (mSlot, firstType); + } + if (mSlot != firstSlot) + Emit3(ir, irToIl, firstType == SType.Vt ? Op.mov_vt : Op.mov, mSlot, firstSlot, instrStart); + evalStack[sp - 1] = (mSlot, firstType); + } + + // Emit call_host_byref. Each byref arg is replaced with a fresh boxed O slot pre-loaded + // with the target's current value (so MethodInfo.Invoke can write through it), then a + // writeback table is appended so the executor copies the post-call value back into + // the original frame slot / field / array element. + static void EmitCallHostByref(List ir, List irToIl, (int slot, SType type)[] evalStack, + ref int sp, ref int frameSize, List slotTypes, List slotStructs, + int[] addrTagBySlot, int[] addrFldObjBySlot, int[] addrFldTokBySlot, int[] addrByteOffBySlot, + ParsedAssembly asm, List tokens, HostEntry hostEntry, int tokIdx, + ParameterInfo[] ps, int instrStart) + { + int argc = hostEntry.Binding.ParamCount; + bool isVoid = hostEntry.IsVoid; + // Type the result slot like the regular call_host dst-picker: from the resolved + // MethodInfo's return type, falling back to the MemberRef signature for string-keyed + // shims. A blanket O dst left bool/int results boxed in the ref frame, and a boxed + // false condition was then null-tested TRUE under brtrue_o's pure reference semantics + // (found by fuzzing: TryHalve-shaped bool-returning byref calls). Vt returns keep O. + SType retSt = SType.O; + if (!isVoid && (hostEntry.ResolvedMethod ?? hostEntry.Binding.Method) is MethodInfo bmi) + { + var brt = bmi.ReturnType; + if (brt == typeof(float)) retSt = SType.R4; + else if (brt == typeof(int) || brt == typeof(bool) || brt == typeof(byte) + || brt == typeof(sbyte) || brt == typeof(short) || brt == typeof(ushort)) retSt = SType.I4; + } + else if (!isVoid && hostEntry.SigRetSType is SType.I4 or SType.R4) + retSt = hostEntry.SigRetSType; + int dst = isVoid ? -1 : AllocSlot(ref frameSize, slotTypes, slotStructs, retSt, null); + + // Receiver — same address-resolution rules as the regular call_host path. + int recvSlotRaw = hostEntry.Binding.HasThis ? evalStack[sp - argc - 1].slot : -1; + int recvSlot = recvSlotRaw; + int recvVtOffB = 0; bool recvVtWbB = false; int recvVtWbBaseB = -1; + if (recvSlotRaw >= 0) + { + int addrTag = addrTagBySlot[recvSlotRaw]; + if (addrTag >= 0) { recvSlot = addrTag; recvVtOffB = addrByteOffBySlot[recvSlotRaw]; } + else if (addrTag == -1) + { + int fldObj = addrFldObjBySlot[recvSlotRaw]; + int fldToki = addrFldTokBySlot[recvSlotRaw]; + int fldTok = tokens[fldToki]; + asm.FieldSTypes.TryGetValue(fldTok, out var fldSt); + bool isScFieldB = asm.FieldSlots.TryGetValue(fldTok, out var fldFsB); + // Vt field receiver: struct slot + byte-range load (see the call_host site). + var fldVtLayB = isScFieldB && fldSt == SType.Vt + ? fldFsB.Item1.VtFieldLayouts?[fldFsB.Item2] : null; + recvSlot = fldVtLayB != null + ? AllocStructSlot(ref frameSize, slotTypes, slotStructs, fldVtLayB) + : AllocSlot(ref frameSize, slotTypes, slotStructs, fldSt, null); + if (isScFieldB) + { + int fldOffB = fldFsB.Item1.FieldOffsets[fldFsB.Item2]; + var fldOpB = fldVtLayB != null ? Op.ldfld_sc_vt + : fldSt == SType.I4 ? Op.ldfld_sc_i4 : fldSt == SType.R4 ? Op.ldfld_sc_r4 : Op.ldfld_sc_o; + Emit4(ir, irToIl, fldOpB, recvSlot, fldObj, fldOffB, instrStart); + } + else + { + var fldLoadOp = fldSt == SType.I4 ? Op.ldfld_i4 : fldSt == SType.R4 ? Op.ldfld_r4 : Op.ldfld_o; + Emit4(ir, irToIl, fldLoadOp, recvSlot, fldObj, fldToki, instrStart); + } + } + } + + // Nested-offset Vt receiver — same materialize + write-back as the call_host path. + if (hostEntry.Binding.HasThis && recvSlot >= 0 && recvVtOffB != 0 + && recvSlot < slotTypes.Count && slotTypes[recvSlot] == SType.Vt + && hostEntry.ReceiverStruct != null) + { + int rTmpB = AllocStructSlot(ref frameSize, slotTypes, slotStructs, hostEntry.ReceiverStruct); + Emit4(ir, irToIl, Op.ldfld_vt_vt, rTmpB, recvSlot, recvVtOffB, instrStart); + recvVtWbB = true; recvVtWbBaseB = recvSlot; + recvSlot = rTmpB; + } + + int argBase = sp - argc; + int spBase = sp - argc - (hostEntry.Binding.HasThis ? 1 : 0); + + // Walk byref args: replace each phantom-addr arg slot with a fresh O slot pre-loaded + // with the current value at the address. Record the writeback target in parallel arrays. + // Max 8 byref args is plenty (Physics.Raycast is the worst common case at ~3). + Span wbArgIdx = stackalloc int[8]; + Span wbKind = stackalloc int[8]; + Span wbT1 = stackalloc int[8]; + Span wbT2 = stackalloc int[8]; + int wbCount = 0; + for (int k = 0; k < argc; k++) + { + if (k >= ps.Length || !ps[k].ParameterType.IsByRef) continue; + int origSlot = evalStack[argBase + k].slot; + int tag = addrTagBySlot[origSlot]; + int boxed = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + if (tag >= 0) + { + // Pre-load current value (may be irrelevant for `out`). A flat (Vt) source + // lives in the numeric frame — box it; a plain mov would read the wrong frame. + Emit3(ir, irToIl, slotTypes[tag] == SType.Vt ? Op.box_vt : Op.mov, boxed, tag, instrStart); + if (wbCount < 8) + { wbArgIdx[wbCount] = k; wbKind[wbCount] = 0; wbT1[wbCount] = tag; wbT2[wbCount] = 0; wbCount++; } + } + else if (tag == -1) + { + int fldObj = addrFldObjBySlot[origSlot]; + int fldToki = addrFldTokBySlot[origSlot]; + int fldTok = tokens[fldToki]; + asm.FieldSTypes.TryGetValue(fldTok, out var fldSt); + bool isScFieldC = asm.FieldSlots.TryGetValue(fldTok, out var fldFsC); + var fldVtLayC = isScFieldC && fldSt == SType.Vt + ? fldFsC.Item1.VtFieldLayouts?[fldFsC.Item2] : null; + // Pre-load into a slot tagged with the FIELD's type, not O: the field-load ops + // write I4/R4 values into the numeric frame, so an O-tagged slot reads back + // null and the host call sees 0 (found by fuzzing: `h.Accumulate(ref acc)` on + // a hoisted iterator field passed 0 in — the writeback masked the wrong input). + // RdObj boxes an I4/R4-tagged slot correctly at call time, like the receiver + // resolution above. Vt and O fields keep the O tag — their pre-load lands a + // boxed object in the ref frame. + if (fldSt == SType.I4 || fldSt == SType.R4) + slotTypes[boxed] = fldSt; + if (fldVtLayC != null) + { + // Flat (Vt) script field — e.g. a Vector3 hoisted into an iterator state + // machine because an out-arg takes its address: materialize the bytes into + // a struct slot, then box for the reflective call. The op picker below + // would emit ldfld_sc_o, indexing RefSlots with a PrimBytes byte offset. + int tmpVtC = AllocStructSlot(ref frameSize, slotTypes, slotStructs, fldVtLayC); + Emit4(ir, irToIl, Op.ldfld_sc_vt, tmpVtC, fldObj, fldFsC.Item1.FieldOffsets[fldFsC.Item2], instrStart); + Emit3(ir, irToIl, Op.box_vt, boxed, tmpVtC, instrStart); + } + else if (isScFieldC) + { + int fldOffC = fldFsC.Item1.FieldOffsets[fldFsC.Item2]; + var fldOpC = fldSt == SType.I4 ? Op.ldfld_sc_i4 : fldSt == SType.R4 ? Op.ldfld_sc_r4 : Op.ldfld_sc_o; + Emit4(ir, irToIl, fldOpC, boxed, fldObj, fldOffC, instrStart); + } + else + { + var fldLoadOp = fldSt == SType.I4 ? Op.ldfld_i4 : fldSt == SType.R4 ? Op.ldfld_r4 : Op.ldfld_o; + Emit4(ir, irToIl, fldLoadOp, boxed, fldObj, fldToki, instrStart); + } + if (wbCount < 8) + { wbArgIdx[wbCount] = k; wbKind[wbCount] = 1; wbT1[wbCount] = fldObj; wbT2[wbCount] = fldToki; wbCount++; } + } + else if (tag == -3) + { + int arrSlot = addrFldObjBySlot[origSlot]; + int idxSlot = addrFldTokBySlot[origSlot]; + Emit4(ir, irToIl, Op.ldelem_o, boxed, arrSlot, idxSlot, instrStart); + if (wbCount < 8) + { wbArgIdx[wbCount] = k; wbKind[wbCount] = 2; wbT1[wbCount] = arrSlot; wbT2[wbCount] = idxSlot; wbCount++; } + } + else + { + // Non-phantom passed by ref — read whatever's there but no writeback target. + Emit3(ir, irToIl, slotTypes[origSlot] == SType.Vt ? Op.box_vt : Op.mov, boxed, origSlot, instrStart); + } + evalStack[argBase + k] = (boxed, SType.O); + } + + // Box any leftover Vt args (slow path doesn't read flat). + for (int k = 0; k < argc; k++) + { + if (evalStack[argBase + k].type == SType.Vt) + { + int boxed = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + Emit3(ir, irToIl, Op.box_vt, boxed, evalStack[argBase + k].slot, instrStart); + evalStack[argBase + k] = (boxed, SType.O); + } + } + + ir.Add((uint)Op.call_host_byref); irToIl.Add(instrStart); + ir.Add((uint)(isVoid ? -1 : dst)); irToIl.Add(instrStart); + ir.Add((uint)(hostEntry.Binding.HasThis ? recvSlot : -1)); irToIl.Add(instrStart); + ir.Add((uint)tokIdx); irToIl.Add(instrStart); + ir.Add((uint)argc); irToIl.Add(instrStart); + for (int k = 0; k < argc; k++) + { ir.Add((uint)evalStack[argBase + k].slot); irToIl.Add(instrStart); } + ir.Add((uint)wbCount); irToIl.Add(instrStart); + for (int k = 0; k < wbCount; k++) + { + ir.Add((uint)wbArgIdx[k]); irToIl.Add(instrStart); + ir.Add((uint)wbKind[k]); irToIl.Add(instrStart); + ir.Add((uint)wbT1[k]); irToIl.Add(instrStart); + ir.Add((uint)wbT2[k]); irToIl.Add(instrStart); + } + if (recvVtWbB) + Emit4(ir, irToIl, Op.stfld_vt_vt, recvVtWbBaseB, recvVtOffB, recvSlot, instrStart); + sp = spBase; + if (!isVoid) evalStack[sp++] = (dst, retSt); + } + + // Operator inlining: synthesize per-field arithmetic IR for a pure host-struct operator + // or constructor. The args on evalStack at [argBase..argBase+argc) are either Vt slots + // (for struct args) or scalar slots (for float/int args). The result goes into dst (a Vt slot). + // + // Strategy: iterate the return struct's fields in byte-offset order, match each to the + // corresponding field in one of the Vt args, and emit a scalar arithmetic op per field. + // For scalar args (no struct layout), use the slot directly for all fields. + // Supported shapes: Vt op Vt, Vt op Scalar, Scalar op Vt, Scalar Scalar Scalar (ctor). + // Cheap pre-check for EmitInlinedOp, evaluated BEFORE the Vt-arg boxing decision: once + // the lowerer skips boundary boxing it is committed to inlining, so every precondition + // must hold up front (a mid-emission bail-out would leave partial IR behind). + static bool CanInlineOp(HostEntry hostEntry, int argc, HostBinding.StructLayout retLayout, bool isCtor) + { + if (retLayout.Fields.Count == 0) return false; + if (isCtor) return argc == retLayout.Fields.Count; + var n = hostEntry.Binding.Method?.Name; + if (n != "op_Addition" && n != "op_Subtraction" && n != "op_Multiply" + && n != "op_Division" && n != "op_UnaryNegation") return false; + return n == "op_UnaryNegation" ? argc == 1 : argc == 2; + } + + // Synthesizes per-field IR for a VERIFIED component-wise operator or field-order ctor + // (see HostBinding.InspectAndMarkOperators — marking guarantees the IL is exactly the + // per-field pattern, so this name-driven emission is semantically equivalent). + static void EmitInlinedOp(List ir, List irToIl, (int slot, SType type)[] evalStack, + int argBase, int argc, int dst, HostBinding.StructLayout retLayout, + HostEntry hostEntry, int instrStart, bool isCtor) + { + // Build sorted field list: (byteOffset, SType) sorted by byte offset. + var fields = new (int Off, SType St)[retLayout.Fields.Count]; + int fi = 0; + foreach (var kv in retLayout.Fields) + fields[fi++] = (kv.Value.Offset, kv.Value.St); + // Sort by offset (Fields dict may be unordered) + for (int a = 0; a < fields.Length - 1; a++) + for (int b = a + 1; b < fields.Length; b++) + if (fields[b].Off < fields[a].Off) { var tmp = fields[a]; fields[a] = fields[b]; fields[b] = tmp; } + + if (isCtor) + { + // Verified field-order ctor: param i is exactly field i's value (arity checked + // by CanInlineOp before any IR was committed). + for (int f = 0; f < fields.Length; f++) + { + var stF = fields[f].St == SType.R4 ? Op.stfld_vt_r4 : Op.stfld_vt_i4; + ir.Add((uint)stF); irToIl.Add(instrStart); + ir.Add((uint)dst); irToIl.Add(instrStart); + ir.Add((uint)fields[f].Off); irToIl.Add(instrStart); + ir.Add((uint)evalStack[argBase + f].slot); irToIl.Add(instrStart); + } + return; + } + + // For each result field, emit one arithmetic instruction. Struct-vs-scalar per arg is + // decided from the CALL SITE's slot types, not the entry's ArgStructs — aliased + // operator pairs ((V3,float) + (float,V3)) share one Entry, so the entry's signature + // metadata may describe the other overload. + for (int f = 0; f < fields.Length; f++) + { + int dstSubSlot = dst + fields[f].Off / 4; + var argSubs = new int[argc]; + for (int k = 0; k < argc; k++) + { + int argSlot = evalStack[argBase + k].slot; + argSubs[k] = evalStack[argBase + k].type == SType.Vt + ? argSlot + fields[f].Off / 4 // struct arg: same-offset component + : argSlot; // scalar arg: same slot for every field + } + + string mName = hostEntry.Binding.Method?.Name ?? ""; + Op fieldOp; + if (mName == "op_Addition") fieldOp = fields[f].St == SType.R4 ? Op.add_r4_nn : Op.add_i4_nn; + else if (mName == "op_Subtraction") fieldOp = fields[f].St == SType.R4 ? Op.sub_r4_nn : Op.sub_i4_nn; + else if (mName == "op_Multiply") fieldOp = fields[f].St == SType.R4 ? Op.mul_r4_nn : Op.mul_i4_nn; + else if (mName == "op_Division") fieldOp = fields[f].St == SType.R4 ? Op.div_r4_nn : Op.div_i4_nn; + else fieldOp = fields[f].St == SType.R4 ? Op.neg_r4_n : Op.neg_i4_n; // op_UnaryNegation (CanInlineOp vetted the name) + + if (argc == 1) + { + ir.Add((uint)fieldOp); irToIl.Add(instrStart); + ir.Add((uint)dstSubSlot); irToIl.Add(instrStart); + ir.Add((uint)argSubs[0]); irToIl.Add(instrStart); + } + else + { + ir.Add((uint)fieldOp); irToIl.Add(instrStart); + ir.Add((uint)dstSubSlot); irToIl.Add(instrStart); + ir.Add((uint)argSubs[0]); irToIl.Add(instrStart); + ir.Add((uint)argSubs[1]); irToIl.Add(instrStart); + } + } + } + + // Value-load a local, cloning script-defined structs so the pushed value is an independent + // copy (struct copy semantics). Address loads (ldloca) never come through here, so in-place + // field mutation is unaffected. + static void PushLocal(List ir, List irToIl, (int, SType)[] stack, ref int sp, + ref int frameSize, int localIdx, int[] localFrameSlot, SType[] localTypes, + List slotTypes, List slotStructs, + bool[]? localIsScStruct, int instrStart) + { + int srcSlot = localFrameSlot[localIdx]; + SType t = localTypes[localIdx]; + if (t == SType.O && localIsScStruct != null && localIdx < localIsScStruct.Length && localIsScStruct[localIdx]) + { + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.O, null); + Emit3(ir, irToIl, Op.clone_sc, dst, srcSlot, instrStart); + stack[sp++] = (dst, SType.O); + } + else + { + stack[sp++] = (srcSlot, t); + } + } + + // Any pending eval-stack entry that ALIASES the slot about to be stored must be + // materialized into a temp first: PushLocal/PushArg push the local's own frame slot, so + // `(a, b) = (b, a)` — `ldloc b; ldloc a; stloc b; stloc a` — left the second-from-top + // entry pointing AT b's slot when b was overwritten, and a came back as the new b + // (found by fuzzing). Address phantoms don't match here (their entry is a fresh phantom + // slot), and a store through an address is genuine aliasing anyway. + static void ProtectStackAliases(List ir, List irToIl, (int, SType)[] stack, int sp, + ref int frameSize, List slotTypes, List slotStructs, + int dst, int instrStart) + { + for (int i = 0; i < sp; i++) + { + if (stack[i].Item1 != dst) continue; + var t = stack[i].Item2; + if (t == SType.Vt && dst < slotStructs.Count && slotStructs[dst] != null) + { + int tmp = AllocStructSlot(ref frameSize, slotTypes, slotStructs, slotStructs[dst]!); + Emit3(ir, irToIl, Op.mov_vt, tmp, dst, instrStart); + stack[i] = (tmp, t); + } + else + { + int tmp = AllocSlot(ref frameSize, slotTypes, slotStructs, dst < slotTypes.Count ? slotTypes[dst] : t, null); + Emit3(ir, irToIl, Op.mov, tmp, dst, instrStart); + stack[i] = (tmp, t); + } + } + } + + static void StoreLocal(List ir, List irToIl, (int, SType)[] stack, ref int sp, int localIdx, int[] localFrameSlot, SType[] localTypes, List slotTypes, List slotStructs, bool[] localDeclared, ref int frameSize, int instrStart) + { + var (src, t) = stack[--sp]; + int dst = localFrameSlot[localIdx]; + ProtectStackAliases(ir, irToIl, stack, sp, ref frameSize, slotTypes, slotStructs, dst, instrStart); + // Vt local: byte-copy struct bytes from src into the local slot. Requires the dst + // slot to actually carry a layout — a Vt-typed slot without one must not alias. + if (slotTypes[dst] == SType.Vt && t == SType.Vt && slotStructs[dst] != null) + { + Emit3(ir, irToIl, Op.mov_vt, dst, src, instrStart); + return; + } + // Vt local being stored from O slot: unbox + flat-write. + if (slotTypes[dst] == SType.Vt && t == SType.O) + { + Emit3(ir, irToIl, Op.unbox_vt, dst, src, instrStart); + return; + } + // Vt source going into a non-Vt local (struct-returning ops produce Vt slots even + // when the local stays O because no ldloca was taken on it). Box the Vt bytes + // into a refFrame entry so the O local sees a normal boxed reference. + if (slotTypes[dst] != SType.Vt && t == SType.Vt) + { + localTypes[localIdx] = SType.O; + if (dst < slotTypes.Count) slotTypes[dst] = SType.O; + Emit3(ir, irToIl, Op.box_vt, dst, src, instrStart); + return; + } + if (localIdx < localDeclared.Length && localDeclared[localIdx]) + { + // Declared type is FROZEN — never retype the slot. Op.mov routes by the (fixed) dst + // slot type at runtime, boxing/unboxing the source to match, so an int stored into a + // declared `object` local boxes and a boxed value read back unboxes, consistently. + localTypes[localIdx] = slotTypes[dst]; + Emit3(ir, irToIl, Op.mov, dst, src, instrStart); + return; + } + localTypes[localIdx] = t; // remember what type this local holds (non-Vt locals only) + if (dst < slotTypes.Count) slotTypes[dst] = t; + Emit3(ir, irToIl, Op.mov, dst, src, instrStart); + } + + static void PushArg((int, SType)[] stack, ref int sp, int argIdx, SType[] argTypes, int[] argFrameSlot) + { + stack[sp++] = (argFrameSlot[argIdx], argTypes[argIdx]); + } + + static void StoreArg(List ir, List irToIl, (int, SType)[] stack, ref int sp, + int argIdx, SType[] argTypes, int[] argFrameSlot, List slotTypes, List slotStructs, ref int frameSize, int instrStart) + { + var (src, t) = stack[--sp]; + int dst = argFrameSlot[argIdx]; + ProtectStackAliases(ir, irToIl, stack, sp, ref frameSize, slotTypes, slotStructs, dst, instrStart); + // Vt arg slots have a FIXED type (the frame layout is part of the call ABI): copy + // struct bytes in (or unbox a boxed ScriptObject); never retype the slot. + if (slotTypes[dst] == SType.Vt) + { + Emit3(ir, irToIl, t == SType.Vt ? Op.mov_vt : Op.unbox_vt, dst, src, instrStart); + return; + } + argTypes[argIdx] = t; + Emit3(ir, irToIl, Op.mov, dst, src, instrStart); + } + + static void PushConst(List ir, List irToIl, (int, SType)[] stack, ref int sp, ref int frameSize, List slotTypes, List slotStructs, int imm, SType t, Op ldc, int instrStart) + { + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, t, null); + stack[sp++] = (dst, t); + Emit3(ir, irToIl, ldc, dst, imm, instrStart); + } + + // Map an i4-family op to its wide sibling. Throws for combinations IL can't + // produce or the subset doesn't support — the LowerAll catch-all keeps it loud. + static Op Wide64(Op i4Op, bool isR8) => (i4Op, isR8) switch + { + (Op.add_i4, false) => Op.add_i8, (Op.add_i4, true) => Op.add_r8, + (Op.sub_i4, false) => Op.sub_i8, (Op.sub_i4, true) => Op.sub_r8, + (Op.mul_i4, false) => Op.mul_i8, (Op.mul_i4, true) => Op.mul_r8, + (Op.div_i4, false) => Op.div_i8, (Op.div_i4, true) => Op.div_r8, + (Op.rem_i4, false) => Op.rem_i8, (Op.rem_i4, true) => Op.rem_r8, + (Op.div_un_i4, false) => Op.div_un_i8, + (Op.rem_un_i4, false) => Op.rem_un_i8, + (Op.and_i4, false) => Op.and_i8, + (Op.or_i4, false) => Op.or_i8, + (Op.xor_i4, false) => Op.xor_i8, + (Op.shl_i4, false) => Op.shl_i8, + (Op.shr_i4, false) => Op.shr_i8, + (Op.shr_un_i4, false) => Op.shr_un_i8, + (Op.ceq_i4, false) => Op.ceq_i8, (Op.ceq_i4, true) => Op.ceq_r8, + (Op.cgt_i4, false) => Op.cgt_i8, (Op.cgt_i4, true) => Op.cgt_r8, + (Op.clt_i4, false) => Op.clt_i8, (Op.clt_i4, true) => Op.clt_r8, + (Op.cgt_un_i4, false) => Op.cgt_un_i8, (Op.cgt_un_i4, true) => Op.cgt_un_r8, + (Op.clt_un_i4, false) => Op.clt_un_i8, (Op.clt_un_i4, true) => Op.clt_un_r8, + _ => throw new NotSupportedException($"IR lowering: no 64-bit form of {i4Op}"), + }; + + // R8 compare from the R4 op the call site chose — the ordered/unordered intent + // (NaN semantics through negated branches) lives in that choice, not the I4 op. + static Op WideCmpR8(Op r4Op) => r4Op switch + { + Op.ceq_r4 or Op.ceq_r4_nn => Op.ceq_r8, + Op.cgt_r4 or Op.cgt_r4_nn => Op.cgt_r8, + Op.clt_r4 or Op.clt_r4_nn => Op.clt_r8, + Op.cgt_un_r4 => Op.cgt_un_r8, + Op.clt_un_r4 => Op.clt_un_r8, + _ => throw new NotSupportedException($"IR lowering: no R8 form of {r4Op}"), + }; + + static void EmitBinop(List ir, List irToIl, (int, SType)[] stack, ref int sp, ref int frameSize, List slotTypes, List slotStructs, + Op opI4, Op opR4, Op opI4_nn, Op opR4_nn, int instrStart) + { + var (s2, t2) = stack[--sp]; + var (s1, t1) = stack[sp - 1]; + // 64-bit operands (Roslyn emits explicit convs, so pairs arrive homogeneous; + // a stray I4 operand is widened by the executor's Rd helpers). + if (t1 is SType.I8 or SType.R8 || t2 is SType.I8 or SType.R8) + { + bool isR8w = t1 == SType.R8 || t2 == SType.R8; + SType wrt = isR8w ? SType.R8 : SType.I8; + int wdst = AllocSlot(ref frameSize, slotTypes, slotStructs, wrt, null); + stack[sp - 1] = (wdst, wrt); + Emit4(ir, irToIl, Wide64(opI4, isR8w), wdst, s1, s2, instrStart); + return; + } + bool isFloat = t1 == SType.R4 || t2 == SType.R4; + SType rt = isFloat ? SType.R4 : SType.I4; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, rt, null); + stack[sp - 1] = (dst, rt); + bool both_r4 = t1 == SType.R4 && t2 == SType.R4 && slotTypes[s1] == SType.R4 && slotTypes[s2] == SType.R4; + bool both_i4 = t1 == SType.I4 && t2 == SType.I4 && slotTypes[s1] == SType.I4 && slotTypes[s2] == SType.I4; + Op op = isFloat ? (both_r4 ? opR4_nn : opR4) : (both_i4 ? opI4_nn : opI4); + Emit4(ir, irToIl, op, dst, s1, s2, instrStart); + } + + static void EmitBinopI4(List ir, List irToIl, (int, SType)[] stack, ref int sp, ref int frameSize, List slotTypes, List slotStructs, Op opI4, Op opI4_nn, int instrStart) + { + var (s2, t2) = stack[--sp]; + var (s1, t1) = stack[sp - 1]; + // 64-bit form: and/or/xor take two I8s; shifts take (I8 value, I4 count) — either + // way the value operand's width decides. + if (t1 == SType.I8) + { + int wdst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I8, null); + stack[sp - 1] = (wdst, SType.I8); + Emit4(ir, irToIl, Wide64(opI4, false), wdst, s1, s2, instrStart); + return; + } + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I4, null); + stack[sp - 1] = (dst, SType.I4); + bool both_i4 = t1 == SType.I4 && t2 == SType.I4 && slotTypes[s1] == SType.I4 && slotTypes[s2] == SType.I4; + Emit4(ir, irToIl, both_i4 ? opI4_nn : opI4, dst, s1, s2, instrStart); + } + + static void EmitUnop(List ir, List irToIl, (int, SType)[] stack, ref int sp, ref int frameSize, List slotTypes, List slotStructs, Op opI4, Op opR4, Op opI4_n, Op opR4_n, int instrStart) + { + var (src, t) = stack[sp - 1]; + if (t is SType.I8 or SType.R8) + { + int wdst = AllocSlot(ref frameSize, slotTypes, slotStructs, t, null); + stack[sp - 1] = (wdst, t); + Emit3(ir, irToIl, t == SType.R8 ? Op.neg_r8 : Op.neg_i8, wdst, src, instrStart); + return; + } + bool is_r4 = t == SType.R4; + // The dst must be typed by what the op WRITES (always flat numeric), not by the source: + // an O-typed src (e.g. a boxed int flowing out of a host call) would otherwise stamp the + // dst O, and every downstream reader would look at the (never-written) ref slot. + var rt = is_r4 ? SType.R4 : SType.I4; + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, rt, null); + stack[sp - 1] = (dst, rt); + bool src_n = is_r4 ? slotTypes[src] == SType.R4 : slotTypes[src] == SType.I4; + Op op = is_r4 ? (src_n ? opR4_n : opR4) : (src_n ? opI4_n : opI4); + Emit3(ir, irToIl, op, dst, src, instrStart); + } + + static void EmitUnopI4(List ir, List irToIl, (int, SType)[] stack, ref int sp, ref int frameSize, List slotTypes, List slotStructs, Op opI4, Op opI4_n, int instrStart) + { + var (src, t) = stack[sp - 1]; + if (t == SType.I8) + { + int wdst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I8, null); + stack[sp - 1] = (wdst, SType.I8); + Emit3(ir, irToIl, Op.not_i8, wdst, src, instrStart); + return; + } + int dst = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I4, null); + stack[sp - 1] = (dst, SType.I4); + bool src_n = t == SType.I4 && slotTypes[src] == SType.I4; + Emit3(ir, irToIl, src_n ? opI4_n : opI4, dst, src, instrStart); + } + + static void EmitCmpBranch(List ir, List irToIl, (int, SType)[] stack, ref int sp, ref int frameSize, List slotTypes, List slotStructs, + Op cmpI4, Op cmpR4, Op cmpO, Op cmpI4_nn, Op cmpR4_nn, Op branch, List<(int, int)> patchList, int ilTarget, int instrStart, + Dictionary branchTargetDepth) + { + var (s2, t2) = stack[--sp]; + var (s1, t1) = stack[sp - 1]; + sp--; + // Both operands are consumed; the compare-branch leaves the base stack. Record the depth + // it delivers to a forward target so the join reconciles correctly (see branchTargetDepth). + if (ilTarget > instrStart) branchTargetDepth[ilTarget] = sp; + int tmp = AllocSlot(ref frameSize, slotTypes, slotStructs, SType.I4, null); + Op cmp; + if (t1 == SType.R8 || t2 == SType.R8) + cmp = WideCmpR8(cmpR4); + else if (t1 == SType.I8 || t2 == SType.I8) + cmp = Wide64(cmpI4, false); + else if (t1 == SType.R4 || t2 == SType.R4) + cmp = (t1 == SType.R4 && t2 == SType.R4 && slotTypes[s1] == SType.R4 && slotTypes[s2] == SType.R4) ? cmpR4_nn : cmpR4; + else if (t1 == SType.I4 || t2 == SType.I4) + // cmpI4 reads each operand from the correct stack at runtime (handles O/I4 mix). + cmp = (slotTypes[s1] == SType.I4 && slotTypes[s2] == SType.I4) ? cmpI4_nn : cmpI4; + else + cmp = cmpO; + Emit4(ir, irToIl, cmp, tmp, s1, s2, instrStart); + int patchIdx = ir.Count + 2; + Emit3(ir, irToIl, branch, tmp, -1, instrStart); + patchList.Add((patchIdx, ilTarget)); + } + + // Read a branch target from the IL stream: 1-byte sbyte offset for short, 4-byte int for long. + static int ReadBranchTarget(byte[] il, ref int ip, bool isShort) + { + int off = isShort ? (sbyte)il[ip++] : BitConverter.ToInt32(il, ip); + if (!isShort) ip += 4; + return ip + off; + } + + // --- IR emission helpers (all params are int; cast to uint internally) --- + + static void Emit2(List ir, List irToIl, Op op, int a, int ilOff) + { + ir.Add((uint)op); irToIl.Add(ilOff); + ir.Add((uint)a); irToIl.Add(ilOff); + } + + static void Emit3(List ir, List irToIl, Op op, int a, int b, int ilOff) + { + ir.Add((uint)op); irToIl.Add(ilOff); + ir.Add((uint)a); irToIl.Add(ilOff); + ir.Add((uint)b); irToIl.Add(ilOff); + } + + static void Emit4(List ir, List irToIl, Op op, int a, int b, int c, int ilOff) + { + ir.Add((uint)op); irToIl.Add(ilOff); + ir.Add((uint)a); irToIl.Add(ilOff); + ir.Add((uint)b); irToIl.Add(ilOff); + ir.Add((uint)c); irToIl.Add(ilOff); + } + } + + static (int Line, string Doc) FindSourceLine(ParsedMethod method, int ilOffset) + { + var pts = method.SeqPoints; + if (pts == null) return (-1, ""); + int lo = 0, hi = pts.Length - 1, result = -1; + while (lo <= hi) + { + int mid = (lo + hi) / 2; + if (pts[mid].IlOffset <= ilOffset) { result = mid; lo = mid + 1; } + else hi = mid - 1; + } + return result >= 0 ? (pts[result].Line, pts[result].Doc) : (-1, ""); + } + + static string At(ParsedMethod method, int ilOffset) + { + var (line, doc) = FindSourceLine(method, ilOffset); + if (line <= 0) return $" at IL+0x{ilOffset:X4}"; + // Keep the " at line " marker: the VM's host-call rethrow filter matches it to avoid + // appending a second location when an already-located error bubbles through nested frames. + return doc.Length > 0 ? $" at line {line} ({doc})" : $" at line {line}"; + } + + static int ReadU16(byte[] il, ref int ip) + { + int v = il[ip] | (il[ip + 1] << 8); + ip += 2; + return v; + } + + // Fill `arr` from the FieldDef's RVA blob, decoding bytes per element type. + // Returns false if the element type is non-primitive or the field has no RVA — caller + // then leaves the array zero-initialized and lets the InitializeArray no-op binding run. + // Typed-backing fills for newarr_i4/newarr_r4 array initializers: element type is statically + // Int32/Single (the lowerer only emits the typed ops for those), so only the RVA blob lookup + // remains. + static bool TryFillI4ArrayFromFieldBlob(int[] arr, ParsedAssembly asm, int fieldTok) + { + if (!TryReadFieldBlob(asm, fieldTok, arr.Length * 4, out var bytes)) return false; + for (int i = 0; i < arr.Length; i++) arr[i] = BitConverter.ToInt32(bytes, i * 4); + return true; + } + + static bool TryFillR4ArrayFromFieldBlob(float[] arr, ParsedAssembly asm, int fieldTok) + { + if (!TryReadFieldBlob(asm, fieldTok, arr.Length * 4, out var bytes)) return false; + for (int i = 0; i < arr.Length; i++) arr[i] = BitConverter.ToSingle(bytes, i * 4); + return true; + } + + // Fill a typed primitive array (bool[]/char[]/byte[]/…) from a RuntimeHelpers.InitializeArray + // field blob. The blob layout (little-endian, element-sized) matches the primitive array's + // memory, so a raw block copy is correct. + static bool TryFillTypedArrayFromFieldBlob(Array arr, int elemSize, ParsedAssembly asm, int fieldTok) + { + if (!TryReadFieldBlob(asm, fieldTok, arr.Length * elemSize, out var bytes)) return false; + Buffer.BlockCopy(bytes, 0, arr, 0, arr.Length * elemSize); + return true; + } + + static bool TryReadFieldBlob(ParsedAssembly asm, int fieldTok, int byteLen, out byte[] bytes) + { + bytes = Array.Empty(); + var fieldHandle = MetadataTokens.EntityHandle(fieldTok); + if (fieldHandle.Kind != HandleKind.FieldDefinition) return false; + var fd = asm.Reader.GetFieldDefinition((FieldDefinitionHandle)fieldHandle); + int rva = fd.GetRelativeVirtualAddress(); + if (rva == 0) return false; + var section = asm.Pe.GetSectionData(rva); + var blobReader = section.GetReader(); + bytes = blobReader.ReadBytes(byteLen); + return true; + } + + static bool TryFillArrayFromFieldBlob(object?[] arr, ParsedAssembly asm, int elemTok, int fieldTok) + { + var elemHandle = MetadataTokens.EntityHandle(elemTok); + if (elemHandle.Kind != HandleKind.TypeReference) return false; + var elemRef = asm.Reader.GetTypeReference((TypeReferenceHandle)elemHandle); + var elemName = asm.Reader.GetString(elemRef.Name); + + int elemSize = elemName switch + { + "Boolean" or "Byte" or "SByte" => 1, + "Int16" or "UInt16" or "Char" => 2, + "Int32" or "UInt32" or "Single" => 4, + "Int64" or "UInt64" or "Double" => 8, + _ => 0, + }; + if (elemSize == 0) return false; + + var fieldHandle = MetadataTokens.EntityHandle(fieldTok); + if (fieldHandle.Kind != HandleKind.FieldDefinition) return false; + var fd = asm.Reader.GetFieldDefinition((FieldDefinitionHandle)fieldHandle); + int rva = fd.GetRelativeVirtualAddress(); + if (rva == 0) return false; + + var section = asm.Pe.GetSectionData(rva); + var blobReader = section.GetReader(); + var bytes = blobReader.ReadBytes(arr.Length * elemSize); + + for (int i = 0; i < arr.Length; i++) + { + int o = i * elemSize; + arr[i] = elemName switch + { + "Boolean" => bytes[o] != 0 ? 1 : 0, // bools live as int (1/0) on the stack + "Byte" => (int)bytes[o], + "SByte" => (int)(sbyte)bytes[o], + "Int16" => (int)BitConverter.ToInt16(bytes, o), + "UInt16" => (int)BitConverter.ToUInt16(bytes, o), + "Char" => (int)BitConverter.ToChar(bytes, o), + "Int32" => BitConverter.ToInt32(bytes, o), + "UInt32" => (int)BitConverter.ToUInt32(bytes, o), + "Single" => BitConverter.ToSingle(bytes, o), + "Int64" => (object)BitConverter.ToInt64(bytes, o), + "UInt64" => (object)(long)BitConverter.ToUInt64(bytes, o), + "Double" => (object)(float)BitConverter.ToDouble(bytes, o), // float-only subset + _ => null, + }; + } + return true; + } +} + +// Simple stack-type tag (what the lowerer tracks per eval-stack slot). +// Placed at namespace level so HostBinding.cs can use it in FastCallDelegate. +// Vt = a host value type stored as contiguous bytes in numFrame, occupying +// ceil(byteSize/4) consecutive logical slot indices. Only the first slot is +// referenced; the rest are filler. byteSize and Type live in parallel +// LoweredMethod tables. See PLAN_FLAT_STRUCTS.md. +// O is first so default(SType) == SType.O. Anything that holds an SType field +// without initializing it (e.g. HostBinding.Entry.FlatReturnSType) gets the +// "no specific numeric type, use refFrame slot" default, which is what every +// caller wants. Changing this order will break any code that depends on the +// underlying byte values — none does today (no serialization, no (SType)0 casts). +internal enum SType : byte { O, I4, R4, Vt, I8, R8 } + + +// Typed return value from the Vm engine — eliminates boxing of primitive return values +// on script-to-script call paths. I4 covers both int and bool (bool stored as 0/1). +// Void (no-return) is represented by default(CallReturn): Type == I4, I4 == 0, O == null. +// The Type field disambiguates: I4 means no value or an int/bool; callers that need to +// know whether the callee was void check the calling method's IsVoid flag separately. +readonly struct CallReturn +{ + public readonly SType Type; + public readonly int I4; // valid when Type == I4 (includes bool as 0/1, and Void) + public readonly float R4; // valid when Type == R4 + public readonly long I8; // valid when Type == I8 + public readonly double R8; // valid when Type == R8 + public readonly object? O; // valid when Type == O or Vt + + CallReturn(SType t, int i4, float r4, long i8, double r8, object? o) + { Type = t; I4 = i4; R4 = r4; I8 = i8; R8 = r8; O = o; } + + public static CallReturn FromI4(int v) => new CallReturn(SType.I4, v, 0f, 0L, 0d, null); + public static CallReturn FromR4(float v) => new CallReturn(SType.R4, 0, v, 0L, 0d, null); + public static CallReturn FromI8(long v) => new CallReturn(SType.I8, 0, 0f, v, 0d, null); + public static CallReturn FromR8(double v) => new CallReturn(SType.R8, 0, 0f, 0L, v, null); + public static CallReturn FromO(object? v) => new CallReturn(SType.O, 0, 0f, 0L, 0d, v); +} +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptInterpreter.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptInterpreter.cs.meta new file mode 100644 index 0000000..11bde4e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptInterpreter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 726e649c2ce1b774ca646a1e82fa5471 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptObject.cs b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptObject.cs new file mode 100644 index 0000000..19dc59b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptObject.cs @@ -0,0 +1,123 @@ +#nullable enable +using System; +using System.Collections.Generic; +namespace IlInterpreter.Interpreter +{ + +// Describes a script-defined class: maps field tokens to typed offsets. +internal sealed class ScriptTypeDescriptor +{ + // Non-nullable fields are always set by the descriptor-building pass in Parse; + // `= null!` marks that invariant. + public string Name = null!; + public int FieldCount; + // FieldDef metadata token → slot index in ScriptObject.Fields + // (kept for write-back path in call_host_byref; slot index = field index) + public Dictionary FieldSlots = null!; + // Per-field type tag (parallel to field declaration order 0..FieldCount-1) + public SType[] FieldTypes = null!; + // Per-field storage offset: + // I4/R4 fields: byte offset into ScriptObject.PrimBytes + // O fields: index into ScriptObject.RefSlots + // Vt fields: byte offset into ScriptObject.PrimBytes (inline storage) + public int[] FieldOffsets = null!; + public int PrimByteSize; // total size of PrimBytes array + public int RefSlotCount; // total length of RefSlots array + // Per-field struct layout, non-null only for SType.Vt fields. + // Length = FieldCount. Parallel to FieldTypes/FieldOffsets. + public HostBinding.StructLayout?[]? VtFieldLayouts; + // Per-field flag: true when field i is a SCRIPT-defined struct stored in an O slot (a value type + // masquerading as a ScriptObject reference). Non-null only when at least one such field exists. + // Parallel to FieldTypes/FieldOffsets. Used by Clone() to deep-copy nested value-type fields so a + // struct copy does not alias the source's nested structs (genuine reference fields stay shared). + public bool[]? FieldIsScriptStruct; + // Per-field descriptor of a script-struct field's own type (parallel to FieldTypes). Non-null entry + // only for a resolved script-struct field; used by ScriptObject.Create to recursively allocate the + // nested struct so a value-type field is never null (matching C#'s zero-init of value fields). + public ScriptTypeDescriptor?[]? FieldStructDescriptors; + // True when this type is a script-defined STRUCT (value type, not an enum, not a class). Used to + // zero-init the elements of `new T[n]` — a value-type array holds usable structs, not nulls. + public bool IsScriptStructValue; + + // Non-null when this script struct is BLITTABLE (no reference fields, transitively) and is + // represented FLAT: locals/args/returns live as raw bytes in Vt frame slots (the flat image is + // identical to the PrimBytes image), and only O-boundary crossings materialize a boxed + // ScriptObject via the layout's marshallers. Synthesized by the parser's flat-resolution pass. + public HostBinding.StructLayout? FlatLayout; +} + +// An instance of a script-defined class. +// Primitives (I4, R4) live in PrimBytes at their FieldOffsets[i] byte offset. +// References (O, boxed Vt) live in RefSlots at their FieldOffsets[i] ref index. +internal sealed class ScriptObject +{ + // Create and Clone (the only construction sites) always set all three fields; + // `= null!` marks that invariant. + public ScriptTypeDescriptor Type = null!; + public byte[] PrimBytes = null!; // typed primitive storage (I4/R4/bool) + public object?[] RefSlots = null!; // reference fields only (O slots) + + // Allocate an instance of a script class/struct, recursively initializing nested SCRIPT-struct + // fields to fresh zeroed instances. A value-type field is never null in C# (`new Outer()` gives a + // usable `inner`), so `a.inner.v = x` must not dereference null. Recursion terminates because + // value-type containment is acyclic (a struct cannot contain itself by value). + public static ScriptObject Create(ScriptTypeDescriptor desc) + { + var o = new ScriptObject + { + Type = desc, + PrimBytes = new byte[desc.PrimByteSize], + RefSlots = new object?[desc.RefSlotCount], + }; + var subs = desc.FieldStructDescriptors; + if (subs != null) + { + var offs = desc.FieldOffsets; + for (int i = 0; i < subs.Length; i++) + if (subs[i] != null) + o.RefSlots[offs[i]] = Create(subs[i]!); + } + return o; + } + + // Value-copy for script-struct copy semantics. Genuine reference fields keep their reference + // shared (a struct copy copies the reference, not its target) — but a NESTED SCRIPT STRUCT is + // itself a value type stored in an O slot, so it must be deep-copied recursively, or + // `b = a; b.inner.v = x` would alias a.inner. Recursion terminates because struct nesting + // is acyclic. + public ScriptObject Clone() + { + var copy = new ScriptObject + { + Type = Type, + PrimBytes = PrimBytes == null ? null! : (byte[])PrimBytes.Clone(), + RefSlots = RefSlots == null ? null! : (object?[])RefSlots.Clone(), + }; + var isSc = Type?.FieldIsScriptStruct; + if (isSc != null && copy.RefSlots != null) + { + var offs = Type!.FieldOffsets; + for (int i = 0; i < isSc.Length; i++) + { + if (!isSc[i]) continue; + int ri = offs[i]; + if ((uint)ri < (uint)copy.RefSlots.Length && copy.RefSlots[ri] is ScriptObject so) + copy.RefSlots[ri] = so.Clone(); + } + } + return copy; + } +} + +// A script-created array of FLAT structs: one byte[] backing for all elements instead of one +// ScriptObject (3+ allocations) per element. Elements are memcpy'd in/out of Vt frame slots by +// ldelem_vt/stelem_vt. Host-ABI note: like the old object?[] representation, this cannot be +// passed to a host T[] parameter — unchanged limitation. +internal sealed class ScriptVtArray +{ + public HostBinding.StructLayout Layout = null!; + public byte[] Bytes = null!; + public int Length; + public int Stride; // element size rounded up to 4 +} +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptObject.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptObject.cs.meta new file mode 100644 index 0000000..cfc7253 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/ScriptObject.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 88d8c601d0ce53c42953bc95b8b0af6c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Unity.Pipeline.IlInterpreter.asmdef b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Unity.Pipeline.IlInterpreter.asmdef new file mode 100644 index 0000000..d4f4667 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Unity.Pipeline.IlInterpreter.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Unity.Pipeline.IlInterpreter", + "rootNamespace": "", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": true, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [ + "UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Unity.Pipeline.IlInterpreter.asmdef.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Unity.Pipeline.IlInterpreter.asmdef.meta new file mode 100644 index 0000000..e5310cb --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Unity.Pipeline.IlInterpreter.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3352b3594156b1b49bc408b732c23df5 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Value.cs b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Value.cs new file mode 100644 index 0000000..4f68514 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Value.cs @@ -0,0 +1,19 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace IlInterpreter.Interpreter +{ + +// A single 4-byte slot of the Vm engine's unmanaged numeric frame. The slot's static SType +// (LoweredMethod.SlotTypes) tells the executor which field is live — there is no runtime tag. +// The dispatch loop reinterprets slots via byte* casts, so the backing memory stays native (no GC). +[StructLayout(LayoutKind.Explicit, Size = 4)] +internal struct Value +{ + [FieldOffset(0)] public int I4; // int, bool (0/1), char, short — sign/zero-extended + [FieldOffset(0)] public float R4; + [FieldOffset(0)] public uint U4; // bit-exact copies (mov, Vt span words) +} +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Value.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Value.cs.meta new file mode 100644 index 0000000..8730065 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Value.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 617b9a71d914e1842b16719af056ad77 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Vm.cs b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Vm.cs new file mode 100644 index 0000000..fabc23f --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Vm.cs @@ -0,0 +1,14 @@ +#nullable enable +using System; +using System.Collections.Generic; +namespace IlInterpreter +{ + +// VM intrinsics — always present, host-independent. Like syscalls / WASI imports. +// The interpreter intercepts calls to these methods; bodies exist only so the +// methods can be referenced from script source at compile time. +static class Vm +{ + public static void Log(string message) { } +} +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Vm.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Vm.cs.meta new file mode 100644 index 0000000..e09b8b2 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/Vm.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 95648017851331b429ece1d370bdee8a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/VmEngine.cs b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/VmEngine.cs new file mode 100644 index 0000000..b2cf216 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/VmEngine.cs @@ -0,0 +1,1979 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +// Use the Unsafe bundled with the package (renamed with a "UnityPipeline." prefix), not the BCL or editor copy. +using Unsafe = UnityPipeline.System.Runtime.CompilerServices.Unsafe; + +#if !NET5_0_OR_GREATER +// SkipLocalsInit polyfill for the Unity (Mono/netstandard) build — the compiler keys on the +// attribute's full name, so a local declaration works like the BCL one. +namespace System.Runtime.CompilerServices +{ + [AttributeUsage(AttributeTargets.Module | AttributeTargets.Class | AttributeTargets.Struct + | AttributeTargets.Interface | AttributeTargets.Constructor | AttributeTargets.Method + | AttributeTargets.Property | AttributeTargets.Event, Inherited = false)] + internal sealed class SkipLocalsInitAttribute : Attribute { } +} +#endif + +namespace IlInterpreter.Interpreter +{ + +#if ILINTERPRETER_OPSTATS +// Opcode histogram for the bench harness (IlInterpreter/tests/IlInterpreter.BenchStats). +static class VmOpStats +{ + public static readonly long[] Counts = new long[512]; + public static void Reset() => Array.Clear(Counts, 0, Counts.Length); +} +#endif + +sealed partial class ScriptInterpreter +{ + // The execution engine. Runs lowered IR over a native (unmanaged) Value frame instead of a + // managed byte[] — no GC for the frame, no per-dispatch `fixed` pin on the numeric stack. + // SkipLocalsInit: Run() is reentered on every reentrant Invoke; without this the JIT + // zero-fills its whole frame (including the 128-byte stackalloc continuation stack) per + // call — a major chunk of Mono's 5x call overhead vs CoreCLR. Every local and stackalloc + // slot below is definitely-assigned before use, so dropping .locals init is safe. + [System.Runtime.CompilerServices.SkipLocalsInit] + sealed unsafe class Vm : IDisposable + { + internal const int NumSlots = 256 * 1024; // Value slots (4 bytes each); also the lowering frame cap + internal const int RefSlots = 128 * 1024; + + // Both arenas are indexed by bp+slot, but the reference arena (_ref, RefSlots) is smaller + // than the numeric one (_num, NumSlots). A frame that fits the numeric arena can still + // overrun _ref via its RefClearLen reference slots, so every frame entry must bound BOTH. + // bp is cumulative across reentrant Invokes and nested script calls. + static bool FrameFits(int bp, LoweredMethod lm) => + (long)bp + lm.FrameSize <= NumSlots && (long)bp + lm.RefClearLen <= RefSlots; + + // Cached array Types for the ldelem/stelem exact-type dispatch. typeof(byte[]) folds to a + // constant on Mono, but IL2CPP re-resolves the array class through a metadata hashtable per + // typeof site — hoisting to static readonly makes per-element dispatch a Type ref-compare. + static readonly Type T_byteA = typeof(byte[]), T_sbyteA = typeof(sbyte[]), + T_shortA = typeof(short[]), T_ushortA = typeof(ushort[]), + T_charA = typeof(char[]), T_boolA = typeof(bool[]), + T_intA = typeof(int[]), T_uintA = typeof(uint[]), T_floatA = typeof(float[]), + T_longA = typeof(long[]), T_ulongA = typeof(ulong[]), T_doubleA = typeof(double[]); + + readonly ScriptInterpreter _owner; + readonly IFrameAllocator _alloc; + readonly Value* _num; // unmanaged numeric/Vt frame + readonly object?[] _ref; // managed reference frame (GC roots) + readonly int[] _steps = new int[1]; + bool _disposed; + + // Frame base for the NEXT Invoke: 0 at top level, bumped to the current frame's top while + // a call executes, so a reentrant Invoke (a host call that woven-dispatches back into this + // same VM) runs above the live frame instead of clobbering it at 0. + int _base; + + // Saved caller state for the iterative script-call stack: call_script/newobj_script push + // one of these and jump ip to the callee's blob region instead of recursing into Run() + // (a recursive call of the giant dispatch method cost ~120ns on Mono). Shared across + // reentrant Runs: each Run treats _framesTop at its entry as the floor, and + // Invoke*/InvokeTyped restore _framesTop in their finally. + struct SavedFrame + { + public ParsedMethod M; // caller method + public int Ip; // blob-absolute resume ip (after the call op) + public int Bp; // caller frame base + public int RetDst; // caller slot for the callee's return; -1 = discard + public int ContSp; // caller's finally-continuation stack top + // Caller slot to receive the callee's (mutated) flat-struct `this` bytes on return + // (copy-in/copy-out byref semantics for struct instance methods/ctors); -1 = none. + public int VtThisWb; + } + const int MaxCallDepth = 4096; + readonly SavedFrame[] _frames = new SavedFrame[MaxCallDepth]; + int _framesTop; + + // Poison mode (ILINTERPRETER_POISON_FRAMES=1, tests/CI only): fill the numeric frame with + // 0xCD before every top-level call so a never-written cell reads loud garbage instead of + // a plausible zero. Frame-typing bugs are silent on Mono/CoreCLR's zeroed pages but + // corrupt IL2CPP players running on recycled memory; poisoning makes every environment + // behave like the worst case. + static readonly bool s_PoisonFrames = + Environment.GetEnvironmentVariable("ILINTERPRETER_POISON_FRAMES") == "1"; + + // Execution value-trace (ILINTERPRETER_TRACE=1, dev diagnosis only): dump each IR op with its + // IL offset and the current value of its first operand/dst slot, so a divergence can be + // localized to the first step where a slot goes wrong — the value-flow view that otherwise + // has to be hand-instrumented. One well-predicted branch per op when off; zero formatting. + static readonly bool s_Trace = + Environment.GetEnvironmentVariable("ILINTERPRETER_TRACE") == "1"; + + void PoisonNumFrame() => Unsafe.InitBlockUnaligned((byte*)_num, 0xCD, (uint)(NumSlots * sizeof(Value))); + + // Formats one dispatch step for ILINTERPRETER_TRACE: IL offset + op + its operand slots' + // CURRENT values (state entering the op — a source read here is the value the op consumes; + // a dst read is its pre-op value, whose result shows up when the next op reads it). Words + // that aren't frame slots (immediates/tokens/targets) fall back to their raw int. + static void TraceStep(int ip, Op irop, uint* irP, int irLen, byte* numF, + object?[] refStack, SType[] slotT, int bp, LoweredMethod lm, ParsedMethod method) + { + int rel = ip - lm.IrStart; + int ilOff = (uint)rel < (uint)lm.IrToIlOffset.Length ? lm.IrToIlOffset[rel] : 0; + string Slot(int w) + { + if ((uint)w >= (uint)slotT.Length) return w.ToString(); + return slotT[w] switch + { + SType.I4 => $"s{w}={*(int*)(numF + w * 4)}", + SType.R4 => $"s{w}={*(float*)(numF + w * 4)}f", + _ => $"s{w}=<{refStack[bp + w] ?? "null"}>", + }; + } + string a = ip + 1 < irLen ? Slot((int)irP[ip + 1]) : ""; + string b = ip + 2 < irLen ? Slot((int)irP[ip + 2]) : ""; + string c = ip + 3 < irLen ? Slot((int)irP[ip + 3]) : ""; + string args = ""; + if (irop == Op.call_host || irop == Op.call_host_byref) + { + int argc = (int)irP[ip + 4]; + var sb = new System.Text.StringBuilder(" args=["); + for (int k = 0; k < argc; k++) { if (k > 0) sb.Append(", "); sb.Append(Slot((int)irP[ip + 5 + k])); } + sb.Append(']'); + args = sb.ToString(); + } + Console.Error.WriteLine($"[exec] {method.Name} IL+0x{ilOff:X4} {irop} [{a} | {b} | {c}]{args}"); + } + + public Vm(ScriptInterpreter owner) + { + _owner = owner; + _alloc = owner._allocator; + _num = (Value*)_alloc.Alloc(NumSlots * sizeof(Value)); + _ref = new object?[RefSlots]; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _alloc.Free(_num); + } + + // Public entry: place args at the current frame base and run — the only boxing boundary. + // _base is 0 at the top level and the live frame's top during a reentrant call, so nested + // invocations never overwrite an in-progress frame. + public object? Invoke(ParsedMethod method, object?[] args) + { + var asm = _owner._parsed + ?? throw new ScriptRuntimeException("No script loaded — call Load() first"); + _steps[0] = _owner.StepLimit; + if (s_PoisonFrames && _base == 0) PoisonNumFrame(); + int bp = _base, saved = _base, savedTop = _framesTop; + _base = bp + method.Lowered.FrameSize; + try { return Box(method, Run(method, args, asm, bp)); } + finally { _base = saved; _framesTop = savedTop; } + } + + // Zero-allocation typed entries — no object?[], no arg boxing. The result is boxed once + // only if the method returns a primitive. + public object? InvokeTyped(ParsedMethod method) + { + var asm = _owner._parsed ?? throw new ScriptRuntimeException("No script loaded — call Load() first"); + _steps[0] = _owner.StepLimit; + if (s_PoisonFrames && _base == 0) PoisonNumFrame(); + var lm = method.Lowered; + int bp = _base, saved = _base, savedTop = _framesTop; + _base = bp + lm.FrameSize; + try + { + if (!FrameFits(bp, lm)) throw new ScriptRuntimeException("Script frame overflow"); + if (lm.RefClearLen > 0) Array.Clear(_ref, bp, lm.RefClearLen); + if (!method.IsStatic && _owner._instance != null) _ref[bp + (lm.ArgSlot != null ? lm.ArgSlot[0] : 0)] = _owner._instance; + return Box(method, Run(method, null, asm, bp)); + } + finally { _base = saved; _framesTop = savedTop; } + } + + // Run an instance method with an EXPLICIT receiver instead of the script singleton — + // the ScriptEnumerator bridge pumping an iterator state machine (receiver = the state + // machine ScriptObject). Mirrors InvokeTyped(); reentrancy-safe via _base. + public object? InvokeReceiver(ParsedMethod method, object receiver) + { + var asm = _owner._parsed ?? throw new ScriptRuntimeException("No script loaded — call Load() first"); + _steps[0] = _owner.StepLimit; + if (s_PoisonFrames && _base == 0) PoisonNumFrame(); + var lm = method.Lowered; + int bp = _base, saved = _base, savedTop = _framesTop; + _base = bp + lm.FrameSize; + try + { + if (!FrameFits(bp, lm)) throw new ScriptRuntimeException("Script frame overflow"); + if (lm.RefClearLen > 0) Array.Clear(_ref, bp, lm.RefClearLen); + _ref[bp + (lm.ArgSlot != null ? lm.ArgSlot[0] : 0)] = receiver; + return Box(method, Run(method, null, asm, bp)); + } + finally { _base = saved; _framesTop = savedTop; } + } + + public object? InvokeTyped(ParsedMethod method, T1 a1) + { + var asm = _owner._parsed ?? throw new ScriptRuntimeException("No script loaded — call Load() first"); + _steps[0] = _owner.StepLimit; + if (s_PoisonFrames && _base == 0) PoisonNumFrame(); + var lm = method.Lowered; + int bp = _base, saved = _base, savedTop = _framesTop; + _base = bp + lm.FrameSize; + try + { + if (!FrameFits(bp, lm)) throw new ScriptRuntimeException("Script frame overflow"); + if (lm.RefClearLen > 0) Array.Clear(_ref, bp, lm.RefClearLen); + byte* num = (byte*)_num; + int s = 0; + if (!method.IsStatic && _owner._instance != null) { _ref[bp + (lm.ArgSlot != null ? lm.ArgSlot[0] : 0)] = _owner._instance; s = 1; } + WriteTypedArg(num, _ref, lm.SlotTypes, bp, lm.ArgSlot != null && s < lm.ArgSlot.Length ? lm.ArgSlot[s] : s, a1); + return Box(method, Run(method, null, asm, bp)); + } + finally { _base = saved; _framesTop = savedTop; } + } + + public object? InvokeTyped(ParsedMethod method, T1 a1, T2 a2) + { + var asm = _owner._parsed ?? throw new ScriptRuntimeException("No script loaded — call Load() first"); + _steps[0] = _owner.StepLimit; + if (s_PoisonFrames && _base == 0) PoisonNumFrame(); + var lm = method.Lowered; + int bp = _base, saved = _base, savedTop = _framesTop; + _base = bp + lm.FrameSize; + try + { + if (!FrameFits(bp, lm)) throw new ScriptRuntimeException("Script frame overflow"); + if (lm.RefClearLen > 0) Array.Clear(_ref, bp, lm.RefClearLen); + byte* num = (byte*)_num; + int s = 0; + if (!method.IsStatic && _owner._instance != null) { _ref[bp + (lm.ArgSlot != null ? lm.ArgSlot[0] : 0)] = _owner._instance; s = 1; } + WriteTypedArg(num, _ref, lm.SlotTypes, bp, lm.ArgSlot != null && s < lm.ArgSlot.Length ? lm.ArgSlot[s] : s, a1); s++; + WriteTypedArg(num, _ref, lm.SlotTypes, bp, lm.ArgSlot != null && s < lm.ArgSlot.Length ? lm.ArgSlot[s] : s, a2); + return Box(method, Run(method, null, asm, bp)); + } + finally { _base = saved; _framesTop = savedTop; } + } + + public object? InvokeTyped(ParsedMethod method, T1 a1, T2 a2, T3 a3) + { + var asm = _owner._parsed ?? throw new ScriptRuntimeException("No script loaded — call Load() first"); + _steps[0] = _owner.StepLimit; + if (s_PoisonFrames && _base == 0) PoisonNumFrame(); + var lm = method.Lowered; + int bp = _base, saved = _base, savedTop = _framesTop; + _base = bp + lm.FrameSize; + try + { + if (!FrameFits(bp, lm)) throw new ScriptRuntimeException("Script frame overflow"); + if (lm.RefClearLen > 0) Array.Clear(_ref, bp, lm.RefClearLen); + byte* num = (byte*)_num; + int s = 0; + if (!method.IsStatic && _owner._instance != null) { _ref[bp + (lm.ArgSlot != null ? lm.ArgSlot[0] : 0)] = _owner._instance; s = 1; } + WriteTypedArg(num, _ref, lm.SlotTypes, bp, lm.ArgSlot != null && s < lm.ArgSlot.Length ? lm.ArgSlot[s] : s, a1); s++; + WriteTypedArg(num, _ref, lm.SlotTypes, bp, lm.ArgSlot != null && s < lm.ArgSlot.Length ? lm.ArgSlot[s] : s, a2); s++; + WriteTypedArg(num, _ref, lm.SlotTypes, bp, lm.ArgSlot != null && s < lm.ArgSlot.Length ? lm.ArgSlot[s] : s, a3); + return Box(method, Run(method, null, asm, bp)); + } + finally { _base = saved; _framesTop = savedTop; } + } + + static object? Box(ParsedMethod method, CallReturn cr) + { + if (method.IsVoid) return null; + // A bool-returning method stores its result as I4 but must box as bool — the lowerer + // picks the ret op from the eval-stack slot type, so a bool value that flowed through an + // I4 slot (e.g. a host op_Equality result now typed I4) would otherwise box as int and + // fault a caller's (bool) cast. + if (cr.Type == SType.I4 && method.ReturnIsBool) return cr.I4 != 0; + return cr.Type switch + { + SType.I4 => (object?)cr.I4, SType.R4 => (object?)cr.R4, + SType.I8 => cr.I8, SType.R8 => cr.R8, + _ => cr.O, + }; + } + + // Boxed value → I4 slot bits, covering the whole I4-mapped family: a delegate adapter (or + // host callback) hands args boxed as their REAL type — char/byte/short/enum — and an + // int/bool-only check silently wrote 0 (found by the delegate arg-type matrix). + static int CoerceBoxedI4(object? a) => a switch + { + int i => i, bool b => b ? 1 : 0, + char c => c, byte by => by, sbyte sb => sb, short sh => sh, ushort us => us, + uint u => unchecked((int)u), + Enum => unchecked((int)Convert.ToInt64(a)), + _ => 0, + }; + + static float CoerceBoxedR4(object? a) => + a is float f ? f : a is int i ? i : a is double d ? (float)d : CoerceBoxedI4(a); + + // Boxed value → I8, covering everything the interpreter maps to a 64-bit slot plus the + // narrower families (a host return or delegate arg can arrive boxed as any of them). + static long CoerceBoxedI8(object? a) => a switch + { + long l => l, ulong u => unchecked((long)u), + int i => i, uint u4 => u4, + bool b => b ? 1L : 0L, char c => c, + byte by => by, sbyte sb => sb, short sh => sh, ushort us => us, + float f => (long)f, double d => (long)d, + Enum => Convert.ToInt64(a), + _ => 0L, + }; + + static double CoerceBoxedR8(object? a) => a switch + { + double d => d, float f => f, + long l => l, ulong u => u, + int i => i, uint u4 => u4, + _ => CoerceBoxedI8(a), + }; + + // Writes arg `val` at the absolute frame position bp+slot (slot type indexed + // frame-relative), so a reentrant call writes into its own frame region. + static void WriteTypedArg(byte* num, object?[] r, SType[] t, int bp, int slot, T val) + { + if (slot < t.Length && t[slot] == SType.I4) + { + int iv = typeof(T) == typeof(int) ? Unsafe.As(ref val) + : typeof(T) == typeof(bool) ? (Unsafe.As(ref val) ? 1 : 0) + : CoerceBoxedI4(val); + *(int*)(num + (bp + slot) * 4) = iv; + } + else if (slot < t.Length && t[slot] == SType.R4) + { + float fv = typeof(T) == typeof(float) ? Unsafe.As(ref val) + : typeof(T) == typeof(int) ? (float)Unsafe.As(ref val) + : CoerceBoxedR4(val); + *(float*)(num + (bp + slot) * 4) = fv; + } + else if (slot < t.Length && t[slot] == SType.I8) + { + Unsafe.WriteUnaligned(num + (bp + slot) * 4, CoerceBoxedI8(val)); + } + else if (slot < t.Length && t[slot] == SType.R8) + { + Unsafe.WriteUnaligned(num + (bp + slot) * 4, CoerceBoxedR8(val)); + } + else + { + r[bp + slot] = (object?)val; + } + } + + CallReturn Run(ParsedMethod method, object?[]? args, ParsedAssembly asm, int bp) + { + var lm = method.Lowered; + // Frame overflow guard for the top-level entries (Invoke*/InvokeTyped) and for reentrant + // host-callback invokes that stack frames at a high bp. Without it a frame overrunning + // either arena writes past the backing block. Bounds both the numeric and reference + // arenas; FrameSize/RefClearLen are also capped at lowering. + if (!FrameFits(bp, lm)) + throw new ScriptRuntimeException("Script frame overflow"); + var ir = asm.IrBlob!; // all methods' IR, blob-absolute branch targets + var slotT = lm.SlotTypes; + var refStack = _ref; + byte* numF = (byte*)(_num + bp); // frame base; slot s lives at numF + s*4 + bool trace = s_Trace; // hoist the debug-trace gate out of the per-op path + + // Copy args into their frame slots (only on the outermost call). Arg index maps + // through lm.ArgSlot when flat-struct args shift the layout. + if (args != null) + { + if (lm.RefClearLen > 0) Array.Clear(refStack, bp, lm.RefClearLen); + var argSlots = lm.ArgSlot; + for (int i = 0; i < args.Length && i < method.ArgCount; i++) + { + object? a = args[i]; + int si = argSlots != null && i < argSlots.Length ? argSlots[i] : i; + var st = si < slotT.Length ? slotT[si] : SType.O; + if (st == SType.I4) + *(int*)(numF + si * 4) = CoerceBoxedI4(a); + else if (st == SType.R4) + *(float*)(numF + si * 4) = CoerceBoxedR4(a); + else if (st == SType.I8) + Unsafe.WriteUnaligned(numF + si * 4, CoerceBoxedI8(a)); + else if (st == SType.R8) + Unsafe.WriteUnaligned(numF + si * 4, CoerceBoxedR8(a)); + else if (st == SType.Vt) + UnboxVt(numF, si, lm.StructLayouts![si]!, a); // boxed ScriptObject (or null → zero) + else + refStack[bp + si] = a; + } + } + + int ip = lm.IrStart; + var steps = _steps; + + // Continuation stack for lowered try/finally: `leave` pushes the addresses to visit + // after each finally handler (outer handlers, then the leave target); each handler's + // br_cont (endfinally) pops the next. Shared by every frame of this Run (script calls + // save/restore their contSp watermark in SavedFrame), so ContMax bounds the TOTAL + // pending-finally depth across the whole script call stack. + const int ContMax = 256; + int* contStack = stackalloc int[ContMax]; + int contSp = 0; + + // Floor of the iterative call stack for THIS Run. Frames below it belong to outer + // (reentrant) Runs. Invoke*/InvokeTyped restore _framesTop on exit, including when + // an exception unwinds past us. + int frameBase = _framesTop; + var frames = _frames; + + fixed (uint* irP = ir) + { + while (true) + { + if (--steps[0] < 0) + { + int rel = ip - lm.IrStart; + int ilOff = (uint)rel < (uint)lm.IrToIlOffset.Length ? lm.IrToIlOffset[rel] : 0; + throw new ScriptRuntimeException($"Script exceeded step limit{At(method, ilOff)}"); + } + + var irop = (Op)irP[ip]; + if (trace) TraceStep(ip, irop, irP, ir.Length, numF, refStack, slotT, bp, lm, method); +#if ILINTERPRETER_OPSTATS + VmOpStats.Counts[(int)irop]++; +#endif + switch (irop) + { + case Op.nop: ip++; break; + + case Op.mov: + { + int dst = (int)irP[ip + 1], src = (int)irP[ip + 2]; + var st = slotT[src]; + var dt = slotT[dst]; + // Wide slots first: an 8-byte copy (or typed conversion) — the narrow + // matrix below would copy half the value. + if (dt == SType.I8) + { Unsafe.WriteUnaligned(numF + dst * 4, RdI8(numF, refStack, slotT, src, bp)); ip += 3; break; } + if (dt == SType.R8) + { Unsafe.WriteUnaligned(numF + dst * 4, RdR8(numF, refStack, slotT, src, bp)); ip += 3; break; } + if (st == SType.I8) + { + long wv = Unsafe.ReadUnaligned(numF + src * 4); + if (dt == SType.I4) *(int*)(numF + dst * 4) = unchecked((int)wv); + else if (dt == SType.R4) *(float*)(numF + dst * 4) = wv; + else refStack[bp + dst] = wv; + ip += 3; break; + } + if (st == SType.R8) + { + double wd = Unsafe.ReadUnaligned(numF + src * 4); + if (dt == SType.I4) *(int*)(numF + dst * 4) = (int)wd; + else if (dt == SType.R4) *(float*)(numF + dst * 4) = (float)wd; + else refStack[bp + dst] = wd; + ip += 3; break; + } + if (st != SType.O && dt != SType.O) + *(int*)(numF + dst * 4) = *(int*)(numF + src * 4); + else if (st == SType.O && dt == SType.O) + refStack[bp + dst] = refStack[bp + src]; + else if (st == SType.I4) + refStack[bp + dst] = *(int*)(numF + src * 4); + else if (st == SType.R4) + refStack[bp + dst] = *(float*)(numF + src * 4); + else if (dt == SType.I4) + { + // Full boxed-I4 family, not just int/bool: a byref write-back can + // leave a boxed enum/char/uint in the O slot (Enum.TryParse out). + *(int*)(numF + dst * 4) = CoerceBoxedI4(refStack[bp + src]); + } + else + { + *(float*)(numF + dst * 4) = CoerceBoxedR4(refStack[bp + src]); + } + ip += 3; break; + } + + case Op.clone_sc: + { + int dst = (int)irP[ip + 1], src = (int)irP[ip + 2]; + var v = refStack[bp + src]; + refStack[bp + dst] = v is ScriptObject so ? so.Clone() : v; + ip += 3; break; + } + + case Op.ldc_i4: + { + int dst = (int)irP[ip + 1]; + *(int*)(numF + dst * 4) = (int)irP[ip + 2]; + ip += 3; break; + } + case Op.ldc_r4: + { + int dst = (int)irP[ip + 1]; + uint raw = irP[ip + 2]; *(float*)(numF + dst * 4) = Unsafe.As(ref raw); + ip += 3; break; + } + case Op.ldnull: + refStack[bp + (int)irP[ip + 1]] = null; + ip += 2; break; + case Op.ldstr: + { + int dst = (int)irP[ip + 1]; + refStack[bp + dst] = lm.Strings[(int)irP[ip + 2]]; + ip += 3; break; + } + + case Op.add_i4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; int v1=RdI4(numF,refStack,slotT,s1,bp); int v2=RdI4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=v1+v2; ip+=4; break; } + case Op.add_i4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=*(int*)(numF+s1*4)+*(int*)(numF+s2*4); ip+=4; break; } + case Op.sub_i4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; int v1=RdI4(numF,refStack,slotT,s1,bp); int v2=RdI4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=v1-v2; ip+=4; break; } + case Op.sub_i4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=*(int*)(numF+s1*4)-*(int*)(numF+s2*4); ip+=4; break; } + case Op.mul_i4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; int v1=RdI4(numF,refStack,slotT,s1,bp); int v2=RdI4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=v1*v2; ip+=4; break; } + case Op.mul_i4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=*(int*)(numF+s1*4) * *(int*)(numF+s2*4); ip+=4; break; } + case Op.div_i4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; int v1=RdI4(numF,refStack,slotT,s1,bp); int v2=RdI4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=v1/v2; ip+=4; break; } + case Op.div_i4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=*(int*)(numF+s1*4) / *(int*)(numF+s2*4); ip+=4; break; } + case Op.rem_i4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; int v1=RdI4(numF,refStack,slotT,s1,bp); int v2=RdI4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=v1%v2; ip+=4; break; } + case Op.rem_i4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=*(int*)(numF+s1*4)%*(int*)(numF+s2*4); ip+=4; break; } + case Op.neg_i4: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; int v=RdI4(numF,refStack,slotT,s,bp); *(int*)(numF+d*4)=-v; ip+=3; break; } + case Op.neg_i4_n: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(int*)(numF+d*4)=-*(int*)(numF+s*4); ip+=3; break; } + + case Op.add_r4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; float v1=RdR4(numF,refStack,slotT,s1,bp); float v2=RdR4(numF,refStack,slotT,s2,bp); *(float*)(numF+d*4)=v1+v2; ip+=4; break; } + case Op.add_r4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(float*)(numF+d*4)=*(float*)(numF+s1*4)+*(float*)(numF+s2*4); ip+=4; break; } + case Op.sub_r4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; float v1=RdR4(numF,refStack,slotT,s1,bp); float v2=RdR4(numF,refStack,slotT,s2,bp); *(float*)(numF+d*4)=v1-v2; ip+=4; break; } + case Op.sub_r4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(float*)(numF+d*4)=*(float*)(numF+s1*4)-*(float*)(numF+s2*4); ip+=4; break; } + case Op.mul_r4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; float v1=RdR4(numF,refStack,slotT,s1,bp); float v2=RdR4(numF,refStack,slotT,s2,bp); *(float*)(numF+d*4)=v1*v2; ip+=4; break; } + case Op.mul_r4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(float*)(numF+d*4)=*(float*)(numF+s1*4) * *(float*)(numF+s2*4); ip+=4; break; } + case Op.div_r4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; float v1=RdR4(numF,refStack,slotT,s1,bp); float v2=RdR4(numF,refStack,slotT,s2,bp); *(float*)(numF+d*4)=v1/v2; ip+=4; break; } + case Op.div_r4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(float*)(numF+d*4)=*(float*)(numF+s1*4) / *(float*)(numF+s2*4); ip+=4; break; } + case Op.rem_r4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; float v1=RdR4(numF,refStack,slotT,s1,bp); float v2=RdR4(numF,refStack,slotT,s2,bp); *(float*)(numF+d*4)=v1%v2; ip+=4; break; } + case Op.rem_r4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(float*)(numF+d*4)=*(float*)(numF+s1*4)%*(float*)(numF+s2*4); ip+=4; break; } + case Op.neg_r4: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; float v=RdR4(numF,refStack,slotT,s,bp); *(float*)(numF+d*4)=-v; ip+=3; break; } + case Op.neg_r4_n: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(float*)(numF+d*4)=-*(float*)(numF+s*4); ip+=3; break; } + + case Op.and_i4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; int v1=RdI4(numF,refStack,slotT,s1,bp); int v2=RdI4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=v1&v2; ip+=4; break; } + case Op.and_i4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=*(int*)(numF+s1*4)&*(int*)(numF+s2*4); ip+=4; break; } + case Op.or_i4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; int v1=RdI4(numF,refStack,slotT,s1,bp); int v2=RdI4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=v1|v2; ip+=4; break; } + case Op.or_i4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=*(int*)(numF+s1*4)|*(int*)(numF+s2*4); ip+=4; break; } + case Op.xor_i4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; int v1=RdI4(numF,refStack,slotT,s1,bp); int v2=RdI4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=v1^v2; ip+=4; break; } + case Op.xor_i4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=*(int*)(numF+s1*4)^*(int*)(numF+s2*4); ip+=4; break; } + case Op.not_i4: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; int v=RdI4(numF,refStack,slotT,s,bp); *(int*)(numF+d*4)=~v; ip+=3; break; } + case Op.not_i4_n: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(int*)(numF+d*4)=~*(int*)(numF+s*4); ip+=3; break; } + case Op.shl_i4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; int v1=RdI4(numF,refStack,slotT,s1,bp); int v2=RdI4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=v1<>v2; ip+=4; break; } + case Op.shr_i4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=*(int*)(numF+s1*4)>>*(int*)(numF+s2*4); ip+=4; break; } + case Op.shr_un_i4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; int v1=RdI4(numF,refStack,slotT,s1,bp); int v2=RdI4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=(int)((uint)v1>>v2); ip+=4; break; } + case Op.shr_un_i4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=(int)((uint)*(int*)(numF+s1*4)>>*(int*)(numF+s2*4)); ip+=4; break; } + case Op.div_un_i4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; int v1=RdI4(numF,refStack,slotT,s1,bp); int v2=RdI4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=(int)((uint)v1/(uint)v2); ip+=4; break; } + case Op.div_un_i4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=(int)((uint)*(int*)(numF+s1*4)/(uint)*(int*)(numF+s2*4)); ip+=4; break; } + case Op.rem_un_i4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; int v1=RdI4(numF,refStack,slotT,s1,bp); int v2=RdI4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=(int)((uint)v1%(uint)v2); ip+=4; break; } + case Op.rem_un_i4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=(int)((uint)*(int*)(numF+s1*4)%(uint)*(int*)(numF+s2*4)); ip+=4; break; } + + // --- 64-bit family. Wide slots: Unsafe.Read/WriteUnaligned — a slot's + // byte offset is only 4-aligned. Operands read through Rd helpers so a + // stray I4/boxed operand coerces instead of reading garbage. + case Op.ldc_i8: { int d=(int)irP[ip+1]; ulong v=(ulong)irP[ip+2]|((ulong)irP[ip+3]<<32); Unsafe.WriteUnaligned(numF+d*4, unchecked((long)v)); ip+=4; break; } + case Op.ldc_r8: { int d=(int)irP[ip+1]; ulong v=(ulong)irP[ip+2]|((ulong)irP[ip+3]<<32); Unsafe.WriteUnaligned(numF+d*4, BitConverter.Int64BitsToDouble(unchecked((long)v))); ip+=4; break; } + case Op.add_i8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) + RdI8(numF,refStack,slotT,(int)irP[ip+3],bp)); ip+=4; break; } + case Op.sub_i8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) - RdI8(numF,refStack,slotT,(int)irP[ip+3],bp)); ip+=4; break; } + case Op.mul_i8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) * RdI8(numF,refStack,slotT,(int)irP[ip+3],bp)); ip+=4; break; } + case Op.div_i8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) / RdI8(numF,refStack,slotT,(int)irP[ip+3],bp)); ip+=4; break; } + case Op.rem_i8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) % RdI8(numF,refStack,slotT,(int)irP[ip+3],bp)); ip+=4; break; } + case Op.div_un_i8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, unchecked((long)((ulong)RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) / (ulong)RdI8(numF,refStack,slotT,(int)irP[ip+3],bp)))); ip+=4; break; } + case Op.rem_un_i8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, unchecked((long)((ulong)RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) % (ulong)RdI8(numF,refStack,slotT,(int)irP[ip+3],bp)))); ip+=4; break; } + case Op.and_i8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) & RdI8(numF,refStack,slotT,(int)irP[ip+3],bp)); ip+=4; break; } + case Op.or_i8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) | RdI8(numF,refStack,slotT,(int)irP[ip+3],bp)); ip+=4; break; } + case Op.xor_i8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) ^ RdI8(numF,refStack,slotT,(int)irP[ip+3],bp)); ip+=4; break; } + case Op.shl_i8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) << RdI4(numF,refStack,slotT,(int)irP[ip+3],bp)); ip+=4; break; } + case Op.shr_i8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) >> RdI4(numF,refStack,slotT,(int)irP[ip+3],bp)); ip+=4; break; } + case Op.shr_un_i8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, unchecked((long)((ulong)RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) >> RdI4(numF,refStack,slotT,(int)irP[ip+3],bp)))); ip+=4; break; } + case Op.neg_i8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, -RdI8(numF,refStack,slotT,(int)irP[ip+2],bp)); ip+=3; break; } + case Op.not_i8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, ~RdI8(numF,refStack,slotT,(int)irP[ip+2],bp)); ip+=3; break; } + case Op.add_r8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, RdR8(numF,refStack,slotT,(int)irP[ip+2],bp) + RdR8(numF,refStack,slotT,(int)irP[ip+3],bp)); ip+=4; break; } + case Op.sub_r8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, RdR8(numF,refStack,slotT,(int)irP[ip+2],bp) - RdR8(numF,refStack,slotT,(int)irP[ip+3],bp)); ip+=4; break; } + case Op.mul_r8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, RdR8(numF,refStack,slotT,(int)irP[ip+2],bp) * RdR8(numF,refStack,slotT,(int)irP[ip+3],bp)); ip+=4; break; } + case Op.div_r8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, RdR8(numF,refStack,slotT,(int)irP[ip+2],bp) / RdR8(numF,refStack,slotT,(int)irP[ip+3],bp)); ip+=4; break; } + case Op.rem_r8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, RdR8(numF,refStack,slotT,(int)irP[ip+2],bp) % RdR8(numF,refStack,slotT,(int)irP[ip+3],bp)); ip+=4; break; } + case Op.neg_r8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, -RdR8(numF,refStack,slotT,(int)irP[ip+2],bp)); ip+=3; break; } + case Op.ceq_i8: { int d=(int)irP[ip+1]; *(int*)(numF+d*4) = RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) == RdI8(numF,refStack,slotT,(int)irP[ip+3],bp) ? 1 : 0; ip+=4; break; } + case Op.cgt_i8: { int d=(int)irP[ip+1]; *(int*)(numF+d*4) = RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) > RdI8(numF,refStack,slotT,(int)irP[ip+3],bp) ? 1 : 0; ip+=4; break; } + case Op.clt_i8: { int d=(int)irP[ip+1]; *(int*)(numF+d*4) = RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) < RdI8(numF,refStack,slotT,(int)irP[ip+3],bp) ? 1 : 0; ip+=4; break; } + case Op.cgt_un_i8: { int d=(int)irP[ip+1]; *(int*)(numF+d*4) = (ulong)RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) > (ulong)RdI8(numF,refStack,slotT,(int)irP[ip+3],bp) ? 1 : 0; ip+=4; break; } + case Op.clt_un_i8: { int d=(int)irP[ip+1]; *(int*)(numF+d*4) = (ulong)RdI8(numF,refStack,slotT,(int)irP[ip+2],bp) < (ulong)RdI8(numF,refStack,slotT,(int)irP[ip+3],bp) ? 1 : 0; ip+=4; break; } + case Op.ceq_r8: { int d=(int)irP[ip+1]; *(int*)(numF+d*4) = RdR8(numF,refStack,slotT,(int)irP[ip+2],bp) == RdR8(numF,refStack,slotT,(int)irP[ip+3],bp) ? 1 : 0; ip+=4; break; } + case Op.cgt_r8: { int d=(int)irP[ip+1]; *(int*)(numF+d*4) = RdR8(numF,refStack,slotT,(int)irP[ip+2],bp) > RdR8(numF,refStack,slotT,(int)irP[ip+3],bp) ? 1 : 0; ip+=4; break; } + case Op.clt_r8: { int d=(int)irP[ip+1]; *(int*)(numF+d*4) = RdR8(numF,refStack,slotT,(int)irP[ip+2],bp) < RdR8(numF,refStack,slotT,(int)irP[ip+3],bp) ? 1 : 0; ip+=4; break; } + // Unordered: true when NaN participates — IL semantics for negated float branches. + case Op.cgt_un_r8: { int d=(int)irP[ip+1]; double a8=RdR8(numF,refStack,slotT,(int)irP[ip+2],bp), b8=RdR8(numF,refStack,slotT,(int)irP[ip+3],bp); *(int*)(numF+d*4) = (a8 > b8 || double.IsNaN(a8) || double.IsNaN(b8)) ? 1 : 0; ip+=4; break; } + case Op.clt_un_r8: { int d=(int)irP[ip+1]; double a8=RdR8(numF,refStack,slotT,(int)irP[ip+2],bp), b8=RdR8(numF,refStack,slotT,(int)irP[ip+3],bp); *(int*)(numF+d*4) = (a8 < b8 || double.IsNaN(a8) || double.IsNaN(b8)) ? 1 : 0; ip+=4; break; } + case Op.conv_i8_i4: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, (long)RdI4(numF,refStack,slotT,(int)irP[ip+2],bp)); ip+=3; break; } + case Op.conv_i8_u4: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, (long)(uint)RdI4(numF,refStack,slotT,(int)irP[ip+2],bp)); ip+=3; break; } + case Op.conv_i4_i8: { int d=(int)irP[ip+1]; *(int*)(numF+d*4) = unchecked((int)RdI8(numF,refStack,slotT,(int)irP[ip+2],bp)); ip+=3; break; } + case Op.conv_i8_r4: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, (long)RdR4(numF,refStack,slotT,(int)irP[ip+2],bp)); ip+=3; break; } + case Op.conv_r4_i8: { int d=(int)irP[ip+1]; *(float*)(numF+d*4) = RdI8(numF,refStack,slotT,(int)irP[ip+2],bp); ip+=3; break; } + case Op.conv_i8_r8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, (long)RdR8(numF,refStack,slotT,(int)irP[ip+2],bp)); ip+=3; break; } + case Op.conv_r8_i8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, (double)RdI8(numF,refStack,slotT,(int)irP[ip+2],bp)); ip+=3; break; } + case Op.conv_r8_r4: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, (double)RdR4(numF,refStack,slotT,(int)irP[ip+2],bp)); ip+=3; break; } + case Op.conv_r4_r8: { int d=(int)irP[ip+1]; *(float*)(numF+d*4) = (float)RdR8(numF,refStack,slotT,(int)irP[ip+2],bp); ip+=3; break; } + case Op.conv_r8_i4: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, (double)RdI4(numF,refStack,slotT,(int)irP[ip+2],bp)); ip+=3; break; } + case Op.conv_i4_r8: { int d=(int)irP[ip+1]; *(int*)(numF+d*4) = (int)RdR8(numF,refStack,slotT,(int)irP[ip+2],bp); ip+=3; break; } + case Op.conv_r8_u8: { int d=(int)irP[ip+1]; Unsafe.WriteUnaligned(numF+d*4, (double)(ulong)RdI8(numF,refStack,slotT,(int)irP[ip+2],bp)); ip+=3; break; } + case Op.brtrue_i8: { int cs=(int)irP[ip+1]; ip = RdI8(numF,refStack,slotT,cs,bp) != 0 ? (int)irP[ip+2] : ip+3; break; } + case Op.brfalse_i8: { int cs=(int)irP[ip+1]; ip = RdI8(numF,refStack,slotT,cs,bp) == 0 ? (int)irP[ip+2] : ip+3; break; } + + case Op.ceq_i4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; int v1=RdI4(numF,refStack,slotT,s1,bp); int v2=RdI4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=v1==v2?1:0; ip+=4; break; } + case Op.ceq_i4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=*(int*)(numF+s1*4)==*(int*)(numF+s2*4)?1:0; ip+=4; break; } + case Op.cgt_i4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; int v1=RdI4(numF,refStack,slotT,s1,bp); int v2=RdI4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=v1>v2?1:0; ip+=4; break; } + case Op.cgt_i4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=*(int*)(numF+s1*4)>*(int*)(numF+s2*4)?1:0; ip+=4; break; } + case Op.clt_i4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; int v1=RdI4(numF,refStack,slotT,s1,bp); int v2=RdI4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=v1(uint)v2?1:0; ip+=4; break; } + case Op.cgt_un_i4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=(uint)*(int*)(numF+s1*4)>(uint)*(int*)(numF+s2*4)?1:0; ip+=4; break; } + case Op.clt_un_i4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; int v1=RdI4(numF,refStack,slotT,s1,bp); int v2=RdI4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=(uint)v1<(uint)v2?1:0; ip+=4; break; } + case Op.clt_un_i4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=(uint)*(int*)(numF+s1*4)<(uint)*(int*)(numF+s2*4)?1:0; ip+=4; break; } + + case Op.ceq_r4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; float v1=RdR4(numF,refStack,slotT,s1,bp); float v2=RdR4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=v1==v2?1:0; ip+=4; break; } + case Op.ceq_r4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=*(float*)(numF+s1*4)==*(float*)(numF+s2*4)?1:0; ip+=4; break; } + case Op.cgt_r4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; float v1=RdR4(numF,refStack,slotT,s1,bp); float v2=RdR4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=v1>v2?1:0; ip+=4; break; } + case Op.cgt_r4_nn: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; *(int*)(numF+d*4)=*(float*)(numF+s1*4)>*(float*)(numF+s2*4)?1:0; ip+=4; break; } + case Op.clt_r4: { int d=(int)irP[ip+1],s1=(int)irP[ip+2],s2=(int)irP[ip+3]; float v1=RdR4(numF,refStack,slotT,s1,bp); float v2=RdR4(numF,refStack,slotT,s2,bp); *(int*)(numF+d*4)=v1=v2)?1:0; ip+=4; break; } + + // --- Operand-immediate forms (_nk) --- + case Op.add_i4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(int*)(numF+d*4)=*(int*)(numF+s*4)+(int)irP[ip+3]; ip+=4; break; } + case Op.add_r4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; uint k=irP[ip+3]; *(float*)(numF+d*4)=*(float*)(numF+s*4)+Unsafe.As(ref k); ip+=4; break; } + case Op.sub_i4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(int*)(numF+d*4)=*(int*)(numF+s*4)-(int)irP[ip+3]; ip+=4; break; } + case Op.sub_r4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; uint k=irP[ip+3]; *(float*)(numF+d*4)=*(float*)(numF+s*4)-Unsafe.As(ref k); ip+=4; break; } + case Op.mul_r4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; uint k=irP[ip+3]; *(float*)(numF+d*4)=*(float*)(numF+s*4)*Unsafe.As(ref k); ip+=4; break; } + case Op.clt_i4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(int*)(numF+d*4)=*(int*)(numF+s*4)<(int)irP[ip+3]?1:0; ip+=4; break; } + case Op.clt_r4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; uint k=irP[ip+3]; *(int*)(numF+d*4)=*(float*)(numF+s*4)(ref k)?1:0; ip+=4; break; } + case Op.cgt_i4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(int*)(numF+d*4)=*(int*)(numF+s*4)>(int)irP[ip+3]?1:0; ip+=4; break; } + case Op.cgt_r4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; uint k=irP[ip+3]; *(int*)(numF+d*4)=*(float*)(numF+s*4)>Unsafe.As(ref k)?1:0; ip+=4; break; } + case Op.ceq_i4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(int*)(numF+d*4)=*(int*)(numF+s*4)==(int)irP[ip+3]?1:0; ip+=4; break; } + case Op.mul_i4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(int*)(numF+d*4)=*(int*)(numF+s*4)*(int)irP[ip+3]; ip+=4; break; } + case Op.div_i4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(int*)(numF+d*4)=*(int*)(numF+s*4)/(int)irP[ip+3]; ip+=4; break; } + case Op.rem_i4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(int*)(numF+d*4)=*(int*)(numF+s*4)%(int)irP[ip+3]; ip+=4; break; } + case Op.and_i4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(int*)(numF+d*4)=*(int*)(numF+s*4)&(int)irP[ip+3]; ip+=4; break; } + case Op.or_i4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(int*)(numF+d*4)=*(int*)(numF+s*4)|(int)irP[ip+3]; ip+=4; break; } + case Op.xor_i4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(int*)(numF+d*4)=*(int*)(numF+s*4)^(int)irP[ip+3]; ip+=4; break; } + case Op.shl_i4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(int*)(numF+d*4)=*(int*)(numF+s*4)<<(int)irP[ip+3]; ip+=4; break; } + case Op.shr_i4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(int*)(numF+d*4)=*(int*)(numF+s*4)>>(int)irP[ip+3]; ip+=4; break; } + case Op.shr_un_i4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; *(int*)(numF+d*4)=(int)((uint)*(int*)(numF+s*4)>>(int)irP[ip+3]); ip+=4; break; } + case Op.div_r4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; uint k=irP[ip+3]; *(float*)(numF+d*4)=*(float*)(numF+s*4)/Unsafe.As(ref k); ip+=4; break; } + case Op.ceq_r4_nk: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; uint k=irP[ip+3]; *(int*)(numF+d*4)=*(float*)(numF+s*4)==Unsafe.As(ref k)?1:0; ip+=4; break; } + + // --- Fused compare-and-branch --- + case Op.blt_i4_nn: { int s1=(int)irP[ip+1],s2=(int)irP[ip+2]; int t=(int)irP[ip+3]; if (*(int*)(numF+s1*4) < *(int*)(numF+s2*4)) ip = t; else ip += 4; break; } + case Op.blt_i4_nk: { int s=(int)irP[ip+1]; int k=(int)irP[ip+2]; int t=(int)irP[ip+3]; if (*(int*)(numF+s*4) < k) ip = t; else ip += 4; break; } + case Op.bgt_r4_nn: { int s1=(int)irP[ip+1],s2=(int)irP[ip+2]; int t=(int)irP[ip+3]; if (*(float*)(numF+s1*4) > *(float*)(numF+s2*4)) ip = t; else ip += 4; break; } + case Op.bgt_r4_nk: { int s=(int)irP[ip+1]; uint kk=irP[ip+2]; int t=(int)irP[ip+3]; if (*(float*)(numF+s*4) > Unsafe.As(ref kk)) ip = t; else ip += 4; break; } + case Op.beq_i4_nn: { int s1=(int)irP[ip+1],s2=(int)irP[ip+2]; int t=(int)irP[ip+3]; if (*(int*)(numF+s1*4) == *(int*)(numF+s2*4)) ip = t; else ip += 4; break; } + case Op.beq_i4_nk: { int s=(int)irP[ip+1]; int k=(int)irP[ip+2]; int t=(int)irP[ip+3]; if (*(int*)(numF+s*4) == k) ip = t; else ip += 4; break; } + case Op.bne_i4_nn: { int s1=(int)irP[ip+1],s2=(int)irP[ip+2]; int t=(int)irP[ip+3]; if (*(int*)(numF+s1*4) != *(int*)(numF+s2*4)) ip = t; else ip += 4; break; } + case Op.bne_i4_nk: { int s=(int)irP[ip+1]; int k=(int)irP[ip+2]; int t=(int)irP[ip+3]; if (*(int*)(numF+s*4) != k) ip = t; else ip += 4; break; } + + // --- For-loop super-instruction --- + case Op.for_i4_nk: + { + int s=(int)irP[ip+1]; int limit=(int)irP[ip+2]; int t=(int)irP[ip+3]; + int v = *(int*)(numF+s*4) + 1; + *(int*)(numF+s*4) = v; + if (v < limit) ip = t; else ip += 4; + break; + } + + case Op.ceq_o: + { + int d=(int)irP[ip+1]; + var a=refStack[bp + (int)irP[ip+2]]; var b=refStack[bp + (int)irP[ip+3]]; + // C# `==` on reference/object operands is reference equality (value types go + // through op_Equality, a host call). Object.Equals would value-compare two + // distinct boxes of the same primitive as equal — diverging from C#. + *(int*)(numF+d*4)=ReferenceEquals(a,b)?1:0; ip+=4; break; + } + case Op.cgt_un_o: + { + int d=(int)irP[ip+1]; + *(int*)(numF+d*4)=refStack[bp + (int)irP[ip+2]] != null ? 1 : 0; ip+=4; break; + } + + case Op.br: ip = (int)irP[ip + 1]; break; + case Op.push_cont: + { + if (contSp >= ContMax) { int il=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"finally nesting exceeds {ContMax}{At(method, il)}"); } + contStack[contSp++] = (int)irP[ip + 1]; + ip += 2; break; + } + case Op.br_cont: + { + if (contSp == 0) { int il=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"endfinally with no pending continuation{At(method, il)}"); } + ip = contStack[--contSp]; + break; + } + case Op.brtrue_i4: + { + int cond=(int)irP[ip+1]; int target=(int)irP[ip+2]; + ip = *(int*)(numF+cond*4) != 0 ? target : ip + 3; break; + } + case Op.brfalse_i4: + { + int cond=(int)irP[ip+1]; int target=(int)irP[ip+2]; + ip = *(int*)(numF+cond*4) == 0 ? target : ip + 3; break; + } + case Op.brtrue_o: + { + int cond=(int)irP[ip+1]; int target=(int)irP[ip+2]; + // Pure REFERENCE test. Verified IL only puts references (or boxes) in + // an O-typed condition, and a box is non-null no matter its value — + // value-testing here made `(object)0 == null` true and flipped isinst + // results whose matched box held 0/false (found by fuzzing). Value-typed + // conditions run through brtrue_i4/brfalse_i4 instead. + ip = refStack[bp + cond] != null ? target : ip + 3; break; + } + case Op.brfalse_o: + { + int cond=(int)irP[ip+1]; int target=(int)irP[ip+2]; + // Pure reference test — see brtrue_o. + ip = refStack[bp + cond] == null ? target : ip + 3; break; + } + + case Op.conv_i4_r4: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; int sv=RdI4(numF,refStack,slotT,s,bp); *(float*)(numF+d*4)=(float)sv; ip+=3; break; } + case Op.conv_r4_i4: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; float sv=RdR4(numF,refStack,slotT,s,bp); *(int*)(numF+d*4)=(int)sv; ip+=3; break; } + case Op.conv_i4_i1: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; int sv=RdI4(numF,refStack,slotT,s,bp); *(int*)(numF+d*4)=(int)(sbyte)sv; ip+=3; break; } + case Op.conv_i4_u1: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; int sv=RdI4(numF,refStack,slotT,s,bp); *(int*)(numF+d*4)=(int)(byte)sv; ip+=3; break; } + case Op.conv_i4_i2: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; int sv=RdI4(numF,refStack,slotT,s,bp); *(int*)(numF+d*4)=(int)(short)sv; ip+=3; break; } + case Op.conv_i4_u2: { int d=(int)irP[ip+1],s=(int)irP[ip+2]; int sv=RdI4(numF,refStack,slotT,s,bp); *(int*)(numF+d*4)=(int)(ushort)sv; ip+=3; break; } + + // A ret either leaves Run (call stack at this Run's floor) or pops the caller + // frame: read the return value from the CALLEE frame first, switch locals back + // to the caller, then store into the caller's RetDst slot. A primitive return + // into an O dst stays null; an O return into a numeric dst reads 0. + case Op.ret_void: + { + if (_framesTop == frameBase) return default; + var f = frames[--_framesTop]; + byte* wbSrc = null; int wbSize = 0; + if (f.VtThisWb >= 0) + { + int thisSlot = lm.ArgSlot != null ? lm.ArgSlot[0] : 0; + wbSrc = numF + thisSlot * 4; wbSize = lm.StructLayouts![thisSlot]!.Size; + } + method = f.M; lm = method.Lowered; slotT = lm.SlotTypes; bp = f.Bp; + numF = (byte*)(_num + bp); ip = f.Ip; contSp = f.ContSp; + _base = bp + lm.FrameSize; + if (wbSize > 0) Buffer.MemoryCopy(wbSrc, numF + f.VtThisWb * 4, wbSize, wbSize); + if (f.RetDst != -1) + { + var st = slotT[f.RetDst]; + if (st == SType.I4) *(int*)(numF + f.RetDst * 4) = 0; + else if (st == SType.R4) *(float*)(numF + f.RetDst * 4) = 0f; + else refStack[bp + f.RetDst] = null; + } + break; + } + case Op.ret_i4: + { + int s=(int)irP[ip+1]; var t=slotT[s]; + int v=t==SType.I4?*(int*)(numF+s*4):t==SType.R4?(int)*(float*)(numF+s*4):CoerceBoxedI4(refStack[bp + s]); + if (_framesTop == frameBase) return CallReturn.FromI4(v); + var f = frames[--_framesTop]; + byte* wbSrc = null; int wbSize = 0; + if (f.VtThisWb >= 0) + { + int thisSlot = lm.ArgSlot != null ? lm.ArgSlot[0] : 0; + wbSrc = numF + thisSlot * 4; wbSize = lm.StructLayouts![thisSlot]!.Size; + } + method = f.M; lm = method.Lowered; slotT = lm.SlotTypes; bp = f.Bp; + numF = (byte*)(_num + bp); ip = f.Ip; contSp = f.ContSp; + _base = bp + lm.FrameSize; + if (wbSize > 0) Buffer.MemoryCopy(wbSrc, numF + f.VtThisWb * 4, wbSize, wbSize); + if (f.RetDst != -1) + { + var st = slotT[f.RetDst]; + if (st == SType.I4) *(int*)(numF + f.RetDst * 4) = v; + else if (st == SType.R4) *(float*)(numF + f.RetDst * 4) = (float)v; + else if (st == SType.I8) Unsafe.WriteUnaligned(numF + f.RetDst * 4, (long)v); + else if (st == SType.R8) Unsafe.WriteUnaligned(numF + f.RetDst * 4, (double)v); + else refStack[bp + f.RetDst] = v; // O-typed dst: box, don't drop + } + break; + } + case Op.ret_r4: + { + int s=(int)irP[ip+1]; var t=slotT[s]; + float v=t==SType.R4?*(float*)(numF+s*4):t==SType.I4?(float)*(int*)(numF+s*4):CoerceBoxedR4(refStack[bp + s]); + if (_framesTop == frameBase) return CallReturn.FromR4(v); + var f = frames[--_framesTop]; + byte* wbSrc = null; int wbSize = 0; + if (f.VtThisWb >= 0) + { + int thisSlot = lm.ArgSlot != null ? lm.ArgSlot[0] : 0; + wbSrc = numF + thisSlot * 4; wbSize = lm.StructLayouts![thisSlot]!.Size; + } + method = f.M; lm = method.Lowered; slotT = lm.SlotTypes; bp = f.Bp; + numF = (byte*)(_num + bp); ip = f.Ip; contSp = f.ContSp; + _base = bp + lm.FrameSize; + if (wbSize > 0) Buffer.MemoryCopy(wbSrc, numF + f.VtThisWb * 4, wbSize, wbSize); + if (f.RetDst != -1) + { + var st = slotT[f.RetDst]; + if (st == SType.I4) *(int*)(numF + f.RetDst * 4) = (int)v; + else if (st == SType.R4) *(float*)(numF + f.RetDst * 4) = v; + else if (st == SType.I8) Unsafe.WriteUnaligned(numF + f.RetDst * 4, (long)v); + else if (st == SType.R8) Unsafe.WriteUnaligned(numF + f.RetDst * 4, (double)v); + else refStack[bp + f.RetDst] = v; // O-typed dst: box, don't drop + } + break; + } + case Op.ret_i8: + { + int s=(int)irP[ip+1]; + long v=RdI8(numF,refStack,slotT,s,bp); + if (_framesTop == frameBase) return CallReturn.FromI8(v); + var f = frames[--_framesTop]; + byte* wbSrc = null; int wbSize = 0; + if (f.VtThisWb >= 0) + { + int thisSlot = lm.ArgSlot != null ? lm.ArgSlot[0] : 0; + wbSrc = numF + thisSlot * 4; wbSize = lm.StructLayouts![thisSlot]!.Size; + } + method = f.M; lm = method.Lowered; slotT = lm.SlotTypes; bp = f.Bp; + numF = (byte*)(_num + bp); ip = f.Ip; contSp = f.ContSp; + _base = bp + lm.FrameSize; + if (wbSize > 0) Buffer.MemoryCopy(wbSrc, numF + f.VtThisWb * 4, wbSize, wbSize); + if (f.RetDst != -1) + { + var st = slotT[f.RetDst]; + if (st == SType.I8) Unsafe.WriteUnaligned(numF + f.RetDst * 4, v); + else if (st == SType.I4) *(int*)(numF + f.RetDst * 4) = unchecked((int)v); + else if (st == SType.R4) *(float*)(numF + f.RetDst * 4) = v; + else if (st == SType.R8) Unsafe.WriteUnaligned(numF + f.RetDst * 4, (double)v); + else refStack[bp + f.RetDst] = v; // O-typed dst: box as long + } + break; + } + case Op.ret_r8: + { + int s=(int)irP[ip+1]; + double v=RdR8(numF,refStack,slotT,s,bp); + if (_framesTop == frameBase) return CallReturn.FromR8(v); + var f = frames[--_framesTop]; + byte* wbSrc = null; int wbSize = 0; + if (f.VtThisWb >= 0) + { + int thisSlot = lm.ArgSlot != null ? lm.ArgSlot[0] : 0; + wbSrc = numF + thisSlot * 4; wbSize = lm.StructLayouts![thisSlot]!.Size; + } + method = f.M; lm = method.Lowered; slotT = lm.SlotTypes; bp = f.Bp; + numF = (byte*)(_num + bp); ip = f.Ip; contSp = f.ContSp; + _base = bp + lm.FrameSize; + if (wbSize > 0) Buffer.MemoryCopy(wbSrc, numF + f.VtThisWb * 4, wbSize, wbSize); + if (f.RetDst != -1) + { + var st = slotT[f.RetDst]; + if (st == SType.R8) Unsafe.WriteUnaligned(numF + f.RetDst * 4, v); + else if (st == SType.R4) *(float*)(numF + f.RetDst * 4) = (float)v; + else if (st == SType.I4) *(int*)(numF + f.RetDst * 4) = (int)v; + else if (st == SType.I8) Unsafe.WriteUnaligned(numF + f.RetDst * 4, (long)v); + else refStack[bp + f.RetDst] = v; // O-typed dst: box as double + } + break; + } + case Op.ret_vt: + { + int sV=(int)irP[ip+1]; + var vlay = lm.StructLayouts![sV]!; + byte* retSrc = numF + sV * 4; + if (_framesTop == frameBase) return CallReturn.FromO(vlay.BoxFromPtr(retSrc)); + var f = frames[--_framesTop]; + byte* wbSrc = null; int wbSize = 0; + if (f.VtThisWb >= 0) + { + int thisSlot = lm.ArgSlot != null ? lm.ArgSlot[0] : 0; + wbSrc = numF + thisSlot * 4; wbSize = lm.StructLayouts![thisSlot]!.Size; + } + method = f.M; lm = method.Lowered; slotT = lm.SlotTypes; bp = f.Bp; + numF = (byte*)(_num + bp); ip = f.Ip; contSp = f.ContSp; + _base = bp + lm.FrameSize; + if (wbSize > 0) Buffer.MemoryCopy(wbSrc, numF + f.VtThisWb * 4, wbSize, wbSize); + if (f.RetDst != -1) + { + var st = slotT[f.RetDst]; + // retSrc points into the (now-popped) callee frame region — still + // intact: nothing reuses it until the next call pushes past bp. + if (st == SType.Vt) Buffer.MemoryCopy(retSrc, numF + f.RetDst * 4, vlay.Size, vlay.Size); + else if (st == SType.O) refStack[bp + f.RetDst] = vlay.BoxFromPtr(retSrc); + else if (st == SType.I4) *(int*)(numF + f.RetDst * 4) = 0; + else *(float*)(numF + f.RetDst * 4) = 0f; + } + break; + } + case Op.throw_o: + { + // Script `throw`: unwinds exactly like a host-call throw — the shared + // exception path runs pending finally regions and maps the location. + object? ex = refStack[bp + (int)irP[ip + 1]]; + throw ex as Exception + ?? new ScriptRuntimeException("throw of a null or non-exception object"); + } + case Op.ret_o: + { + object? vo = refStack[bp + (int)irP[ip + 1]]; + if (_framesTop == frameBase) return CallReturn.FromO(vo); + var f = frames[--_framesTop]; + byte* wbSrc = null; int wbSize = 0; + if (f.VtThisWb >= 0) + { + int thisSlot = lm.ArgSlot != null ? lm.ArgSlot[0] : 0; + wbSrc = numF + thisSlot * 4; wbSize = lm.StructLayouts![thisSlot]!.Size; + } + method = f.M; lm = method.Lowered; slotT = lm.SlotTypes; bp = f.Bp; + numF = (byte*)(_num + bp); ip = f.Ip; contSp = f.ContSp; + _base = bp + lm.FrameSize; + if (wbSize > 0) Buffer.MemoryCopy(wbSrc, numF + f.VtThisWb * 4, wbSize, wbSize); + if (f.RetDst != -1) + { + // A numeric caller dst means the returned object is a boxed value + // (e.g. a host call's int/bool routed through an O-typed callee + // slot) — unbox it like WrObj; writing 0 would drop the value. + var st = slotT[f.RetDst]; + if (st == SType.I4) *(int*)(numF + f.RetDst * 4) = CoerceBoxedI4(vo); + else if (st == SType.R4) *(float*)(numF + f.RetDst * 4) = CoerceBoxedR4(vo); + else if (st == SType.I8) Unsafe.WriteUnaligned(numF + f.RetDst * 4, CoerceBoxedI8(vo)); + else if (st == SType.R8) Unsafe.WriteUnaligned(numF + f.RetDst * 4, CoerceBoxedR8(vo)); + else refStack[bp + f.RetDst] = vo; + } + break; + } + + // --- Script-class fields (flat typed storage in ScriptObject) --- + case Op.ldfld_sc_i4: { int dst=(int)irP[ip+1]; int o=(int)irP[ip+2]; int off=(int)irP[ip+3]; var so=(ScriptObject)refStack[bp+o]!; *(int*)(numF+dst*4)=Unsafe.ReadUnaligned(ref so.PrimBytes[off]); ip+=4; break; } + case Op.ldfld_sc_r4: { int dst=(int)irP[ip+1]; int o=(int)irP[ip+2]; int off=(int)irP[ip+3]; var so=(ScriptObject)refStack[bp+o]!; *(float*)(numF+dst*4)=Unsafe.ReadUnaligned(ref so.PrimBytes[off]); ip+=4; break; } + case Op.ldfld_sc_o: { int dst=(int)irP[ip+1]; int o=(int)irP[ip+2]; int off=(int)irP[ip+3]; var so=(ScriptObject)refStack[bp+o]!; refStack[bp+dst]=so.RefSlots[off]; ip+=4; break; } + case Op.stfld_sc_i4: { int o=(int)irP[ip+1]; int off=(int)irP[ip+2]; int s=(int)irP[ip+3]; var so=(ScriptObject)refStack[bp+o]!; Unsafe.WriteUnaligned(ref so.PrimBytes[off], RdI4(numF,refStack,slotT,s,bp)); ip+=4; break; } + case Op.stfld_sc_r4: { int o=(int)irP[ip+1]; int off=(int)irP[ip+2]; int s=(int)irP[ip+3]; var so=(ScriptObject)refStack[bp+o]!; Unsafe.WriteUnaligned(ref so.PrimBytes[off], RdR4(numF,refStack,slotT,s,bp)); ip+=4; break; } + case Op.stfld_sc_o: { int o=(int)irP[ip+1]; int off=(int)irP[ip+2]; int s=(int)irP[ip+3]; var so=(ScriptObject)refStack[bp+o]!; + // A described throw instead of a bare IndexOutOfRangeException: an OOB + // ref-slot index here means a lowering emitted a PrimBytes byte offset + // where a RefSlots index belongs (unit-mixing bug), and the naive store + // could silently corrupt a neighboring field instead of landing OOB. + if ((uint)off >= (uint)so.RefSlots.Length) { int il=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"stfld_sc_o: ref-slot index {off} out of range for '{so.Type.Name}' ({so.RefSlots.Length} ref slots) — offset-unit bug in '{method.Name}'{At(method, il)}"); } + so.RefSlots[off]=RdObj(numF,refStack,slotT,s,bp); ip+=4; break; } + // Vt (flat host struct) field inline in ScriptObject.PrimBytes. Vt slots are + // addressed frame-relative (numF+slot*4; numF is already at this frame's base) — + // adding bp would double-apply the base and silently corrupt any nested call. + case Op.ldfld_sc_vt: + { + int dst=(int)irP[ip+1]; int o=(int)irP[ip+2]; int off=(int)irP[ip+3]; + var so=(ScriptObject)refStack[bp+o]!; int size=lm.StructLayouts![dst]!.Size; + byte* d=numF+dst*4; int c=0; + while (c+4<=size) { *(int*)(d+c)=Unsafe.ReadUnaligned(ref so.PrimBytes[off+c]); c+=4; } + while (c> 24) == 0x04) + { + // Primitive statics read from the UNBOXED store (miss -> 0). ldsfld_o + // (reference/long/double) reads the boxed store. See ScriptStaticsNum. + if (irop == Op.ldsfld_i4) + { + asm.ScriptStaticsNum.TryGetValue(tok, out var nv); + *(int*)(numF + dst * 4) = (int)nv; + } + else if (irop == Op.ldsfld_r4) + { + asm.ScriptStaticsNum.TryGetValue(tok, out var nv); + *(float*)(numF + dst * 4) = BitConverter.Int32BitsToSingle((int)nv); + } + else + { + asm.ScriptStatics.TryGetValue(tok, out var sv); + refStack[bp+dst]=sv; + } + } + else { int il=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"Unknown static field token 0x{tok:X8}{At(method, il)}"); } + ip+=3; break; + } + case Op.stsfld_o: case Op.stsfld_i4: case Op.stsfld_r4: case Op.stsfld_struct: + { + int tokIdx=(int)irP[ip+1]; int s=(int)irP[ip+2]; + int tok=lm.Tokens[tokIdx]; + // Primitive SCRIPT statics store UNBOXED (raw bits) — no per-write heap box. + // The lowerer only emits stsfld_i4/r4 for script FieldDefs (never host fields). + if (irop == Op.stsfld_i4) + { asm.ScriptStaticsNum[tok] = RdI4(numF,refStack,slotT,s,bp); ip+=3; break; } + if (irop == Op.stsfld_r4) + { asm.ScriptStaticsNum[tok] = BitConverter.SingleToInt32Bits(RdR4(numF,refStack,slotT,s,bp)); ip+=3; break; } + object? val=RdObj(numF,refStack,slotT,s,bp); + if (asm.HostFields.TryGetValue(tok, out var shf)) shf.Set(null, val); + else if ((tok >> 24) == 0x04) asm.ScriptStatics[tok]=val; + else { int il=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"Unknown static field token 0x{tok:X8}{At(method, il)}"); } + ip+=3; break; + } + + // --- Arrays (object?[] by default; typed backings via newarr_i4/r4/vt) --- + case Op.newarr: + { + int dst=(int)irP[ip+1]; int lenSlot=(int)irP[ip+2]; + int elemTokIdx=(int)irP[ip+3]; int fieldTokIdx=(int)irP[ip+4]; + // The length slot can be classified O (boxed int in refStack), not I4-flat, + // so read via RdI4 (honours the slot type). A raw *(int*)(numF+…) read + // returns poison when the value is actually boxed. + int len=RdI4(numF,refStack,slotT,lenSlot,bp); + // Typed primitive backing (bool[]/char[]/byte[]/…): a real runtime array + // so element reads box with the correct type and defaults are C#-zeroed. + var primType = lm.PrimArrayElemTypeByTokIdx?[elemTokIdx]; + if (primType != null) + { + var parr = System.Array.CreateInstance(primType, len); + if (fieldTokIdx != -1) + { + int es = primType.IsEnum ? 4 + : primType == typeof(long) || primType == typeof(ulong) || primType == typeof(double) ? 8 + : primType == typeof(bool) || primType == typeof(byte) || primType == typeof(sbyte) ? 1 : 2; + TryFillTypedArrayFromFieldBlob(parr, es, asm, lm.Tokens[fieldTokIdx]); + } + refStack[bp+dst]=parr; ip+=5; break; + } + var arr=new object?[len]; + if (fieldTokIdx != -1) TryFillArrayFromFieldBlob(arr, asm, lm.Tokens[elemTokIdx], lm.Tokens[fieldTokIdx]); + // A script-struct element type is a value type: `new T[n]` holds n zeroed + // structs, not nulls, so per-element field mutation (arr[i].x = …) works. + else if (asm.TypeDefToType.TryGetValue(lm.Tokens[elemTokIdx], out var ed) && ed.IsScriptStructValue) + for (int i=0;iint.MaxValue) + { int il2=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"array dimensions exceeded the supported range (length {len}){At(method, il2)}"); } + refStack[bp+dst]=new ScriptVtArray + { Layout=elay, Bytes=new byte[(int)byteLen], Length=len, Stride=stride }; + ip+=5; break; + } + case Op.ldelem_vt: + { + int dst=(int)irP[ip+1]; int arrSlot=(int)irP[ip+2]; int idxSlot=(int)irP[ip+3]; + int idx=*(int*)(numF+idxSlot*4); + var arrObj=refStack[bp+arrSlot]; + var dlay=lm.StructLayouts![dst]!; + if (arrObj is ScriptVtArray sva) + { + if ((uint)idx >= (uint)sva.Length) + { int il2=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"array index out of bounds{At(method, il2)}"); } + fixed (byte* srcB = sva.Bytes) + Buffer.MemoryCopy(srcB + idx * sva.Stride, numF + dst * 4, dlay.Size, dlay.Size); + } + else + { + // Boxed fallback (object?[] of ScriptObjects, or a host T[]). + object? ev; + try { ev = arrObj is object?[] oa ? oa[idx] : ((System.Array)arrObj!).GetValue(idx); } + catch (IndexOutOfRangeException) { int il2=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"array index out of bounds{At(method, il2)}"); } + UnboxVt(numF, dst, dlay, ev); + } + ip+=4; break; + } + case Op.stelem_vt: + { + int arrSlot=(int)irP[ip+1]; int idxSlot=(int)irP[ip+2]; int srcS=(int)irP[ip+3]; + int idx=*(int*)(numF+idxSlot*4); + var arrObj=refStack[bp+arrSlot]; + var slay=lm.StructLayouts![srcS]!; + if (arrObj is ScriptVtArray sva) + { + if ((uint)idx >= (uint)sva.Length) + { int il2=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"array index out of bounds{At(method, il2)}"); } + fixed (byte* dstB = sva.Bytes) + Buffer.MemoryCopy(numF + srcS * 4, dstB + idx * sva.Stride, slay.Size, slay.Size); + } + else + { + var v = BoxVt(numF, srcS, slay); + try { if (arrObj is object?[] oa) oa[idx]=v; else ((System.Array)arrObj!).SetValue(v, idx); } + catch (IndexOutOfRangeException) { int il2=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"array index out of bounds{At(method, il2)}"); } + } + ip+=4; break; + } + // Script-created arrays are object?[] (int[]/float[] for the typed newarr variants); + // host members return real typed arrays. int[]/float[] hit the direct branches; + // reference-type arrays satisfy `is object?[]` by covariance; everything else falls + // back to System.Array (GetValue boxes, SetValue unboxes). + case Op.ldlen: + { + int dst=(int)irP[ip+1]; int s=(int)irP[ip+2]; + var aObj=refStack[bp+s]; + *(int*)(numF+dst*4) = aObj is ScriptVtArray svaL ? svaL.Length : ((System.Array)aObj!).Length; + ip+=3; break; + } + case Op.ldelem_o: case Op.ldelem_i4: case Op.ldelem_r4: case Op.ldelem_struct: + { + int dst=(int)irP[ip+1]; int arrSlot=(int)irP[ip+2]; int idxSlot=(int)irP[ip+3]; + int idx=*(int*)(numF+idxSlot*4); + var arrObj=refStack[bp+arrSlot]; + var dt=slotT[dst]; + if (arrObj is int[] ia && (uint)idx < (uint)ia.Length) + { + int ei=ia[idx]; + if (dt==SType.I4) *(int*)(numF+dst*4)=ei; + else if (dt==SType.R4) *(float*)(numF+dst*4)=(float)ei; + else if (dt==SType.I8) Unsafe.WriteUnaligned(numF+dst*4, (long)ei); + else if (dt==SType.R8) Unsafe.WriteUnaligned(numF+dst*4, (double)ei); + // CLR array covariance: Shape[] pattern-matches int[]. An O dst must + // box the element as its REAL type or Format holes render the number. + else refStack[bp+dst]=arrObj.GetType().GetElementType() is { IsEnum: true } eet2 + ? Enum.ToObject(eet2, ei) : ei; + } + else if (arrObj is long[] la8 && (uint)idx < (uint)la8.Length) + { + // Covers ulong[] via covariance — same 8-byte cells; the O-dst box + // must respect the REAL element type or ulongs print negative. + long el=la8[idx]; + if (dt==SType.I8) Unsafe.WriteUnaligned(numF+dst*4, el); + else if (dt==SType.I4) *(int*)(numF+dst*4)=unchecked((int)el); + else if (dt==SType.R4) *(float*)(numF+dst*4)=el; + else if (dt==SType.R8) Unsafe.WriteUnaligned(numF+dst*4, (double)el); + else refStack[bp+dst]=arrObj is ulong[] ? unchecked((ulong)el) : (object)el; + } + else if (arrObj is double[] da8 && (uint)idx < (uint)da8.Length) + { + double ed=da8[idx]; + if (dt==SType.R8) Unsafe.WriteUnaligned(numF+dst*4, ed); + else if (dt==SType.R4) *(float*)(numF+dst*4)=(float)ed; + else if (dt==SType.I4) *(int*)(numF+dst*4)=(int)ed; + else if (dt==SType.I8) Unsafe.WriteUnaligned(numF+dst*4, (long)ed); + else refStack[bp+dst]=ed; + } + else if (arrObj is float[] fa && (uint)idx < (uint)fa.Length) + { + float ef=fa[idx]; + if (dt==SType.R4) *(float*)(numF+dst*4)=ef; + else if (dt==SType.I4) *(int*)(numF+dst*4)=(int)ef; + else refStack[bp+dst]=ef; + } + // Sub-4-byte primitive arrays read into a numeric slot WITHOUT boxing (the + // fallback's Array.GetValue heap-allocs per element). Dispatch on the EXACT type, + // not `is`: (object)sbyte[] is byte[] is true, so a byte[] arm reads it unsigned. + else if (dt != SType.O + && arrObj is object arr && arr.GetType() is Type et + && (et == T_byteA || et == T_sbyteA || et == T_shortA + || et == T_ushortA || et == T_boolA || et == T_charA) + && (uint)idx < (uint)Unsafe.As(arr).Length) + { + int ei = + et == T_byteA ? (int)Unsafe.As(arr)[idx] : + et == T_sbyteA ? (int)Unsafe.As(arr)[idx] : + et == T_shortA ? (int)Unsafe.As(arr)[idx] : + et == T_ushortA ? (int)Unsafe.As(arr)[idx] : + et == T_charA ? (int)Unsafe.As(arr)[idx] : + (Unsafe.As(arr)[idx] ? 1 : 0); + if (dt==SType.I4) *(int*)(numF+dst*4)=ei; + else if (dt==SType.R4) *(float*)(numF+dst*4)=ei; + else if (dt==SType.I8) Unsafe.WriteUnaligned(numF+dst*4, (long)ei); + else Unsafe.WriteUnaligned(numF+dst*4, (double)ei); // R8 + } + else + { + object? ev; + try { ev = arrObj is object?[] oa ? oa[idx] : ((System.Array)arrObj!).GetValue(idx); } + catch (IndexOutOfRangeException) { int il=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"array index out of bounds{At(method, il)}"); } + // Typed primitive arrays (bool[]/char[]/byte[]/…) box their element + // with the correct runtime type here; unbox every I4-mapped primitive. + // O dst keeps the correctly-typed box (constrained-callvirt receiver). + // (int) per arm: without it the switch's best-common-type over mixed + // signed/unsigned arms zero-extends sbyte/short (an sbyte -1 → 255). + if (dt==SType.I4) *(int*)(numF+dst*4)= ev switch + { + int ei => ei, bool eb => eb?1:0, char ec => (int)ec, + byte by => (int)by, sbyte sb => (int)sb, short sh => (int)sh, ushort us => (int)us, + uint eu => unchecked((int)eu), + Enum => unchecked((int)Convert.ToInt64(ev)), // host enum[] elements + _ => 0, + }; + else if (dt==SType.R4) *(float*)(numF+dst*4)=CoerceBoxedR4(ev); + else if (dt==SType.I8) Unsafe.WriteUnaligned(numF+dst*4, CoerceBoxedI8(ev)); + else if (dt==SType.R8) Unsafe.WriteUnaligned(numF+dst*4, CoerceBoxedR8(ev)); + else refStack[bp+dst]=ev; + } + ip+=4; break; + } + case Op.stelem_o: case Op.stelem_i4: case Op.stelem_r4: case Op.stelem_struct: + { + int arrSlot=(int)irP[ip+1]; int idxSlot=(int)irP[ip+2]; int s=(int)irP[ip+3]; + int idx=*(int*)(numF+idxSlot*4); + var arrObj=refStack[bp+arrSlot]; + var st2=slotT[s]; + // Dispatch on the exact array Type via cached-Type compares + Unsafe.As (no + // per-write isinst). Signed/unsigned pairs share layout, so one branch each. + // Fast writes guard (uint)idx(arrW).Length) + { Unsafe.As(arrW)[idx]=RdI4(numF,refStack,slotT,s,bp); ip+=4; break; } + } + else if (wt == T_floatA) + { + if ((uint)idx < (uint)Unsafe.As(arrW).Length) + { Unsafe.As(arrW)[idx]=RdR4(numF,refStack,slotT,s,bp); ip+=4; break; } + } + else if (wt == T_longA || wt == T_ulongA) + { + if ((uint)idx < (uint)Unsafe.As(arrW).Length) + { Unsafe.As(arrW)[idx]=RdI8(numF,refStack,slotT,s,bp); ip+=4; break; } + } + else if (wt == T_doubleA) + { + if ((uint)idx < (uint)Unsafe.As(arrW).Length) + { Unsafe.As(arrW)[idx]=RdR8(numF,refStack,slotT,s,bp); ip+=4; break; } + } + else if ((wt == T_boolA || wt == T_charA || wt == T_byteA || wt == T_sbyteA + || wt == T_shortA || wt == T_ushortA) + && (uint)idx < (uint)Unsafe.As(arrW).Length) + { + int val = RdI4(numF,refStack,slotT,s,bp); + if (wt == T_boolA) Unsafe.As(arrW)[idx] = val != 0; + else if (wt == T_charA) Unsafe.As(arrW)[idx] = (char)val; + else if (wt == T_byteA) Unsafe.As(arrW)[idx] = (byte)val; + else if (wt == T_sbyteA) Unsafe.As(arrW)[idx] = (sbyte)val; + else if (wt == T_shortA) Unsafe.As(arrW)[idx] = (short)val; + else Unsafe.As(arrW)[idx]= (ushort)val; + ip+=4; break; + } + } + // Fallback: object?[] element, a struct/enum-typed source, or an index the fast + // paths above declined (out of range) — box + SetValue, which keeps its own bounds + // check and throws a clean error. + { + // A Vt source slot lives in the numeric frame, not refStack; RdObj returns null + // for it, silently storing a default element. Box via the struct marshaller so + // both object?[] and real typed arrays receive the actual struct value. + var v = st2==SType.Vt ? BoxVt(numF, s, lm.StructLayouts![s]!) : RdObj(numF,refStack,slotT,s,bp); + // Enum arrays are real typed arrays; the source rides an I4 slot as a + // boxed int, which SetValue refuses — rebox as the element type. + if (arrObj?.GetType().GetElementType() is { IsEnum: true } eet && v is not Enum) + v = Enum.ToObject(eet, CoerceBoxedI4(v)); + try { if (arrObj is object?[] oa) oa[idx]=v; else ((System.Array)arrObj!).SetValue(v, idx); } + catch (IndexOutOfRangeException) { int il=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"array index out of bounds{At(method, il)}"); } + } + ip+=4; break; + } + + // --- Script-to-script call (iterative: push caller frame, jump into callee) --- + case Op.call_script: + { + int dstSlot=(int)irP[ip+1]; int tokIdx=(int)irP[ip+2]; int argc=(int)irP[ip+3]; + var callee=lm.CalleeByTokIdx![tokIdx]!; + var clm=callee.Lowered; + int calleeBp=bp+lm.FrameSize; + if (!FrameFits(calleeBp, clm) || _framesTop >= MaxCallDepth) + throw new ScriptRuntimeException("Script call stack overflow"); + if (clm.RefClearLen > 0) Array.Clear(refStack, calleeBp, clm.RefClearLen); + var cst=clm.SlotTypes; + var cas=clm.ArgSlot; // arg index → callee frame slot (null = identity) + byte* cnum=(byte*)(_num+calleeBp); + int vtThisWb = -1; + for (int k=0;k= MaxCallDepth) + throw new ScriptRuntimeException("Script call stack overflow"); + if (clm.RefClearLen > 0) Array.Clear(refStack, ctorBp, clm.RefClearLen); + var cst=clm.SlotTypes; + var cas=clm.ArgSlot; + byte* cnum=(byte*)(_num+ctorBp); + refStack[ctorBp + (cas != null ? cas[0] : 0)]=obj; // 'this' (heap ctor: O slot) + for (int k=0;k boxed bool, not boxed int). + refStack[bp+dst] = tc switch + { + 0 => (object)RdI4(numF,refStack,slotT,s,bp), + 1 => RdI4(numF,refStack,slotT,s,bp) != 0, + 2 => (char)RdI4(numF,refStack,slotT,s,bp), + 3 => (byte)RdI4(numF,refStack,slotT,s,bp), + 4 => (sbyte)RdI4(numF,refStack,slotT,s,bp), + 5 => (short)RdI4(numF,refStack,slotT,s,bp), + 6 => (ushort)RdI4(numF,refStack,slotT,s,bp), + 8 => unchecked((uint)RdI4(numF,refStack,slotT,s,bp)), + 9 => RdI8(numF,refStack,slotT,s,bp), + 10 => unchecked((ulong)RdI8(numF,refStack,slotT,s,bp)), + 11 => RdR8(numF,refStack,slotT,s,bp), + _ => RdR4(numF,refStack,slotT,s,bp), + }; + ip+=4; break; + } + case Op.box_enum: + { + int dst=(int)irP[ip+1]; int s=(int)irP[ip+2]; int tokIdx=(int)irP[ip+3]; + int ev=RdI4(numF,refStack,slotT,s,bp); + refStack[bp+dst] = asm.TokenTypes.TryGetValue(lm.Tokens[tokIdx], out var et) + ? Enum.ToObject(et, ev) : (object)ev; + ip+=4; break; + } + case Op.initobj: + { + int dst=(int)irP[ip+1]; var lay=lm.StructLayouts![dst]; int size=lay!=null?lay.Size:4; + byte* d=numF+dst*4; for (int i=0;i()); + } + ip+=3; break; + } + case Op.unbox_any: case Op.castclass: + { + int dst=(int)irP[ip+1]; int s=(int)irP[ip+2]; int tokIdx=(int)irP[ip+3]; + int tok=lm.Tokens[tokIdx]; var val=refStack[bp+s]; + // Script TypeDef target (`(C0)(object)o`): exact-descriptor check — + // script classes have no inheritance, so identity IS the type test. + // Without this, castclass to a script type silently passed anything + // (found by fuzzing alongside the isinst case below). + if (val != null && irop==Op.castclass && asm.TypeDefToType.TryGetValue(tok, out var scd)) + { + if (!(val is ScriptObject vso && vso.Type == scd)) + { int il=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"InvalidCastException{At(method, il)}: cannot cast to script type {scd.Name}"); } + } + else if (val != null && asm.TokenTypes.TryGetValue(tok, out var tt)) + { + if (irop==Op.castclass && !tt.IsInstanceOfType(val)) + { int il=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"InvalidCastException{At(method, il)}: cannot cast {val.GetType().Name} to {tt.Name}"); } + // unbox.any is type-EXACT for value types: a boxed char can't unbox to + // int (C# throws InvalidCastException). Match that — the lenient RdI4 + // read would otherwise silently convert. (Nullable accepts a boxed U.) + if (irop==Op.unbox_any && tt.IsValueType) + { + var underlying = Nullable.GetUnderlyingType(tt) ?? tt; + if (val.GetType() != underlying) + { int il=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"InvalidCastException{At(method, il)}: cannot unbox {val.GetType().Name} to {tt.Name}"); } + } + } + // Route through WrObj: an I4/R4 dst (unbox.any of a primitive) gets the + // unboxed value in the numeric frame; an O dst keeps the reference. + WrObj(numF, refStack, slotT, dst, val, bp); ip+=4; break; + } + case Op.isinst: + { + int dst=(int)irP[ip+1]; int s=(int)irP[ip+2]; int tokIdx=(int)irP[ip+3]; + int tok=lm.Tokens[tokIdx]; var val=refStack[bp+s]; + if (val==null) refStack[bp+dst]=null; + // Script TypeDef target (`o is C0`): exact-descriptor identity — covers + // script classes AND boxed script structs (found by fuzzing: threw + // "unallowed type" for `(object)o is C0`). + else if (asm.TypeDefToType.TryGetValue(tok, out var sisd)) refStack[bp+dst]=val is ScriptObject vso2 && vso2.Type == sisd ? val : null; + else if (asm.TokenTypes.TryGetValue(tok, out var tt)) refStack[bp+dst]=tt.IsInstanceOfType(val)?val:null; + else { int il=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"isinst for unallowed type{At(method, il)}"); } + ip+=4; break; + } + case Op.ldtoken: + { + int dst=(int)irP[ip+1]; int tokIdx=(int)irP[ip+2]; int tok=lm.Tokens[tokIdx]; + if (!asm.TokenTypes.TryGetValue(tok, out var tt)) { int il=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"ldtoken for unallowed type{At(method, il)}"); } + refStack[bp+dst]=tt; ip+=3; break; + } + case Op.switch_i4: + { + // Read the subject via RdI4 so an O-typed slot (a boxed int, e.g. a slow host + // call returning an enum) is honoured; a raw numF read yields 0 for a value + // that lives boxed in refFrame. + int valSlot=(int)irP[ip+1]; int n=(int)irP[ip+2]; int val=RdI4(numF,refStack,slotT,valSlot,bp); + ip = (uint)val < (uint)n ? (int)irP[ip+4+val] : (int)irP[ip+3]; + break; + } + + // --- Flat-struct (Vt) ops --- + case Op.ldfld_vt_vt: + { + int dst=(int)irP[ip+1]; int o=(int)irP[ip+2]; int off=(int)irP[ip+3]; + int size=lm.StructLayouts![dst]!.Size; + Buffer.MemoryCopy(numF+o*4+off, numF+dst*4, size, size); + ip+=4; break; + } + case Op.stfld_vt_vt: + { + int o=(int)irP[ip+1]; int off=(int)irP[ip+2]; int src2=(int)irP[ip+3]; + int size=lm.StructLayouts![src2]!.Size; + Buffer.MemoryCopy(numF+src2*4, numF+o*4+off, size, size); + ip+=4; break; + } + case Op.ldfld_vt_i4: { int dst=(int)irP[ip+1]; int o=(int)irP[ip+2]; int off=(int)irP[ip+3]; *(int*)(numF+dst*4)=*(int*)(numF+o*4+off); ip+=4; break; } + case Op.ldfld_vt_r4: { int dst=(int)irP[ip+1]; int o=(int)irP[ip+2]; int off=(int)irP[ip+3]; *(float*)(numF+dst*4)=*(float*)(numF+o*4+off); ip+=4; break; } + case Op.stfld_vt_i4: { int o=(int)irP[ip+1]; int off=(int)irP[ip+2]; int s=(int)irP[ip+3]; *(int*)(numF+o*4+off)=RdI4(numF,refStack,slotT,s,bp); ip+=4; break; } + case Op.stfld_vt_r4: { int o=(int)irP[ip+1]; int off=(int)irP[ip+2]; int s=(int)irP[ip+3]; *(float*)(numF+o*4+off)=RdR4(numF,refStack,slotT,s,bp); ip+=4; break; } + // Sub-4-byte flat fields (Color32.r-class): loads widen into the I4 dst + // cell, stores truncate. Byte pointers — no alignment assumptions. + case Op.ldfld_vt_u1: { int dst=(int)irP[ip+1]; int o=(int)irP[ip+2]; int off=(int)irP[ip+3]; *(int*)(numF+dst*4)=*(numF+o*4+off); ip+=4; break; } + case Op.ldfld_vt_i1: { int dst=(int)irP[ip+1]; int o=(int)irP[ip+2]; int off=(int)irP[ip+3]; *(int*)(numF+dst*4)=*(sbyte*)(numF+o*4+off); ip+=4; break; } + case Op.ldfld_vt_u2: { int dst=(int)irP[ip+1]; int o=(int)irP[ip+2]; int off=(int)irP[ip+3]; byte* p=numF+o*4+off; *(int*)(numF+dst*4)=(int)(uint)(p[0]|(p[1]<<8)); ip+=4; break; } + case Op.ldfld_vt_i2: { int dst=(int)irP[ip+1]; int o=(int)irP[ip+2]; int off=(int)irP[ip+3]; byte* p=numF+o*4+off; *(int*)(numF+dst*4)=(short)(p[0]|(p[1]<<8)); ip+=4; break; } + case Op.stfld_vt_b1: { int o=(int)irP[ip+1]; int off=(int)irP[ip+2]; int s=(int)irP[ip+3]; *(numF+o*4+off)=(byte)RdI4(numF,refStack,slotT,s,bp); ip+=4; break; } + case Op.stfld_vt_b2: { int o=(int)irP[ip+1]; int off=(int)irP[ip+2]; int s=(int)irP[ip+3]; int v=RdI4(numF,refStack,slotT,s,bp); byte* p=numF+o*4+off; p[0]=(byte)v; p[1]=(byte)(v>>8); ip+=4; break; } + case Op.mov_vt: + { + int dst=(int)irP[ip+1]; int s=(int)irP[ip+2]; int size=lm.StructLayouts![dst]!.Size; + byte* d=numF+dst*4; byte* src=numF+s*4; int c=0; + while (c+4<=size) { *(int*)(d+c)=*(int*)(src+c); c+=4; } + while (c helper + // (GetEnumerator on the state machine). Name the limitation. + int il=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; + string nm=asm.TokenNames.TryGetValue(tok, out var tn2)?tn2:"a host member"; + throw new ScriptRuntimeException( + $"called {nm} on a script-declared object — script types implementing " + + "host interfaces (e.g. IEnumerable iterator helpers) are not supported; " + + $"coroutines returning IEnumerator are{At(method, il)}"); + } + catch (Exception ex) + { int il=(uint)(ip-lm.IrStart)<(uint)lm.IrToIlOffset.Length?lm.IrToIlOffset[ip-lm.IrStart]:0; throw new ScriptRuntimeException($"{ex.GetType().Name}: {ex.Message}{At(method, il)}", ex); } + // A mutating instance member on a flat (Vt) receiver ran against a boxed + // copy — write the box back so property setters and mutating methods + // update the local. + if (recvSlot != -1 && slotT[recvSlot] == SType.Vt && hostEntry.ReceiverStruct != null && recv != null) + UnboxVt(numF, recvSlot, hostEntry.ReceiverStruct, recv); + if (dstSlot != -1) + { + // The lowerer may type the result slot I4/R4, so route the boxed return + // through WrObj — a direct refStack store would leave a numeric dst reading 0. + if (slotT[dstSlot]==SType.Vt) UnboxVt(numF, dstSlot, lm.StructLayouts![dstSlot]!, ret); + else WrObj(numF, refStack, slotT, dstSlot, ret, bp); + } + ip += 5 + argc; break; + } + case Op.call_host_byref: + { + int dstSlot=(int)irP[ip+1]; int recvSlot=(int)irP[ip+2]; int tokIdx=(int)irP[ip+3]; int argc=(int)irP[ip+4]; + int tok=lm.Tokens[tokIdx]; + var hostEntry=asm.HostCalls[tok]; + object? recv = recvSlot == -1 ? null + : hostEntry.ReceiverBox != ReceiverBoxKind.None && slotT[recvSlot] == SType.I4 + ? (hostEntry.ReceiverBox == ReceiverBoxKind.Char // box as char/bool, not int + ? (char)RdI4(numF, refStack, slotT, recvSlot, bp) + : RdI4(numF, refStack, slotT, recvSlot, bp) != 0) + : slotT[recvSlot]==SType.Vt && hostEntry.ReceiverStruct != null + ? BoxVt(numF, recvSlot, hostEntry.ReceiverStruct) + : RdObj(numF, refStack, slotT, recvSlot, bp); + // Exact-length buffer (ArgBuf, like the slow call_host path): the + // grow-only shared buffer kept its LARGEST length, and a later byref + // call with fewer params handed MethodInfo.Invoke an oversized array — + // TargetParameterCountException on the second invocation of any method + // whose small-arity byref call follows a larger one (found by fuzzing). + var hArgs = ArgBuf(argc); + for (int k=0;k(numF + s * 4)); + if (tt == SType.R8) return (int)Unsafe.ReadUnaligned(numF + s * 4); + // Vt slots live in the numeric frame; their refFrame entry is never cleared + // (RefClearLen covers only O slots) so it must not be read. + if (tt == SType.Vt) return 0; + // Unbox any boxed primitive the VM maps to I4 (box_prim can now produce boxed + // char/byte/short/…, not just int/bool). + var v = r[bp + s]; + return v switch + { + int ri => ri, bool rb => rb ? 1 : 0, char rc => rc, + byte rby => rby, sbyte rsb => rsb, short rsh => rsh, ushort rus => rus, + uint ru => unchecked((int)ru), + // A host caller (delegate adapter, Invoke) can hand an enum arg boxed as its real + // type into an O slot; reading it as I4 must see the underlying value, like the + // enum host-field fix did for ldfld. + Enum => unchecked((int)Convert.ToInt64(v)), + _ => 0, + }; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static float RdR4(byte* numF, object?[] r, SType[] t, int s, int bp) + { + var tt = t[s]; + if (tt == SType.R4) return *(float*)(numF + s * 4); + if (tt == SType.I4) return (float)*(int*)(numF + s * 4); + if (tt == SType.I8) return Unsafe.ReadUnaligned(numF + s * 4); + if (tt == SType.R8) return (float)Unsafe.ReadUnaligned(numF + s * 4); + if (tt == SType.Vt) return 0f; + return CoerceBoxedR4(r[bp + s]); + } + + // Read a slot as object? (boxes I4/R4). Used at the remaining boxing boundaries: + // host fields, array elements, O-typed stores. + // Host-call ARGUMENT read: like RdObj, but a flat (Vt) argument boxes through its slot + // layout. RdObj alone yields null for Vt slots, and MethodBase.Invoke silently turns a + // null arg into default(T) — zeroed struct fields (the bounds_autobind divergence). + static object? RdArg(byte* numF, object?[] r, SType[] t, int s, int bp, + HostBinding.StructLayout?[]? layouts) + { + if (t[s] == SType.Vt && layouts?[s] is { } lay) + return BoxVt(numF, s, lay); + return RdObj(numF, r, t, s, bp); + } + + static object? RdObj(byte* numF, object?[] r, SType[] t, int s, int bp) + { + var tt = t[s]; + if (tt == SType.I4) return *(int*)(numF + s * 4); + if (tt == SType.R4) return *(float*)(numF + s * 4); + // I8 boxes as long — like the I4 slot's int default, a ulong value loses its + // unsigned identity here; the explicit box path (box_prim tc 10) keeps it. + if (tt == SType.I8) return Unsafe.ReadUnaligned(numF + s * 4); + if (tt == SType.R8) return Unsafe.ReadUnaligned(numF + s * 4); + if (tt == SType.Vt) return null; + return r[bp + s]; + } + + static long RdI8(byte* numF, object?[] r, SType[] t, int s, int bp) + { + var tt = t[s]; + if (tt == SType.I8) return Unsafe.ReadUnaligned(numF + s * 4); + if (tt == SType.I4) return *(int*)(numF + s * 4); + if (tt == SType.R4) return (long)*(float*)(numF + s * 4); + if (tt == SType.R8) return (long)Unsafe.ReadUnaligned(numF + s * 4); + if (tt == SType.Vt) return 0L; + return CoerceBoxedI8(r[bp + s]); + } + + static double RdR8(byte* numF, object?[] r, SType[] t, int s, int bp) + { + var tt = t[s]; + if (tt == SType.R8) return Unsafe.ReadUnaligned(numF + s * 4); + if (tt == SType.R4) return *(float*)(numF + s * 4); + if (tt == SType.I4) return *(int*)(numF + s * 4); + if (tt == SType.I8) return Unsafe.ReadUnaligned(numF + s * 4); + if (tt == SType.Vt) return 0d; + return CoerceBoxedR8(r[bp + s]); + } + + // Write an object? into a slot, routing to the correct frame (unboxes I4/R4). + static void WrObj(byte* numF, object?[] r, SType[] t, int s, object? v, int bp) + { + var tt = t[s]; + // An I4 slot can receive any type the interpreter maps to I4 (Int32/Boolean/Char and the + // sub-int integrals). A host method returning char (String.get_Chars) or byte/short lands + // here boxed; recognizing only int/bool wrote 0 and dropped the value. (found by fuzzing.) + if (tt == SType.I4) *(int*)(numF + s * 4) = v switch + { + int vi => vi, + bool bv => bv ? 1 : 0, + char vc => vc, + byte vb => vb, + sbyte vsb => vsb, + short vsh => vsh, + ushort vus => vus, + uint vu => unchecked((int)vu), + // A byref out-param written back boxed as its real enum (Enum.TryParse) — the + // int/char family above dropped it to 0. Truncate like `(int)someEnum`. + Enum => unchecked((int)Convert.ToInt64(v)), + _ => 0, + }; + else if (tt == SType.R4) *(float*)(numF + s * 4) = CoerceBoxedR4(v); + else if (tt == SType.I8) Unsafe.WriteUnaligned(numF + s * 4, CoerceBoxedI8(v)); + else if (tt == SType.R8) Unsafe.WriteUnaligned(numF + s * 4, CoerceBoxedR8(v)); + else r[bp + s] = v; + } + + // Vt box/unbox via the registered pointer marshallers — straight off the unmanaged frame; + // the box allocation in BoxVt is the boundary's only alloc. + + static object BoxVt(byte* numF, int slot, HostBinding.StructLayout lay) + => lay.BoxFromPtr(numF + slot * 4); + + static void UnboxVt(byte* numF, int slot, HostBinding.StructLayout lay, object? boxed) + { + byte* dst = numF + slot * 4; + if (boxed == null) { for (int i = 0; i < lay.Size; i++) dst[i] = 0; return; } + lay.CopyToPtr(dst, boxed); + } + } +} +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/VmEngine.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/VmEngine.cs.meta new file mode 100644 index 0000000..b887898 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/IlInterpreter/VmEngine.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b14fe6feb7a4ab04e913f44be3fef0a5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models.meta new file mode 100644 index 0000000..425d69a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 68a343bc078aa5e459201276f7813b32 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/ArgProblem.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ArgProblem.cs new file mode 100644 index 0000000..25bf38b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ArgProblem.cs @@ -0,0 +1,101 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Unity.Pipeline +{ + /// + /// One machine-readable argument-binding defect, reported in /api/exec's + /// argProblems array. + /// + /// The vocabulary matches the unity CLI's own ArgProblemKind one-for-one, plus + /// , which needs the declared CLR type and so can + /// only come from the server. Field names mirror the CLI's record exactly, so neither side + /// needs a translation table. + /// + /// Deliberately machine-readable rather than pre-rendered English: the CLI renders AND + /// LOCALIZES these itself across 10 locales. A server sending prose would make + /// unity command the one surface that answers in English while the rest of the CLI + /// localizes. The envelope's errorDetails carries the English fallback for curl, MCP, + /// and for kinds a client has never heard of. + /// + public class ArgProblem + { + /// The kind of issue with provided argument. + [JsonProperty("kind")] + [JsonConverter(typeof(StringEnumConverter), true)] + public ArgProblemKind Kind { get; set; } + + /// The offending raw token, where the defect is about a token. + [JsonProperty("token", NullValueHandling = NullValueHandling.Ignore)] + public string Token { get; set; } + + /// The parameter name involved, where the defect is about a name. + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] + public string Name { get; set; } + + /// A did-you-mean candidate for . + [JsonProperty("suggestion", NullValueHandling = NullValueHandling.Ignore)] + public string Suggestion { get; set; } + + /// Declared positional slots, for . + [JsonProperty("capacity", NullValueHandling = NullValueHandling.Ignore)] + public int? Capacity { get; set; } + + /// Positionals supplied, for . + [JsonProperty("given", NullValueHandling = NullValueHandling.Ignore)] + public int? Given { get; set; } + + /// + /// The declared CLR type name, for . Sent as + /// Int32-style short names, matching what the catalog's schema already reports. + /// + [JsonProperty("expectedType", NullValueHandling = NullValueHandling.Ignore)] + public string ExpectedType { get; set; } + } + + /// + /// Kinds of argument-binding defect. Serialized camelCase (unknownName) to match the + /// CLI's wire expectations; the C# spelling stays PascalCase per package convention. + /// + public enum ArgProblemKind + { + // Shape — schema-independent. + + /// + /// A flag with no name: --= or --=value. + /// + /// Not a bare --: the first one is the end-of-flags separator and is consumed + /// before any name parsing, and every later one is a positional. + /// + EmptyName, + + /// --key= with nothing after the =. + EmptyValue, + + /// A -x token in positional position (never a flag's value). + SingleDash, + + /// The same --key supplied more than once. + Duplicate, + + // Names — requires the declared-parameter array. + + /// --nope, or --group-by when the command declares group_by. + UnknownName, + + /// A bare key=value positional whose left side names a real parameter. + BareAssignment, + + /// More positionals than the command has parameter slots. + ExcessPositional, + + /// A positional whose slot was already filled by an explicit flag. + PositionalConflict, + + /// + /// A token the declared parameter type cannot accept. Detectable only by the server, + /// which has the real . + /// + TypeMismatch, + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/ArgProblem.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ArgProblem.cs.meta new file mode 100644 index 0000000..e8a50c0 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ArgProblem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9735aca395c65e64ea689c065eee20c3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/AuthoringResult.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/AuthoringResult.cs new file mode 100644 index 0000000..ea23282 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Models/AuthoringResult.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/AuthoringResult.cs.meta new file mode 100644 index 0000000..6875c3a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Models/BaseResponse.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/BaseResponse.cs new file mode 100644 index 0000000..2d4c7c0 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/BaseResponse.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +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; } + + /// + /// Machine-actionable warnings for the caller: things the server dropped, coerced, or + /// wants the agent to correct (e.g. an unrecognized option value), without failing the + /// request. Null when there is nothing to say, and omitted from the JSON in EVERY mode + /// (an empty warnings array carries no information — attribute-level gating, so this + /// holds on all endpoints and in verbose serialization too). (AUTHAPI-21) + /// + [JsonProperty("warnings", NullValueHandling = NullValueHandling.Ignore)] + public List Warnings { 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. + /// + /// + /// On the /api/exec wire this is emitted only in verbose mode — the lean/verbose gating lives + /// in ExecResponseSerializer, NOT here, so every other serialization of this type + /// (status endpoints, logs, tests) keeps the field. (AUTHAPI-21) + /// + [JsonProperty("executedAt")] + public DateTime ExecutedAt { get; set; } + + /// + /// Create a failed execution response. + /// + /// Error message. + /// Additional error details for debugging. + /// A response with / set. + 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/AIClient/Packages/com.unity.pipeline/Runtime/Models/BaseResponse.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/BaseResponse.cs.meta new file mode 100644 index 0000000..a5c363b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Models/CommandExecutionRequest.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/CommandExecutionRequest.cs new file mode 100644 index 0000000..9ac97b9 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/CommandExecutionRequest.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +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] + class CommandExecutionRequest + { + /// + /// Name of the command to execute. + /// Must match a registered [CliCommand] name. + /// + /// Optional, because and carry the name in + /// their first token instead; the server assigns it here before anything downstream reads + /// it. Exactly one of the three forms may be present - enforces + /// that. + /// + [JsonProperty("command")] + public string Command { get; set; } + + /// + /// An unparsed command line: the server tokenizes it (see + /// ) and then binds it. For clients that genuinely + /// hold raw text — MCP, a chat box, a pasted line. Mutually exclusive with + /// and . + /// + [JsonProperty("commandLine")] + public string CommandLine { get; set; } + + /// + /// Pre-split tokens, first of which is the command name. Preferred over + /// by any caller that already has argv — on POSIX a CLI never + /// holds the original command STRING, so making it re-quote tokens for the server to + /// re-split would be a lossy round-trip across two independently-versioned quoting + /// dialects. Mutually exclusive with and . + /// + [JsonProperty("argv")] + public List Argv { get; set; } + + /// + /// True when this request arrived in a raw form, so the reply should echo the bound + /// parameters back. [JsonIgnore] is MANDATORY — this type is serialized + /// (ExecResponseSerializer), and an internal flag must never reach the wire. + /// + [JsonIgnore] + public bool IsRawForm => CommandLine != null || Argv != null; + + /// + /// 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; } + + /// + /// Opt into the full response envelope. When false (default) the server returns a lean, + /// token-efficient reply — compact JSON, the envelope's own null keys omitted, and the + /// always-on metadata (executedAt, command, executionTimeMs) dropped; the command's + /// result payload keeps explicit nulls (see ). Set true to get + /// every envelope field back for debugging/correlation. (AUTHAPI-21) + /// + [JsonProperty("verbose")] + public bool Verbose { get; set; } + + /// + /// When true, drop null keys from the whole reply INCLUDING the command's result payload. + /// Off by default: an absent payload key would otherwise be indistinguishable from a + /// nonexistent or misspelled one. Opt in when the result schema is already known and the + /// nulls are pure bytes — e.g. bulk list reads where a null column repeats per item. + /// Orthogonal to (envelope metadata). (AUTHAPI-21) + /// + [JsonProperty("omitNulls")] + public bool OmitNulls { get; set; } + + /// + /// When true, run the command as a detached job (CLI-335): the response returns a job + /// id immediately and the client collects the result later via GET /api/job?id=…, + /// surviving its own HTTP timeout. Execution still queues through the server's + /// one-command-at-a-time exec gate. + /// + [JsonProperty("job")] + public bool Job { get; set; } + + /// + /// Create a new command execution request. + /// + public CommandExecutionRequest() + { + Parameters = new JObject(); + } + + /// + /// Create a command execution request with specified command and parameters. + /// + /// Name of the command to execute. + /// Parameters for the command execution. + 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. + /// + /// The type to convert the parameter value to. + /// Parameter name. + /// Value to return if the parameter is missing or unconvertible. + /// The parameter's value, or . + 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. + /// + /// Parameter name. + /// True if the parameter is present. + public bool HasParameter(string name) + { + return Parameters != null && Parameters.ContainsKey(name); + } + + /// + /// Validate the request structure. + /// + /// An error message, or null if the request is valid. + public string Validate() + { + // Exactly one of the three request forms. Counting them first keeps every + // combination -- including all three at once -- on one message. + var forms = 0; + if (Command != null) forms++; + if (CommandLine != null) forms++; + if (Argv != null) forms++; + + if (forms > 1) + return "Specify exactly one of 'command', 'commandLine' or 'argv'"; + + if (IsRawForm) + { + // Rejected, not ignored: a silently-dropped payload is indistinguishable from + // one that was never sent, which is the ambiguity the lean envelope already + // legislates against. + if (Parameters != null && Parameters.Count > 0) + return "'parameters' cannot be combined with 'commandLine' or 'argv'; the arguments belong in the command line itself"; + + if (Argv != null) + { + if (Argv.Count == 0) + return "'argv' must contain at least the command name"; + for (var i = 0; i < Argv.Count; i++) + if (Argv[i] == null) + return "'argv' must not contain null elements"; + if (string.IsNullOrWhiteSpace(Argv[0])) + return "'argv' must start with the command name"; + } + else if (string.IsNullOrWhiteSpace(CommandLine)) + { + return "'commandLine' must not be empty"; + } + } + else 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/AIClient/Packages/com.unity.pipeline/Runtime/Models/CommandExecutionRequest.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/CommandExecutionRequest.cs.meta new file mode 100644 index 0000000..94629e6 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Models/CommandExecutionResponse.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/CommandExecutionResponse.cs new file mode 100644 index 0000000..f42d1b9 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/CommandExecutionResponse.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +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. + /// + /// + /// Like command and executedAt, emitted on the /api/exec wire only in verbose + /// mode — the gating lives in ExecResponseSerializer, not on this model. (AUTHAPI-21) + /// + [JsonProperty("executionTimeMs")] + public long? ExecutionTimeMs { get; set; } + + /// + /// Coarse machine-readable server state marker. Set to "busy" when the command was not + /// executed because the server's host cannot service it yet (e.g. the Editor is still + /// settling after a cold start). Omitted from the JSON on ordinary success/failure + /// responses, so existing envelopes are unchanged. + /// + [JsonProperty("status", NullValueHandling = NullValueHandling.Ignore)] + public string Status { get; set; } + + /// + /// True when the failure is transient and the same request is expected to succeed after a + /// short wait (poll /api/status until it reports "ready"). Omitted from the JSON on + /// ordinary success/failure responses. + /// + [JsonProperty("retryable", NullValueHandling = NullValueHandling.Ignore)] + public bool? Retryable { get; set; } + + /// + /// Stable, machine-readable failure discriminator (e.g. INVALID_COMMAND_ARGS). + /// + /// HTTP status cannot serve as the discriminator here: every /api/exec failure is 400, so + /// status alone cannot separate "your arguments are wrong" from "the command threw". + /// Spelled to match the unity CLI's own InvalidArgsCode literal, so neither side + /// needs a translation table. + /// + [JsonProperty("errorCode", NullValueHandling = NullValueHandling.Ignore)] + public string ErrorCode { get; set; } + + /// + /// Every argument-binding defect found, one entry each — the binder accumulates rather + /// than bailing, so a user fixing a command line sees all of its problems at once. + /// Machine-readable rather than prose, because the unity CLI renders and localizes + /// these itself; carries the English fallback for + /// clients that do not. + /// + [JsonProperty("argProblems", NullValueHandling = NullValueHandling.Ignore)] + public List ArgProblems { get; set; } + + /// + /// The resolved command's catalog entry, shaped like a /api/commands commands[] + /// element. Sent with an argument error so a client can render usage without a separate + /// schema request. Free to produce - the command is already resolved at that point. + /// + [JsonProperty("commandSchema", NullValueHandling = NullValueHandling.Ignore)] + public JObject CommandSchema { get; set; } + + /// + /// What the server bound from a raw command line. Set ONLY for argv/commandLine requests, + /// leaving the structured path's envelope unchanged. A client that does not bind locally + /// has no other way to report what the command actually received. + /// + [JsonProperty("parameters", NullValueHandling = NullValueHandling.Ignore)] + public JObject BoundParameters { get; set; } + + /// + /// Create the argument-binding failure response (sent with HTTP 400): the command was + /// resolved but its arguments could not be bound, so nothing executed. + /// + internal static CommandExecutionResponse CmdInvalidArgs(string cmd, string details, + List problems, JObject commandSchema) + { + return new CommandExecutionResponse + { + Command = cmd, + Success = false, + ExecutedAt = DateTime.UtcNow, + Error = "Invalid Command Arguments", + ErrorDetails = details, + ErrorCode = "INVALID_COMMAND_ARGS", + ArgProblems = problems, + CommandSchema = commandSchema + }; + } + + /// + /// The specific cause of a "busy" response, matching /api/status's own "status" values for + /// the same conditions ("settling", "blocked_by_dialog") — a caller can branch on this + /// directly instead of inferring the cause from whether "dialogs" happens to be populated. + /// Omitted from the JSON on ordinary success/failure responses. + /// + [JsonProperty("busyReason", NullValueHandling = NullValueHandling.Ignore)] + public string BusyReason { get; set; } + + /// + /// Dialogs (see EditorDialogEvents) that opened at or after this call started. Omitted + /// when none occurred, so existing envelopes are unchanged. Lets a caller learn "this + /// call took 40s because a dialog was shown" even without polling /api/dialog + /// concurrently. + /// + [JsonProperty("dialogsDuringExecution", NullValueHandling = NullValueHandling.Ignore)] + public List DialogsDuringExecution { get; set; } + + /// + /// Currently-open dialog(s) (see EditorDialogEvents), attached to a busy response when a + /// MainThreadRequired command was rejected because a modal dialog is blocking the main + /// thread. Omitted otherwise. + /// + [JsonProperty("dialogs", NullValueHandling = NullValueHandling.Ignore)] + public List Dialogs { get; set; } + + /// + /// Create a successful execution response. + /// + /// Name of the executed command. + /// The command's result payload. + /// How long the command took to execute, in milliseconds. + /// A successful response wrapping . + 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. + /// + /// Name of the executed command. + /// Error message. + /// Additional error details for debugging. + /// A failed response with / set. + public static CommandExecutionResponse CmdFailure(string cmd, string error, string errorDetails) + { + return new CommandExecutionResponse + { + Command = cmd, + Success = false, + ExecutedAt = DateTime.UtcNow, + Error = error, + ErrorDetails = errorDetails + }; + } + + /// + /// Create the retryable "server busy" response (sent with HTTP 503): the command was not + /// executed because the host cannot service it yet — distinguishable from a genuine + /// command failure via status/retryable so callers know to retry shortly instead of + /// guessing (AUTHAPI-35). is the specific cause ("settling", + /// "blocked_by_dialog", ...); omit it only for a generic/legacy busy state. + /// + /// Name of the command that could not be serviced yet. + /// Human-readable reason the host is busy. + /// The specific cause of the busy state, or null for a generic/legacy busy state. + /// A retryable "server busy" response. + public static CommandExecutionResponse CmdBusy(string cmd, string details, string busyReason = null) + { + return new CommandExecutionResponse + { + Command = cmd, + Success = false, + ExecutedAt = DateTime.UtcNow, + Error = "Server Busy", + ErrorDetails = details, + Status = "busy", + Retryable = true, + BusyReason = busyReason + }; + } + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/CommandExecutionResponse.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/CommandExecutionResponse.cs.meta new file mode 100644 index 0000000..1303e89 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleGroundTruth.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleGroundTruth.cs new file mode 100644 index 0000000..33dbd03 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleGroundTruth.cs @@ -0,0 +1,63 @@ +using System; +using Newtonsoft.Json; + +namespace Unity.Pipeline.Models +{ + /// + /// Editor ground truth about the console, sampled on the main thread and served alongside + /// buffered entries so a client can tell whether the buffer agrees with the Editor console + /// without a second round trip. Null when served from a player build, which has no Editor + /// console. + /// + [Serializable] + public class ConsoleGroundTruth + { + /// When the sample was taken (UTC). + [JsonProperty("sampledUtc")] + public DateTime SampledUtc { get; set; } + + /// + /// How long ago the sample was taken. Sampling runs on the main thread, so it stops while + /// the Editor is blocked compiling or importing; a large age means the rest of this object + /// describes an older state of the console. + /// + [JsonProperty("ageMs")] + public long AgeMs { get; set; } + + /// + /// True while the project has editor compile errors. Read from + /// EditorUtility.scriptCompilationFailed, which is native state independent of the + /// log callback and of any domain reload, so it stays true for as long as the errors do. + /// + [JsonProperty("compilationFailed")] + public bool CompilationFailed { get; set; } + + /// True while script compilation is in progress. + [JsonProperty("compiling")] + public bool Compiling { get; set; } + + /// + /// Errors the Editor console holds. This is a console-lifetime total, so comparing it with + /// shows whether the buffer is missing entries the + /// console still displays. + /// + [JsonProperty("consoleErrors")] + public int ConsoleErrors { get; set; } + + /// Warnings the Editor console holds. + [JsonProperty("consoleWarnings")] + public int ConsoleWarnings { get; set; } + + /// Informational entries the Editor console holds. + [JsonProperty("consoleLogs")] + public int ConsoleLogs { get; set; } + + /// + /// True once the buffer has been backfilled from the Editor console's own store. False + /// means that backfill is unavailable on this Editor version, which is a benign explanation + /// for the buffer holding fewer entries than and friends report. + /// + [JsonProperty("seeded")] + public bool Seeded { get; set; } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleGroundTruth.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleGroundTruth.cs.meta new file mode 100644 index 0000000..8ee528c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleGroundTruth.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 10d7f65f989380a4aa80b77361180394 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleLogEntry.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleLogEntry.cs new file mode 100644 index 0000000..f4910c3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleLogEntry.cs @@ -0,0 +1,63 @@ +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; } + + /// + /// Unity's own name for the entry ("Log", "Warning", + /// "Error", "Assert", "Exception"), keeping the distinction that + /// collapses. + /// + [JsonProperty("logType")] + public string LogType { 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; } + + /// + /// True when the entry was read from the Editor console's own store rather than captured + /// live. Its is when it was read, not when it was logged, and + /// its position relative to live-captured entries reflects the read, not the original + /// chronology. + /// + [JsonProperty("seeded")] + public bool Seeded { get; set; } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleLogEntry.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleLogEntry.cs.meta new file mode 100644 index 0000000..b375237 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleLogEntry.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bd1bd665815e523ee0da6504d78923df diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleLogResponse.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleLogResponse.cs new file mode 100644 index 0000000..1ebde66 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleLogResponse.cs @@ -0,0 +1,102 @@ +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. + /// Only meaningful paired with : sequence numbers restart from zero + /// with the capture session, so a cursor from an earlier session names different entries. + /// + [JsonProperty("cursor")] + public long Cursor { get; set; } + + /// + /// Identifies the capture session belongs to. A "--follow" client + /// passes this back as since_session alongside since; a cursor from a + /// different session is refused rather than silently misapplied. + /// + [JsonProperty("session")] + public string Session { 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. Also true + /// whenever is. + /// + [JsonProperty("dropped")] + public bool Dropped { get; set; } + + /// + /// True when the requested cursor could not be honored — it came from another session, or + /// it was past the current counter — so the cursor was ignored and the tail returned + /// instead. A "--follow" client should adopt and + /// and treat the returned entries as a fresh start. + /// + [JsonProperty("reset")] + public bool Reset { get; set; } + + /// + /// How many entries of each severity the buffer currently holds, before the level filter + /// and tail limit. Compare with and + /// friends to see whether the buffer is missing entries the Editor console still shows. + /// + [JsonProperty("counts")] + public ConsoleLevelCounts Counts { get; set; } + + /// + /// Editor ground truth about the console at the time it was last sampled. Null in a player + /// build, and before the first sample after an Editor load. + /// + [JsonProperty("groundTruth")] + public ConsoleGroundTruth GroundTruth { get; set; } + } + + /// + /// Entry counts per severity retained in the buffer, before the level filter and tail + /// limit are applied. Compare with the Editor console's own totals in + /// to see whether the buffer is missing entries the console + /// still shows. + /// + [Serializable] + public class ConsoleLevelCounts + { + /// + /// Retained entries at error severity. Unity's Error, Exception and Assert log types all + /// count here. + /// + [JsonProperty("error")] + public int Error { get; set; } + + /// Retained entries at warning severity. + [JsonProperty("warn")] + public int Warn { get; set; } + + /// Retained entries at informational severity. + [JsonProperty("log")] + public int Log { get; set; } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleLogResponse.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleLogResponse.cs.meta new file mode 100644 index 0000000..3ac7c4c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ConsoleLogResponse.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3382b91cda66d80db6ab090f9fbeac16 diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/EvalResponse.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/EvalResponse.cs new file mode 100644 index 0000000..2628d17 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/EvalResponse.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Unity.Pipeline.Models +{ + /// + /// Response from code evaluation commands containing results, output, and diagnostics. + /// + [Serializable] + 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. + /// + /// The evaluated expression's result. + /// Console output captured during execution. + /// How long execution took, in milliseconds. + /// Compilation diagnostics (errors, warnings) from Roslyn. + /// A successful response wrapping . + public static EvalResponse EvalSuccess(object result, string output = null, long executionTimeMs = 0, List diagnostics = null) + { + return new EvalResponse + { + Success = true, + Command = "eval", + Result = result, + Output = output, + ExecutionTimeMs = executionTimeMs, + Diagnostics = diagnostics ?? new List(), + ExecutedAt = DateTime.UtcNow + }; + } + + /// + /// Create a failed evaluation response. + /// + /// Error message. + /// Additional error details for debugging. + /// How long execution took, in milliseconds. + /// Compilation diagnostics (errors, warnings) from Roslyn. + /// A failed response. + public static EvalResponse EvalFailure(string error, string errorDetails = null, long executionTimeMs = 0, List diagnostics = null) + { + return new EvalResponse + { + Success = false, + Command = "eval", + Error = error, + ErrorDetails = errorDetails, + ExecutionTimeMs = executionTimeMs, + Diagnostics = diagnostics ?? new List(), + ExecutedAt = DateTime.UtcNow + }; + } + } + + /// + /// 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; } + + /// + /// User source file the diagnostic maps to through a #line directive, or null when + /// the compiled source carries no mapping (eval snippets, generated scaffolding). + /// + [JsonProperty("file", NullValueHandling = NullValueHandling.Ignore)] + public string File { get; set; } + + /// + /// 1-based line in ; null when is null. + /// + [JsonProperty("fileLine", NullValueHandling = NullValueHandling.Ignore)] + public int? FileLine { get; set; } + + /// + /// Trimmed text of the compiled source line the diagnostic points at. For code reload that + /// is the generated override source, which is what actually failed to compile. + /// + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public string Source { get; set; } + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/EvalResponse.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/EvalResponse.cs.meta new file mode 100644 index 0000000..3ee6d5b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Models/InstanceDescriptor.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/InstanceDescriptor.cs new file mode 100644 index 0000000..4c2e15f --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/InstanceDescriptor.cs @@ -0,0 +1,273 @@ +using System; +using System.IO; +using System.Diagnostics; +using Newtonsoft.Json; +using UnityEngine; +using Unity.Pipeline.Security; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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] +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + class InstanceDescriptor + { + private const string DescriptorFileName = ".unity-pipeline-port"; + + /// + /// Corrective guidance surfaced via when the Editor is interactive and + /// wasn't launched with -automated (UUM-149977). Read by every client before it can even + /// connect (see ), unlike a console warning which was logged once per + /// server instance and nobody watches anyway. + /// + private const string AutomationInfoMessage = + "This editor has not been opened in automated mode and therefore could get stuck on modal dialogs. Human intervention may be required."; + + /// + /// 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; } + + /// + /// Corrective guidance for the client, e.g. warning that this instance could get stuck on + /// a modal dialog. Null when there is nothing to say (launched with -automated, or + /// batchmode — which can't show modals regardless, see ) — omitted from + /// the JSON entirely, same convention as . Read this + /// before issuing commands: it's the one piece of state every client sees, since reading + /// the descriptor is a mandatory first step just to connect (UUM-149977). There is no + /// separate "automated" flag: it would only ever disagree with a null/non-null `Info` for + /// batchmode instances, and that distinction carries no information — batchmode can't show + /// modal dialogs either way. + /// + [JsonProperty("info", NullValueHandling = NullValueHandling.Ignore)] + public string Info { get; set; } + + /// + /// Optional wire features this server understands (e.g. exec.argv), mirrored from + /// so the two carriers cannot drift. + /// + /// Carried in the descriptor because every client already reads this file locally to + /// obtain the eval token, so negotiation costs no extra request. A descriptor with NO + /// capabilities key at all comes from a server too old to have the field, and a client + /// must then assume it supports none of these features. + /// + [JsonProperty("capabilities", NullValueHandling = NullValueHandling.Ignore)] + public string[] Capabilities { get; set; } + + /// + /// Create instance descriptor for current Editor session + /// + /// Port the server is listening on. + /// Whether the Editor was launched with -automated. + /// A populated descriptor for this Editor session. + public static InstanceDescriptor CreateCurrent(int port, bool automated) + { + 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 + { + Capabilities = BasePipelineServer.Capabilities, + Pid = pid, + Port = port, + ProjectPath = projectPath, + ProjectName = projectName, + UnityVersion = unityVersion, + Mode = isBatchMode ? "batchmode" : "editor", + StartedAt = DateTime.UtcNow, + LastHeartbeat = DateTime.UtcNow, + EvalToken = evalToken, + // Only in an interactive Editor: batchmode (CI, upm-pvp, UTR) can't show modal + // popups, so the guidance would be pure noise there. + Info = (!automated && !isBatchMode) ? AutomationInfoMessage : null + }; + } + catch (Exception ex) + { + UnityEngine.Debug.LogError($"CreateCurrent failed: {ex.Message}"); + UnityEngine.Debug.LogError($"Stack trace: {ex.StackTrace}"); + throw; + } + } + + /// + /// Serializes descriptor writes. Heartbeat rewrites can now run concurrently (requests + /// are processed in parallel since the /api/progress work); a torn concurrent write + /// would leave clients reading half a descriptor. + /// + private static readonly object m_WriteGate = new object(); + + /// + /// Write instance descriptor to project root + /// + /// The descriptor to write. + public static void WriteToProjectRoot(InstanceDescriptor descriptor) + { + lock (m_WriteGate) + { + WriteToProjectRootLocked(descriptor); + } + } + + private static void WriteToProjectRootLocked(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 + /// + /// Absolute path to the Unity project. + /// The descriptor, or null if missing/corrupted. + 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 + /// + /// Absolute path to the Unity project. + 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 + /// + /// Absolute path to the Unity project. + 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. Kept in one place so discovery code + /// and tests can't drift from where this class itself reads/writes. + /// + /// Absolute path to the Unity project. + /// The descriptor file's absolute path. + public static string GetDescriptorFilePath(string projectPath) + { + return Path.Combine(projectPath, "Library", "Pipeline", DescriptorFileName); + } + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/InstanceDescriptor.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/InstanceDescriptor.cs.meta new file mode 100644 index 0000000..c86575b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Models/ObjectRef.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ObjectRef.cs new file mode 100644 index 0000000..b2ab3f3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ObjectRef.cs @@ -0,0 +1,60 @@ +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; } + + /// True when none of the reference forms above are set. + [JsonIgnore] + public bool IsEmpty => + string.IsNullOrEmpty(GlobalId) && + string.IsNullOrEmpty(Path) && + string.IsNullOrEmpty(Guid) && + InstanceId == null && + string.IsNullOrEmpty(HierarchyPath); + + /// The reference in its most specific set form, for diagnostics/logging. + /// The formatted string. + 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/AIClient/Packages/com.unity.pipeline/Runtime/Models/ObjectRef.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ObjectRef.cs.meta new file mode 100644 index 0000000..e436c15 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Models/ObjectRefConverter.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ObjectRefConverter.cs new file mode 100644 index 0000000..2e70544 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ObjectRefConverter.cs @@ -0,0 +1,199 @@ +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. + /// + 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); + + /// Read an from a structured object, a canonical string, or a bare instance id. + /// The JSON reader. + /// The declared property type. + /// The existing value, if any. + /// Whether is valid. + /// The active serializer. + /// The parsed reference, or null for a JSON null. + 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; + } + } + + /// Write an as a structured JSON object. + /// The JSON writer. + /// The reference to write, or null. + /// The active serializer. + 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/AIClient/Packages/com.unity.pipeline/Runtime/Models/ObjectRefConverter.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/ObjectRefConverter.cs.meta new file mode 100644 index 0000000..0b538de --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Models/RuntimeInstanceDescriptor.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/RuntimeInstanceDescriptor.cs new file mode 100644 index 0000000..cbe7107 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/RuntimeInstanceDescriptor.cs @@ -0,0 +1,251 @@ +using System; +using Newtonsoft.Json; +using UnityEngine; +using Unity.Pipeline.Config; +using System.IO; +using Unity.Pipeline.Security; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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] +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + 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; } + + /// + /// Optional wire features this server understands (e.g. exec.argv), mirrored from + /// so the two carriers cannot drift. + /// + /// Carried in the descriptor because every client already reads this file locally to + /// obtain the eval token, so negotiation costs no extra request. A descriptor with NO + /// capabilities key at all comes from a server too old to have the field, and a client + /// must then assume it supports none of these features. + /// + [JsonProperty("capabilities", NullValueHandling = NullValueHandling.Ignore)] + public string[] Capabilities { get; set; } + + /// + /// Create runtime instance descriptor for current Player application. + /// + /// Port the runtime server is listening on. + /// The runtime pipeline configuration (currently unused, reserved for future fields). + /// A populated descriptor for this Player instance. + 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 + { + Capabilities = BasePipelineServer.Capabilities, + 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; + } + } + + /// + /// Serializes descriptor writes so concurrent heartbeat rewrites (e.g. overlapping + /// /api/status probes) can't leave a torn file on disk. Mirrors InstanceDescriptor's + /// m_WriteGate. + /// + private static readonly object m_WriteGate = new object(); + + /// + /// Write runtime instance descriptor to working directory. + /// + /// The descriptor to write. + public static void WriteToWorkingDirectory(RuntimeInstanceDescriptor descriptor) + { + lock (m_WriteGate) + { + 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. + /// + /// The descriptor, or null if missing/corrupted. + 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}"); + } + } + + /// + /// Test-only override of the detected platform, so the Android branch below is reachable + /// without a real device. Null (the default) uses the real . + /// + internal static RuntimePlatform? OverridePlatform; + + internal static string GetDescriptorFilePath() + { + var platform = OverridePlatform ?? Application.platform; + + // On Android, Application.dataPath points inside the read-only, system-owned APK + // install directory (e.g. ".../base.apk") — the app cannot write there. persistentDataPath + // is the one location Unity guarantees is writable on Android (UUM-151968). + var workingDir = platform == RuntimePlatform.Android + ? Application.persistentDataPath + : new FileInfo($"{Application.dataPath}/..").FullName; + + return Path.Combine(workingDir, RuntimeDescriptorFileName); + } + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/RuntimeInstanceDescriptor.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/RuntimeInstanceDescriptor.cs.meta new file mode 100644 index 0000000..6c7a30e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Models/StatusResponse.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/StatusResponse.cs new file mode 100644 index 0000000..9eb853e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/StatusResponse.cs @@ -0,0 +1,62 @@ +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; } + + /// + /// The dialog currently blocking the main thread, present only when + /// is "blocked_by_dialog". + /// + [JsonProperty("dialog", NullValueHandling = NullValueHandling.Ignore)] + public object Dialog { get; set; } + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/StatusResponse.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/StatusResponse.cs.meta new file mode 100644 index 0000000..12a903d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Models/TestExecutionResponse.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/TestExecutionResponse.cs new file mode 100644 index 0000000..d64991d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/TestExecutionResponse.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using Unity.Pipeline.Models; + +namespace Unity.Pipeline +{ + /// + /// Response for test execution commands, containing summary and detailed results + /// + [Serializable] + class TestExecutionResponse : CommandExecutionResponse + { + /// Aggregate pass/fail/skip counts for the run. + public TestSummary Summary { get; set; } + /// Per-test results. + public List Results { get; set; } + /// Total run duration, in seconds. + public double Duration { get; set; } + /// Path to poll for status when the run was started in async mode. + public string StatusPath { get; set; } // For async mode + /// Which suite was run: EditMode, PlayMode, or All. + public string Mode { get; set; } // EditMode, PlayMode, or All + /// The test filter that was applied, if any. + public string FilterApplied { get; set; } // What filter was used, if any + + /// Create an empty response with initialized collections. + public TestExecutionResponse() + { + Results = new List(); + Summary = new TestSummary(); + } + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/TestExecutionResponse.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/TestExecutionResponse.cs.meta new file mode 100644 index 0000000..29f2d39 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Models/TestResult.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/TestResult.cs new file mode 100644 index 0000000..83d4790 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/TestResult.cs @@ -0,0 +1,22 @@ +using System; + +namespace Unity.Pipeline +{ + /// + /// Individual test result information + /// + [Serializable] + class TestResult + { + /// Fully-qualified test name. + public string FullName { get; set; } + /// Outcome: Passed, Failed, Skipped, or Inconclusive. + public string Status { get; set; } // Passed, Failed, Skipped, Inconclusive + /// How long the test took, in seconds. + public double Duration { get; set; } + /// Failure message, if any. + public string Message { get; set; } + /// Failure stack trace, if any. + public string StackTrace { get; set; } + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/TestResult.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/TestResult.cs.meta new file mode 100644 index 0000000..80f8419 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Models/TestSummary.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Models/TestSummary.cs new file mode 100644 index 0000000..443c1a8 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Models/TestSummary.cs @@ -0,0 +1,32 @@ +using System; + +namespace Unity.Pipeline +{ + /// + /// Summary statistics for test execution + /// + [Serializable] + class TestSummary + { + /// Total number of tests run. + public int Total { get; set; } + /// Number of tests that passed. + public int Passed { get; set; } + /// Number of tests that failed. + public int Failed { get; set; } + /// Number of tests skipped. + public int Skipped { get; set; } + /// Number of tests that ran but produced no pass/fail verdict. + public int Inconclusive { get; set; } + + /// Create a zeroed summary. + public TestSummary() + { + Total = 0; + Passed = 0; + Failed = 0; + Skipped = 0; + Inconclusive = 0; + } + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Models/TestSummary.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Models/TestSummary.cs.meta new file mode 100644 index 0000000..7bbaa28 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport.meta b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport.meta new file mode 100644 index 0000000..21bb235 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4a339d7263cb1ac4f879d79c8e7fe3ca +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/CodeReloadStatusOverlay.cs b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/CodeReloadStatusOverlay.cs new file mode 100644 index 0000000..c1acbab --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/CodeReloadStatusOverlay.cs @@ -0,0 +1,120 @@ +using UnityEngine; +using Unity.Pipeline.CodeReload; + +namespace Unity.Pipeline +{ + /// + /// Minimal on-screen code-reload status for players: what is happening now (compiling → applied, + /// or failed) and how many method overrides are active. Deliberately IMGUI — no canvas, + /// EventSystem or render-pipeline dependency, and it is development-only anyway (the body + /// compiles out of release players; the type itself stays so AddComponent call sites compile). + /// Added automatically by when its + /// reload-overlay flag is on. Hidden entirely while idle with no overrides. + /// + [DisallowMultipleComponent] + class CodeReloadStatusOverlay : MonoBehaviour + { +#if UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE + // How long the "applied" confirmation stays up before falling back to the count line. + private const int AppliedFlashMs = 3500; + // A "compiling…" with no follow-up payload or failure notice goes stale (editor died, + // connection dropped) — stop showing it after this. + private const int CompilingStaleMs = 15000; + private const float StatsPollSeconds = 1f; + private const int MaxMessageLength = 96; + + private int m_OverrideCount; + private int m_CallsPerSecond; + private long m_LastCallCount; + private float m_LastPollTime; + private float m_NextStatsPoll; + private GUIStyle m_Style; + + private void Update() + { + // GetStats() allocates; poll at 1 Hz instead of per frame. + if (Time.unscaledTime >= m_NextStatsPoll) + { + m_NextStatsPoll = Time.unscaledTime + StatsPollSeconds; + m_OverrideCount = CodeReloadRegistry.GetStats().ActiveOverrideCount; + + long calls = CodeReloadActivity.OverrideCallCount; + float elapsed = Time.unscaledTime - m_LastPollTime; + if (m_LastPollTime > 0 && elapsed > 0) + m_CallsPerSecond = (int)((calls - m_LastCallCount) / elapsed); + m_LastCallCount = calls; + m_LastPollTime = Time.unscaledTime; + } + } + + private void OnGUI() + { + var phase = CodeReloadActivity.Snapshot(out var message, out var ageMs); + + string activity = null; + Color activityColor = Color.white; + switch (phase) + { + case CodeReloadActivity.Phase.Compiling when ageMs < CompilingStaleMs: + activity = $"compiling {Truncate(message)}…"; + activityColor = new Color(1f, 0.8f, 0.25f); // amber + break; + case CodeReloadActivity.Phase.Applied when ageMs < AppliedFlashMs: + activity = Truncate(message); + activityColor = new Color(0.45f, 1f, 0.45f); // green + break; + case CodeReloadActivity.Phase.Failed: + activity = Truncate(message); // sticky until the next reload attempt + activityColor = new Color(1f, 0.45f, 0.45f); // red + break; + } + + if (activity == null && m_OverrideCount == 0) + return; // nothing to show + + m_Style ??= new GUIStyle(GUI.skin.label) { fontSize = 12, wordWrap = false }; + + var lines = activity != null + ? new[] { ($"CodeReload: {activity}", activityColor), (CountLine(), Color.white) } + : new[] { (CountLine(), Color.white) }; + + float width = 0, height = 4; + foreach (var l in lines) + { + var s = m_Style.CalcSize(new GUIContent(l.Item1)); + if (s.x > width) width = s.x; + height += s.y; + } + + var box = new Rect(8, 8, width + 12, height + 4); + GUI.color = new Color(0f, 0f, 0f, 0.6f); + GUI.DrawTexture(box, Texture2D.whiteTexture); + + float y = box.y + 4; + foreach (var l in lines) + { + var size = m_Style.CalcSize(new GUIContent(l.Item1)); + var r = new Rect(box.x + 6, y, size.x, size.y); + // 1px shadow keeps it readable over bright scenes. + GUI.color = new Color(0f, 0f, 0f, 0.9f); + GUI.Label(new Rect(r.x + 1, r.y + 1, r.width, r.height), l.Item1, m_Style); + GUI.color = l.Item2; + GUI.Label(r, l.Item1, m_Style); + y += size.y; + } + GUI.color = Color.white; + } + + // "N overrides · 0 calls/s" is a diagnostic in itself: bound but never dispatched. + private string CountLine() => $"{m_OverrideCount} override(s) active · {m_CallsPerSecond} calls/s"; + + private static string Truncate(string s) + { + if (string.IsNullOrEmpty(s)) return ""; + int nl = s.IndexOf('\n'); + if (nl >= 0) s = s.Substring(0, nl); // failures can carry multi-line diagnostics + return s.Length <= MaxMessageLength ? s : s.Substring(0, MaxMessageLength) + "…"; + } +#endif + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/CodeReloadStatusOverlay.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/CodeReloadStatusOverlay.cs.meta new file mode 100644 index 0000000..cb29d33 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/CodeReloadStatusOverlay.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d38c4fa15aa73074793fc9d4f45cb451 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/FrameStatsSampler.cs b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/FrameStatsSampler.cs new file mode 100644 index 0000000..9561b1a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/FrameStatsSampler.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using Unity.Profiling; +using UnityEngine; +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#endif + +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. + /// +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + sealed class FrameStatsSampler : IDisposable + { + /// + /// The process-wide sampler fed by the active . Null when no + /// driver is present (e.g. EditMode tests, or a Player build without Pipeline enabled). 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; + + /// Create a sampler with the given rolling-window size. + /// Number of frames the rolling window holds (minimum 1). + 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). + /// + /// Unscaled delta time of the frame just completed. + 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). + /// + /// A point-in-time snapshot of the current window. + 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) + }); + } + + /// Dispose the underlying profiler recorders. + 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] + 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; } + + /// Average frame time over the window, in milliseconds. + public float AverageFrameTimeMs { get; set; } + /// Minimum frame time over the window, in milliseconds. + public float MinFrameTimeMs { get; set; } + /// Maximum frame time over the window, in milliseconds. + public float MaxFrameTimeMs { get; set; } + /// Most recently sampled frame's time, in milliseconds. + public float LastFrameTimeMs { get; set; } + + /// True when FrameTimingManager returned CPU/GPU timings for a recent frame. + public bool GpuTimingAvailable { get; set; } + /// CPU frame time from FrameTimingManager, in milliseconds. Only valid when is true. + public double CpuFrameTimeMs { get; set; } + /// GPU frame time from FrameTimingManager, in milliseconds. Only valid when is true. + public double GpuFrameTimeMs { get; set; } + + /// Valid profiler counters by reported name (e.g. DrawCalls, Triangles). + public Dictionary Counters { get; set; } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/FrameStatsSampler.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/FrameStatsSampler.cs.meta new file mode 100644 index 0000000..4fd085d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineBootstrap.cs b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineBootstrap.cs new file mode 100644 index 0000000..ded5be1 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineBootstrap.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using Unity.Pipeline.Config; +using UnityEngine; + +namespace Unity.Pipeline +{ + /// + /// Creates the runtime Pipeline driver automatically when a Player boots (or Play Mode is + /// entered in the Editor) — no scene setup required. Reads RuntimePipelineConfig (JSON-backed + /// in the Editor, a build-baked Resources asset in a Player) and the build-baked + /// RuntimePipelineBuildInfo from Resources; does nothing if no config exists or the config + /// has the server disabled. + /// + public static class RuntimePipelineBootstrap + { + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + static void RuntimeBootstrap() + { + Bootstrap(); + } + + /// + /// The driver created by , or null if it has not run yet (or created + /// nothing because there was no usable config). Reachable for tests and manual control. + /// + public static RuntimePipelineDriver Instance { get; internal set; } + + /// + /// Core bootstrap logic, split out from the attributed entry point so tests can invoke it + /// directly instead of waiting for a real domain/Player boot. Returns the created driver, + /// or null if the server should not start (no config asset, or disabled). Idempotent: a + /// second call returns the existing rather than creating a duplicate. + /// + /// The created (or already-existing) driver, or null if the server should not start. + public static RuntimePipelineDriver Bootstrap() + { + if (Instance != null) + return Instance; + + var config = RuntimePipelineConfig.Load(); + if (config == null || !config.enableInBuilds) + { +#if UNITY_EDITOR + // The runtime server is a player-build feature, but in-editor code reload still + // needs the [CodeReload] targets registered for this Play session — the driver + // would do it in Awake; with no driver, do it here so a save can bind. + RuntimePipelineDriver.RegisterDiscoveredCodeReloadMethods(); +#endif + return null; + } + + var buildInfo = RuntimePipelineBuildInfo.Load(); + IReadOnlyList roots = buildInfo != null + ? (IReadOnlyList)buildInfo.allowedReloadRoots + : System.Array.Empty(); + // Empty when there's no build info (e.g. play mode never built): the driver then rejects + // every push, which is the correct strict default outside a real signed build. + string pushPublicKey = buildInfo != null ? buildInfo.pushPublicKey : ""; + + var go = new GameObject("Pipeline Runtime Driver") { hideFlags = HideFlags.DontSave }; + var driver = go.AddComponent(); + driver.Initialize(config, roots, pushPublicKey); + Instance = driver; + return driver; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineBootstrap.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineBootstrap.cs.meta new file mode 100644 index 0000000..e49ee49 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineBootstrap.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: da4d1b70b51a29841938b3c1361b8300 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineBuildInfo.cs b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineBuildInfo.cs new file mode 100644 index 0000000..dd24060 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineBuildInfo.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace Unity.Pipeline.Config +{ + /// + /// Build-only, generated companion to : holds the absolute, + /// machine-specific code-reload roots baked in by PipelineRuntimeBuildProcessor. Created fresh + /// before each build and deleted immediately after — never checked into source control, since + /// its contents are meaningless outside the machine that produced them. Absent outside of a + /// real build (e.g. Editor Play Mode without ever having built) is not an error; + /// RuntimePipelineBootstrap simply has no roots to allow in that case. + /// + public class RuntimePipelineBuildInfo : ScriptableObject + { + /// Resource name this asset must be saved under inside a Resources folder for to find it. + public const string ResourceName = "RuntimePipelineBuildInfo"; + + /// Absolute paths of the project's Assets folder and every loaded package, baked in at build time. + public List allowedReloadRoots = new List(); + + /// Public half of the project's push-signing key (from Library/Pipeline/signing.key). + /// A player build rejects every code-reload push not signed by the matching private key; empty + /// means reject all pushes (strict policy — there is deliberately no unsigned fallback). + public string pushPublicKey = ""; + + /// Load the asset from Resources, or null if this is not a real build. + /// The build-time-baked info, or null if this is not a real build. + public static RuntimePipelineBuildInfo Load() => Resources.Load(ResourceName); + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineBuildInfo.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineBuildInfo.cs.meta new file mode 100644 index 0000000..3aa9184 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineBuildInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bffff28fa8e3a084d86374beef94b84d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineConfig.cs b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineConfig.cs new file mode 100644 index 0000000..fccaef0 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineConfig.cs @@ -0,0 +1,222 @@ +using System; +#if UNITY_EDITOR +using System.IO; +#endif +using UnityEngine; + +namespace Unity.Pipeline.Config +{ + /// + /// Configuration for Pipeline server functionality in Unity Player builds. Authored settings + /// live in a JSON file under ProjectSettings/ (see /) — + /// never as an asset under Assets/, so nothing about this package appears in the user's + /// Project window. At real Player build time, PipelineRuntimeBuildProcessor bakes the current + /// settings into a transient Resources asset (deleted again after the build) so the Player + /// can find them via Resources.Load. + /// + public class RuntimePipelineConfig : ScriptableObject + { + /// Master switch. The server only starts when true. Off by default for safety. + [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; + + /// Listen port. 0 auto-assigns from 7900-7999. + [Header("Server")] + [Tooltip("HTTP port for Pipeline server. Use 0 for auto-assignment from range 7900-7999.")] + public int port = 0; + + /// Per-request timeout, in milliseconds. + [Tooltip("Request timeout in milliseconds. Higher values allow longer-running commands.")] + [Range(1000, 60000)] + public int requestTimeoutMs = 30000; + + /// Log remote requests for auditing. + [Header("Runtime Behavior")] + [Tooltip("Enable detailed logging of all remote requests for security auditing.")] + public bool enableAuditLogging = true; + + /// Start the server automatically when the Player boots (or Play Mode is entered). + [Tooltip("Start the server automatically when the Player boots (or Play Mode is entered).")] + public bool autoStart = true; + + /// Dispatcher work items processed per frame. + [Tooltip("Maximum work items the dispatcher processes per frame to maintain performance.")] + [Range(1, 50)] + public int maxWorkItemsPerFrame = 10; + + // Plain int field (not [Range]) so the Inspector shows a text field rather than an + // unusable 0-65535 slider; the bound is still enforced here, same pattern as + // EditorPipelineManager.OnValidate's m_WatchdogIntervalSeconds clamp. + private void OnValidate() + { + port = Mathf.Clamp(port, 0, 65535); + } + + /// Resource name the build-time-generated transient asset is saved under for to find in a Player build. + public const string ResourceName = "RuntimePipelineConfig"; + +#if UNITY_EDITOR + /// Project-relative path of the authored settings file (never under Assets/). + private const string SettingsFilePath = "ProjectSettings/Packages/com.unity.pipeline/RuntimePipelineConfig.json"; +#endif + + /// + /// Load the current settings, or null if none exist yet. In the Editor (including Play + /// Mode) this reads the authored ProjectSettings/ JSON file directly; in a real Player + /// build it reads the build-time-generated transient Resources asset. + /// + /// The current settings, or null if none exist yet. + public static RuntimePipelineConfig Load() + { +#if UNITY_EDITOR + var path = GetAbsoluteSettingsPath(); + if (!File.Exists(path)) + return null; + + var config = CreateInstance(); + try + { + JsonUtility.FromJsonOverwrite(File.ReadAllText(path), config); + } + catch (Exception ex) + { + Debug.LogWarning($"Pipeline: could not read runtime settings at '{path}' ({ex.Message}); using no config. Delete the file to reset."); + DestroyImmediate(config); + return null; + } + return config; +#else + return Resources.Load(ResourceName); +#endif + } + +#if UNITY_EDITOR + private static int s_LiveMaxWorkItemsPerFrameCache = 10; + private static DateTime s_LiveMaxWorkItemsPerFrameCacheFileTime; + + /// + /// Cheap, per-frame-callable read of just maxWorkItemsPerFrame from the authored settings + /// file, for RuntimePipelineDriver.Update() to poll without paying Load()'s full + /// CreateInstance+JSON-parse cost every frame. Re-parses only when the file's last-write + /// time changes, so a Project Settings edit (including while in Play Mode) takes effect on + /// the very next frame, while an idle frame costs only a single file-timestamp check. + /// Editor-only: a Player's config is frozen at build time, so there is nothing to poll for. + /// + public static int GetLiveMaxWorkItemsPerFrame(int fallback) + { + var path = GetAbsoluteSettingsPath(); + if (!File.Exists(path)) + return fallback; + + var writeTime = File.GetLastWriteTimeUtc(path); + if (writeTime != s_LiveMaxWorkItemsPerFrameCacheFileTime) + { + var config = Load(); + if (config != null) + { + s_LiveMaxWorkItemsPerFrameCache = config.maxWorkItemsPerFrame; + DestroyImmediate(config); + } + s_LiveMaxWorkItemsPerFrameCacheFileTime = writeTime; + } + + return s_LiveMaxWorkItemsPerFrameCache; + } + + /// Persist this instance's current field values to the ProjectSettings/ JSON file. + public void Save() + { + var path = GetAbsoluteSettingsPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + var previousWrite = File.Exists(path) ? File.GetLastWriteTimeUtc(path) : DateTime.MinValue; + File.WriteAllText(path, JsonUtility.ToJson(this, true)); + + // GetLiveMaxWorkItemsPerFrame and the settings page detect changes by last-write time. + // Windows stamps writes with the system clock tick (~15 ms), so two saves in quick + // succession can share a stamp and the second one goes unnoticed. Make every save + // observable by moving the stamp forward when the file system did not. + if (File.GetLastWriteTimeUtc(path) <= previousWrite) + File.SetLastWriteTimeUtc(path, previousWrite.AddMilliseconds(1)); + } + + /// + /// Last-write-time of the authored settings file (UTC), or DateTime.MinValue if it doesn't + /// exist yet. Lets a long-lived caller (the Project Settings page) detect that the file + /// changed underneath it — e.g. a build's "Disable Pipeline" security-dialog choice, or a + /// set_runtime_pipeline_settings CLI call — without reloading from disk on every frame. + /// + public static DateTime GetSettingsFileWriteTimeUtc() + { + var path = GetAbsoluteSettingsPath(); + return File.Exists(path) ? File.GetLastWriteTimeUtc(path) : DateTime.MinValue; + } + + /// Delete the authored settings file, if any. For test cleanup only. + public static void DeleteSettingsFileForTesting() + { + var path = GetAbsoluteSettingsPath(); + if (File.Exists(path)) + File.Delete(path); + } + + private static string GetAbsoluteSettingsPath() + { + // Application.dataPath is ".../Assets"; ProjectSettings is a sibling directory. + var projectRoot = Path.GetDirectoryName(Application.dataPath); + return Path.Combine(projectRoot, SettingsFilePath); + } +#endif + + /// + /// Validate the configuration for correctness. + /// Called by build processor to ensure safe deployment. + /// + /// The validation result. + 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 + { + /// False only for results. + public bool IsValid { get; set; } + /// "success", "warning", or "error". + public string Level { get; set; } // "success", "warning", "error" + /// Human-readable validation message. + public string Message { get; set; } + + /// Create a successful validation result. + /// Human-readable message. + /// A valid, "success"-level result. + public static ValidationResult Success(string message = "Valid") => + new ValidationResult { IsValid = true, Level = "success", Message = message }; + + /// Create a valid-but-noteworthy validation result. + /// Human-readable message. + /// A valid, "warning"-level result. + public static ValidationResult Warning(string message) => + new ValidationResult { IsValid = true, Level = "warning", Message = message }; + + /// Create a failed validation result. + /// Human-readable message. + /// An invalid, "error"-level result. + public static ValidationResult Error(string message) => + new ValidationResult { IsValid = false, Level = "error", Message = message }; + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineConfig.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineConfig.cs.meta new file mode 100644 index 0000000..7f579c1 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineDriver.cs b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineDriver.cs new file mode 100644 index 0000000..7722f1b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineDriver.cs @@ -0,0 +1,664 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Unity.Pipeline.Config; +using Unity.Pipeline.Commands; +using Unity.Pipeline.CodeReload; +using Unity.Pipeline.Runtime.Telemetry; +using UnityEngine; +#if (UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE) +using UnityEngine.Networking.PlayerConnection; +#endif + +namespace Unity.Pipeline +{ + /// + /// Drives the runtime Pipeline server. Created exactly once, in code, by + /// — never authored in a scene. Hidden from Add + /// Component (nothing to configure via the Inspector; see instead) + /// and guarded against stacking multiple instances on one GameObject, in case it's ever added + /// manually or via a script despite that. + /// + [AddComponentMenu("")] + [DisallowMultipleComponent] + public class RuntimePipelineDriver : MonoBehaviour + { + // Not yet exposed via RuntimePipelineConfig/the runtime settings page — always on. Wire up + // through m_Config alongside the other Runtime Behavior settings if a project needs to + // disable the overlay. + private const bool ShowReloadOverlay = true; + + private RuntimePipelineServer m_Server; + private RuntimePipelineConfig m_Config; + private IReadOnlyList m_AllowedReloadRoots = Array.Empty(); + + // Public half of the project's push-signing key. Baked at build time into the + // RuntimePipelineBuildInfo asset (from Library/Pipeline/signing.key in the editor) and + // supplied via Initialize — a player build rejects every code-reload push that is not signed + // by the matching private key; empty means "reject all pushes" (strict policy, no fallback). + private string m_PushPublicKey = ""; + private bool m_OwnsSampler; + + // Restored by StopServer so Play Mode exit doesn't leave the user's setting overridden. + private bool m_PreviousRunInBackground; + + // True only between something overriding Application.runInBackground (StartServer, or + // DrainPendingReloads reacting to a code-reload push that arrived with no HTTP server + // running) and StopServer restoring it. Both writers check this before saving + // m_PreviousRunInBackground, so whichever gets there first "owns" the saved value — the + // other must not clobber it with the already-overridden "true". Gating the restore on this + // (rather than m_Server.IsRunning) also means it still happens even if the listener already + // died on its own before StopServer runs — otherwise runInBackground would stay stuck on, + // reproducing a narrower version of the bug this ownership move fixed in the first place. + private bool m_RunInBackgroundOverridden; + + /// 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; + + /// The configuration this driver was initialized with. + public RuntimePipelineConfig Config => m_Config; + + /// Absolute roots this build may code reload source files from. Baked at build time. + public IReadOnlyList AllowedReloadRoots => m_AllowedReloadRoots; + + /// + /// Supply configuration and baked code-reload roots. Must be called once, immediately after + /// AddComponent (before the end of the current frame, i.e. before Start runs). + /// + /// The configuration to run with. + /// Absolute roots this build may code reload source files from. + /// Public half of the push-signing key pushes must be signed with; empty rejects all pushes. + public void Initialize(RuntimePipelineConfig config, IReadOnlyList allowedReloadRoots, + string pushPublicKey = "") + { + m_Config = config; + m_AllowedReloadRoots = allowedReloadRoots ?? Array.Empty(); + m_PushPublicKey = pushPublicKey ?? ""; + } + + /// Public half of the project push-signing key. Baked at build time. + public string PushPublicKey => m_PushPublicKey; + + void Awake() + { + // Persistent across scene loads; exactly one of these ever exists (created by + // RuntimePipelineBootstrap), so there is no duplicate-instance case to defend against. + // DontDestroyOnLoad only functions (and is only legal to call) in Play Mode; invoking it + // from an Edit Mode context (e.g. an EditMode test, or this driver created via eval) + // throws, which would otherwise abort the rest of Awake below. + if (Application.isPlaying) + 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 reloadable methods. Code reload requires this driver to + // be present (it owns the communication workflow), so discovery lives here rather than in + // each gameplay script's Awake. + RegisterDiscoveredCodeReloadMethods(); + + // Own the process-wide frame-stats sampler. A Player has no profiler window or + // EditorApplication.update to drive sampling, so the driver 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. Only claim + // ownership if nothing already created one — a second, transient driver (e.g. a test creating + // its own driver alongside one RuntimeInitializeOnLoadMethod already created) must not steal or + // later tear down a sibling driver's sampler. + if (FrameStatsSampler.Shared == null) + { + FrameStatsSampler.Shared = new FrameStatsSampler(); + m_OwnsSampler = true; + } + } + + void Start() + { + if (m_Config != null && m_Config.autoStart && m_Config.enableInBuilds) + { + StartServer(); + } + +#if (UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE) + // Independent of the HTTP server: code-reload IL pushed from the editor arrives over + // PlayerConnection (the Profiler's channel — no open port needed, tunnels over USB). + RegisterReloadReceiver(); + + if (ShowReloadOverlay && GetComponent() == null) + gameObject.AddComponent(); +#endif + } + + /// + /// Scan loaded user assemblies for methods tagged [CodeReload] and register them as reload targets. + /// + /// + /// Scan loaded user assemblies for [CodeReload] methods and register them as reload + /// targets. Runs from Awake; RuntimePipelineBootstrap also calls it in the Editor when the + /// runtime server is disabled and no driver is created. + /// + internal static void RegisterDiscoveredCodeReloadMethods() + { + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic + | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + int registered = 0; + + foreach (var assembly in PipelineUtils.GetLoadedAssemblies()) + { + if (!ShouldScanForCodeReload(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 reloadable = method.GetCustomAttribute(); + if (reloadable == null) + continue; + + // The weaver keys dispatch on TypeName.MethodName, so register with the + // default id (ignore any custom Id) to match. + CodeReloadRegistry.RegisterReloadableMethod( + method, new CodeReloadAttribute { RequireMainThread = reloadable.RequireMainThread }); + registered++; + } + } + } + + // The count only matters once someone tries to reload, so it joins the first-reload + // summary instead of the startup noise (this runs on every Play Mode entry). That holds + // for zero too: the summary prints on the first reload ATTEMPT, so a project that never + // reloads stays quiet, and one that does sees the stripping/attribute hint right next to + // the "target not registered" reasons. + CodeReloadRegistry.NoteForFirstReload($"{registered} reloadable method(s) registered" + + (registered == 0 + ? " (if methods were expected: are the [CodeReload] attributes in this build, and did the owning assembly survive stripping?)" + : "")); + } + + /// Skip engine/framework assemblies that cannot contain user code-reload methods. + private static bool ShouldScanForCodeReload(Assembly assembly) + { + if (assembly.IsDynamic) + return false; + + var name = assembly.GetName().Name; + if (string.IsNullOrEmpty(name)) + return false; + + foreach (var prefix in s_CodeReloadSkipPrefixes) + { + if (name.StartsWith(prefix, StringComparison.Ordinal)) + return false; + } + + return true; + } + + private static readonly string[] s_CodeReloadSkipPrefixes = + { + "System", "mscorlib", "netstandard", "Microsoft", "Mono.", "nunit", + "UnityEngine", "UnityEditor", "Unity.Collections", "Unity.Burst", + "Unity.Mathematics", "Newtonsoft", "log4net", "ICSharpCode", + }; + + /// + /// The maxWorkItemsPerFrame value actually governing the dispatcher right now — live from + /// the settings file in the Editor, frozen to the build-time value in a Player. Exposed + /// (rather than reading m_Config.maxWorkItemsPerFrame directly) so status reporting and + /// Update() can never disagree about which value is actually in effect. + /// + public int CurrentMaxWorkItemsPerFrame + { + get + { + var fallback = m_Config != null ? m_Config.maxWorkItemsPerFrame : 10; +#if UNITY_EDITOR + return RuntimePipelineConfig.GetLiveMaxWorkItemsPerFrame(fallback); +#else + return fallback; +#endif + } + } + + 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(CurrentMaxWorkItemsPerFrame); + + // Drive the watchdog (player builds have no EditorApplication.update). No-op unless the + // server's watchdog is enabled and armed. + m_Server?.WatchdogTick(); + +#if (UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE) + DrainPendingReloads(); +#endif + } + + void OnApplicationQuit() + { + StopServer(); + } + + void OnDestroy() + { + StopServer(); + + if (RuntimePipelineBootstrap.Instance == this) + RuntimePipelineBootstrap.Instance = null; + + // Only the driver that actually created the shared sampler tears it down — see Awake(). + if (m_OwnsSampler) + { + FrameStatsSampler.Shared?.Dispose(); + FrameStatsSampler.Shared = null; + } +#if (UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE) + UnregisterReloadReceiver(); +#endif + } + +#if (UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE) + // ---- PlayerConnection code-reload receiver (editor pushes compiled override IL to this player) ---- + + // Pushes arrive on the connection message pump (not guaranteed main thread); enqueue here and + // apply in Update() where the interpreter + Unity APIs are safe to touch. + private readonly List m_PendingReloads = new List(); + private bool m_ReloadReceiverRegistered; + + // Push-signing session state. The nonce is fresh per receiver lifetime and announced to the + // editor via HandshakeMsg; every accepted push must be signed over it (see PushEnvelope). + private byte[] m_PushSessionNonce; + private PushVerifier m_PushVerifier; + + private void RegisterReloadReceiver() + { + try + { + PlayerConnection.instance.Register(PipelineCodeReloadConnect.ApplyReloadMsg, OnApplyReloadMessage); + PlayerConnection.instance.Register(PipelineCodeReloadConnect.ReloadPendingMsg, OnReloadPendingMessage); + PlayerConnection.instance.Register(PipelineCodeReloadConnect.ReloadFailedMsg, OnReloadFailedMessage); + PlayerConnection.instance.Register(PipelineCodeReloadConnect.RequestHandshakeMsg, OnRequestHandshakeMessage); + PlayerConnection.instance.RegisterConnection(OnPushPeerConnected); + m_ReloadReceiverRegistered = true; + + m_PushSessionNonce = PushEnvelope.CreateNonce(); +#if UNITY_EDITOR + // Editor play mode is not a network surface for pushes (they originate in this same + // process, and remote access goes through the token-authenticated HTTP server), so + // signing is not enforced here — see DrainPendingReloads. + CodeReloadRegistry.NoteForFirstReload("PlayerConnection receiver ready (editor play mode, push signing not enforced)"); +#else + if (!string.IsNullOrEmpty(m_PushPublicKey)) + { + m_PushVerifier = new PushVerifier(m_PushPublicKey, m_PushSessionNonce); + var fingerprint = PushEnvelope.Fingerprint(m_PushPublicKey); + if (PushEnvelope.VerifySelfTest(m_PushPublicKey, out var selfTestError)) + Debug.Log($"Pipeline: PlayerConnection code-reload receiver ready — signed pushes only (key {fingerprint}, verify self-test OK). Push IL from the editor via 'reload_file_player_interpreter'."); + else + Debug.LogError($"Pipeline: push-signature verify self-test FAILED on this platform ({selfTestError}) — every push will be rejected as CryptoUnavailable."); + } + else + { + // Strict policy: a receiver without a baked key accepts nothing. The only way to + // get here is a build whose RuntimePipelineBuildInfo carries no key (e.g. an older + // build, or one produced without the build processor running). + Debug.LogWarning("Pipeline: code-reload push receiver has NO baked signing key — all pushes will be rejected. " + + "Rebuild with the Pipeline build processor enabled (it generates the key and bakes the public half into RuntimePipelineBuildInfo)."); + } +#endif + SendPushHandshake(); // the editor may already be connected (e.g. autoconnect raced us) + } + catch (Exception ex) + { + Debug.LogWarning($"Pipeline: PlayerConnection register failed: {ex.Message}"); + } + } + + private void UnregisterReloadReceiver() + { + if (!m_ReloadReceiverRegistered) return; + try + { + PlayerConnection.instance.Unregister(PipelineCodeReloadConnect.ApplyReloadMsg, OnApplyReloadMessage); + PlayerConnection.instance.Unregister(PipelineCodeReloadConnect.ReloadPendingMsg, OnReloadPendingMessage); + PlayerConnection.instance.Unregister(PipelineCodeReloadConnect.ReloadFailedMsg, OnReloadFailedMessage); + PlayerConnection.instance.Unregister(PipelineCodeReloadConnect.RequestHandshakeMsg, OnRequestHandshakeMessage); + PlayerConnection.instance.UnregisterConnection(OnPushPeerConnected); + } + catch { } + m_ReloadReceiverRegistered = false; + } + + /// Announce this session (protocol version, expected key fingerprint, session nonce) + /// to the editor. Sent on receiver start, on every new connection, and on RequestHandshakeMsg. + private void SendPushHandshake() + { + try + { + var fingerprint = string.IsNullOrEmpty(m_PushPublicKey) ? "" : PushEnvelope.Fingerprint(m_PushPublicKey); + PlayerConnection.instance.Send(PipelineCodeReloadConnect.HandshakeMsg, + PipelineCodeReloadConnect.EncodeHandshake(PushEnvelope.ProtocolVersion, fingerprint, m_PushSessionNonce)); + } + catch { /* no connection yet — the connect callback will re-announce */ } + } + + // Both may fire on the connection thread; SendPushHandshake only touches immutable session state. + private void OnRequestHandshakeMessage(MessageEventArgs args) => SendPushHandshake(); + private void OnPushPeerConnected(int playerId) => SendPushHandshake(); + + private void OnApplyReloadMessage(MessageEventArgs args) + { + if (args?.data == null) return; + lock (m_PendingReloads) m_PendingReloads.Add(args.data); + } + + // Connection-thread handlers: CodeReloadActivity is thread-safe, no marshalling needed. + private void OnReloadPendingMessage(MessageEventArgs args) => + CodeReloadActivity.ReportCompileStarted(PipelineCodeReloadConnect.DecodeText(args?.data)); + + private void OnReloadFailedMessage(MessageEventArgs args) => + CodeReloadActivity.ReportFailed(PipelineCodeReloadConnect.DecodeText(args?.data)); + + private void DrainPendingReloads() + { + byte[][] batch; + lock (m_PendingReloads) + { + if (m_PendingReloads.Count == 0) return; + batch = m_PendingReloads.ToArray(); + m_PendingReloads.Clear(); + } + + // A player that received a code-reload push must keep ticking while unfocused, otherwise + // Update() (which drains + applies these pushes, and runs the reloaded code) pauses the + // moment the window loses focus. Force it on once, on first receipt — reusing StartServer/ + // StopServer's own save/restore bookkeeping (rather than writing Application.runInBackground + // directly) so OnDestroy's StopServer() still restores the user's original value even when + // the HTTP server itself was never started (PlayerConnection needs no open port). + if (!m_RunInBackgroundOverridden) + { + m_PreviousRunInBackground = Application.runInBackground; + Application.runInBackground = true; + m_RunInBackgroundOverridden = true; + Debug.Log("Pipeline: forced Application.runInBackground = true (code-reload push received)"); + } + + foreach (var data in batch) + { + bool ok = false; + string summary; + try + { + // ---- authentication gate (before anything touches the bytes) ---- + byte[] payload = null; + string rejectAck = null; // generic wire ack; the device log carries the detail +#if UNITY_EDITOR + // Editor play mode: strip a signed envelope without verification (no key is baked + // in play mode), accept legacy raw payloads as-is. Not a security gate — pushes to + // the editor originate from the editor itself. + if (PushEnvelope.LooksLikeEnvelope(data)) + { + if (!PushEnvelope.TryParse(data, out _, out _, out payload)) + rejectAck = "malformed code-reload push"; + } + else + { + payload = data; + } +#else + if (m_PushVerifier == null) + { + LogPushReject("no signing key baked in this build — rebuild with the Pipeline build processor enabled"); + rejectAck = "push rejected (authentication)"; + } + else + { + var status = m_PushVerifier.Verify(data, out payload); + if (status != PushVerifyStatus.Ok) + { + // Q10 diagnostics: a legacy (pre-signing) editor sends a raw payload, which + // is the one failure the sender can actually fix without touching the build. + if (status == PushVerifyStatus.Malformed && !PushEnvelope.LooksLikeEnvelope(data)) + { + LogPushReject("unsigned legacy push — the sending editor's com.unity.pipeline package predates push signing"); + rejectAck = "push rejected: unsigned legacy push — update the com.unity.pipeline package in the editor"; + } + else + { + LogPushReject($"{status} (session nonce/counter and signature details are in this device log only)"); + rejectAck = "push rejected (authentication)"; + } + } + } +#endif + if (rejectAck != null) + { + summary = rejectAck; + } + else if (!PipelineCodeReloadConnect.TryDecode(payload, out var typeName, out var methodNames, out var il)) + { + summary = "malformed code-reload push"; + } + else + { + var registered = Unity.Pipeline.Compilation.InterpreterCodeReloadExecutor.Register( + il, typeName, methodNames, out var skipped, out var warnings); + CodeReloadRegistry.InvokeReloadCallbacks(registered); + // Full warning text (unbound host members) goes to the device log; the ack + // summary the editor shows carries just the count so it stays one line. + foreach (var w in warnings) + Debug.LogWarning($"Pipeline: code-reload {typeName} — {w}"); + // Same for per-method skip reasons: the summary carries only the count, but a + // method that didn't apply (e.g. SetupUI referencing a member the running + // build stripped) is only diagnosable if the reason reaches the device log — + // "skipped 1" alone gives the user nothing to act on. + foreach (var s in skipped) + Debug.LogWarning($"Pipeline: code-reload {typeName} — skipped {s}"); + ok = registered.Count > 0; + summary = ok + ? $"applied {registered.Count} method(s) on {typeName}" + + (skipped.Count > 0 ? $"; skipped {skipped.Count}" : "") + + (warnings.Count > 0 ? $"; {warnings.Count} binding warning(s)" : "") + : $"no methods applied on {typeName}" + + (skipped.Count > 0 ? $": {string.Join("; ", skipped)}" : ""); + } + } + catch (Exception ex) + { + // The interpreter binds via reflection, so the real cause (e.g. + // ExecutionEngineException from a generic instantiation IL2CPP never + // AOT-compiled) arrives wrapped in TargetInvocationException, whose Message is + // just "Exception has been thrown by the target of an invocation." Unwrap for + // the ack the editor shows; keep the full chain + stack in the device log. + var root = ex; + while (root is System.Reflection.TargetInvocationException && root.InnerException != null) + root = root.InnerException; + Debug.LogException(ex); + summary = $"apply failed: {root.GetType().Name}: {root.Message}"; + } + + Debug.Log($"Pipeline: code-reload push — {summary}"); + if (ok) CodeReloadActivity.ReportApplied(summary); + else CodeReloadActivity.ReportFailed(summary); + try + { + PlayerConnection.instance.Send( + PipelineCodeReloadConnect.ResultMsg, PipelineCodeReloadConnect.EncodeResult(ok, summary)); + } + catch { } + } + } + + // Reject-log rate limiting: full detail for the first few failures, then one summary line per + // minute — an attacker probing the port must not be able to spam the device log into + // uselessness, but "signature failures are happening" stays visible (it IS the intrusion signal). + private const int RejectLogBurst = 10; + private const float RejectWindowSeconds = 60f; + private int m_RejectsInWindow; + private float m_RejectWindowStart = float.NegativeInfinity; + private int m_SuppressedRejects; + private float m_LastRejectSummaryTime = float.NegativeInfinity; + + private void LogPushReject(string detail) + { + float now = Time.realtimeSinceStartup; + if (now - m_RejectWindowStart > RejectWindowSeconds) + { + m_RejectWindowStart = now; + m_RejectsInWindow = 0; + } + + m_RejectsInWindow++; + if (m_RejectsInWindow <= RejectLogBurst) + { + Debug.LogWarning($"Pipeline: code-reload push rejected — {detail}"); + return; + } + + m_SuppressedRejects++; + if (now - m_LastRejectSummaryTime >= RejectWindowSeconds) + { + Debug.LogWarning($"Pipeline: {m_SuppressedRejects} further code-reload push(es) rejected in the last minute " + + $"(possible probing on this network); most recent: {detail}"); + m_LastRejectSummaryTime = now; + m_SuppressedRejects = 0; + } + } +#endif + + /// 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 RuntimePipelineDriver..."); + + if (m_Config == null || !m_Config.enableInBuilds) + { + Debug.LogWarning("Pipeline: Runtime server disabled in configuration (enableInBuilds = false)"); + return; + } + + var validation = m_Config.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}"); + } + + m_Server = new RuntimePipelineServer(m_Config); + m_Server.Start(m_Config.port); + + if (m_Server.IsRunning) + { + // Keeps Update -> Dispatcher.ProcessWorkQueue/WatchdogTick running while unfocused. + // Guarded on m_RunInBackgroundOverridden (rather than unconditional) because + // DrainPendingReloads can already have overridden it — a code-reload push over + // PlayerConnection needs no HTTP server, so it can arrive and force this on before + // StartServer ever runs. Saving unconditionally here would clobber the real + // original value with the already-overridden "true", and StopServer would then + // restore to "true" instead of the user's setting. + if (!m_RunInBackgroundOverridden) + { + m_PreviousRunInBackground = Application.runInBackground; + Application.runInBackground = true; + m_RunInBackgroundOverridden = true; + } + + // The code-reload registry marshals main-thread overrides through a dispatcher + // when reloaded code is invoked from a background thread at runtime. Inject + // this server's dispatcher here (a player has exactly one driver), so editor and + // per-test servers never clobber the global registry's dispatcher. + CodeReloadRegistry.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. + CodeReloadRegistry.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 (CodeReloadRegistry.Dispatcher == m_Server.Dispatcher) + CodeReloadRegistry.Dispatcher = null; + m_Server.Stop(); // Stop() shuts down the server's own dispatcher. + } + } + catch (Exception ex) + { + Debug.LogError($"Pipeline: Runtime shutdown error: {ex.Message}"); + } + + // Independent of whether the listener was still alive above: if we overrode + // runInBackground, restore it regardless, so a listener that already died on its own + // (e.g. the watchdog never got to it) can't leave the setting stuck on. + if (m_RunInBackgroundOverridden) + { + Application.runInBackground = m_PreviousRunInBackground; + m_RunInBackgroundOverridden = false; + } + } + + /// Validate the current configuration. + /// The validation result. + public ValidationResult ValidateConfiguration() + { + return m_Config != null ? m_Config.Validate() : ValidationResult.Error("No RuntimePipelineConfig assigned"); + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineDriver.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineDriver.cs.meta new file mode 100644 index 0000000..4336557 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineDriver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 962b2a2812855af4b8ef33c0edccd103 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineServer.cs b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineServer.cs new file mode 100644 index 0000000..8404d90 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineServer.cs @@ -0,0 +1,98 @@ +using System; +using Unity.Pipeline.Config; +using Unity.Pipeline.Models; +using UnityEngine; + +namespace Unity.Pipeline +{ + /// The Player-side , owned by a . + public class RuntimePipelineServer : BasePipelineServer + { + private RuntimePipelineConfig m_Config; + private RuntimeInstanceDescriptor m_InstanceDescriptor; + + /// + /// Guards mutate-then-write of m_InstanceDescriptor: concurrent /api/status probes both + /// reach UpdateHeartBeat now, and RuntimeInstanceDescriptor.WriteToWorkingDirectory's own + /// lock only covers the write, not the field mutation feeding it. + /// + private readonly object m_HeartbeatLock = new object(); + + /// UTC time this server instance started listening. + public override DateTime StartedAt => m_InstanceDescriptor == null ? new DateTime() : m_InstanceDescriptor.StartedAt; + + /// Create a server bound to the given configuration. + /// The runtime pipeline configuration. + public RuntimePipelineServer(RuntimePipelineConfig config) + { + m_Config = config; + } + + /// Runtime servers use ports 7900-7949 (test runtime servers use 7950-7999). + /// The inclusive port range to try when binding the listener. + protected override (int basePort, int maxPort) GetPortRange() + { + return (7900, 7949); // Runtime production (test runtime servers use 7950-7999) + } + + /// Write the runtime instance descriptor to the working directory for CLI discovery. + protected override void CreateInstanceDescriptor() + { + // Create and write instance descriptor for CLI discovery + m_InstanceDescriptor = RuntimeInstanceDescriptor.CreateCurrent(Port, m_Config); + RuntimeInstanceDescriptor.WriteToWorkingDirectory(m_InstanceDescriptor); + } + + /// Remove the runtime instance descriptor on shutdown. + protected override void DeleteInstanceDescriptor() + { + // Clean up instance descriptor file + if (m_InstanceDescriptor != null) + { + RuntimeInstanceDescriptor.RemoveFromWorkingDirectory(); + } + m_InstanceDescriptor = null; + } + + /// Refresh the descriptor's heartbeat timestamp. + protected override void UpdateHeartBeat() + { + // Update heartbeat in instance descriptor + if (m_InstanceDescriptor == null) + return; + + lock (m_HeartbeatLock) + { + m_InstanceDescriptor.LastHeartbeat = DateTime.UtcNow; + try + { + RuntimeInstanceDescriptor.WriteToWorkingDirectory(m_InstanceDescriptor); + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to update instance descriptor: {ex.Message}"); + } + } + } + + /// Build the Player-specific status/heartbeat payload for /api/status. + /// The status payload. + protected override object GetServerStatus() + { + UpdateHeartBeat(); + return new + { + status = m_InstanceDescriptor == null ? "error" : "ready", + lastHeartbeat = m_InstanceDescriptor?.LastHeartbeat, + capabilities = Capabilities + }; + } + + /// The bearer token clients must present to authenticate requests. + /// The current token. + protected override string GetToken() + { + return m_InstanceDescriptor.EvalToken; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineServer.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/PlayerSupport/RuntimePipelineServer.cs.meta new file mode 100644 index 0000000..5afc6dc --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Plugins.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins.meta new file mode 100644 index 0000000..40fe832 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a25774b4dfb214b4f8c39d802c589d7d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis.meta new file mode 100644 index 0000000..95f2154 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 014d6925adce7c84bb3742e7557d8581 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/CHECKSUMS b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/CHECKSUMS new file mode 100644 index 0000000..80a3ad2 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/CHECKSUMS @@ -0,0 +1,17 @@ +# 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. +# +# These DLLs are the NuGet originals renamed with a "UnityPipeline." prefix (assembly name, module name +# and namespaces) by RenameAsm, so they cannot collide with the copies the Unity editor loads. +# The originals are the NuGet packages listed in bundled-roslyn-original/ (hashes + rename script). +# +# Format per line: # +# Regenerate hashes with: shasum -a 256 *.dll (run in this folder) +# Version fields marked TODO should be set to the exact NuGet package version the DLL came from. + +83836addf8d51df5d6d8cff3c43c1663655bdd85eb539b44a56dcd96ff2297a0 UnityPipeline.Microsoft.CodeAnalysis.dll # Microsoft.CodeAnalysis.Common TODO +6ca7115fb0256a4d415a73fe7e264d23c89d6d4019ac463fe519bc9097fbdf9b UnityPipeline.Microsoft.CodeAnalysis.CSharp.dll # Microsoft.CodeAnalysis.CSharp TODO +fb05543596e537d7a4383c7330b181cf31151e985578c57a8b5eef00c6c8a460 UnityPipeline.System.Collections.Immutable.dll # System.Collections.Immutable TODO +3de473fbc3e3e01eef020cb82cb6a3eb288959e24e65175182a818c9d09dbe01 UnityPipeline.System.Reflection.Metadata.dll # System.Reflection.Metadata TODO +6f2becafbd15867d52bfad08f77a23d26c818e55473d6347aef41130572f14f7 UnityPipeline.System.Runtime.CompilerServices.Unsafe.dll # System.Runtime.CompilerServices.Unsafe TODO diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/CHECKSUMS.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/CHECKSUMS.meta new file mode 100644 index 0000000..9ac3330 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/CHECKSUMS.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b61d5dbe422f84644aa94e10778b9e94 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.Microsoft.CodeAnalysis.CSharp.dll b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.Microsoft.CodeAnalysis.CSharp.dll new file mode 100644 index 0000000..2f8d10f Binary files /dev/null and b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.Microsoft.CodeAnalysis.CSharp.dll differ diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.Microsoft.CodeAnalysis.CSharp.dll.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.Microsoft.CodeAnalysis.CSharp.dll.meta new file mode 100644 index 0000000..fa6e21f --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.Microsoft.CodeAnalysis.CSharp.dll.meta @@ -0,0 +1,60 @@ +fileFormatVersion: 2 +guid: b5a1f47d969a4054bbbf0bc7cc231ee5 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: + - UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: AnyOS + - first: + Standalone: Linux64 + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: OSXUniversal + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: Win + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: Win64 + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.Microsoft.CodeAnalysis.dll b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.Microsoft.CodeAnalysis.dll new file mode 100644 index 0000000..5804195 Binary files /dev/null and b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.Microsoft.CodeAnalysis.dll differ diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.Microsoft.CodeAnalysis.dll.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.Microsoft.CodeAnalysis.dll.meta new file mode 100644 index 0000000..c9bb60e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.Microsoft.CodeAnalysis.dll.meta @@ -0,0 +1,60 @@ +fileFormatVersion: 2 +guid: ff3508870465d8e4b83e5a2c71492756 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: + - UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: AnyOS + - first: + Standalone: Linux64 + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: OSXUniversal + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: Win + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: Win64 + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Collections.Immutable.dll b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Collections.Immutable.dll new file mode 100644 index 0000000..b01df61 Binary files /dev/null and b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Collections.Immutable.dll differ diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Collections.Immutable.dll.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Collections.Immutable.dll.meta new file mode 100644 index 0000000..ae88ca1 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Collections.Immutable.dll.meta @@ -0,0 +1,60 @@ +fileFormatVersion: 2 +guid: 1c674545fbd78e64281587eb24ab64c3 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: + - UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: AnyOS + - first: + Standalone: Linux64 + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: OSXUniversal + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: Win + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: Win64 + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Reflection.Metadata.dll b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Reflection.Metadata.dll new file mode 100644 index 0000000..3067f3f Binary files /dev/null and b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Reflection.Metadata.dll differ diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Reflection.Metadata.dll.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Reflection.Metadata.dll.meta new file mode 100644 index 0000000..b1b668b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Reflection.Metadata.dll.meta @@ -0,0 +1,60 @@ +fileFormatVersion: 2 +guid: bfb1ff29d529b904b8f210e2a1b4b8ce +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: + - UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: AnyOS + - first: + Standalone: Linux64 + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: OSXUniversal + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: Win + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: Win64 + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Runtime.CompilerServices.Unsafe.dll b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Runtime.CompilerServices.Unsafe.dll new file mode 100644 index 0000000..a041e0a Binary files /dev/null and b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Runtime.CompilerServices.Unsafe.dll differ diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Runtime.CompilerServices.Unsafe.dll.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Runtime.CompilerServices.Unsafe.dll.meta new file mode 100644 index 0000000..49c939a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Plugins/CodeAnalysis/UnityPipeline.System.Runtime.CompilerServices.Unsafe.dll.meta @@ -0,0 +1,60 @@ +fileFormatVersion: 2 +guid: cab0ad3a2a404673a7c7a6b3939109b6 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: + - UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: AnyOS + - first: + Standalone: Linux64 + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: OSXUniversal + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: Win + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: Win64 + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/SecurityTokenManager.cs b/AIClient/Packages/com.unity.pipeline/Runtime/SecurityTokenManager.cs new file mode 100644 index 0000000..5ea2f97 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/SecurityTokenManager.cs @@ -0,0 +1,150 @@ +using System; +using System.Security.Cryptography; +using System.Threading; +#if UNITY_EDITOR +using UnityEditor; +#endif +#if UNITY_6000_5_OR_NEWER +using Unity.Scripting.LifecycleManagement; +#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. + /// +#if UNITY_6000_5_OR_NEWER + [NoAutoStaticsCleanup] +#endif + 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. + /// + /// The current session token. + 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. + /// + /// First token. + /// Second token. + /// True if the tokens are equal. + 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. + /// + /// The new token. + 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/AIClient/Packages/com.unity.pipeline/Runtime/SecurityTokenManager.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/SecurityTokenManager.cs.meta new file mode 100644 index 0000000..cbd37aa --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry.meta new file mode 100644 index 0000000..0dcb221 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 114afe9c22f14d80babb5231767c7d61 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageFingerprinter.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageFingerprinter.cs new file mode 100644 index 0000000..4fc4a1c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageFingerprinter.cs @@ -0,0 +1,422 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; + +#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG) +using UnityPipeline.Microsoft.CodeAnalysis; +using UnityPipeline.Microsoft.CodeAnalysis.CSharp; +using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax; +#endif + +namespace Unity.Pipeline.Telemetry +{ + /// + /// Structural classification of an eval body. Matches the epic's usage buckets: the majority of + /// eval calls are one-liners (a single method invoke / value read / flag set), which we tag as + /// ; anything with more than one statement (polling loops, bulk + /// setup) is . + /// + static class EvalClassification + { + public const string SingleExpression = "single-expression"; + public const string Statements = "statements"; + } + + /// + /// Result of analysing one eval body: its structural and the ranked + /// API (top-level member-access paths) extracted from the syntax tree. + /// + struct EvalUsageAnalysis + { + public string Classification; + public IReadOnlyList Fingerprints; + } + + /// + /// Extracts a privacy-preserving "fingerprint" of an eval body from its C# syntax tree, without + /// storing the raw source (AUTHAPI-29). A fingerprint is the top-level member-access path of each + /// access site — e.g. AssetDatabase.Refresh, PlayerSettings.SetScriptingBackend, + /// Object.FindFirstObjectByType<T>, MatchManager.Instance.State — which is + /// enough to rank which APIs agents actually reach for (steering the eval-displacement backlog, + /// AUTHAPI-24). + /// + /// PRIVACY CONTRACT — strict whitelist rendering. The only source text that can ever appear in a + /// fingerprint is an identifier, type name, or member name taken from the syntax tree. Literals, + /// interpolated strings, and every expression form not on the whitelist are replaced by the + /// neutral ("secret".Length records as <expr>.Length); + /// object creations render as new TypeName (constructor arguments never appear); casts + /// unwrap to the inner expression (((Foo)o).Bar records as o.Bar); generic type + /// arguments are normalised to arity placeholders (<T>). A path that would be nothing + /// but the placeholder carries no API information and is dropped entirely. + /// + /// Conditional-access chains (a?.B.C()) are fingerprinted with ?. normalised to + /// ., so they rank together with their unconditional form. + /// + /// Known blind spots — the ranking UNDERCOUNTS these forms (deliberate, to keep the walker + /// simple and re-entrancy-free); consumers of the report should not read absence as proof of + /// non-use: + /// + /// Arguments nested inside a chain's receiver call are not walked: in + /// Configure(Settings.Load()).Apply() only Configure().Apply is recorded — the + /// callee chain is not re-entered, so Settings.Load is missed. + /// A bare object creation with no trailing member access (new Foo(...); as a whole + /// statement) produces no fingerprint. + /// Bodies nested deeper than the walker's depth guard are abandoned mid-walk. + /// + /// + /// Threading: pure Roslyn + BCL — no Unity API calls — so is safe to run + /// off the main thread. The telemetry writer relies on this and runs it on a background task + /// from a snapshot captured at eval completion. + /// + /// This is a SEPARATE parse from the one EvalCodeCompiler does to actually compile and + /// run the eval, by necessity rather than oversight: that parse is of a generated wrapper + /// source (the raw body embedded inside a class PipelineEval_<id> { ... Execute() { + /// ... } }) under SourceCodeKind.Regular with the project's #if defines, not + /// of the raw body alone as a top-level SourceCodeKind.Script compilation unit the way + /// expects it. Sharing that tree would mean threading it out of + /// RoslynCompilationService (on both the success AND failure path — failures still get + /// recorded) and locating the wrapped Execute() body inside it, coupling this class to + /// the wrapper's exact shape. Not worth it today: the extra parse already runs off the eval + /// response path (see Threading above), so it costs background CPU, not eval latency. + /// + static class EvalUsageFingerprinter + { + /// + /// Upper bound on fingerprints kept per record, so a pathological body can't bloat the log. + /// Truncation keeps the FIRST N access sites in tree (source) order, not the most frequent — + /// acceptable because bodies with more than 32 access sites are rare outliers. + /// + public const int MaxFingerprints = 32; + + /// + /// Neutral stand-in for any expression not on the rendering whitelist (literals, interpolated + /// strings, arithmetic, lambdas, …). Guarantees no raw source text leaks into a fingerprint. + /// + public const string Placeholder = ""; + +#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG) + + private static readonly CSharpParseOptions m_ScriptOptions = + new CSharpParseOptions(kind: SourceCodeKind.Script); + + /// + /// Analyse an eval body: classify it and extract its API fingerprints. Never throws — a body + /// that fails to parse yields an empty fingerprint list and a best-effort classification. + /// + public static EvalUsageAnalysis Analyze(string code) + { + if (string.IsNullOrWhiteSpace(code)) + return new EvalUsageAnalysis { Classification = EvalClassification.Statements, Fingerprints = new List() }; + + CompilationUnitSyntax root; + try + { + root = CSharpSyntaxTree.ParseText(code, m_ScriptOptions).GetCompilationUnitRoot(); + } + catch + { + return new EvalUsageAnalysis { Classification = EvalClassification.Statements, Fingerprints = new List() }; + } + + return new EvalUsageAnalysis + { + Classification = Classify(root), + Fingerprints = ExtractFingerprints(root) + }; + } + + /// Convenience overload used by tests: fingerprints only. + public static IReadOnlyList ExtractFingerprints(string code) => Analyze(code).Fingerprints; + + /// Convenience overload used by tests: classification only. + public static string Classify(string code) => Analyze(code).Classification; + + /// + /// One-liner iff the body is exactly one statement that is an expression or a value-returning + /// return. A lone loop/if/block (e.g. a polling loop) is a statement body, not a one-liner. + /// + private static string Classify(CompilationUnitSyntax root) + { + var statements = root.Members + .OfType() + .Select(m => m.Statement) + .ToList(); + + if (statements.Count == 1) + { + var only = statements[0]; + if (only is ExpressionStatementSyntax || + (only is ReturnStatementSyntax ret && ret.Expression != null)) + { + return EvalClassification.SingleExpression; + } + } + + return EvalClassification.Statements; + } + + private static IReadOnlyList ExtractFingerprints(CompilationUnitSyntax root) + { + var walker = new FingerprintWalker(); + walker.Visit(root); + // Take() keeps the first MaxFingerprints access sites in tree (source) order — see the + // MaxFingerprints doc for why that truncation policy is acceptable. + return walker.Fingerprints.Count > MaxFingerprints + ? walker.Fingerprints.Take(MaxFingerprints).ToList() + : walker.Fingerprints; + } + + /// + /// Walks the syntax tree collecting one fingerprint per top-level access site: an invocation's + /// callee (A.B.C(...) → A.B.C) or a member-access chain that is not itself the + /// head of an enclosing call/access (A.Instance.State). Nested access sites inside call + /// arguments are still collected (they are their own sites); the callee chain is not re-counted. + /// A depth counter abandons subtrees nested past so a pathological body + /// (machine-generated nesting) can't blow the stack — fingerprinting is best-effort. + /// + private sealed class FingerprintWalker : CSharpSyntaxWalker + { + /// Maximum syntax depth walked; deeper subtrees are abandoned. + private const int MaxDepth = 64; + + public readonly List Fingerprints = new List(); + private int m_Depth; + + public override void Visit(SyntaxNode node) + { + if (m_Depth >= MaxDepth) + return; // Abandon this subtree — best-effort telemetry, never a liability. + + m_Depth++; + try + { + base.Visit(node); + } + finally + { + m_Depth--; + } + } + + public override void VisitInvocationExpression(InvocationExpressionSyntax node) + { + AddChainHeadFingerprint(node, node.Expression, () => RenderPath(node.Expression)); + + // Deliberately do NOT recurse into the callee chain (already captured above), but do + // walk the arguments so nested calls/reads passed as arguments are captured too. + VisitArgumentsIfPresent(node.ArgumentList); + } + + public override void VisitMemberAccessExpression(MemberAccessExpressionSyntax node) + { + if (IsChainHead(node)) + Add(RenderPath(node)); + + // Continue into the expression side so nested access sites are still discovered; + // inner chain members are guarded by IsChainHead and won't be double-counted. + base.VisitMemberAccessExpression(node); + } + + public override void VisitElementAccessExpression(ElementAccessExpressionSyntax node) + { + // A bare element-access chain head (cache[key]) previously produced NO fingerprint + // at all — no member access for the walker to see — while the conditional form + // (cache?[key]) records "cache[]": the two must rank together, so bare element + // access on a member/name receiver is fingerprinted the same way. + AddChainHeadFingerprint(node, node.Expression, () => RenderPath(node)); + + // Walk the bracket arguments so nested reads/calls inside the indexer are captured. + VisitArgumentsIfPresent(node.ArgumentList); + } + + public override void VisitConditionalAccessExpression(ConditionalAccessExpressionSyntax node) + { + // The whole ?. chain is ONE fingerprint (RenderPath normalizes '?.' to '.'): + // descending into its parts would record the receiver and the bound members as + // separate (and misleading) entries. + if (IsChainHead(node)) + Add(RenderPath(node)); + + // Parity with VisitInvocationExpression/VisitElementAccessExpression: only the head + // access's OWN argument list is walked, scoped to WhenNotNull (not the receiver + // before '?.') — the receiver's nested-call arguments are the same documented + // blind spot the unconditional forms accept, not something '?.' should uncover. + // Only the outermost argument lists are visited — the walker recurses within. + // Covers both parenthesized (ArgumentListSyntax) and bracketed + // (BracketedArgumentListSyntax) forms, so an indexer inside a conditional chain + // (a?.b[SomeCall()]) is captured too — a bracketed list was previously invisible + // to this sweep, silently dropping SomeCall. + if (node.WhenNotNull != null) + { + foreach (var argumentList in node.WhenNotNull + .DescendantNodes(n => !(n is ArgumentListSyntax) && !(n is BracketedArgumentListSyntax)) + .Where(n => n is ArgumentListSyntax || n is BracketedArgumentListSyntax)) + Visit(argumentList); + } + } + + // Shared shape between VisitInvocationExpression and VisitElementAccessExpression: add + // the rendered path only for a chain head whose receiver is renderable, computing the + // path lazily so non-chain-head nodes (the overwhelming majority visited) skip RenderPath + // entirely instead of just discarding its result. + private void AddChainHeadFingerprint(SyntaxNode node, ExpressionSyntax receiver, System.Func render) + { + if (IsChainHead(node) && IsMemberOrName(receiver)) + Add(render()); + } + + private void VisitArgumentsIfPresent(BaseArgumentListSyntax argumentList) + { + if (argumentList != null) + Visit(argumentList); + } + + private void Add(string path) + { + // A path that is nothing but the neutral placeholder carries no API information — + // drop the fingerprint rather than pollute the ranking with "" noise. + if (string.IsNullOrEmpty(path) || path == Placeholder) + return; + Fingerprints.Add(path); + } + + // A node is the head of its access/call chain when its parent is not another link of the + // same chain (member access, invocation, or element access). + private static bool IsChainHead(SyntaxNode node) + { + var parent = node.Parent; + // See through parentheses: in (a?.b).c the conditional is part of the enclosing + // chain, not a head of its own. + while (parent is ParenthesizedExpressionSyntax paren) + parent = paren.Parent; + return !(parent is MemberAccessExpressionSyntax) + && !(parent is InvocationExpressionSyntax) + && !(parent is ElementAccessExpressionSyntax); + } + + // Also accepts a call or parenthesized expression as the receiver: RenderPath already + // knows how to render both (GetHandler()() -> "GetHandler()", (A.B)() -> "A.B"), so + // gating them out here was dropping the whole access site instead of rendering it — + // not even the placeholder, since Add() was never reached. + private static bool IsMemberOrName(ExpressionSyntax expr) => + expr is MemberAccessExpressionSyntax || expr is IdentifierNameSyntax || expr is GenericNameSyntax + || expr is InvocationExpressionSyntax || expr is ParenthesizedExpressionSyntax; + } + + /// + /// Render an expression to its fingerprint path: dotted identifiers, with generic method type + /// arguments normalised to arity placeholders (<T>, <T1, T2>) so + /// FindFirstObjectByType<Foo> and <Bar> rank as one API. + /// + /// STRICT WHITELIST (the privacy contract of the class doc): only the expression forms listed + /// below render source text, and each renders identifiers/type/member names exclusively. + /// Everything else — literals, interpolated strings, arithmetic, lambdas, conditionals, … — + /// falls through to the neutral so no raw source can ever leak into + /// a fingerprint. Casts unwrap to the inner expression; object creations render as + /// new TypeName. Rendering recursion is bounded by as a second + /// layer of defence against pathological nesting (chains hang below their head, so they are + /// not covered by the walker's descent guard). + /// + private static string RenderPath(ExpressionSyntax expr) => RenderPath(expr, 0); + + private const int MaxRenderDepth = 64; + + private static string RenderPath(ExpressionSyntax expr, int depth) + { + if (depth >= MaxRenderDepth) + return Placeholder; + depth++; + + switch (expr) + { + case IdentifierNameSyntax id: + return id.Identifier.Text; + case GenericNameSyntax gn: + return gn.Identifier.Text + RenderArity(gn.TypeArgumentList.Arguments.Count); + case QualifiedNameSyntax qn: // Namespace-qualified type in type position, e.g. new UnityEngine.GameObject + return RenderPath(qn.Left, depth) + "." + RenderSimpleName(qn.Right); + case AliasQualifiedNameSyntax aq: // global::UnityEngine → keep the name, drop the alias + return RenderSimpleName(aq.Name); + case MemberAccessExpressionSyntax ma: + return RenderPath(ma.Expression, depth) + "." + RenderSimpleName(ma.Name); + case InvocationExpressionSyntax inv: + return RenderPath(inv.Expression, depth) + "()"; + case ElementAccessExpressionSyntax ea: + return RenderPath(ea.Expression, depth) + "[]"; + case ParenthesizedExpressionSyntax pe: + return RenderPath(pe.Expression, depth); + case CastExpressionSyntax ce: // ((Foo)o).Bar → o.Bar: unwrap to the casted expression + return RenderPath(ce.Expression, depth); + case ObjectCreationExpressionSyntax oc: // new GameObject("x").AddComponent → new GameObject.AddComponent + return "new " + RenderPath(oc.Type, depth); + case PredefinedTypeSyntax pt: + return pt.Keyword.Text; + case ThisExpressionSyntax _: + return "this"; + case BaseExpressionSyntax _: + return "base"; + case ConditionalAccessExpressionSyntax cond: + // a?.B.C renders as a.B.C: '?.' is null-propagation noise for ranking + // purposes. Rendering only the bound member (the old fall-through via the bare + // MemberBinding) silently dropped the receiver, producing a colliding entry + // ("State.Value" for MatchManager.Instance?.State.Value). When the chain ends + // in a call, fingerprint the CALLEE (a?.B() -> a.B) so it ranks together with + // its unconditional form — the walker uses the same callee convention for + // plain invocation heads. + var conditionalTail = cond.WhenNotNull is InvocationExpressionSyntax topCall + ? topCall.Expression + : cond.WhenNotNull; + var tailRendered = RenderPath(conditionalTail, depth); + // An element-binding tail (cache?[key] -> "[]") concatenates WITHOUT a dot so + // the fingerprint matches the unconditional cache[key] form ("cache[]"). + return tailRendered.Length > 0 && tailRendered[0] == '[' + ? RenderPath(cond.Expression, depth) + tailRendered + : RenderPath(cond.Expression, depth) + "." + tailRendered; + case MemberBindingExpressionSyntax mb: + return RenderSimpleName(mb.Name); + case ElementBindingExpressionSyntax _: // the ?[...] binding of a conditional chain + return "[]"; + default: + // NOT a raw ToString(): anything off the whitelist (string/char/numeric literals, + // interpolated strings, lambdas, arithmetic, …) is reduced to the neutral + // placeholder so source text can never leak into the telemetry log (AUTHAPI-29). + return Placeholder; + } + } + + private static string RenderSimpleName(SimpleNameSyntax name) + { + if (name is GenericNameSyntax gn) + return gn.Identifier.Text + RenderArity(gn.TypeArgumentList.Arguments.Count); + return name.Identifier.Text; + } + + private static string RenderArity(int count) + { + if (count <= 0) + return string.Empty; + if (count == 1) + return ""; + + var sb = new StringBuilder("<"); + for (var i = 1; i <= count; i++) + { + if (i > 1) + sb.Append(", "); + sb.Append("T").Append(i); + } + sb.Append(">"); + return sb.ToString(); + } + +#else + /// Runtime compilation (and therefore eval) is unavailable on this platform. + public static EvalUsageAnalysis Analyze(string code) => + new EvalUsageAnalysis { Classification = EvalClassification.Statements, Fingerprints = new List() }; + + public static IReadOnlyList ExtractFingerprints(string code) => new List(); + + public static string Classify(string code) => EvalClassification.Statements; +#endif + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageFingerprinter.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageFingerprinter.cs.meta new file mode 100644 index 0000000..9126cc0 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageFingerprinter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a08e235068524a4aa303c035a80f9008 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageReport.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageReport.cs new file mode 100644 index 0000000..9c29f1c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageReport.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; + +namespace Unity.Pipeline.Telemetry +{ + /// A ranked API fingerprint with its occurrence count across all recorded evals. + [Serializable] + class FingerprintCount + { + [JsonProperty("member")] + public string Member { get; set; } + + [JsonProperty("count")] + public int Count { get; set; } + } + + /// An eval pattern already served by an existing catalog command — a "stop using eval" hit. + [Serializable] + class CoverageHit + { + [JsonProperty("member")] + public string Member { get; set; } + + [JsonProperty("count")] + public int Count { get; set; } + + /// The existing command that already covers this pattern. + [JsonProperty("command")] + public string Command { get; set; } + } + + /// A frequently-eval'd pattern with no covering command — a candidate for a new typed command. + [Serializable] + class CoverageGap + { + [JsonProperty("member")] + public string Member { get; set; } + + [JsonProperty("count")] + public int Count { get; set; } + + /// A proposed command name from the coverage map, when one exists but is not yet built. + [JsonProperty("suggestedCommand", NullValueHandling = NullValueHandling.Ignore)] + public string SuggestedCommand { get; set; } + } + + /// + /// Coverage cross-reference between recorded eval fingerprints and the live command catalog: + /// which patterns are already coverable (habit/eval-avoidable) and which high-frequency patterns + /// are still uncovered (the next commands to build — feeds AUTHAPI-17 / AUTHAPI-24). + /// + [Serializable] + class EvalCoverageSuggestions + { + [JsonProperty("covered")] + public List Covered { get; set; } = new List(); + + [JsonProperty("topUncovered")] + public List TopUncovered { get; set; } = new List(); + } + + /// + /// Aggregated eval-usage report: ranked API fingerprints, one-liner vs multi-statement split, + /// error rate, and coverage suggestions. This is the artifact that turns eval-displacement backlog + /// triage into a lookup (AUTHAPI-24 / AUTHAPI-29). + /// + [Serializable] + class EvalUsageReport + { + [JsonProperty("totalCount")] + public int TotalCount { get; set; } + + [JsonProperty("successCount")] + public int SuccessCount { get; set; } + + [JsonProperty("errorCount")] + public int ErrorCount { get; set; } + + /// Fraction of recorded evals that failed, 0..1. + [JsonProperty("errorRate")] + public double ErrorRate { get; set; } + + [JsonProperty("singleExpressionCount")] + public int SingleExpressionCount { get; set; } + + [JsonProperty("statementsCount")] + public int StatementsCount { get; set; } + + /// Percentage of evals classified as one-liners (single-expression), 0..100. + [JsonProperty("oneLinerPercentage")] + public double OneLinerPercentage { get; set; } + + /// + /// Total number of DISTINCT fingerprints observed across all records, before the ranked + /// list is capped — so truncation is visible + /// (distinctFingerprints > fingerprints.Count means the tail was cut). + /// + [JsonProperty("distinctFingerprints")] + public int DistinctFingerprints { get; set; } + + /// + /// API fingerprints ranked by descending occurrence count. Capped (see + /// ); carries the + /// uncapped total. + /// + [JsonProperty("fingerprints")] + public List Fingerprints { get; set; } = new List(); + + [JsonProperty("coverageSuggestions")] + public EvalCoverageSuggestions CoverageSuggestions { get; set; } = new EvalCoverageSuggestions(); + } + + /// + /// Curated, heuristic map from an API fingerprint to the command name that would cover it. Truth + /// about whether that command actually exists comes from the live catalog at report time, so a + /// mapped-but-absent command surfaces as a concrete gap ("build refresh_assets"). Kept + /// deliberately small and extensible — this is a hint layer, not an exhaustive registry. + /// + static class EvalCoverageMap + { + public static readonly IReadOnlyDictionary Default = new Dictionary + { + // The habit case: a command already exists, so these should stop using eval. + { "PlayerSettings.SetScriptingBackend", "set_player_settings" }, + { "PlayerSettings.GetScriptingBackend", "get_player_settings" }, + // Known gaps from the WizardDuelVR evidence: no command yet (feeds AUTHAPI-17). + // NOTE: save_all is NOT a cover for AssetDatabase.SaveAssets — it saves open scenes + // (EditorSceneManager.SaveOpenScenes + AssetDatabase.Refresh) and never persists dirty + // non-scene assets; steering agents off that eval to save_all would silently drop those + // writes. Mapped to a proposed save_assets command so the report surfaces it as a gap. + { "AssetDatabase.SaveAssets", "save_assets" }, + { "AssetDatabase.Refresh", "refresh_assets" }, + }; + } + + /// + /// Builds an from recorded eval telemetry, cross-referencing the API + /// fingerprints against the set of available command names. + /// + static class EvalUsageAggregator + { + /// Default cap on the suggested-gaps (topUncovered) list. + public const int DefaultTopUncoveredLimit = 20; + + /// Default cap on the ranked list. + public const int DefaultFingerprintLimit = 50; + + /// + /// Aggregate into a report. is + /// the set of command names in the live catalog; defaults to + /// . caps the + /// suggested-gaps list and caps the ranked fingerprint + /// list ( makes any truncation visible; + /// coverage is computed from the FULL ranking, so the fingerprint cap never skews it). + /// Accepted range for both limits: 0 or greater — negative values fall back to the defaults + /// (0 legitimately empties the list). + /// + public static EvalUsageReport Build( + IReadOnlyList records, + ISet availableCommands, + IReadOnlyDictionary coverageMap = null, + int topUncoveredLimit = DefaultTopUncoveredLimit, + int fingerprintLimit = DefaultFingerprintLimit) + { + records = records ?? new List(); + availableCommands = availableCommands ?? new HashSet(); + coverageMap = coverageMap ?? EvalCoverageMap.Default; + if (topUncoveredLimit < 0) + topUncoveredLimit = DefaultTopUncoveredLimit; + if (fingerprintLimit < 0) + fingerprintLimit = DefaultFingerprintLimit; + + var report = new EvalUsageReport { TotalCount = records.Count }; + + var counts = new Dictionary(); + foreach (var record in records) + { + if (record.Success) + report.SuccessCount++; + else + report.ErrorCount++; + + if (record.Classification == EvalClassification.SingleExpression) + report.SingleExpressionCount++; + else + report.StatementsCount++; + + if (record.Fingerprints == null) + continue; + foreach (var fingerprint in record.Fingerprints) + { + if (string.IsNullOrEmpty(fingerprint)) + continue; + counts.TryGetValue(fingerprint, out var current); + counts[fingerprint] = current + 1; + } + } + + if (report.TotalCount > 0) + { + report.ErrorRate = (double)report.ErrorCount / report.TotalCount; + report.OneLinerPercentage = 100.0 * report.SingleExpressionCount / report.TotalCount; + } + + // Deterministic ranking: count desc, then member name asc as a tiebreak. + var ranked = counts + .Select(kv => new FingerprintCount { Member = kv.Key, Count = kv.Value }) + .OrderByDescending(f => f.Count) + .ThenBy(f => f.Member, StringComparer.Ordinal) + .ToList(); + + // Cap the reported ranking (an accumulated log can hold thousands of distinct + // fingerprints); DistinctFingerprints preserves the uncapped total so truncation shows. + report.DistinctFingerprints = ranked.Count; + report.Fingerprints = ranked.Count > fingerprintLimit ? ranked.Take(fingerprintLimit).ToList() : ranked; + + // Coverage cross-references the FULL ranking, so a tight fingerprint cap cannot hide a + // covered pattern or demote an uncovered one; topUncovered has its own cap. + report.CoverageSuggestions = BuildCoverage(ranked, availableCommands, coverageMap, topUncoveredLimit); + return report; + } + + private static EvalCoverageSuggestions BuildCoverage( + List ranked, + ISet availableCommands, + IReadOnlyDictionary coverageMap, + int topUncoveredLimit) + { + var suggestions = new EvalCoverageSuggestions(); + + foreach (var entry in ranked) + { + coverageMap.TryGetValue(entry.Member, out var mappedCommand); + var covered = mappedCommand != null && availableCommands.Contains(mappedCommand); + + if (covered) + { + suggestions.Covered.Add(new CoverageHit + { + Member = entry.Member, + Count = entry.Count, + Command = mappedCommand + }); + } + else + { + suggestions.TopUncovered.Add(new CoverageGap + { + Member = entry.Member, + Count = entry.Count, + // A mapped-but-absent command is a concrete proposal; no mapping leaves it null. + SuggestedCommand = mappedCommand + }); + } + } + + // Negative limits were normalised to the default in Build, so a plain cap suffices. + if (suggestions.TopUncovered.Count > topUncoveredLimit) + suggestions.TopUncovered = suggestions.TopUncovered.Take(topUncoveredLimit).ToList(); + + return suggestions; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageReport.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageReport.cs.meta new file mode 100644 index 0000000..5a38353 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageReport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ffdbe00cca0b4defb784b5c92af3bdf2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageTelemetry.cs b/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageTelemetry.cs new file mode 100644 index 0000000..a382f2d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageTelemetry.cs @@ -0,0 +1,481 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using Newtonsoft.Json; +using UnityEngine; + +#if UNITY_EDITOR +using System.Threading.Tasks; +#endif + +namespace Unity.Pipeline.Telemetry +{ + /// + /// One appended line of eval-usage telemetry. Serialized as a single compact JSON object per line + /// (JSONL). The raw is omitted unless the local + /// opt-in is set, so by default only shape/fingerprint data is persisted (AUTHAPI-29). + /// + [Serializable] + class EvalUsageRecord + { + [JsonProperty("time")] + public string Time { get; set; } + + /// The command that ran the source: "eval" or "eval_file". + [JsonProperty("command")] + public string Command { get; set; } + + [JsonProperty("success")] + public bool Success { get; set; } + + /// Short error category on failure (e.g. "Compilation Failed"); omitted on success. + [JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)] + public string Error { get; set; } + + /// Character length of the eval body. + [JsonProperty("payloadLength")] + public int PayloadLength { get; set; } + + /// Newline-delimited line count of the eval body. + [JsonProperty("lineCount")] + public int LineCount { get; set; } + + [JsonProperty("executionTimeMs")] + public long ExecutionTimeMs { get; set; } + + /// "single-expression" | "statements" — see . + [JsonProperty("classification")] + public string Classification { get; set; } + + /// Top-level API member-access paths extracted from the syntax tree. + [JsonProperty("fingerprints")] + public List Fingerprints { get; set; } = new List(); + + /// Raw eval source. Present ONLY when the explicit opt-in is set; omitted otherwise. + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public string Source { get; set; } + } + + /// + /// Local-first, privacy-first instrumentation of eval/eval_file (AUTHAPI-29). Per + /// invocation it appends one to + /// <project>/Library/Pipeline/eval-usage.jsonl. There is NO off-machine transmission: + /// disabling stops even local recording, which satisfies "analytics opt-out + /// fully disables any off-machine transmission" trivially — nothing ever leaves the machine. + /// + /// RECORDING IS EDITOR-ONLY. Eval telemetry exists to steer the editor command-surface migration, + /// so / compile to no-ops outside the editor + /// (#if UNITY_EDITOR). This also keeps player builds from writing a "Library/Pipeline" + /// folder next to Application.dataPath (the APK path on Android, inside the .app bundle on + /// macOS) — a location players may not even be able to write. Release players cannot eval at all + /// (the compiler stub blocks it); development players can, but are deliberately not instrumented. + /// The class itself stays compiled in players so shared code links, and the read side returns + /// empty results there. + /// + /// The production hook is fire-and-forget: snapshots the eval + /// outcome plus the current configuration on the calling (main) thread and runs the Roslyn parse, + /// fingerprint extraction, and file append on a thread-pool task, so recording adds no latency to + /// the eval response. + /// + /// Modelled on PipelineTransactionLog: a lock serialises the append (evals can overlap on + /// the /api/progress path) and the reader takes the same lock, all I/O is best-effort and swallows + /// errors so telemetry never breaks a command, and a directory-parameterised seam makes it + /// hermetically testable. Unlike the debug transaction log, the JSONL is meant to ACCUMULATE + /// across sessions so the ranked report has data, so it is bounded by a byte-size cap with + /// rotation rather than reset per session; merges the rotated ".old" + /// file back in, bounding readable history to roughly 2× . + /// + static class EvalUsageTelemetry + { + /// + /// Byte-size cap for the active JSONL before it rotates to the ".old" file (default 5 MB). + /// A settable field (not a const) so tests can force the size-cap rotation path cheaply. + /// + public static long MaxFileBytes = 5L * 1024 * 1024; + + /// + /// Upper bound, in characters, on the raw source persisted per record when the + /// opt-in is set (64 K). Longer bodies are cut at this bound and + /// is appended so truncation is explicit; + /// still reports the full original length. + /// + public const int MaxStoredSourceChars = 64 * 1024; + + /// Marker appended to a stored that was truncated. + public const string SourceTruncationMarker = "…[truncated]"; + + private const string FileName = "eval-usage.jsonl"; + private const string OldFileName = "eval-usage.old.jsonl"; + + /// Master switch. When false, is a no-op (no local write, no transmission). + public static bool Enabled = true; + + /// Opt-in: persist the raw eval source in each record. Off by default (privacy-first). + public static bool StoreSource = false; + + /// + /// Overrides the storage directory. Null uses <project>/Library/Pipeline. Tests set + /// this to a temp directory so they never touch the real project telemetry file. + /// + public static string OverrideDirectory; + + private static readonly object s_AppendGate = new object(); + + /// + /// Number of background records queued by and not yet + /// flushed. Explicitly initialised: in player builds the recording path compiles away and the + /// field is only ever read (by ), which would otherwise + /// raise CS0649. + /// + private static int s_PendingRecords = 0; + +#if UNITY_EDITOR + /// Serialises background writes in submission order (see ). + /// Editor-only like the recording path itself: Task is not even imported in player builds. + private static readonly object s_WriteChainGate = new object(); + private static Task s_WriteChain = Task.CompletedTask; +#endif + + /// Main-thread-resolved cache of the default (dataPath-derived) storage directory. + private static string s_CachedDefaultDirectory; + + /// + /// Record one eval invocation synchronously on the calling thread. Editor-only: compiles to a + /// no-op in player builds (see class doc). Best-effort: any failure (parse, I/O) is swallowed + /// with a warning so instrumentation can never break the eval it is measuring. The production + /// eval hook uses so the eval response is never delayed; this + /// synchronous seam remains for tests and direct callers that need the write completed on + /// return. + /// + public static void Record(string command, string code, bool success, string error, long executionTimeMs) + { +#if UNITY_EDITOR + if (!Enabled) + return; + + TryWriteRecord(ResolveDirectory(), DateTime.UtcNow, command, code, success, error, executionTimeMs, StoreSource); +#endif + } + + /// + /// Record one eval invocation without blocking the calling thread. Editor-only: compiles to a + /// no-op in player builds (see class doc). Everything the write needs — the eval primitives + /// plus a snapshot of the current / configuration + /// and the resolved directory (which touches Unity API and must be read on the main thread) — + /// is captured here on the calling thread; the Roslyn parse, fingerprint extraction, and + /// lock-serialised append then run on a thread-pool task, so telemetry adds zero latency to + /// the eval response. Failures on the background task are swallowed with a warning + /// (Debug.LogWarning is thread-safe). A domain reload can at worst abandon one in-flight + /// record; its partial trailing line is skipped by . + /// + public static void RecordInBackground(string command, string code, bool success, string error, long executionTimeMs) + { +#if UNITY_EDITOR + if (!Enabled) + return; + + // Snapshot on the calling thread: config statics can be flipped live by the inspector, + // and ResolveDirectory may touch Application.dataPath (main-thread-only Unity API). + var directory = ResolveDirectory(); + var storeSource = StoreSource; + var timeUtc = DateTime.UtcNow; + + Interlocked.Increment(ref s_PendingRecords); + // Chain onto the previous write instead of racing independent Task.Runs: back-to-back + // evals (batch, tight agent loops) must land in submission order — ReadRecords + // documents the log as chronological — and the chain also means at most one telemetry + // write competes with the pool at a time. + lock (s_WriteChainGate) + { + s_WriteChain = s_WriteChain.ContinueWith(_ => + { + try + { + TryWriteRecord(directory, timeUtc, command, code, success, error, executionTimeMs, storeSource); + } + finally + { + Interlocked.Decrement(ref s_PendingRecords); + } + }, System.Threading.CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default); + } +#endif + } + + /// + /// Block until every background record queued by has been + /// flushed, or elapses (returns false on timeout). Recording is + /// fire-and-forget, so tests (and teardown paths that delete the storage directory) drain the + /// queue with this before asserting on / removing the JSONL. + /// + public static bool WaitForPendingRecords(int timeoutMs = 5000) + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + while (Volatile.Read(ref s_PendingRecords) > 0) + { + if (stopwatch.ElapsedMilliseconds >= timeoutMs) + return false; + Thread.Sleep(1); + } + return true; + } + +#if UNITY_EDITOR + /// + /// , with the shared best-effort guard: telemetry must never affect + /// eval behavior (or crash a background task), so any failure (parse, I/O) is swallowed with + /// a warning instead of propagating. Shared by 's synchronous call and + /// 's queued continuation. + /// + private static void TryWriteRecord( + string directory, + DateTime timeUtc, + string command, + string code, + bool success, + string error, + long executionTimeMs, + bool storeSource) + { + try + { + WriteRecord(directory, timeUtc, command, code, success, error, executionTimeMs, storeSource); + } + catch (Exception ex) + { + Debug.LogWarning($"Pipeline eval telemetry write failed: {ex.Message}"); + } + } + + /// + /// Build and append one record from an already-captured snapshot. Runs on whichever thread the + /// caller chose; deliberately touches no Unity API (the fingerprinter is pure Roslyn and the + /// directory was resolved by the caller), so it is safe on a thread-pool task. + /// + private static void WriteRecord( + string directory, + DateTime timeUtc, + string command, + string code, + bool success, + string error, + long executionTimeMs, + bool storeSource) + { + var analysis = EvalUsageFingerprinter.Analyze(code); + var record = new EvalUsageRecord + { + Time = timeUtc.ToString("o"), + Command = command, + Success = success, + Error = success ? null : error, + PayloadLength = code?.Length ?? 0, + LineCount = CountLines(code), + ExecutionTimeMs = executionTimeMs, + Classification = analysis.Classification, + Fingerprints = new List(analysis.Fingerprints), + Source = storeSource ? BoundStoredSource(code) : null + }; + + Append(directory, record); + } + + /// + /// Bound the opt-in raw source at , appending + /// when cut, so one giant eval_file body can't balloon + /// a single record (the JSONL size cap only rotates between appends). + /// + private static string BoundStoredSource(string code) + { + if (string.IsNullOrEmpty(code) || code.Length <= MaxStoredSourceChars) + return code; + return code.Substring(0, MaxStoredSourceChars) + SourceTruncationMarker; + } +#endif + + /// + /// Append one record to the JSONL in an explicit directory. Testable seam behind + /// . Rotates first if the active file has reached the size cap, so each + /// file stays bounded to roughly plus one record. + /// + public static void Append(string directory, EvalUsageRecord record) + { + lock (s_AppendGate) + { + var root = Path.GetFullPath(directory); + Directory.CreateDirectory(root); + var path = DataFilePath(root, FileName); + + if (File.Exists(path) && new FileInfo(path).Length >= MaxFileBytes) + Rotate(root); + + File.AppendAllText(path, JsonConvert.SerializeObject(record, Formatting.None) + "\n"); + } + } + + /// + /// Canonical absolute path of one of this class's fixed data files inside the telemetry + /// directory, with containment re-checked after normalization. The directory is the only + /// dynamic path segment (a local editor setting or the test override — never protocol + /// input) and the file names are compile-time constants, so this is an explicit invariant + /// at the IO boundary: any input that would resolve outside its canonical directory is + /// rejected instead of opened. + /// + private static string DataFilePath(string directory, string fileName) + { + if (!TryResolveDataFilePath(directory, fileName, out var full)) + throw new InvalidOperationException( + $"Telemetry data file path '{full}' escapes its directory '{directory}'."); + return full; + } + + /// + /// The single canonicalize-and-contain implementation behind both the throwing + /// (writer sites) and the silently-skipping reader — one place + /// to fix normalization, per review. + /// + private static bool TryResolveDataFilePath(string directory, string fileName, out string fullPath) + { + var root = Path.GetFullPath(directory); + fullPath = Path.GetFullPath(Path.Combine(root, fileName)); + var rootWithSeparator = root.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + ? root + : root + Path.DirectorySeparatorChar; + return fullPath.StartsWith(rootWithSeparator, StringComparison.Ordinal); + } + + /// + /// Move the active JSONL to the ".old" backup, replacing any prior backup, so the active file + /// starts fresh. No-op when there is no active file. Exposed as the testable rotation seam. + /// Takes the append gate so a rotation can never interleave with a concurrent append or read + /// (the lock is re-entrant, so the rotate-inside- path is unaffected). + /// + public static void Rotate(string directory) + { + lock (s_AppendGate) + { + var root = Path.GetFullPath(directory); + Directory.CreateDirectory(root); + var path = DataFilePath(root, FileName); + var oldPath = DataFilePath(root, OldFileName); + RotatingFileBackup.RotateToBackup(path, oldPath); + } + } + + /// + /// Read back every record from : the rotated + /// eval-usage.old.jsonl first (when present), then the active file, so the report sees + /// the full retained history — bounded to roughly 2× — in + /// chronological order. Malformed lines are skipped so a partial trailing write can never + /// break the report. + /// + /// Concurrency: takes the same gate as / (this class + /// is the only writer, and appends now happen on background tasks), and additionally opens + /// each file tolerantly — FileShare.ReadWrite, with I/O errors caught — so a reader + /// racing an out-of-process writer, or a file swapped away between the existence check and the + /// open (the rotation TOCTOU), degrades to "return what parsed" instead of throwing. + /// + public static IReadOnlyList ReadRecords(string directory) + { + var records = new List(); + lock (s_AppendGate) + { + ReadFileInto(directory, OldFileName, records); + ReadFileInto(directory, FileName, records); + } + return records; + } + + /// + /// Best-effort JSONL parse of one fixed-name data file into ; see + /// . The canonicalize-and-contain check is repeated inline here (not + /// only in ) so the validated path and the open are in the same + /// method — the same invariant, kept visible at the IO site. + /// + private static void ReadFileInto(string directory, string fileName, List records) + { + // Same canonicalize-and-contain invariant as the writer sites, via the one shared + // implementation; a reader skips silently where a writer throws. + if (!TryResolveDataFilePath(directory, fileName, out var path)) + return; // outside the telemetry directory — unreachable for the constant file names + + try + { + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + using (var reader = new StreamReader(stream)) + { + string line; + while ((line = reader.ReadLine()) != null) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + try + { + var record = JsonConvert.DeserializeObject(line); + if (record != null) + records.Add(record); + } + catch (JsonException) + { + // Skip a malformed / partially written line; keep the rest of the log usable. + } + } + } + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + // Covers FileNotFound/DirectoryNotFound (nothing recorded yet, or the file was rotated + // away mid-read), sharing violations from external processes, and ACL/AV denials + // (UnauthorizedAccessException does NOT derive from IOException): telemetry read-back + // is best-effort, so return whatever parsed instead of throwing. + } + } + + /// + /// Resolve the storage directory: the override if set, else <project>/Library/Pipeline. + /// The default derives from Application.dataPath (a main-thread Unity API), so the + /// first non-overridden call must happen on the main thread — the editor startup path primes + /// it — after which the cached value is safe from any thread (e.g. the off-main-thread + /// report_evals command). + /// + public static string ResolveDirectory() + { + if (!string.IsNullOrEmpty(OverrideDirectory)) + return OverrideDirectory; + + if (s_CachedDefaultDirectory == null) + { +#if UNITY_EDITOR + var projectRoot = Path.GetDirectoryName(Application.dataPath); + s_CachedDefaultDirectory = Path.Combine(projectRoot, "Library", "Pipeline"); +#else + // Player builds: recording is compiled out and nothing is ever written, but + // report_evals (MainThreadRequired=false) can reach this off the main thread + // before any main-thread code primed the cache — and Application.dataPath is + // main-thread-only. A stable process-temp location keeps the read path safe and + // empty, so the command returns the documented zeroed report instead of throwing. + s_CachedDefaultDirectory = Path.Combine(Path.GetTempPath(), "UnityPipelineEvalUsage"); +#endif + } + return s_CachedDefaultDirectory; + } + + private static int CountLines(string code) + { + if (string.IsNullOrEmpty(code)) + return 0; + + var lines = 1; + for (var i = 0; i < code.Length; i++) + { + var c = code[i]; + if (c == '\n') + lines++; + else if (c == '\r' && (i + 1 >= code.Length || code[i + 1] != '\n')) + lines++; // lone \r (classic Mac) is a line break; \r\n already counts via \n + } + return lines; + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageTelemetry.cs.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageTelemetry.cs.meta new file mode 100644 index 0000000..35614d6 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Telemetry/EvalUsageTelemetry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ae2e135fbc1d424592da87027c687d94 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Unity.Pipeline.asmdef b/AIClient/Packages/com.unity.pipeline/Runtime/Unity.Pipeline.asmdef new file mode 100644 index 0000000..7727b8d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Unity.Pipeline.asmdef @@ -0,0 +1,33 @@ +{ + "name": "Unity.Pipeline", + "rootNamespace": "Unity.Pipeline", + "references": [ + "Unity.InputSystem", + "Unity.Pipeline.Attributes", + "Unity.Pipeline.IlInterpreter" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "UnityPipeline.Microsoft.CodeAnalysis.dll", + "UnityPipeline.Microsoft.CodeAnalysis.CSharp.dll", + "UnityPipeline.System.Collections.Immutable.dll", + "UnityPipeline.System.Runtime.CompilerServices.Unsafe.dll", + "UnityPipeline.System.Reflection.Metadata.dll", + "Newtonsoft.Json.dll" + ], + "autoReferenced": true, + "defineConstraints": [ + "UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE" + ], + "versionDefines": [ + { + "name": "com.unity.modules.unitywebrequest", + "expression": "1.0.0", + "define": "UNITY_2022_3_OR_NEWER" + } + ], + "noEngineReferences": false +} diff --git a/AIClient/Packages/com.unity.pipeline/Runtime/Unity.Pipeline.asmdef.meta b/AIClient/Packages/com.unity.pipeline/Runtime/Unity.Pipeline.asmdef.meta new file mode 100644 index 0000000..023d6c2 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Runtime/Unity.Pipeline.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 71e9815631ba2574b92564e93b8efc9c +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload.meta b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload.meta new file mode 100644 index 0000000..370c8e5 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7a0ab883fda51549426c60701bd05576 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_InPlace.meta b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_InPlace.meta new file mode 100644 index 0000000..0846c6d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_InPlace.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4f40406811f5efe4eb95440aebf17388 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_InPlace/CodeReload_InPlace.unity b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_InPlace/CodeReload_InPlace.unity new file mode 100644 index 0000000..6fe55e9 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_InPlace/CodeReload_InPlace.unity @@ -0,0 +1,447 @@ +%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.CodeReload.InPlaceCodeReloadExample + 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!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 1185268906} + - {fileID: 701989030} + - {fileID: 890368707} diff --git a/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_InPlace/CodeReload_InPlace.unity.meta b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_InPlace/CodeReload_InPlace.unity.meta new file mode 100644 index 0000000..0446187 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_InPlace/CodeReload_InPlace.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1a4fb3f52f24e174d9e515c8b77ceb71 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_InPlace/InPlaceCodeReloadExample.cs b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_InPlace/InPlaceCodeReloadExample.cs new file mode 100644 index 0000000..65da083 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_InPlace/InPlaceCodeReloadExample.cs @@ -0,0 +1,83 @@ +using UnityEngine; +using Unity.Pipeline.CodeReload; + +namespace Unity.Pipeline.Samples.CodeReload +{ + /// + /// Example demonstrating in-place code 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/CodeReload_InPlace/InPlaceCodeReloadExample.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 InPlaceCodeReloadExample : MonoBehaviour + { + [Header("Code 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; + + [CodeReload] + 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); + } + } + + [CodeReload] + public void ResetTransform() + { + // Another method you can code reload + transform.position = Vector3.zero; + transform.rotation = Quaternion.identity; + Debug.Log("Transform reset via code 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 reloadable) - these work normally + void Start() + { + Debug.Log($"InPlaceCodeReloadExample started. Try code reloading the Update method!"); + Debug.Log($"Command: reload_file {GetType().Name}.cs"); + } + + public void TriggerResetFromUI() + { + // This calls the reloadable method + ResetTransform(); + } + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_InPlace/InPlaceCodeReloadExample.cs.meta b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_InPlace/InPlaceCodeReloadExample.cs.meta new file mode 100644 index 0000000..8db378d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_InPlace/InPlaceCodeReloadExample.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f2e2dbbd2e928074d83ba49a57cb9cfa \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_Minics/PongScene.unity b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_Minics/PongScene.unity new file mode 100644 index 0000000..a5a7436 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_Minics/PongScene.unity @@ -0,0 +1,437 @@ +%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: 2 + m_PVRDenoiserTypeIndirect: 2 + m_PVRDenoiserTypeAO: 2 + 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 &201913770 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 201913773} + - component: {fileID: 201913772} + - component: {fileID: 201913771} + 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 &201913771 +AudioListener: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 201913770} + m_Enabled: 1 +--- !u!20 &201913772 +Camera: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 201913770} + 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 &201913773 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 201913770} + 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 &453203916 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 453203918} + - component: {fileID: 453203917} + m_Layer: 0 + m_Name: Pong + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &453203917 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 453203916} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 8ba7fa42014981348997afa11a492f9c, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::PongScript + leftPaddle: {fileID: 0} + rightPaddle: {fileID: 0} + ball: {fileID: 0} + leftScoreGO: {fileID: 0} + rightScoreGO: {fileID: 0} + arenaGO: {fileID: 0} + arena: {fileID: 0} + leftScoreText: {fileID: 0} + rightScoreText: {fileID: 0} + mat: {fileID: 0} + ballRenderer: {fileID: 0} + leftPaddleAngle: 180 + rightPaddleAngle: 0 + ballVelocity: {x: 0, y: 0, z: 0} + ballIsLeftPaddleColor: 0 + leftLives: 3 + rightLives: 3 + gameOver: 0 + ballSpeed: 5 + circleRadius: 6 + paddleAngularSpeed: 180 + gravity: 3 + updateTicks: 0 + marker: none +--- !u!4 &453203918 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 453203916} + 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!1 &1639109660 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1639109662} + - component: {fileID: 1639109661} + 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 &1639109661 +Light: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1639109660} + 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 &1639109662 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1639109660} + 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 &1769366718 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1769366720} + - component: {fileID: 1769366719} + m_Layer: 0 + m_Name: PipelineManager + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1769366719 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1769366718} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: dd4ab1c9091e0194196b5f5d51cb932f, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.Pipeline::Unity.Pipeline.RuntimePipelineManager + enableInBuilds: 0 + port: 0 + requestTimeoutMs: 30000 + enableAuditLogging: 1 + autoStart: 1 + maxWorkItemsPerFrame: 10 + m_AllowedReloadRoots: [] +--- !u!4 &1769366720 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1769366718} + 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!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 201913773} + - {fileID: 1639109662} + - {fileID: 453203918} + - {fileID: 1769366720} diff --git a/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_Minics/PongScene.unity.meta b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_Minics/PongScene.unity.meta new file mode 100644 index 0000000..88e54a5 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_Minics/PongScene.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: aade730046d453349bd366dcebbce674 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_Minics/README.md b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_Minics/README.md new file mode 100644 index 0000000..6f14b41 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_Minics/README.md @@ -0,0 +1,42 @@ +# Code Reload - Minics + +Demonstrates code reloading with the minics vm. This allows code reloading cs +scripts on all platforms (even on il2cpp players) with the constraint the the +script must only contain a subset of csharp. + +## What's in this sample + +| File | Purpose | +|------|---------| +| `pong.cs` | `PongScript`, a self-playing Pong game that can be reloaded. | +| `SampleScene.unity` | Scene with a GameObject carrying `PongScript`. | + +`PongScript` is a self-playing arena Pong: two paddles orbit a circle and rally a +ball that switches colour on each hit. + +## Try it + +1. Import this sample and add `PongScene` to the built scene list. +2. Enable **developement build** and **autoconnect profiler** in the build settings (minics uses the profiler connection to reload the scripts.) +2. You may also want to enable run in background in the player settings if you are building for web. +3. Build and run the player. The paddles start rallying the ball automatically, and the profiler begins collecting data. +3. Make a change to pong.cs and push it with `unity command reload_file_player_interpreter "Assets/Samples/Unity Pipeline/0.2.0-exp.2/Code Reload Examples/CodeReload_Minics/pong.cs"` to see the reload live. (For automatic re-push on every save, use the Code Reload Interpreter Watch section of the Pipeline Settings inspector.) + +## How the code reload works + +- **`[CodeReload]` on the class** opts every eligible method (`Start`, `Update`, + `PlacePaddle`, `OnDisable`, …) into in-place reload. Editing any of them takes effect on the next `reload_file`. +- **`[OnCodeReload]` on `OnReloaded()`** runs once after each reload, on every + live instance. Here it tears down and rebuilds the scene (`OnDisable()` + `Start()`) + so edits to setup code apply immediately. +- **Verification markers** `updateTicks` and `marker` are public fields you can read + back over eval to confirm the freshly reloaded `Update` body is actually running. + +Because in-place reloaded bodies may only touch **public** members of the target type, +every field and helper on `PongScript` is public. + +## Minics + +Minics is a subset of all C# features. This means that not all scripts will be able to +be reloaded. Consult the package documentation for more information on which +features are supported. diff --git a/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_Minics/pong.cs b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_Minics/pong.cs new file mode 100644 index 0000000..dde364a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_Minics/pong.cs @@ -0,0 +1,248 @@ +using UnityEngine; +using Unity.Pipeline.CodeReload; + +// Pong sample for com.unity.pipeline in-place code reload: +// - Self-playing AI instead of the Input System (project has no com.unity.inputsystem). +// - Material created in code (project has no Resources/DefaultMaterial; built-in RP). +// - All members public: in-place [CodeReload] method bodies may only touch public members. +// - [CodeReload] on each editable method (Start/Update/PlacePaddle/OnDisable), so editing a helper +// like PlacePaddle takes effect too. `reload_file Assets/pong.cs`. +public class PongScript : MonoBehaviour +{ + public GameObject leftPaddle, rightPaddle, ball; + public GameObject leftScoreGO, rightScoreGO, arenaGO; + public LineRenderer arena; + public TextMesh leftScoreText, rightScoreText; + public Material mat; + public Renderer ballRenderer; + + public float leftPaddleAngle = 180f; + public float rightPaddleAngle = 0f; + public Vector3 ballVelocity; + public bool ballIsLeftPaddleColor = false; + public int leftLives = 3; + public int rightLives = 3; + public bool gameOver = false; + + public float ballSpeed = 5f; + public float circleRadius = 2f; + public float paddleAngularSpeed = 180f; + public float gravity = 3f; + + // Code-reload verification markers (read back via eval to confirm the live body ran). + public int updateTicks = 0; + public string marker = "none"; + private Color leftColor ; + private Color rightColor; + + [OnCodeReload] + public void OnReloaded() + { + Debug.Log($"Pong CodeReloaded"); + OnDisable(); + Start(); + } + + [CodeReload] + public void Start() + { + leftColor = new Color(0.678f, 1f, 0.184f); // green-yellow (Color.greenYellow needs 6000.2+) + rightColor = Color.magenta; + + mat = new Material(Shader.Find("Sprites/Default")); + Camera cam = Camera.main; + cam.orthographic = true; + cam.orthographicSize = 12.5f; + cam.clearFlags = CameraClearFlags.SolidColor; + cam.backgroundColor = Color.grey; + cam.transform.position = new Vector3(0.0f, 0.0f, -10.0f); + + + leftScoreGO = new GameObject("LeftScore"); + leftScoreGO.transform.position = new Vector3(-6f, 6f, 0f); + leftScoreText = leftScoreGO.AddComponent(); + leftScoreText.anchor = TextAnchor.MiddleCenter; + leftScoreText.alignment = TextAlignment.Center; + leftScoreText.text = "Cyan: " + leftLives.ToString(); + leftScoreText.characterSize = 0.5f; + leftScoreText.color = leftColor; + + rightScoreGO = new GameObject("RightScore"); + rightScoreGO.transform.position = new Vector3(6f, 6f, 0f); + rightScoreText = rightScoreGO.AddComponent(); + rightScoreText.anchor = TextAnchor.MiddleCenter; + rightScoreText.alignment = TextAlignment.Center; + rightScoreText.text = "Magenta: " + rightLives.ToString(); + rightScoreText.characterSize = 0.5f; + rightScoreText.color = rightColor; + + arenaGO = new GameObject("CircleIndicator"); + arenaGO.transform.position = new Vector3(0f, 0f, 0.5f); + arena = arenaGO.AddComponent(); + arena.useWorldSpace = false; + arena.loop = true; + arena.startWidth = 0.1f; + arena.endWidth = 0.1f; + arena.material = mat; + arena.material.color = new Color(0.15f, 0.15f, 0.15f, 1f); + arena.positionCount = 64; + int i = 0; + while (i < 64) + { + float a = i * Mathf.PI * 2f / 64f; + arena.SetPosition(i, new Vector3(Mathf.Cos(a) * circleRadius, Mathf.Sin(a) * circleRadius, 0f)); + i = i + 1; + } + + leftPaddle = GameObject.CreatePrimitive(PrimitiveType.Quad); + leftPaddle.transform.localScale = new Vector3(0.3f, 1.5f, 1f); + Renderer lr = leftPaddle.GetComponent(); + lr.material = mat; + lr.material.color = leftColor; + + rightPaddle = GameObject.CreatePrimitive(PrimitiveType.Quad); + rightPaddle.transform.localScale = new Vector3(0.3f, 1.5f, 1f); + Renderer rr = rightPaddle.GetComponent(); + rr.material = mat; + rr.material.color = rightColor; + + PlacePaddle(leftPaddle, leftPaddleAngle); + PlacePaddle(rightPaddle, rightPaddleAngle); + + ball = GameObject.CreatePrimitive(PrimitiveType.Sphere); + ball.transform.localScale = new Vector3(0.4f, 0.4f, 0.4f); + ballRenderer = ball.GetComponent(); + ballRenderer.material = mat; + ballRenderer.material.color = rightColor; + + ballVelocity = new Vector3(ballSpeed, ballSpeed * 0.5f, 0f); + } + + [CodeReload] + public void PlacePaddle(GameObject paddle, float angleDeg) + { + float rad = angleDeg * Mathf.Deg2Rad; + paddle.transform.localScale = new Vector3(.5f,2,1); + paddle.transform.position = new Vector3(Mathf.Cos(rad) * circleRadius, Mathf.Sin(rad) * circleRadius, 0f); + paddle.transform.rotation = Quaternion.Euler(0f, 0f, angleDeg); + } + + [CodeReload] + public void Update() + { + updateTicks++; + marker = "live"; + + if (gameOver) + { + this.OnDisable(); + gameOver = false; + this.Start(); + return; + } + + + float dt = Time.deltaTime; + + // Self-playing AI: each paddle tracks the ball while the ball wears its colour. + float ballAng = Mathf.Atan2(ball.transform.position.y, ball.transform.position.x) * Mathf.Rad2Deg; + if (ballIsLeftPaddleColor) + { + float diff = Mathf.DeltaAngle(leftPaddleAngle, ballAng); + if (diff > 5f) leftPaddleAngle += paddleAngularSpeed * dt; + if (diff < -5f) leftPaddleAngle -= paddleAngularSpeed * dt; + } + else + { + float diff = Mathf.DeltaAngle(rightPaddleAngle, ballAng); + if (diff > 5f) rightPaddleAngle += paddleAngularSpeed * dt; + if (diff < -5f) rightPaddleAngle -= paddleAngularSpeed * dt; + } + + PlacePaddle(leftPaddle, leftPaddleAngle); + PlacePaddle(rightPaddle, rightPaddleAngle); + + ballVelocity = ballVelocity + Vector3.down * gravity * dt * 1.2f; + ball.transform.position = ball.transform.position + ballVelocity * dt ; + Vector3 bPos = ball.transform.position; + float bDist = bPos.magnitude; + float bAng = Mathf.Atan2(bPos.y, bPos.x) * Mathf.Rad2Deg; + + if (bDist > circleRadius - 0.3f && bDist < circleRadius + 0.2f) + { + Vector3 normal = bPos.normalized; + if (Vector3.Dot(ballVelocity, normal) > 0f) + { + if (ballIsLeftPaddleColor) + { + float diff = Mathf.DeltaAngle(bAng, leftPaddleAngle); + if (diff > -12f && diff < 12f) + { + ballVelocity = Vector3.Reflect(ballVelocity, -normal); + ball.transform.position = ball.transform.position - normal * 0.1f; + ballIsLeftPaddleColor = false; + ballRenderer.material.color = rightColor; + } + } + else + { + float diff = Mathf.DeltaAngle(bAng, rightPaddleAngle); + if (diff > -12f && diff < 12f) + { + ballVelocity = Vector3.Reflect(ballVelocity, -normal); + ball.transform.position = ball.transform.position - normal * 0.1f; + ballIsLeftPaddleColor = true; + ballRenderer.material.color = leftColor; + } + } + } + } + + if (bDist > 8f) + { + if (ballIsLeftPaddleColor) + { + leftLives -= 1; + leftScoreText.text = "Cyan: " + leftLives.ToString(); + } + else + { + rightLives -= 1; + rightScoreText.text = "Magenta: " + rightLives.ToString(); + } + + if (leftLives <= 0 || rightLives <= 0) + { + gameOver = true; + } + else + { + ball.transform.position = new Vector3(0f, 0f, 0f); + ballVelocity = new Vector3(ballSpeed, ballSpeed * 0.5f, 0f); + ballIsLeftPaddleColor = rightLives < leftLives; + if (ballIsLeftPaddleColor) + { + ballRenderer.material.color = leftColor; + } + else + { + ballRenderer.material.color = rightColor; + } + } + } + } + + [CodeReload] + public void OnDisable() + { + if (leftPaddle) GameObject.Destroy(leftPaddle); + if (rightPaddle) GameObject.Destroy(rightPaddle); + if (ball) GameObject.Destroy(ball); + if (leftScoreGO) GameObject.Destroy(leftScoreGO); + if (rightScoreGO) GameObject.Destroy(rightScoreGO); + if (arenaGO) GameObject.Destroy(arenaGO); + Camera.main.orthographicSize = 5.0f; + Camera.main.clearFlags = CameraClearFlags.Skybox; + Camera.main.orthographic = false; + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_Minics/pong.cs.meta b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_Minics/pong.cs.meta new file mode 100644 index 0000000..7f65242 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/CodeReload_Minics/pong.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8ba7fa42014981348997afa11a492f9c \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/Unity.Pipeline.Samples.asmdef b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/Unity.Pipeline.Samples.asmdef new file mode 100644 index 0000000..6d6de50 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/Unity.Pipeline.Samples.asmdef @@ -0,0 +1,17 @@ +{ + "name": "Unity.Pipeline.Samples", + "rootNamespace": "Unity.Pipeline.Samples.CodeReload", + "references": [ + "Unity.Pipeline", + "Unity.Pipeline.Attributes" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/Unity.Pipeline.Samples.asmdef.meta b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/Unity.Pipeline.Samples.asmdef.meta new file mode 100644 index 0000000..abc3ce1 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Samples~/CodeReload/Unity.Pipeline.Samples.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: fadfe42d23f96db47a2c41e046892acb +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Tests~.meta b/AIClient/Packages/com.unity.pipeline/Tests~.meta new file mode 100644 index 0000000..4f2196a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 005114723dbbd384bb5fe02db11a3b99 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor.meta new file mode 100644 index 0000000..6b4fea2 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3ffa6a16c12a1a248884139af2530848 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation.meta new file mode 100644 index 0000000..1365e8e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: de3774b0893944d8ac6871cbcaa631ab +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation/AnimationClipCommandsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation/AnimationClipCommandsTests.cs new file mode 100644 index 0000000..f163821 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + class AnimationClipCommandsTests + { + private const string Root = "Assets/__CLI214AnimTest"; + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + } + } + + 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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation/AnimationClipCommandsTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation/AnimationClipCommandsTests.cs.meta new file mode 100644 index 0000000..3af6f9b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation/AnimationClipCommandsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 613a4bb343db4a4cb96ae6fef8195425 diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation/AnimatorControllerCommandsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation/AnimatorControllerCommandsTests.cs new file mode 100644 index 0000000..36cbcc7 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + class AnimatorControllerCommandsTests + { + private const string Root = "Assets/__CLI214CtrlTest"; + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + } + } + + 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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation/AnimatorControllerCommandsTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation/AnimatorControllerCommandsTests.cs.meta new file mode 100644 index 0000000..6655cd6 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation/AnimatorControllerCommandsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c1d78b8f19fc48cea840bc62c3204b3e diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation/TimelineCommandsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation/TimelineCommandsTests.cs new file mode 100644 index 0000000..31ddc4a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + class TimelineCommandsTests + { + private const string Root = "Assets/__CLI214TimelineTest"; + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + } + } + + 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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation/TimelineCommandsTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation/TimelineCommandsTests.cs.meta new file mode 100644 index 0000000..fd8848b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Animation/TimelineCommandsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 72d8c657533f4b8b96feb3af780d619a diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/ApiEndpointsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/ApiEndpointsTests.cs new file mode 100644 index 0000000..a1c3533 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/ApiEndpointsTests.cs @@ -0,0 +1,1229 @@ +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. + /// + 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 commands should be hidden on the Editor server"); + } + + [Test] + public async Task ApiCommands_DefaultDetail_ReturnsFullMetadata() + { + // Act - no detail parameter: full is the default (back-compat with pre-detail clients) + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + + // Assert + Assert.IsTrue(httpResponse.IsSuccessStatusCode, + $"Commands endpoint should return success, got: {httpResponse.StatusCode}"); + var responseJson = JObject.Parse(jsonContent); + var commands = responseJson["commands"] as JArray; + Assert.Greater(commands.Count, 0, "Should have at least one discovered command"); + + var logEditor = commands.Cast() + .First(cmd => cmd["name"]?.ToString() == "log_editor"); + Assert.IsNotNull(logEditor["parameters"], "default detail should include parameters"); + Assert.IsNotNull(logEditor["schema"], "default detail should include schema"); + Assert.IsNotNull(logEditor["tags"], "default detail should include tags"); + Assert.IsNotNull(logEditor["package"], "default detail should include package"); + } + + [Test] + public async Task ApiCommands_DetailCompact_ReturnsLightweightIndex() + { + // Act - detail=compact opts into the lightweight browse/discovery projection + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + + // Assert + Assert.IsTrue(httpResponse.IsSuccessStatusCode, + $"detail=compact should return success, got: {httpResponse.StatusCode}"); + var commands = JObject.Parse(jsonContent)["commands"] as JArray; + Assert.Greater(commands.Count, 0, "Should have at least one discovered command"); + + foreach (var cmd in commands.Cast()) + { + Assert.IsNotNull(cmd["name"], "compact entry should have name"); + Assert.IsNotNull(cmd["description"], "compact entry should have description"); + Assert.IsNotNull(cmd["tags"], "compact entry should have tags"); + Assert.IsNotNull(cmd["package"], "compact entry should have package"); + Assert.IsNull(cmd["parameters"], $"compact entry '{cmd["name"]}' should omit parameters"); + Assert.IsNull(cmd["schema"], $"compact entry '{cmd["name"]}' should omit schema"); + } + } + + [Test] + public async Task ApiCommands_QueryFilter_MatchesTagCaseInsensitively() + { + // Act - 'REGISTRATION' only appears in test_tagged's 'test/registration' tag + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&query=REGISTRATION"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + + // Assert + Assert.IsTrue(httpResponse.IsSuccessStatusCode, + $"query filter should return success, got: {httpResponse.StatusCode}"); + var responseJson = JObject.Parse(jsonContent); + var names = (responseJson["commands"] as JArray) + .Cast() + .Select(cmd => cmd["name"]?.ToString()) + .ToList(); + Assert.Contains("test_tagged", names, "query should match a command via its tag, case-insensitively"); + CollectionAssert.DoesNotContain(names, "log_editor", "commands matching neither name, description nor tag should be filtered out"); + } + + [Test] + public async Task ApiCommands_QueryFilter_MatchesNameByPrefix() + { + // Act - 'log_edi' is a strict prefix of the 'log_editor' command name + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&query=log_edi"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + + // Assert + Assert.IsTrue(httpResponse.IsSuccessStatusCode, + $"query filter should return success, got: {httpResponse.StatusCode}"); + var names = (JObject.Parse(jsonContent)["commands"] as JArray) + .Cast() + .Select(cmd => cmd["name"]?.ToString()) + .ToList(); + Assert.Contains("log_editor", names, "a query that is a prefix of a command name should match that command"); + } + + [Test] + public async Task ApiCommands_QueryFilter_NoMatch_ReturnsEmptyNotError() + { + // Act + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?query=zzz_definitely_no_match_zzz"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + + // Assert - empty result, not an error + Assert.IsTrue(httpResponse.IsSuccessStatusCode, + $"a no-match query should still return success, got: {httpResponse.StatusCode}"); + var responseJson = JObject.Parse(jsonContent); + Assert.AreEqual(0, (responseJson["commands"] as JArray).Count, "no-match query should return an empty commands array"); + Assert.AreEqual(0, responseJson["total"]?.ToObject(), "no-match query should report total 0"); + Assert.AreEqual(0, responseJson["count"]?.ToObject(), "no-match query should report count 0"); + } + + [Test] + public async Task ApiCommands_TagFilter_MatchesSubtreeBySegmentPrefix() + { + // Act / Assert - 'test' matches the exact 'test' tag and the 'test/registration' subtree + var jsonContent = await (await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&tag=test")) + .Content.ReadAsStringAsync(); + var names = (JObject.Parse(jsonContent)["commands"] as JArray) + .Cast().Select(cmd => cmd["name"]?.ToString()).ToList(); + Assert.Contains("test_tagged", names, "tag=test should match the tagged test command"); + CollectionAssert.DoesNotContain(names, "log_editor", "untagged commands should be filtered out"); + + // Drilling into a subtag still matches + jsonContent = await (await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&tag=test/registration")) + .Content.ReadAsStringAsync(); + names = (JObject.Parse(jsonContent)["commands"] as JArray) + .Cast().Select(cmd => cmd["name"]?.ToString()).ToList(); + Assert.Contains("test_tagged", names, "tag=test/registration should match the subtag directly"); + + // Segment-aware: 'tes' is not a whole-segment prefix of 'test' + jsonContent = await (await m_PipelineClient.GetHttpAsync("/api/commands?tag=tes")) + .Content.ReadAsStringAsync(); + Assert.AreEqual(0, JObject.Parse(jsonContent)["total"]?.ToObject(), + "tag matching should respect '/' segment boundaries, not raw string prefixes"); + } + + [Test] + public async Task ApiCommands_CombinedFilters_AreAnded() + { + // Act - log_editor matches the query but carries no tags, so the tag filter excludes it + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?query=log_editor&tag=test"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + + // Assert + Assert.IsTrue(httpResponse.IsSuccessStatusCode, + $"combined filters should return success, got: {httpResponse.StatusCode}"); + Assert.AreEqual(0, JObject.Parse(jsonContent)["total"]?.ToObject(), + "filters should combine with AND"); + } + + [Test] + public async Task ApiCommands_GroupByPackage_GroupsCommandsByOriginatingAssembly() + { + // Act + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&group_by=package&query=log_editor"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + + // Assert - grouped responses carry 'groups' instead of 'commands' + Assert.IsTrue(httpResponse.IsSuccessStatusCode, + $"group_by=package should return success, got: {httpResponse.StatusCode}"); + var responseJson = JObject.Parse(jsonContent); + Assert.IsNull(responseJson["commands"], "grouped response should not have a flat commands array"); + var groups = responseJson["groups"] as JArray; + Assert.IsNotNull(groups, "grouped response should have a groups array"); + + var testGroup = groups.Cast() + .FirstOrDefault(g => g["package"]?.ToString() == "Unity.Pipeline.Tests.Editor"); + Assert.IsNotNull(testGroup, "log_editor's assembly should appear as a package group"); + var groupNames = (testGroup["commands"] as JArray) + .Cast().Select(cmd => cmd["name"]?.ToString()).ToList(); + Assert.Contains("log_editor", groupNames, "the group should contain the matching command"); + } + + [Test] + public async Task ApiCommands_GroupByTag_ReturnsNestedTagTree() + { + // Act + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&group_by=tag&tag=test"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + + // Assert - top-level 'test' node with a nested 'test/registration' child + Assert.IsTrue(httpResponse.IsSuccessStatusCode, + $"group_by=tag should return success, got: {httpResponse.StatusCode}"); + var groups = JObject.Parse(jsonContent)["groups"] as JArray; + Assert.IsNotNull(groups, "grouped response should have a groups array"); + + var testNode = groups.Cast().FirstOrDefault(g => g["tag"]?.ToString() == "test"); + Assert.IsNotNull(testNode, "should have a top-level 'test' tag node"); + var directNames = (testNode["commands"] as JArray) + .Cast().Select(cmd => cmd["name"]?.ToString()).ToList(); + Assert.Contains("test_tagged", directNames, "test_tagged carries the exact 'test' tag"); + + var childNode = (testNode["children"] as JArray)?.Cast() + .FirstOrDefault(g => g["tag"]?.ToString() == "test/registration"); + Assert.IsNotNull(childNode, "'test/registration' should nest under 'test'"); + var childNames = (childNode["commands"] as JArray) + .Cast().Select(cmd => cmd["name"]?.ToString()).ToList(); + Assert.Contains("test_tagged", childNames, "test_tagged also carries the 'test/registration' tag"); + } + + [Test] + public async Task ApiCommands_GroupByInvalid_ReturnsBadRequestListingAcceptedValues() + { + // Act + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?group_by=namespace"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + + // Assert + Assert.AreEqual(400, (int)httpResponse.StatusCode, + $"Invalid group_by value should be rejected with 400. Response: {jsonContent}"); + StringAssert.Contains("flat", jsonContent, "error should list accepted value 'flat'"); + StringAssert.Contains("package", jsonContent, "error should list accepted value 'package'"); + StringAssert.Contains("tag", jsonContent, "error should list accepted value 'tag'"); + } + + [Test] + public async Task ApiCommands_Pagination_SlicesNameSortedListDeterministically() + { + // Arrange - the unpaginated listing (name-sorted for deterministic pages) + var allJson = await (await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact")) + .Content.ReadAsStringAsync(); + var allNames = (JObject.Parse(allJson)["commands"] as JArray) + .Cast().Select(cmd => cmd["name"]?.ToString()).ToList(); + CollectionAssert.AreEqual(allNames.OrderBy(n => n, System.StringComparer.Ordinal).ToList(), allNames, + "commands should be name-sorted so pagination windows are deterministic"); + + // Act + var pageJson = await (await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&offset=1&limit=2")) + .Content.ReadAsStringAsync(); + var pageResponse = JObject.Parse(pageJson); + var pageNames = (pageResponse["commands"] as JArray) + .Cast().Select(cmd => cmd["name"]?.ToString()).ToList(); + + // Assert + CollectionAssert.AreEqual(allNames.Skip(1).Take(2).ToList(), pageNames, + "offset/limit should slice the same ordering the unpaginated listing uses"); + Assert.AreEqual(2, pageResponse["count"]?.ToObject(), "count should be the returned page size"); + Assert.AreEqual(allNames.Count, pageResponse["total"]?.ToObject(), "total should be the match count before pagination"); + Assert.AreEqual(1, pageResponse["offset"]?.ToObject(), "offset should be echoed"); + Assert.AreEqual(2, pageResponse["limit"]?.ToObject(), "limit should be echoed"); + } + + [Test] + public async Task ApiCommands_PaginationInvalid_ReturnsBadRequest() + { + // Act / Assert - negative offset + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?offset=-1"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + Assert.AreEqual(400, (int)httpResponse.StatusCode, + $"negative offset should be rejected with 400. Response: {jsonContent}"); + StringAssert.Contains("offset", jsonContent, "error should name the offending parameter"); + + // Non-numeric limit + httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?limit=abc"); + jsonContent = await httpResponse.Content.ReadAsStringAsync(); + Assert.AreEqual(400, (int)httpResponse.StatusCode, + $"non-numeric limit should be rejected with 400. Response: {jsonContent}"); + StringAssert.Contains("limit", jsonContent, "error should name the offending parameter"); + } + + [Test] + public async Task ApiCommands_SortByPackage_OrdersByPackageThenName() + { + // Act + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&sort=package"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + + // Assert - ordered by originating package, ties broken by name + Assert.IsTrue(httpResponse.IsSuccessStatusCode, + $"sort=package should return success, got: {httpResponse.StatusCode}"); + var pairs = (JObject.Parse(jsonContent)["commands"] as JArray) + .Cast() + .Select(cmd => (Package: cmd["package"]?.ToString(), Name: cmd["name"]?.ToString())) + .ToList(); + var expected = pairs + .OrderBy(p => p.Package, System.StringComparer.Ordinal) + .ThenBy(p => p.Name, System.StringComparer.Ordinal) + .ToList(); + CollectionAssert.AreEqual(expected, pairs, + "sort=package should order by package with name as tiebreak"); + } + + [Test] + public async Task ApiCommands_OrderDesc_ReversesNameSort() + { + // Act + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&order=desc"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + + // Assert + Assert.IsTrue(httpResponse.IsSuccessStatusCode, + $"order=desc should return success, got: {httpResponse.StatusCode}"); + var names = (JObject.Parse(jsonContent)["commands"] as JArray) + .Cast().Select(cmd => cmd["name"]?.ToString()).ToList(); + CollectionAssert.AreEqual(names.OrderByDescending(n => n, System.StringComparer.Ordinal).ToList(), names, + "order=desc should reverse the default name sort"); + } + + [Test] + public async Task ApiCommands_SortInvalid_ReturnsBadRequestListingAcceptedValues() + { + // Act / Assert - unknown sort key + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?sort=alphabetical"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + Assert.AreEqual(400, (int)httpResponse.StatusCode, + $"Invalid sort value should be rejected with 400. Response: {jsonContent}"); + StringAssert.Contains("name", jsonContent, "error should list accepted value 'name'"); + StringAssert.Contains("package", jsonContent, "error should list accepted value 'package'"); + + // Unknown order direction + httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?order=upside_down"); + jsonContent = await httpResponse.Content.ReadAsStringAsync(); + Assert.AreEqual(400, (int)httpResponse.StatusCode, + $"Invalid order value should be rejected with 400. Response: {jsonContent}"); + StringAssert.Contains("asc", jsonContent, "error should list accepted value 'asc'"); + StringAssert.Contains("desc", jsonContent, "error should list accepted value 'desc'"); + } + + [Test] + public async Task ApiCommands_DetailFull_ReturnsFullMetadataWithTagsAndPackage() + { + // Act - detail=full opts into the complete per-command metadata + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=full"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + + // Assert + Assert.IsTrue(httpResponse.IsSuccessStatusCode, + $"detail=full should return success, got: {httpResponse.StatusCode}"); + var commands = JObject.Parse(jsonContent)["commands"] as JArray; + var logEditor = commands.Cast() + .First(cmd => cmd["name"]?.ToString() == "log_editor"); + Assert.IsNotNull(logEditor["parameters"], "full detail should include parameters"); + Assert.IsNotNull(logEditor["schema"], "full detail should include schema"); + Assert.IsNotNull(logEditor["tags"], "full detail should include tags"); + Assert.AreEqual("Unity.Pipeline.Tests.Editor", logEditor["package"]?.ToString(), + "full detail should include the originating package"); + } + + [Test] + public async Task ApiCommands_InvalidDetail_ReturnsBadRequestListingAcceptedValues() + { + // Act - an unsupported detail value + var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=verbose"); + var jsonContent = await httpResponse.Content.ReadAsStringAsync(); + + // Assert - rejected with a clear error naming the accepted values + Assert.AreEqual(400, (int)httpResponse.StatusCode, + $"Invalid detail value should be rejected with 400. Response: {jsonContent}"); + var responseJson = JObject.Parse(jsonContent); + Assert.IsNotNull(responseJson["error"], "400 response should have error field"); + StringAssert.Contains("compact", jsonContent, "error should list accepted value 'compact'"); + StringAssert.Contains("full", jsonContent, "error should list accepted value 'full'"); + } + + [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; + + // Lean default (AUTHAPI-21): a success is just success + result. The always-on metadata + // (command, executedAt, executionTimeMs) is dropped unless the request opts into verbose. + Assert.IsNotNull(responseJson["success"], "Response should have success field"); + Assert.IsTrue(responseJson["success"].ToObject(), "Command should execute successfully"); + Assert.IsNull(responseJson["command"], "Lean envelope should omit the command echo"); + Assert.IsNull(responseJson["executedAt"], "Lean envelope should omit executedAt"); + Assert.IsNull(responseJson["executionTimeMs"], "Lean envelope should omit executionTimeMs"); + } + + [Test] + public async Task ApiExec_VerboseFlag_IncludesEnvelopeMetadata() + { + // Arrange - opt into the full envelope via the request's verbose flag. + var commandRequest = new CommandExecutionRequest("log_editor") { Verbose = true }; + commandRequest.Parameters["message"] = "Test message from CLI"; + + // Act + var response = await m_PipelineClient.PostJsonAsync("/api/exec", commandRequest); + + // Assert - the metadata the lean envelope drops is present again. + Assert.IsTrue(response.IsSuccess, + $"Exec endpoint should return success, got: {response.StatusCode}. Response: {response.RawResponse}"); + var responseJson = response.JsonResponse; + Assert.IsTrue(responseJson["success"].ToObject(), "Command should execute successfully"); + Assert.IsNotNull(responseJson["command"], "Verbose envelope should include the command"); + Assert.AreEqual("log_editor", responseJson["command"]?.ToString()); + Assert.IsNotNull(responseJson["executedAt"], "Verbose envelope should include executedAt"); + } + + [Test] + public async Task ApiExec_OmitNullsFlag_AcceptedEndToEnd() + { + // The payload-null semantics themselves are pinned at the serializer level + // (ResponseEnvelopeSizeTests); this covers the HTTP wiring: the request flag + // deserializes and the reply still parses. + var request = new CommandExecutionRequest("log_editor") { OmitNulls = true }; + request.Parameters["message"] = "omitNulls round-trip"; + + var response = await m_PipelineClient.PostJsonAsync("/api/exec", request); + + Assert.IsTrue(response.IsSuccess, + $"Exec with omitNulls should succeed, got: {response.StatusCode}. Response: {response.RawResponse}"); + Assert.IsTrue(response.JsonResponse["success"].ToObject()); + } + + [Test] + public async Task ApiExec_ValidationFailure_HonorsVerboseFlag() + { + // A post-parse validation failure (structurally valid JSON, invalid content) can and + // must honor the request's verbose flag — that's precisely when a debugging client + // wants the full envelope. Pre-parse failures (unparseable JSON) can't, by definition. + var verboseResponse = await m_PipelineClient.PostJsonAsync("/api/exec", + new { command = "log_editor", timeout = -1, verbose = true }); + var leanResponse = await m_PipelineClient.PostJsonAsync("/api/exec", + new { command = "log_editor", timeout = -1 }); + + Assert.AreEqual(400, verboseResponse.StatusCode, "Invalid timeout should be rejected"); + Assert.IsNotNull(verboseResponse.JsonResponse["executedAt"], + "A verbose request's validation failure should carry the full envelope"); + + Assert.AreEqual(400, leanResponse.StatusCode, "Invalid timeout should be rejected"); + Assert.IsNull(leanResponse.JsonResponse["executedAt"], + "A lean request's validation failure should stay lean"); + } + + [Test] + public async Task ApiExec_NullJsonBody_Returns400InvalidRequest() + { + // A body of literal JSON "null" parses without a JsonException but deserializes to a + // null request — it must be rejected cleanly, not NRE inside the handler. + var response = await m_PipelineClient.PostJsonAsync("/api/exec", null); + + Assert.AreEqual(400, response.StatusCode, + $"A null JSON body should be rejected with 400. Response: {response.RawResponse}"); + Assert.IsTrue(response.HasValidJson, "The rejection should be a structured JSON reply"); + Assert.AreEqual("Invalid Request", response.JsonResponse["error"]?.ToString()); + StringAssert.Contains("JSON object", response.JsonResponse["errorDetails"]?.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"); + // A failure has no `message` (that's for success), so the lean envelope omits it; assert on + // the fields a failure actually carries instead. (AUTHAPI-21) + Assert.IsFalse(responseJson["success"].ToObject(), "Error response should report success=false"); + } + + [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"); + } + + [Test] + public async Task ApiExec_UnconvertibleParameter_ReturnsErrorInsteadOfSubstitutingDefault() + { + // Arrange - 'tail' is an int defaulting to 100. Falling back to that default would + // hand the caller far more entries than it asked for, with no way to detect it. + var request = new CommandExecutionRequest("console"); + request.Parameters["tail"] = "not-a-number"; + + // Act + var response = await m_PipelineClient.PostJsonAsync("/api/exec", request); + + LogAssert.Expect(new Regex("^ExecuteCommandByName: Parameter conversion failed: Parameter 'tail'")); + + // Assert - pin the HTTP contract, not just "some failure": without the status and + // category, a regression to the command-execution handler would still pass. + Assert.IsFalse(response.IsSuccess, "Unconvertible argument should not report success"); + Assert.AreEqual(400, response.StatusCode, + $"Unconvertible argument should be a 400, not another failure class. Response: {response.RawResponse}"); + Assert.IsTrue(response.HasValidJson, "Error response should have valid JSON"); + + var responseJson = response.JsonResponse; + Assert.AreEqual("Parameter Validation Failed", responseJson["error"]?.ToString(), + "Should be classified as a parameter validation failure"); + Assert.That(responseJson["errorDetails"]?.ToString(), + Contains.Substring("tail"), + "Error should name the offending parameter"); + Assert.That(responseJson["errorDetails"]?.ToString(), + Contains.Substring("Int32"), + "Error should name the type the value could not be converted to"); + } + + [Test] + public async Task ApiExec_NumericStringParameter_IsConvertedAndApplied() + { + // Arrange - a numeric *string* is what MCP clients send today, since the tool schema + // advertises numeric params as strings. Seed more entries than the limit asked for: + // asserting success alone would pass even if "5" were dropped for the default of 100, + // so the count is what proves the value was applied. + for (var i = 0; i < 12; i++) + { + UnityEngine.Debug.Log($"CLI978_numeric_string_seed_{i}"); + } + + var request = new CommandExecutionRequest("console"); + request.Parameters["tail"] = "5"; + + // Act + var response = await m_PipelineClient.PostJsonAsync("/api/exec", request); + + // Assert + Assert.IsTrue(response.IsSuccess, + $"A parseable numeric string should still be accepted. Response: {response.RawResponse}"); + Assert.AreEqual(5, ReadReturnedCount(response.JsonResponse), + "tail=\"5\" must be honoured, not replaced by the command's default"); + } + + /// + /// Reads result.returned from an /api/exec envelope. The envelope carries `result` + /// either as a nested object or as a JSON string depending on the command, so handle both. + /// + private static int ReadReturnedCount(JObject responseJson) + { + var result = responseJson["result"]; + if (result is JValue { Type: JTokenType.String } stringResult) + { + result = JToken.Parse(stringResult.Value()); + } + + var returned = result?["returned"]; + Assert.IsNotNull(returned, $"Response should carry result.returned: {responseJson}"); + return returned.ToObject(); + } + + [Test] + public async Task ApiExec_ConverterDeclinedToken_IsRejectedNotTreatedAsOmitted() + { + // ObjectRefConverter returns null for an unsupported token kind instead of throwing, so + // {"parent": true} used to read as an omitted argument and create_gameobject ran with + // parent == null — creating the object at the scene root rather than rejecting the + // malformed handle. A declined token must be a conversion error, not a silent default. + var request = new CommandExecutionRequest("create_gameobject"); + request.Parameters["name"] = "CLI978_should_not_be_created"; + request.Parameters["parent"] = true; + + var response = await m_PipelineClient.PostJsonAsync("/api/exec", request); + + LogAssert.Expect(new Regex("^ExecuteCommandByName: Parameter conversion failed: Parameter 'parent'")); + + Assert.IsFalse(response.IsSuccess, "A declined token should not succeed"); + Assert.AreEqual(400, response.StatusCode, + $"Should be a parameter-validation 400. Response: {response.RawResponse}"); + Assert.AreEqual("Parameter Validation Failed", response.JsonResponse["error"]?.ToString()); + Assert.That(response.JsonResponse["errorDetails"]?.ToString(), + Contains.Substring("parent"), "Error should name the offending parameter"); + Assert.That(response.JsonResponse["errorDetails"]?.ToString(), + Contains.Substring("ObjectRef"), "Error should name the target type"); + } + + [Test] + public async Task ApiExec_ExplicitJsonNull_IsStillAcceptedAsAValue() + { + // The guard above must not catch a legitimately null optional argument: an explicit JSON + // null is the one value allowed to convert to null, and it falls back to the default. + var request = new CommandExecutionRequest("console"); + request.Parameters["level"] = null; + request.Parameters["tail"] = 5; + + var response = await m_PipelineClient.PostJsonAsync("/api/exec", request); + + Assert.IsTrue(response.IsSuccess, + $"An explicit JSON null should remain acceptable. Response: {response.RawResponse}"); + Assert.IsTrue(response.JsonResponse["success"].ToObject(), "Command should succeed"); + } + + [Test] + public async Task ApiExec_EmptyObjectRefString_IsAcceptedNotRejectedAsUnconvertible() + { + // An empty ObjectRef string is a documented value, not a malformed one: + // ObjectRefConverter.FromString returns null for it and set_parent advertises + // "Omit (or empty) to move the object to the scene root". The declined-token guard must + // not reject it. Uses a target that cannot resolve, so this asserts parameter BINDING + // without reparenting anything: the call still fails, but not as a validation failure. + var request = new CommandExecutionRequest("set_parent"); + request.Parameters["target"] = "/CLI978_no_such_object"; + request.Parameters["parent"] = ""; + + var response = await m_PipelineClient.PostJsonAsync("/api/exec", request); + + // The command runs and fails at target resolution — which is itself the proof that + // `parent` bound. That failure logs an error, so it has to be declared. + LogAssert.Expect(new Regex("Could not resolve 'target'")); + + // The error CATEGORY is not the discriminator here: set_parent's own resolve failure + // also throws ArgumentException, so it lands in "Parameter Validation Failed" too. What + // separates binding from execution is the detail text. + Assert.IsFalse(response.IsSuccess, "An unresolvable target should still fail"); + Assert.That(response.JsonResponse["errorDetails"]?.ToString() ?? string.Empty, + Does.Not.Contain("could not be converted"), + $"An empty ObjectRef string must bind, not be rejected as unconvertible. Response: {response.RawResponse}"); + Assert.That(response.JsonResponse["errorDetails"]?.ToString() ?? string.Empty, + Contains.Substring("Could not resolve 'target'"), + "The failure must come from the command body, proving 'parent' bound"); + } + + // ------------------------------------------------------------- raw command-line forms + // {"argv":[...]} and {"commandLine":"..."}. The server + // tokenizes/binds, so a client needs no schema knowledge and spends one request + // instead of two. + + [Test] + public async Task ApiExec_ArgvForm_ExecutesTheCommand() + { + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { argv = new[] { "test_types", "--count", "5" } }); + + Assert.AreEqual(200, response.StatusCode, $"Response: {response.RawResponse}"); + Assert.IsTrue(response.IsCommandSuccess, $"Response: {response.RawResponse}"); + } + + [Test] + public async Task ApiExec_CommandLineForm_ExecutesTheCommand() + { + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { commandLine = "test_types --count 5" }); + + Assert.AreEqual(200, response.StatusCode, $"Response: {response.RawResponse}"); + Assert.IsTrue(response.IsCommandSuccess, $"Response: {response.RawResponse}"); + } + + [Test] + public async Task ApiExec_RawForm_EchoesTheBoundParameters() + { + // The CLI renders this in four output formats; without the echo it has nothing + // to show, because it no longer binds client-side. + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { argv = new[] { "test_types", "--count", "5" } }); + + var echoed = response.JsonResponse["parameters"] as JObject; + Assert.IsNotNull(echoed, $"argv requests must echo bound parameters. Response: {response.RawResponse}"); + Assert.AreEqual("5", echoed["count"]?.ToString()); + } + + [Test] + public async Task ApiExec_StructuredForm_DoesNotEchoParameters() + { + // Back-compat: the structured path's envelope must not grow a key. + var request = new CommandExecutionRequest("test_types"); + request.Parameters["count"] = 5; + + var response = await m_PipelineClient.PostJsonAsync("/api/exec", request); + + Assert.IsNull(response.JsonResponse["parameters"], + $"structured requests must be byte-identical to before. Response: {response.RawResponse}"); + } + + [Test] + public async Task ApiExec_ArgvWithSpacesInAToken_IsNotRetokenized() + { + // argv is already split; re-splitting it would corrupt any value containing a space. + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { argv = new[] { "log_editor", "hello world" } }); + + var echoed = response.JsonResponse["parameters"] as JObject; + Assert.IsNotNull(echoed, $"Response: {response.RawResponse}"); + Assert.AreEqual("hello world", echoed["message"]?.ToString()); + } + + [Test] + public async Task ApiExec_CommandLineQuoting_GroupsIntoOneToken() + { + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { commandLine = "log_editor \"hello world\"" }); + + var echoed = response.JsonResponse["parameters"] as JObject; + Assert.IsNotNull(echoed, $"Response: {response.RawResponse}"); + Assert.AreEqual("hello world", echoed["message"]?.ToString()); + } + + // ------------------------------------------------------------------ mutual exclusion + + [Test] + public async Task ApiExec_CommandAndArgvTogether_IsRejected() + { + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { command = "test_types", argv = new[] { "test_types" } }); + + Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}"); + } + + [Test] + public async Task ApiExec_ArgvAndCommandLineTogether_IsRejected() + { + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { argv = new[] { "test_types" }, commandLine = "test_types" }); + + Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}"); + } + + [Test] + public async Task ApiExec_ParametersAlongsideArgv_IsRejectedNotIgnored() + { + // Silently dropping the payload would recreate exactly the absent-vs-null + // ambiguity this codebase legislates against in the lean envelope. + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { argv = new[] { "test_types" }, parameters = new { count = 5 } }); + + Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}"); + } + + [Test] + public async Task ApiExec_EmptyArgv_IsRejected() + { + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { argv = new string[0] }); + + Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}"); + } + + [Test] + public async Task ApiExec_BlankCommandLine_IsRejected() + { + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { commandLine = " " }); + + Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}"); + } + + // ------------------------------------------------------------------ argument errors + + [Test] + public async Task ApiExec_UnknownFlag_ReturnsInvalidCommandArgsWithProblemsAndSchema() + { + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { argv = new[] { "log_editor", "--mesage", "hi" } }); + + Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}"); + + var json = response.JsonResponse; + Assert.AreEqual("INVALID_COMMAND_ARGS", json["errorCode"]?.ToString(), + "errorCode is the discriminator; every /api/exec failure is already 400"); + + var problems = json["argProblems"] as JArray; + Assert.IsNotNull(problems, $"Response: {response.RawResponse}"); + Assert.AreEqual(1, problems.Count); + Assert.AreEqual("unknownName", problems[0]["kind"]?.ToString(), + "kind is camelCase on the wire even though C# spells it PascalCase"); + Assert.AreEqual("mesage", problems[0]["name"]?.ToString()); + Assert.AreEqual("message", problems[0]["suggestion"]?.ToString()); + + // Replaces the retired per-exec schema fetch: shaped like a /api/commands entry + // so the CLI's existing renderer consumes it with no new code. + var schema = json["commandSchema"] as JObject; + Assert.IsNotNull(schema, "an argument error must carry the command's schema"); + Assert.AreEqual("log_editor", schema["name"]?.ToString()); + Assert.IsNotNull(schema["parameters"] as JArray); + } + + [Test] + public async Task ApiExec_TypeMismatch_ReturnsTypeMismatchProblem() + { + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { argv = new[] { "test_types", "--count", "abc" } }); + + Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}"); + + var problems = response.JsonResponse["argProblems"] as JArray; + Assert.IsNotNull(problems, $"Response: {response.RawResponse}"); + Assert.AreEqual("typeMismatch", problems[0]["kind"]?.ToString()); + Assert.AreEqual("Int32", problems[0]["expectedType"]?.ToString()); + } + + [Test] + public async Task ApiExec_UnbalancedQuoteInCommandLine_IsRejected() + { + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { commandLine = "log_editor \"unbalanced" }); + + Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}"); + Assert.That(response.JsonResponse["errorDetails"]?.ToString(), + Contains.Substring("quote").IgnoreCase); + } + + [Test] + public async Task ApiExec_ATokenizerFailure_IsAnInvalidRequestWithNoErrorCode() + { + // A command line that will not tokenize never reached a command, so it is a malformed + // request shape - the same family as two command forms at once. Reporting it as + // INVALID_COMMAND_ARGS promised an argument diagnosis the reply cannot carry: there is + // no commandSchema and no argProblems, because there is no command. A client keying on + // errorCode to render usage found nothing to render. + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { commandLine = "log_editor \"unbalanced" }); + + Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}"); + Assert.AreEqual("Invalid Request", response.JsonResponse["error"]?.ToString()); + Assert.IsNull(response.JsonResponse["errorCode"], + "no command was resolved, so the argument-error discriminator must be absent"); + Assert.IsNull(response.JsonResponse["commandSchema"]); + Assert.IsNull(response.JsonResponse["argProblems"]); + } + + [Test] + public async Task ApiExec_ARequestWithNoCommandForm_IsAnInvalidRequest() + { + // Relaxing `command` from Required.Always (so the raw forms may omit it) moved this + // case out of JSON.NET's deserializer and into Validate(). Both answer 400, but the + // `error` value changed from "Invalid JSON" to "Invalid Request" - pinned here so the + // wording is a decision rather than a side effect. + var response = await m_PipelineClient.PostJsonAsync("/api/exec", new { }); + + Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}"); + Assert.AreEqual("Invalid Request", response.JsonResponse["error"]?.ToString()); + Assert.AreEqual("Command name is required", + response.JsonResponse["errorDetails"]?.ToString()); + Assert.IsNull(response.JsonResponse["errorCode"]); + } + + [Test] + public async Task ApiExec_ArgumentErrorSchema_MatchesTheCatalogEntryWithoutTheGeneratedSchema() + { + // commandSchema is the /api/commands entry minus the generated JSON schema, and it is + // built without generating that schema at all. Pinning both halves keeps the shape + // from drifting when the wasteful generate-then-discard is removed. + var error = await m_PipelineClient.PostJsonAsync("/api/exec", + new { argv = new[] { "log_editor", "--mesage", "hi" } }); + Assert.AreEqual(400, error.StatusCode, $"Response: {error.RawResponse}"); + + var entry = error.JsonResponse["commandSchema"] as JObject; + Assert.IsNotNull(entry, $"Response: {error.RawResponse}"); + Assert.IsNull(entry["schema"], "the generated schema must not ride along on an error"); + + var catalogHttp = await m_PipelineClient.GetHttpAsync("/api/commands"); + var catalog = JObject.Parse(await catalogHttp.Content.ReadAsStringAsync()); + var served = (catalog["commands"] as JArray) + ?.FirstOrDefault(c => c["name"]?.ToString() == "log_editor") as JObject; + Assert.IsNotNull(served, "log_editor must be in the catalog"); + Assert.IsNotNull(served["schema"], "/api/commands still carries the generated schema"); + + foreach (var property in served.Properties()) + { + if (property.Name == "schema") + continue; + Assert.IsTrue(JToken.DeepEquals(property.Value, entry[property.Name]), + $"commandSchema.{property.Name} must match the catalog entry verbatim"); + } + + Assert.AreEqual(served.Properties().Count() - 1, entry.Properties().Count(), + "commandSchema must carry every catalog property except the generated schema"); + } + + [Test] + public async Task ApiExec_UnknownCommandViaArgv_MatchesTheStructuredEnvelope() + { + // Both calls log the same registry error; expect one per call. + LogAssert.Expect(new Regex("^ExecuteCommandByName: No command named")); + LogAssert.Expect(new Regex("^ExecuteCommandByName: No command named")); + + var viaArgv = await m_PipelineClient.PostJsonAsync("/api/exec", + new { argv = new[] { "no_such_command_xyz" } }); + var viaStructured = await m_PipelineClient.PostJsonAsync("/api/exec", + new CommandExecutionRequest("no_such_command_xyz")); + + Assert.AreEqual(viaStructured.StatusCode, viaArgv.StatusCode); + Assert.AreEqual(viaStructured.JsonResponse["error"]?.ToString(), + viaArgv.JsonResponse["error"]?.ToString(), + "an unknown raw command name must produce the identical Command Not Found envelope"); + } + + [Test] + public async Task ApiExec_MissingRequiredParameterViaArgv_IsRejectedBeforeExecution() + { + // A raw form is validated up front, so it never reaches ExecuteCommandByName and + // therefore logs no execution error. Message parity with the structured form is + // asserted directly by + // ApiExec_RawFormMissingRequiredParameter_ReportsTheSameMessageInBothForms, which is + // a stronger check than the log line this used to expect. + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { argv = new[] { "log_editor" } }); + + Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}"); + Assert.AreEqual("Parameter Validation Failed", response.JsonResponse["error"]?.ToString()); + Assert.That(response.JsonResponse["errorDetails"]?.ToString(), + Contains.Substring("message").IgnoreCase); + } + + [Test] + public async Task ApiExec_RawFormFailure_HonorsVerboseFlag() + { + // Reply-shape flags are captured before the raw-form branch, so a malformed + // command line still honours them. + var lean = await m_PipelineClient.PostJsonAsync("/api/exec", + new { argv = new[] { "log_editor", "--mesage", "hi" } }); + var verbose = await m_PipelineClient.PostJsonAsync("/api/exec", + new { argv = new[] { "log_editor", "--mesage", "hi" }, verbose = true }); + + Assert.AreEqual(400, lean.StatusCode); + Assert.AreEqual(400, verbose.StatusCode); + Assert.IsNull(lean.JsonResponse["executedAt"], "lean mode drops envelope metadata"); + Assert.IsNotNull(verbose.JsonResponse["executedAt"], "verbose mode restores it"); + } + + [Test] + public async Task ApiExec_JobWithCommandLine_ReturnsAJobId() + { + // RunJobDetached re-reads the request object, so normalizing in place means the + // detached path needs no changes of its own. + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { commandLine = "test_types --count 5", job = true }); + + Assert.AreEqual(200, response.StatusCode, $"Response: {response.RawResponse}"); + var result = response.JsonResponse["result"] as JObject; + Assert.IsNotNull(result?["jobId"] ?? result?["id"], + $"detached submission should return a job id. Response: {response.RawResponse}"); + } + + [Test] + public async Task ApiStatus_AdvertisesTheRawFormCapabilities() + { + // Capability negotiation exists so a CLI can tell "this server predates argv" from + // "your request was malformed" WITHOUT a probe request -- an old server answers an + // argv request with a bare Invalid JSON, which the CLI would otherwise render as + // exit 6 plus "Run `unity bug`", i.e. version skew reading as a CLI defect. + var response = await m_PipelineClient.GetAsync("/api/status"); + + var capabilities = response.JsonResponse?["capabilities"] as JArray; + Assert.IsNotNull(capabilities, $"/api/status must advertise capabilities. Response: {response.RawResponse}"); + + var tokens = capabilities.Select(t => t.ToString()).ToList(); + CollectionAssert.Contains(tokens, "exec.argv"); + CollectionAssert.Contains(tokens, "exec.commandLine"); + } + + [Test] + public void ServerCapabilities_AreASingleSharedSource() + { + // The descriptor and /api/status must not be able to drift: one array feeds both. + CollectionAssert.Contains(BasePipelineServer.Capabilities, "exec.argv"); + CollectionAssert.Contains(BasePipelineServer.Capabilities, "exec.commandLine"); + } + + [Test] + public async Task ApiExec_RawFormMissingRequiredParameter_NeverCreatesAJob() + { + // A deterministically invalid submission must not be acknowledged with a job id. + // Binding SUCCEEDS here -- a missing required parameter is not a binder problem, it is + // ValidateCommandParameters' job -- so without a preflight this reached job creation, + // returned 200 plus an id, and only failed later inside the detached run. A client + // polling that id learns minutes later what was knowable up front. + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { argv = new[] { "log_editor" }, job = true }); + + Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}"); + Assert.IsNull(response.JsonResponse["result"], "no job id for a known-invalid submission"); + Assert.That(response.JsonResponse["errorDetails"]?.ToString(), + Contains.Substring("message").IgnoreCase); + } + + [Test] + public async Task ApiExec_RawFormMissingRequiredParameter_ReportsTheSameMessageInBothForms() + { + // The preflight must reproduce ValidateCommandParameters' wording exactly, or the two + // request forms would diagnose the same mistake differently. + LogAssert.Expect("ExecuteCommandByName: Parameter validation failed: Required parameter 'message' is missing or empty"); + + var viaArgv = await m_PipelineClient.PostJsonAsync("/api/exec", + new { argv = new[] { "log_editor" } }); + var viaStructured = await m_PipelineClient.PostJsonAsync("/api/exec", + new CommandExecutionRequest("log_editor")); + + Assert.AreEqual(400, viaArgv.StatusCode); + Assert.AreEqual(400, viaStructured.StatusCode); + Assert.AreEqual( + viaStructured.JsonResponse["error"]?.ToString(), + viaArgv.JsonResponse["error"]?.ToString()); + Assert.AreEqual( + viaStructured.JsonResponse["errorDetails"]?.ToString(), + viaArgv.JsonResponse["errorDetails"]?.ToString(), + "both forms must report the identical validation message"); + } + + [Test] + public async Task ApiExec_CommandLineWithAnEmptyFirstToken_IsAnInvalidRequest() + { + // `"" --message hi` is a NONBLANK source string, so Validate() passes, but it + // tokenizes to an empty first token. Left unchecked it became a Command Not Found, + // telling the caller an empty command is merely unavailable. The argv equivalent is + // already rejected as a bad request, so the two raw forms must agree. + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { commandLine = "\"\" --message hi" }); + + Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}"); + Assert.AreEqual("Invalid Request", response.JsonResponse["error"]?.ToString(), + "an empty command name is a malformed request, not a missing command"); + } + + [Test] + public async Task ApiExec_CommandLineWithAWhitespaceFirstToken_IsAlsoRejected() + { + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { commandLine = "\" \" --message hi" }); + + Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}"); + Assert.AreEqual("Invalid Request", response.JsonResponse["error"]?.ToString()); + } + + [Test] + public async Task ApiExec_MalformedArgumentsNeverCreateAJob() + { + // A mistyped parameter must not return a job id and exit 0. + var response = await m_PipelineClient.PostJsonAsync("/api/exec", + new { argv = new[] { "log_editor", "--mesage", "hi" }, job = true }); + + Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}"); + Assert.IsNull(response.JsonResponse["result"], "no job id for a malformed command line"); + } + } +} \ No newline at end of file diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/ApiEndpointsTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/ApiEndpointsTests.cs.meta new file mode 100644 index 0000000..1df8478 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets.meta new file mode 100644 index 0000000..5b478aa --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7571005014bc4727a0ef06b2056351a2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/AssetCommandsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/AssetCommandsTests.cs new file mode 100644 index 0000000..69412f5 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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). + /// + class AssetCommandsTests + { + private const string Root = "Assets/__CLI191Test"; + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + } + } + + 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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/AssetCommandsTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/AssetCommandsTests.cs.meta new file mode 100644 index 0000000..b525995 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/AssetCommandsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d4736a7747a847edb2355c95559ce0b0 diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/AssetImportCommandsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/AssetImportCommandsTests.cs new file mode 100644 index 0000000..1870ba6 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + class AssetImportCommandsTests + { + private const string Root = "Assets/__CLI191ImportTest"; + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + } + } + + 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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/AssetImportCommandsTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/AssetImportCommandsTests.cs.meta new file mode 100644 index 0000000..c008460 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/AssetImportCommandsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 121fa6671cf449ce863c389aa9b7e18c diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/ImportSettingsCommandTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/ImportSettingsCommandTests.cs new file mode 100644 index 0000000..d3e73b7 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + class ImportSettingsCommandTests + { + private const string Root = "Assets/__CLI212ImportTest"; + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + } + } + + 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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/ImportSettingsCommandTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/ImportSettingsCommandTests.cs.meta new file mode 100644 index 0000000..a318d07 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/ImportSettingsCommandTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 48fae49d74ce4b61941f9c5ff541034f diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/MaterialAssetCommandsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/MaterialAssetCommandsTests.cs new file mode 100644 index 0000000..cb87046 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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(ImportAssetOptions.ForceSynchronousImport); + } + } + + /// 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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/MaterialAssetCommandsTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/MaterialAssetCommandsTests.cs.meta new file mode 100644 index 0000000..a6df6e4 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/PhysicsMaterialAssetCommandsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/PhysicsMaterialAssetCommandsTests.cs new file mode 100644 index 0000000..c8c5b0b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/PhysicsMaterialAssetCommandsTests.cs @@ -0,0 +1,158 @@ +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 PhysicsMaterial = 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. + /// + 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(ImportAssetOptions.ForceSynchronousImport); + } + } + + private static void AssertDefaults(PhysicsMaterial 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 PhysicsMaterial); + } + + [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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/PhysicsMaterialAssetCommandsTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/PhysicsMaterialAssetCommandsTests.cs.meta new file mode 100644 index 0000000..e085b83 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/TextFileCommandsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/TextFileCommandsTests.cs new file mode 100644 index 0000000..746681e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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). + /// + class TextFileCommandsTests + { + private const string Root = "Assets/__CLI191TextTest"; + + [TearDown] + public void TearDown() + { + ProjectPaths.ResetAuthoringRoot(); + if (AssetDatabase.IsValidFolder(Root)) + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + } + } + + #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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/TextFileCommandsTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/TextFileCommandsTests.cs.meta new file mode 100644 index 0000000..8c9cb70 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Assets/TextFileCommandsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4ede31badcf640f6acc3c5baf0fc8ef3 diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring.meta new file mode 100644 index 0000000..897d58c --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 497149ab4929cd34483ff611ce25534a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring/FolderCommandsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring/FolderCommandsTests.cs new file mode 100644 index 0000000..1f7ccb8 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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(ImportAssetOptions.ForceSynchronousImport); + } + } + + #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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring/FolderCommandsTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring/FolderCommandsTests.cs.meta new file mode 100644 index 0000000..d202bd3 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring/ObjectRefConverterTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring/ObjectRefConverterTests.cs new file mode 100644 index 0000000..0884f66 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring/ObjectRefConverterTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring/ObjectRefConverterTests.cs.meta new file mode 100644 index 0000000..f22eadc --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring/ObjectResolverTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring/ObjectResolverTests.cs new file mode 100644 index 0000000..d41c659 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring/ObjectResolverTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring/ObjectResolverTests.cs.meta new file mode 100644 index 0000000..2b4e94d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring/ProjectPathsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring/ProjectPathsTests.cs new file mode 100644 index 0000000..c2898f8 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring/ProjectPathsTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Authoring/ProjectPathsTests.cs.meta new file mode 100644 index 0000000..25c7f83 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/AutoTickCommandTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/AutoTickCommandTests.cs new file mode 100644 index 0000000..d6b19ba --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/AutoTickCommandTests.cs @@ -0,0 +1,114 @@ +using NUnit.Framework; +using Unity.Pipeline.Editor.Commands; + +namespace Unity.Pipeline.Tests.Editor +{ + /// + /// Tests for auto-tick's SessionState persistence across (simulated) domain reloads. + /// The live editor's own server may already have auto-tick enabled, so SetUp/TearDown save and + /// restore whatever state was live before each test instead of assuming it starts off. + /// + class AutoTickCommandTests + { + private bool m_PrevEnabled; + private long m_PrevIntervalMs; + private bool m_HadPersisted; + private bool m_PrevPersistedEnabled; + private long m_PrevPersistedIntervalMs; + + [SetUp] + public void SetUp() + { + m_PrevEnabled = AutoTickCommand.IsEnabled; + m_PrevIntervalMs = AutoTickCommand.CurrentIntervalMs; + m_HadPersisted = AutoTickCommand.TryGetPersistedSessionForTests(out m_PrevPersistedEnabled, out m_PrevPersistedIntervalMs); + AutoTickCommand.EraseSessionForTests(); + } + + [TearDown] + public void TearDown() + { + // persist:false so this only restores the live in-memory state, not SessionState - + // the persisted baseline (or lack thereof) is restored/erased separately below. + AutoTickCommand.SetAutoTick(m_PrevEnabled, (int)m_PrevIntervalMs, persist: false); + + if (m_HadPersisted) + AutoTickCommand.SetPersistedSessionForTests(m_PrevPersistedEnabled, m_PrevPersistedIntervalMs); + else + AutoTickCommand.EraseSessionForTests(); + } + + [TestCase(true, 5)] + [TestCase(true, 250)] + [TestCase(false, 40)] + public void RestoreFromSession_AfterSimulatedReload_RestoresExactPriorState(bool enable, int intervalMs) + { + AutoTickCommand.SetAutoTick(enable, intervalMs); + + // A domain reload clears the statics (and the update-loop subscription with them) but + // leaves the SessionState copy this call just wrote intact. + AutoTickCommand.ResetForTests(); + + // Pass the opposite of the persisted value as the default, so a passing assertion proves + // the persisted state won, not the default falling through by coincidence. + AutoTickCommand.RestoreFromSession(defaultEnabled: !enable); + + Assert.AreEqual(enable, AutoTickCommand.IsEnabled, + "Restore must bring back the exact enabled state set via SetAutoTick, not the startup default."); + if (enable) + { + Assert.AreEqual(intervalMs, AutoTickCommand.CurrentIntervalMs, + "Restore must bring back the exact interval set via SetAutoTick, not the default interval."); + } + } + + [TestCase(true)] + [TestCase(false)] + public void RestoreFromSession_NoPriorExplicitCallThisSession_AppliesDefault(bool defaultEnabled) + { + // SetUp already erased SessionState and this simulates a reload on top of that, so there + // is no persisted choice for RestoreFromSession to find. + AutoTickCommand.ResetForTests(); + + AutoTickCommand.RestoreFromSession(defaultEnabled); + + Assert.AreEqual(defaultEnabled, AutoTickCommand.IsEnabled); + if (defaultEnabled) + { + Assert.AreEqual(AutoTickCommand.DefaultIntervalMs, AutoTickCommand.CurrentIntervalMs, + "With no explicit prior choice, restoring 'enabled' must use the default interval."); + } + } + + [Test] + public void SetAutoTick_WithPersistFalse_RevertsToPriorPersistedBaselineAfterSimulatedReload() + { + // Establish a persisted baseline, as if the user had configured a normal auto-tick rate. + AutoTickCommand.SetAutoTick(true, 200); + + // A temporary, expensive override (interval_ms=0 pegs a CPU core) that must not overwrite + // the baseline above. + AutoTickCommand.SetAutoTick(true, 0, persist: false); + Assert.AreEqual(0, AutoTickCommand.CurrentIntervalMs, "The temporary override should still apply immediately."); + + AutoTickCommand.ResetForTests(); + AutoTickCommand.RestoreFromSession(defaultEnabled: false); + + Assert.AreEqual(200, AutoTickCommand.CurrentIntervalMs, + "persist:false must not clobber the previously persisted baseline."); + } + + [Test] + public void SetAutoTick_WithPersistFalse_NoPriorBaseline_DoesNotSurviveSimulatedReload() + { + AutoTickCommand.SetAutoTick(true, 0, persist: false); + Assert.IsTrue(AutoTickCommand.IsEnabled); + + AutoTickCommand.ResetForTests(); + AutoTickCommand.RestoreFromSession(defaultEnabled: false); + + Assert.IsFalse(AutoTickCommand.IsEnabled, + "With no persisted baseline, a session-only (persist:false) call must not survive a reload."); + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/AutoTickCommandTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/AutoTickCommandTests.cs.meta new file mode 100644 index 0000000..9d601ee --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/AutoTickCommandTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 33094abdda6430b4d883852f20d4132f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Baking.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Baking.meta new file mode 100644 index 0000000..8df4238 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Baking.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5e5fd89f0ad042538cfef1bd87763a27 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Baking/LightingBakeCommandsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Baking/LightingBakeCommandsTests.cs new file mode 100644 index 0000000..d1e1c97 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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). + /// + 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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Baking/LightingBakeCommandsTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Baking/LightingBakeCommandsTests.cs.meta new file mode 100644 index 0000000..f0e4f61 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Baking/NavMeshBakeCommandsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Baking/NavMeshBakeCommandsTests.cs new file mode 100644 index 0000000..a60211a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Baking/NavMeshBakeCommandsTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Baking/NavMeshBakeCommandsTests.cs.meta new file mode 100644 index 0000000..5114ac5 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Baking/OcclusionBakeCommandsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Baking/OcclusionBakeCommandsTests.cs new file mode 100644 index 0000000..9f80356 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Baking/OcclusionBakeCommandsTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Baking/OcclusionBakeCommandsTests.cs.meta new file mode 100644 index 0000000..d8a677e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Batch.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Batch.meta new file mode 100644 index 0000000..1a024de --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Batch.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1f0ae5fba45d4e60a0bfc82f77f35069 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Batch/BatchCommandTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Batch/BatchCommandTests.cs new file mode 100644 index 0000000..07c9be5 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Batch/BatchCommandTests.cs @@ -0,0 +1,958 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using Unity.Pipeline; +using Unity.Pipeline.Editor.Commands.Batch; +using Unity.Pipeline.Editor.Commands.GameObjects; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace Unity.Pipeline.Tests.Editor.Batch +{ + /// + /// Tests for the transactional batch command (AUTHAPI-27), driven end-to-end through an + /// isolated (the ViaClient pattern). Covers the acceptance + /// criteria: cross-op references resolve, a mid-batch failure atomically rolls back every prior + /// op, a successful batch collapses into a single Undo group, dry-run validates without mutating, + /// a bad reference errors clearly, on_error=continue applies independent ops without + /// reverting, and the 200-op cap is enforced. + /// + class BatchCommandTests + { + private readonly List m_Spawned = new List(); + + private GameObject Track(GameObject go) + { + if (go != null) + m_Spawned.Add(go); + return go; + } + + private static ObjectId? InstanceIdOf(JToken opResult) => + opResult?["result"]?["instanceId"]?.ToObject(); + + private GameObject ResolveGameObject(JToken opResult) + { + var id = InstanceIdOf(opResult); + if (!id.HasValue) + return null; + return Track(PipelineUtils.IdToObject(id.Value) as GameObject); + } + + [TearDown] + public void TearDown() + { + foreach (var go in m_Spawned) + { + if (go != null) + Object.DestroyImmediate(go); + } + m_Spawned.Clear(); + + // Deliberately NO Undo.ClearAll() here: this suite runs inside the dogfood editor, and + // wiping the global Undo stack would destroy the developer's own undo history. Per-test + // state is cleaned up by destroying m_Spawned above; the batch Undo groups left on the + // stack only reference those (now destroyed) test objects and are inert. + } + + private static JObject Op(string command, JObject parameters = null, string id = null) + { + var op = new JObject { ["command"] = command }; + if (id != null) + op["id"] = id; + op["params"] = parameters ?? new JObject(); + return op; + } + + [Test] + public void Batch_CrossOpReferences_ResolveAndApply() + { + // AC: create_gameobject -> add_component -> set_serialized_field, where later ops use + // $0.instanceId / $1.instanceId, executes in one round-trip with references resolved. + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("create_gameobject", new JObject { ["name"] = "BatchRef_Root" }), + Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "Rigidbody" }), + Op("set_serialized_field", new JObject + { + ["target"] = "$1.instanceId", + ["field"] = "m_Mass", + ["value"] = new JValue(2.5f) + }), + }; + + var response = server.Execute("batch", new JObject { ["operations"] = operations }); + Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); + + var batch = response.JsonResponse["result"]; + Assert.AreEqual(3, batch["applied"]?.ToObject(), "all three ops should apply"); + Assert.IsFalse(batch["reverted"]?.ToObject() ?? true, "a successful batch must not revert"); + Assert.IsNotNull(batch["undoGroup"], "execution result should carry the batch Undo group"); + + var results = batch["results"] as JArray; + Assert.AreEqual(3, results.Count); + for (int i = 0; i < 3; i++) + Assert.IsTrue(results[i]["success"]?.ToObject() ?? false, + $"op {i} should succeed: {results[i]["error"]}"); + + // The reference chain wired a Rigidbody onto op 0's GameObject and set its mass. + var root = ResolveGameObject(results[0]); + Assert.IsNotNull(root, "op 0's GameObject should be resolvable from its returned instanceId"); + var rb = root.GetComponent(); + Assert.IsNotNull(rb, "$0.instanceId must resolve so add_component targets op 0's GameObject"); + Assert.AreEqual(2.5f, rb.mass, 0.0001f, + "$1.instanceId must resolve so set_serialized_field targets the added Rigidbody"); + } + } + + [Test] + public void Batch_Transactional_MidBatchFailure_RevertsAllPriorOps() + { + // AC: an op fails mid-batch -> every prior op is reverted; scene equals pre-batch state. + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("create_gameobject", new JObject { ["name"] = "BatchRollback_A" }), + Op("create_gameobject", new JObject { ["name"] = "BatchRollback_B" }), + // Fails: unknown component type -> add_component throws before mutating. + Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "NoSuchTypeXYZ" }), + }; + + // The failing sub-op logs an error through the shared command-dispatch path (batch + // reuses it); the batch surfaces the failure structurally, but the log still fires. + LogAssert.Expect(LogType.Error, new Regex("add_component' failed: Could not resolve component type 'NoSuchTypeXYZ'")); + + var response = server.Execute("batch", new JObject { ["operations"] = operations }); + Assert.IsTrue(response.IsSuccess, $"batch itself should return HTTP success: {response.RawResponse}"); + + var batch = response.JsonResponse["result"]; + Assert.IsTrue(batch["reverted"]?.ToObject() ?? false, "a transactional failure must revert"); + Assert.AreEqual(2, batch["applied"]?.ToObject(), "two ops ran before the failure"); + + var results = batch["results"] as JArray; + Assert.IsTrue(results[0]["success"]?.ToObject() ?? false); + Assert.IsTrue(results[1]["success"]?.ToObject() ?? false); + Assert.IsFalse(results[2]["success"]?.ToObject() ?? true, "op 2 must fail"); + StringAssert.Contains("component type", results[2]["error"]?.ToString(), + "the failing op's error should be reported"); + + // The two created objects must be gone (reverted), both by name and by their ids. + Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchRollback_A").Count, + "op 0's object must be reverted"); + Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchRollback_B").Count, + "op 1's object must be reverted"); + + var id0 = InstanceIdOf(results[0]); + var id1 = InstanceIdOf(results[1]); + Assert.IsTrue(id0.HasValue && id1.HasValue, "reverted ops still report the ids they had created"); + Assert.IsNull(PipelineUtils.IdToObject(id0.Value), "op 0's object should no longer resolve"); + Assert.IsNull(PipelineUtils.IdToObject(id1.Value), "op 1's object should no longer resolve"); + } + } + + [Test] + public void Batch_SuccessfulBatch_CollapsesIntoSingleUndoGroup() + { + // AC: a successful batch shows exactly one Undo group -> a single Undo reverts the whole batch. + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("create_gameobject", new JObject { ["name"] = "BatchUndo_A" }), + Op("create_gameobject", new JObject { ["name"] = "BatchUndo_B" }), + }; + + var response = server.Execute("batch", new JObject { ["operations"] = operations }); + Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); + + // Track the created objects so TearDown cleans them up even if the Undo below misbehaves. + var results = response.JsonResponse["result"]["results"] as JArray; + ResolveGameObject(results[0]); + ResolveGameObject(results[1]); + + Assert.AreEqual(1, GameObjectCommands.FindGameObjects(name: "BatchUndo_A").Count); + Assert.AreEqual(1, GameObjectCommands.FindGameObjects(name: "BatchUndo_B").Count); + + // One Undo must revert BOTH creations, proving the whole batch collapsed into one group. + Undo.PerformUndo(); + + Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchUndo_A").Count, + "a single Undo should revert the first op (batch is one group)"); + Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchUndo_B").Count, + "a single Undo should revert the second op too (batch is one group)"); + } + } + + [Test] + public void Batch_DryRun_CatchesErrors_AndMutatesNothing() + { + // AC: dry_run catches unknown command, not_batchable, unknown param, and a bad reference, + // with zero mutation. + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("create_gameobject", new JObject { ["name"] = "BatchDry_A" }), // valid + Op("no_such_command_xyz"), // unknown command + Op("build"), // not_batchable + Op("create_gameobject", new JObject { ["bogus_param"] = "x" }), // unknown parameter + Op("add_component", new JObject { ["target"] = "$9.instanceId", ["type"] = "Rigidbody" }), // bad ref + }; + + var response = server.Execute("batch", new JObject + { + ["operations"] = operations, + ["dry_run"] = true + }); + Assert.IsTrue(response.IsSuccess, $"dry_run should return HTTP success: {response.RawResponse}"); + + var batch = response.JsonResponse["result"]; + Assert.IsTrue(batch["dryRun"]?.ToObject() ?? false, "result should be marked dryRun"); + Assert.IsFalse(batch["valid"]?.ToObject() ?? true, "the batch has invalid ops"); + Assert.AreEqual(0, batch["applied"]?.ToObject() ?? -1, "dry_run applies nothing"); + + var results = batch["results"] as JArray; + Assert.IsTrue(results[0]["success"]?.ToObject() ?? false, "op 0 is valid"); + StringAssert.Contains("unknown command", results[1]["error"]?.ToString()); + StringAssert.Contains("not_batchable", results[2]["error"]?.ToString()); + StringAssert.Contains("unknown parameter", results[3]["error"]?.ToString()); + StringAssert.Contains("unknown operation", results[4]["error"]?.ToString()); + + // Zero mutation: the valid create op must NOT have run. + Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchDry_A").Count, + "dry_run must not create anything"); + } + } + + [Test] + public void Batch_BadReference_ErrorsClearly_AndReverts() + { + // AC: a bad reference errors clearly. Here op 1 references a nonexistent op; under the + // default transactional/abort policy op 0 is reverted. + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("create_gameobject", new JObject { ["name"] = "BatchBadRef_X" }), + Op("add_component", new JObject { ["target"] = "$5.instanceId", ["type"] = "Rigidbody" }), + }; + + var response = server.Execute("batch", new JObject { ["operations"] = operations }); + Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); + + var batch = response.JsonResponse["result"]; + var results = batch["results"] as JArray; + Assert.IsFalse(results[1]["success"]?.ToObject() ?? true, "the referencing op must fail"); + var error = results[1]["error"]?.ToString(); + StringAssert.Contains("$5.instanceId", error, "the error should name the offending reference"); + StringAssert.Contains("unknown operation", error, "and explain why it is invalid"); + + Assert.IsTrue(batch["reverted"]?.ToObject() ?? false, "the prior op must be reverted"); + Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchBadRef_X").Count, + "op 0 must be reverted after the bad reference"); + } + } + + [Test] + public void Batch_OnErrorContinue_AppliesIndependentOps_NoRevert() + { + // AC: on_error=continue collects failures, applies independent ops, reports reverted:false. + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("create_gameobject", new JObject { ["name"] = "BatchCont_A" }), + Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "NoSuchTypeXYZ" }), // fails + Op("create_gameobject", new JObject { ["name"] = "BatchCont_C" }), // independent + }; + + LogAssert.Expect(LogType.Error, new Regex("add_component' failed: Could not resolve component type 'NoSuchTypeXYZ'")); + + var response = server.Execute("batch", new JObject + { + ["operations"] = operations, + ["on_error"] = "continue" + }); + Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); + + var batch = response.JsonResponse["result"]; + Assert.IsFalse(batch["reverted"]?.ToObject() ?? true, "continue must not revert"); + Assert.IsFalse(batch["transactional"]?.ToObject() ?? true, "continue forces transactional=false"); + Assert.AreEqual(2, batch["applied"]?.ToObject(), "the two independent creates apply"); + + var results = batch["results"] as JArray; + Assert.IsTrue(results[0]["success"]?.ToObject() ?? false); + Assert.IsFalse(results[1]["success"]?.ToObject() ?? true, "the middle op fails"); + Assert.IsTrue(results[2]["success"]?.ToObject() ?? false, "a later independent op still runs"); + + // Both independent objects must survive (no rollback). + Assert.IsNotNull(ResolveGameObject(results[0]), "op 0's object should survive"); + Assert.IsNotNull(ResolveGameObject(results[2]), "op 2's object should survive"); + } + } + + [Test] + public void Batch_ExceedsOperationCap_ReturnsError() + { + // AC: the 200-op cap is enforced with a structured error (checked before anything runs). + using (var server = new PipelineTestServer()) + { + var operations = new JArray(); + for (int i = 0; i < 201; i++) + operations.Add(Op("create_gameobject", new JObject { ["name"] = "BatchCap_" + i })); + + LogAssert.Expect(LogType.Error, new Regex("batch supports at most 200 operations")); + + var response = server.Execute("batch", new JObject + { + ["operations"] = operations, + ["dry_run"] = true + }); + + Assert.IsFalse(response.IsSuccess, "a batch over the op cap must be rejected"); + StringAssert.Contains("200", response.RawResponse, "the error should state the 200-op cap"); + Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchCap_0").Count, + "an over-cap batch must create nothing"); + } + } + + [Test] + public void Batch_AsyncCommand_NotBatchable_AtDryRunAndExecution() + { + // C1: run_tests/list_tests are async Task commands completed by EditorApplication.update + // callbacks, which cannot fire while the batch blocks the main thread — awaiting them + // inline would freeze the editor permanently. They must be rejected, not dispatched. + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("run_tests", new JObject { ["mode"] = "EditMode" }), + Op("list_tests"), + }; + + // Dry run flags both without executing anything. + var response = server.Execute("batch", new JObject + { + ["operations"] = operations, + ["dry_run"] = true + }); + Assert.IsTrue(response.IsSuccess, $"dry_run should return HTTP success: {response.RawResponse}"); + var results = response.JsonResponse["result"]["results"] as JArray; + foreach (var opResult in results) + { + Assert.IsFalse(opResult["success"]?.ToObject() ?? true, "async ops must fail validation"); + StringAssert.Contains("not_batchable", opResult["error"]?.ToString()); + StringAssert.Contains("async", opResult["error"]?.ToString()); + } + + // Execution rejects them the same way — and returns instead of hanging. + response = server.Execute("batch", new JObject { ["operations"] = new JArray { Op("run_tests") } }); + Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); + var exec = response.JsonResponse["result"]; + Assert.AreEqual(0, exec["applied"]?.ToObject(), "nothing must be dispatched"); + StringAssert.Contains("not_batchable", exec["results"][0]["error"]?.ToString()); + } + } + + [Test] + public void Batch_Transactional_RejectsNonRevertibleCommand_NonTransactionalAllowsIt() + { + // C2: delete_asset mutates the AssetDatabase, which Undo cannot roll back — inside a + // transactional batch "reverted: true" would be a lie, so it is rejected. With + // transactional:false it runs and reports revertible:false. + const string assetPath = "Assets/BatchTests_DeleteTarget.txt"; + File.WriteAllText(assetPath, "batch delete target"); + AssetDatabase.ImportAsset(assetPath); + try + { + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("delete_asset", new JObject { ["asset"] = assetPath, ["confirm"] = true }), + }; + + // Dry run under the default transactional=true flags it. + var response = server.Execute("batch", new JObject + { + ["operations"] = operations, + ["dry_run"] = true + }); + var dry = response.JsonResponse["result"]; + Assert.IsFalse(dry["valid"]?.ToObject() ?? true); + StringAssert.Contains("not_batchable_transactional", dry["results"][0]["error"]?.ToString()); + + // Execution under transactional=true rejects it too; the asset survives. + response = server.Execute("batch", new JObject { ["operations"] = operations }); + var exec = response.JsonResponse["result"]; + Assert.IsFalse(exec["results"][0]["success"]?.ToObject() ?? true); + StringAssert.Contains("not_batchable_transactional", exec["results"][0]["error"]?.ToString()); + Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(assetPath), + "a rejected delete_asset must not touch the asset"); + + // transactional:false allows it — with an honest revertible:false on the op. + response = server.Execute("batch", new JObject + { + ["operations"] = operations, + ["transactional"] = false + }); + exec = response.JsonResponse["result"]; + Assert.IsTrue(exec["results"][0]["success"]?.ToObject() ?? false, + $"transactional:false must allow delete_asset: {exec["results"][0]["error"]}"); + Assert.IsFalse(exec["results"][0]["revertible"]?.ToObject() ?? true, + "the op must report revertible:false"); + Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(assetPath), "the asset must be deleted"); + } + } + finally + { + if (AssetDatabase.LoadMainAssetAtPath(assetPath) != null) + AssetDatabase.DeleteAsset(assetPath); + } + } + + [Test] + public void Batch_OpenScene_AlwaysRejected_EvenNonTransactional() + { + // C2: open_scene/create_scene CLEAR the Editor Undo stack, destroying the batch's ability + // to revert anything before them — they are rejected regardless of the transactional flag. + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("open_scene", new JObject { ["path"] = "Assets/Nope.unity" }), + }; + + var response = server.Execute("batch", new JObject + { + ["operations"] = operations, + ["dry_run"] = true + }); + var dry = response.JsonResponse["result"]; + StringAssert.Contains("not_batchable", dry["results"][0]["error"]?.ToString()); + StringAssert.Contains("Undo stack", dry["results"][0]["error"]?.ToString()); + + response = server.Execute("batch", new JObject + { + ["operations"] = operations, + ["transactional"] = false + }); + var exec = response.JsonResponse["result"]; + Assert.IsFalse(exec["results"][0]["success"]?.ToObject() ?? true); + StringAssert.Contains("not_batchable", exec["results"][0]["error"]?.ToString()); + } + } + + [Test] + public void Batch_FirstOpPartialMutation_IsRolledBack() + { + // I1: an op that mutates and THEN throws leaves partial Undo-registered state even when + // it is the first (and only) op — the transactional rollback must run with applied == 0. + // set_transform applies 'position' before it parses the invalid 'rotation'. + using (var server = new PipelineTestServer()) + { + var go = Track(new GameObject("BatchPartial_Target")); + go.transform.localPosition = Vector3.zero; + + var operations = new JArray + { + Op("set_transform", new JObject + { + // Version-stable id: GetInstanceID() is obsolete-as-error from 6000.4. + ["target"] = PipelineUtils.GetObjectId(go).RawValue, + ["position"] = new JArray(5f, 6f, 7f), + ["rotation"] = new JArray(1f, 2f), // invalid: 2 components -> throws after position applied + }), + }; + + LogAssert.Expect(LogType.Error, new Regex("set_transform' failed: 'rotation' must have exactly 3 components")); + + var response = server.Execute("batch", new JObject { ["operations"] = operations }); + Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); + + var batch = response.JsonResponse["result"]; + Assert.AreEqual(0, batch["applied"]?.ToObject(), "the only op failed"); + Assert.IsTrue(batch["reverted"]?.ToObject() ?? false, + "a transactional failure must revert even when applied == 0"); + Assert.AreEqual(0f, go.transform.localPosition.magnitude, 0.0001f, + "the failing op's partial position change must be reverted"); + } + } + + [Test] + public void Batch_TimeBudgetExceeded_SkipsRemainingOps_AndRevertsTransactional() + { + // I2: the cooperative time budget is checked before each op after the first. Budget 0 is + // exhausted immediately after op 0, so ops 1..n are skipped and the transactional batch + // rolls back with a batch-level error. + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("create_gameobject", new JObject { ["name"] = "BatchBudget_A" }), + Op("create_gameobject", new JObject { ["name"] = "BatchBudget_B" }), + Op("create_gameobject", new JObject { ["name"] = "BatchBudget_C" }), + }; + + var response = server.Execute("batch", new JObject + { + ["operations"] = operations, + ["time_budget_ms"] = 0 + }); + Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); + + var batch = response.JsonResponse["result"]; + StringAssert.Contains("batch time budget exceeded", batch["error"]?.ToString()); + Assert.AreEqual(1, batch["applied"]?.ToObject(), "only op 0 runs on a zero budget"); + Assert.IsTrue(batch["reverted"]?.ToObject() ?? false, "budget abort is a transactional failure"); + + var results = batch["results"] as JArray; + Assert.AreEqual(3, results.Count); + Assert.IsTrue(results[0]["success"]?.ToObject() ?? false, "op 0 always gets to run"); + Assert.IsTrue(results[1]["skipped"]?.ToObject() ?? false, "op 1 must be skipped"); + Assert.IsTrue(results[2]["skipped"]?.ToObject() ?? false, "op 2 must be skipped"); + + Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchBudget_A").Count, + "op 0 must be reverted by the budget-abort rollback"); + Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchBudget_B").Count); + } + } + + [Test] + public void Batch_ReferenceToFailedOp_ErrorsClearly() + { + // I3: under on_error=continue an op can fail without stopping the batch; a later $ref + // into the failed op must error explicitly instead of silently resolving to null. + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("add_component", new JObject { ["target"] = "/NoSuchObjectXYZ_00", ["type"] = "Rigidbody" }), // fails + Op("rename_gameobject", new JObject { ["target"] = "$0.instanceId", ["name"] = "Renamed" }), + }; + + LogAssert.Expect(LogType.Error, new Regex("add_component' failed:")); + + var response = server.Execute("batch", new JObject + { + ["operations"] = operations, + ["on_error"] = "continue" + }); + Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); + + var results = response.JsonResponse["result"]["results"] as JArray; + Assert.IsFalse(results[0]["success"]?.ToObject() ?? true, "op 0 must fail"); + Assert.IsFalse(results[1]["success"]?.ToObject() ?? true, "the referencing op must fail"); + var error = results[1]["error"]?.ToString(); + StringAssert.Contains("$0.instanceId", error, "the error should name the offending reference"); + StringAssert.Contains("failed; its result cannot be referenced", error, + "and say the referenced op failed"); + } + } + + [Test] + public void Resolver_ReferenceToVoidResult_Errors_WholeValueAndPath() + { + // I3: a SUCCEEDED op with a null result (void command) is distinct from a failed op — + // referencing it errors with "returned no result", for whole-value and path refs alike. + var operations = new List + { + new BatchOperationInput { Command = "noop_a" }, + new BatchOperationInput { Command = "noop_b" }, + }; + var outcomes = new List + { + new BatchOperationResult { Command = "noop_a", Success = true }, + new BatchOperationResult { Command = "noop_b" }, + }; + var results = new JToken[] { JValue.CreateNull(), null }; + var idToIndex = new Dictionary(); + + var wholeRef = new JObject { ["value"] = "$0" }; + var ex = Assert.Throws(() => + BatchReferenceResolver.Resolve(wholeRef, 1, operations, idToIndex, results, outcomes)); + StringAssert.Contains("returned no result", ex.Message); + + var pathRef = new JObject { ["value"] = "$0.instanceId" }; + ex = Assert.Throws(() => + BatchReferenceResolver.Resolve(pathRef, 1, operations, idToIndex, results, outcomes)); + StringAssert.Contains("returned no result", ex.Message); + } + + [Test] + public void Resolver_ReferenceToFailedOp_Errors_WholeValueAndPath() + { + // I3: whole-value refs into a FAILED op must error just like path refs (the end-to-end + // test above covers the path form; this pins the whole-value form). + var operations = new List + { + new BatchOperationInput { Command = "noop_a" }, + new BatchOperationInput { Command = "noop_b" }, + }; + var outcomes = new List + { + new BatchOperationResult { Command = "noop_a", Success = false, Error = "boom" }, + new BatchOperationResult { Command = "noop_b" }, + }; + var results = new JToken[] { null, null }; + var idToIndex = new Dictionary(); + + var wholeRef = new JObject { ["value"] = "$0" }; + var ex = Assert.Throws(() => + BatchReferenceResolver.Resolve(wholeRef, 1, operations, idToIndex, results, outcomes)); + StringAssert.Contains("failed; its result cannot be referenced", ex.Message); + } + + [Test] + public void Batch_RuntimeOnlyCommand_NotBatchable() + { + // I5: runtime-only commands (Player server surface) are discoverable via the registry but + // must not be dispatchable through an editor-server batch. + using (var server = new PipelineTestServer()) + { + var response = server.Execute("batch", new JObject + { + ["operations"] = new JArray { Op("set_timescale", new JObject { ["scale"] = 0.5f }) }, + ["dry_run"] = true + }); + + var dry = response.JsonResponse["result"]; + Assert.IsFalse(dry["valid"]?.ToObject() ?? true); + var error = dry["results"][0]["error"]?.ToString(); + StringAssert.Contains("not_batchable", error); + StringAssert.Contains("runtime-only", error); + } + } + + [Test] + public void Batch_MenuCommand_NotBatchable() + { + // I6: menu items can open modal dialogs, which would wedge an unattended batch. + using (var server = new PipelineTestServer()) + { + var response = server.Execute("batch", new JObject + { + ["operations"] = new JArray { Op("menu", new JObject { ["path"] = "File/Save" }) }, + ["dry_run"] = true + }); + + var dry = response.JsonResponse["result"]; + Assert.IsFalse(dry["valid"]?.ToObject() ?? true); + StringAssert.Contains("not_batchable", dry["results"][0]["error"]?.ToString()); + } + } + + [Test] + public void Batch_NumericOpId_Rejected() + { + // M3: a purely numeric id would be ambiguous with a 0-based index selector in "$". + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("create_gameobject", new JObject { ["name"] = "BatchId_A" }, id: "0"), + }; + + LogAssert.Expect(LogType.Error, new Regex("ids must match")); + + var response = server.Execute("batch", new JObject + { + ["operations"] = operations, + ["dry_run"] = true + }); + + Assert.IsFalse(response.IsSuccess, "a numeric op id must be rejected outright"); + StringAssert.Contains("ids must match", response.RawResponse); + } + } + + [Test] + public void Batch_DollarDollar_EscapesToLiteralDollar() + { + // M7: "$$..." escapes a literal '$' — the op receives "$literal", not a reference. + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("create_gameobject", new JObject { ["name"] = "$$literal" }), + }; + + var response = server.Execute("batch", new JObject { ["operations"] = operations }); + Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); + + var results = response.JsonResponse["result"]["results"] as JArray; + var go = ResolveGameObject(results[0]); + Assert.IsNotNull(go, "op 0's GameObject should be resolvable"); + Assert.AreEqual("$literal", go.name, "\"$$literal\" must unescape to the literal \"$literal\""); + } + } + + [Test] + public void Batch_IdBasedReference_Resolves() + { + // M7: references can select the target op by its id, not only by index. + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("create_gameobject", new JObject { ["name"] = "BatchIdRef_Root" }, id: "root"), + Op("add_component", new JObject { ["target"] = "$root.instanceId", ["type"] = "Rigidbody" }), + }; + + var response = server.Execute("batch", new JObject { ["operations"] = operations }); + Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); + + var batch = response.JsonResponse["result"]; + Assert.AreEqual(2, batch["applied"]?.ToObject(), + $"both ops should apply: {batch["results"]}"); + + var root = ResolveGameObject((batch["results"] as JArray)[0]); + Assert.IsNotNull(root); + Assert.IsNotNull(root.GetComponent(), "$root.instanceId must resolve by op id"); + } + } + + [Test] + public void Batch_ResultFields_ProjectsOpResult() + { + // M7: result_fields keeps only the requested result fields for the op (context economy). + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("create_gameobject", new JObject { ["name"] = "BatchProject_A" }), + }; + + var response = server.Execute("batch", new JObject + { + ["operations"] = operations, + ["result_fields"] = new JObject { ["0"] = new JArray("instanceId") } + }); + Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); + + var results = response.JsonResponse["result"]["results"] as JArray; + var projected = results[0]["result"] as JObject; + Assert.IsNotNull(projected, "the projected result should be an object"); + Assert.IsNotNull(projected["instanceId"], "the requested field must be kept"); + Assert.IsNull(projected["hierarchyPath"], "unrequested fields must be dropped"); + Assert.IsNull(projected["globalId"], "unrequested fields must be dropped"); + + // Cleanup: the projection kept instanceId, so the object is still resolvable. + Assert.IsNotNull(ResolveGameObject(results[0])); + } + } + + [Test] + public void Batch_OversizedOpResult_Truncated_ButReferencesStillResolve() + { + // M7: an op result over the per-op cap is replaced by a truncation marker in the envelope, + // but later $refs resolve against the FULL (untruncated) result. + const string bigPath = "Assets/BatchTests_BigFile.txt"; + File.WriteAllText(bigPath, new string('x', 20000)); // > 16 KiB serialized + try + { + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("read_text_file", new JObject { ["path"] = bigPath }), + // References a scalar of the oversized result — must still resolve. + Op("create_gameobject", new JObject { ["name"] = "$0.assetPath" }), + }; + + var response = server.Execute("batch", new JObject { ["operations"] = operations }); + Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); + + var results = response.JsonResponse["result"]["results"] as JArray; + Assert.IsTrue(results[0]["resultTruncated"]?.ToObject() ?? false, + "the oversized read_text_file result must be truncated"); + Assert.IsTrue(results[0]["result"]?["resultTruncated"]?.ToObject() ?? false, + "the result must be replaced by the truncation marker"); + + Assert.IsTrue(results[1]["success"]?.ToObject() ?? false, + $"the referencing op must still succeed: {results[1]["error"]}"); + var go = ResolveGameObject(results[1]); + Assert.IsNotNull(go); + Assert.AreEqual(bigPath, go.name, "$0.assetPath must resolve against the untruncated result"); + } + } + finally + { + if (File.Exists(bigPath)) + File.Delete(bigPath); + if (File.Exists(bigPath + ".meta")) + File.Delete(bigPath + ".meta"); + } + } + + [Test] + public void Batch_NestedBatch_Rejected() + { + // M7: batch-in-batch is excluded (recursion/auditing hazard). + using (var server = new PipelineTestServer()) + { + var response = server.Execute("batch", new JObject + { + ["operations"] = new JArray { Op("batch", new JObject { ["operations"] = new JArray() }) }, + ["dry_run"] = true + }); + + var dry = response.JsonResponse["result"]; + Assert.IsFalse(dry["valid"]?.ToObject() ?? true); + StringAssert.Contains("not_batchable", dry["results"][0]["error"]?.ToString()); + } + } + + [Test] + public void Batch_NonTransactional_Abort_KeepsAppliedOps_SkipsRest() + { + // M7: transactional:false + on_error=abort stops at the failing op but keeps what was + // already applied (no rollback), and marks the rest skipped. + using (var server = new PipelineTestServer()) + { + var operations = new JArray + { + Op("create_gameobject", new JObject { ["name"] = "BatchNoTx_A" }), + Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "NoSuchTypeXYZ" }), // fails + Op("create_gameobject", new JObject { ["name"] = "BatchNoTx_C" }), + }; + + LogAssert.Expect(LogType.Error, new Regex("add_component' failed: Could not resolve component type 'NoSuchTypeXYZ'")); + + var response = server.Execute("batch", new JObject + { + ["operations"] = operations, + ["transactional"] = false + }); + Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); + + var batch = response.JsonResponse["result"]; + Assert.IsFalse(batch["reverted"]?.ToObject() ?? true, "transactional:false must not revert"); + Assert.AreEqual(1, batch["applied"]?.ToObject()); + + var results = batch["results"] as JArray; + Assert.IsTrue(results[0]["success"]?.ToObject() ?? false); + Assert.IsFalse(results[1]["success"]?.ToObject() ?? true, "op 1 must fail"); + Assert.IsTrue(results[2]["skipped"]?.ToObject() ?? false, "op 2 must be skipped (abort)"); + + Assert.IsNotNull(ResolveGameObject(results[0]), "op 0's object must survive (no rollback)"); + Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchNoTx_C").Count, + "op 2 never ran"); + } + } + + [Test] + public void Batch_InvalidResultFieldsPath_DoesNotFailTheAppliedOp() + { + // Review finding: a malformed result_fields JSONPath threw AFTER the op had mutated + // state, and the outer catch flipped the op's Success to false — rolling back a + // succeeded op under on_error=abort. The projection must report the bad path instead. + using (var server = new PipelineTestServer()) + { + var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "BatchProj_BadPath" }) }; + var response = server.Execute("batch", new JObject + { + ["operations"] = operations, + ["result_fields"] = new JObject { ["0"] = new JArray("foo[") } + }); + Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); + + var batch = response.JsonResponse["result"]; + var results = batch["results"] as JArray; + Assert.IsTrue(results[0]["success"]?.ToObject() ?? false, + "a malformed projection path must not flip an already-applied op to failure"); + Assert.IsFalse(batch["reverted"]?.ToObject() ?? true, + "and must not trigger a rollback of the succeeded op"); + StringAssert.Contains("foo[", results[0]["result"]?["invalidResultFields"]?[0]?.ToString(), + "the bad path is reported inside the projection"); + + var survivors = GameObjectCommands.FindGameObjects(name: "BatchProj_BadPath"); + Assert.AreEqual(1, survivors.Count, "the op's mutation must survive"); + Track(GameObject.Find("BatchProj_BadPath")); + } + } + + [Test] + public void Batch_TransactionalRevert_LeavesUndoStackClean() + { + // Review finding: after Undo.RevertAllDownToGroup discarded the batch group, the + // enclosing AuthoringUndoScope still collapsed that stale group id on dispose. The + // scope is now canceled after a revert; the contract this pins: the next PerformUndo + // must undo the USER's previous operation, not be absorbed by a stray batch group. + using (var server = new PipelineTestServer()) + { + Undo.IncrementCurrentGroup(); + var marker = Track(new GameObject("BatchUndoStack_Marker")); + Undo.RegisterCreatedObjectUndo(marker, "create marker"); + Undo.IncrementCurrentGroup(); + + var operations = new JArray + { + Op("create_gameobject", new JObject { ["name"] = "BatchUndoStack_Tx" }), + Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "NoSuchTypeXYZ" }), + }; + LogAssert.Expect(LogType.Error, new Regex("add_component' failed: Could not resolve component type 'NoSuchTypeXYZ'")); + var response = server.Execute("batch", new JObject { ["operations"] = operations }); + Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); + Assert.IsTrue(response.JsonResponse["result"]["reverted"]?.ToObject() ?? false, + "precondition: the transactional batch reverted"); + + Undo.PerformUndo(); + Assert.IsTrue(marker == null, + "PerformUndo after a reverted batch must undo the previous user operation " + + "(the marker creation) — a surviving marker means a stray batch group absorbed it"); + } + } + + [Test] + public void ProjectResult_MeasuresUtf8Bytes_NotUtf16Chars() + { + // Review finding: the per-op cap compared UTF-16 char counts while the wire is UTF-8 — + // a CJK payload at ~12k chars is ~36 KB on the wire and sailed under the 16 KiB cap. + var op = new BatchOperationInput { Command = "noop" }; + var opResult = new BatchOperationResult(); + var budget = int.MaxValue; // isolate the per-op cap from the aggregate budget + var cjk = new string('\u597D', 12_000); // 12k chars, ~36 KB UTF-8 + + var projected = BatchCommand.ProjectResult(cjk, JToken.FromObject(cjk), op, 0, null, opResult, ref budget); + + Assert.IsTrue(opResult.ResultTruncated == true, + "a payload under the char count but over the UTF-8 byte cap must be truncated"); + var marker = (JObject)JToken.FromObject(projected); + Assert.Greater(marker["length"]?.ToObject() ?? 0, 16 * 1024, "length reports wire bytes"); + } + + [Test] + public void ProjectResult_AggregateBudget_ElidesLaterResults() + { + // Review finding: nothing bounded the SUM of per-op results (200 ops x 16 KiB each is a + // ~3.2 MB reply). Once the aggregate budget is exhausted, later results become markers. + var op = new BatchOperationInput { Command = "noop" }; + var budget = BatchCommand.MaxBatchResultBytes; + var big = new string('a', 15_000); // under the per-op cap; ~17 of these exhaust the budget + + object last = null; + BatchOperationResult lastResult = null; + var truncatedAt = -1; + for (var i = 0; i < 30; i++) + { + lastResult = new BatchOperationResult(); + last = BatchCommand.ProjectResult(big, JToken.FromObject(big), op, i, null, lastResult, ref budget); + if (lastResult.ResultTruncated == true) { truncatedAt = i; break; } + } + + Assert.GreaterOrEqual(truncatedAt, 2, "several results must fit before the budget trips"); + Assert.AreNotEqual(-1, truncatedAt, "the aggregate budget must eventually trip"); + StringAssert.Contains("aggregate result budget", ((JObject)JToken.FromObject(last))["error"]?.ToString(), + "the marker explains the aggregate elision"); + } + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Batch/BatchCommandTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Batch/BatchCommandTests.cs.meta new file mode 100644 index 0000000..082609b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Batch/BatchCommandTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7dffec4aa4e549679a30d77044ee314d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/BuildConfigCommandsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/BuildConfigCommandsTests.cs new file mode 100644 index 0000000..3589b5e --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/BuildConfigCommandsTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/BuildConfigCommandsTests.cs.meta new file mode 100644 index 0000000..f786b4a --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Capture.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Capture.meta new file mode 100644 index 0000000..be6ac1d --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Capture.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c83fc76f10b96db43a56fd1bbf79c646 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Capture/CaptureCommandsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Capture/CaptureCommandsTests.cs new file mode 100644 index 0000000..f750a19 --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Capture/CaptureCommandsTests.cs @@ -0,0 +1,428 @@ +using System; +using System.Collections; +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; +using UnityEngine.TestTools; + +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. + /// + 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 Source routing (AUTHAPI-10) + + [Test] + public void CaptureGameView_ScreenSource_InEditMode_Throws() + { + // Overlay/screen capture is a Play-Mode operation; in Edit Mode it must fail with a clear + // message (this guard runs before the GPU check, so the message is deterministic headless too). + var ex = Assert.Throws( + () => CaptureCommands.CaptureGameView(64, 64, source: "screen")); + StringAssert.Contains("Play Mode", ex.Message); + } + + [Test] + public void CaptureGameView_UnknownSource_Throws() + { + var ex = Assert.Throws( + () => CaptureCommands.CaptureGameView(64, 64, source: "bogus")); + StringAssert.Contains("Unknown source", ex.Message); + } + + #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 + + #region Screen capture in Play Mode (AUTHAPI-10) + + // Draws a full-screen two-tone quad via IMGUI. Like a Screen Space - Overlay canvas it + // composites straight to the screen and never renders through a camera — the case AUTHAPI-10 + // covers — so it lets the test prove overlay content shows up in a screen capture but not a + // camera capture, without a uGUI dependency. + // + // The tones are chosen to catch two capture-path bugs a flat saturated color (the old pure + // red) is blind to: + // * MID-GRAYS, because a primary like (255,0,0) is invariant under gamma conversion — 128 + // turns into ~188 when the path double-encodes gamma (the washout Thomas reported), so + // asserting captured luminance pins the tone round-trip. + // * TWO DIFFERENT tones (light top, dark bottom), because a uniform screen is invariant + // under vertical flips — CaptureScreenshotIntoRenderTexture returns GPU-UV-oriented + // content that must be un-flipped on D3D/Metal/Vulkan, so asserting which half is which + // pins the orientation. + private static readonly Color32 OverlayTopGray = new Color32(128, 128, 128, 255); + private static readonly Color32 OverlayBottomGray = new Color32(64, 64, 64, 255); + + private class FullScreenOverlay : MonoBehaviour + { + private Texture2D m_TopTex; + private Texture2D m_BottomTex; + + private static Texture2D Solid(Color32 c) + { + var tex = new Texture2D(1, 1); + tex.SetPixels32(new[] { c }); + tex.Apply(); + return tex; + } + + private void OnGUI() + { + if (m_TopTex == null) + { + m_TopTex = Solid(OverlayTopGray); + m_BottomTex = Solid(OverlayBottomGray); + } + + GUI.depth = -1000; // draw on top of any other IMGUI + var half = Screen.height / 2f; + GUI.DrawTexture(new Rect(0, 0, Screen.width, half), m_TopTex); + GUI.DrawTexture(new Rect(0, half, Screen.width, Screen.height - half), m_BottomTex); + } + + private void OnDestroy() + { + if (m_TopTex != null) DestroyImmediate(m_TopTex); + if (m_BottomTex != null) DestroyImmediate(m_BottomTex); + } + } + + [UnityTearDown] + public IEnumerator ExitPlayModeIfNeeded() + { + // A failed assertion inside the play-mode test would otherwise leave the editor playing. + if (EditorApplication.isPlaying) + yield return new ExitPlayMode(); + } + + [UnityTest] + public IEnumerator CaptureGameView_ScreenSource_CapturesOverlayUi() + { + // Overlay/screen capture reads the presented game-view backbuffer, which requires a real, + // rendering game view. In pure headless/batchmode there is none (and WaitForEndOfFrame never + // fires), so self-ignore early. Edit-Mode + unknown-source guards cover CI regardless. + if (IsHeadless || Application.isBatchMode) + { + Assert.Ignore("Screen capture requires an interactive game view (skipped headless/batchmode)."); + yield break; + } + + yield return new EnterPlayMode(); + + // Tolerate unrelated error logs from whatever project hosts the run (this test only checks pixels). + LogAssert.ignoreFailingMessages = true; + + // A camera that clears to blue and renders nothing, so a camera capture is overlay-free. + var camGo = new GameObject("CLI199_ScreenCam"); + var cam = camGo.AddComponent(); + cam.clearFlags = CameraClearFlags.SolidColor; + cam.backgroundColor = Color.blue; + cam.cullingMask = 0; + + var overlayGo = new GameObject("CLI199_Overlay"); + overlayGo.AddComponent(); + + // Poll a few frames: the overlay only appears in the capture once the game view has + // repainted it into the backbuffer (a single frame is racy in the automated harness). + CaptureResult screen = null; + var overlayObserved = false; + var stats = default((float fraction, float topLuma, float bottomLuma)); + for (var frame = 0; frame < 30 && !overlayObserved; frame++) + { + yield return new WaitForEndOfFrame(); + screen = CaptureCommands.CaptureGameView(64, 64, source: "screen"); + stats = GrayStats(screen.Base64); + // The full-screen overlay should dominate the frame with neutral gray of ANY + // brightness — brightness-agnostic on purpose, so a tone or orientation bug is a + // failed assert below rather than an "overlay not observed" ignore. + overlayObserved = stats.fraction > 0.8f; + } + + // Reliable everywhere the screen path runs: it returns a correctly-sized PNG tagged "screen". + Assert.IsNotNull(screen, "screen capture should have run"); + Assert.AreEqual("screen", screen.Source); + Assert.IsNotEmpty(screen.Base64, "screen capture should return an image"); + Assert.AreEqual(64, screen.Width); + + // Pixel-presence needs the environment to actually present the overlay into the backbuffer. + // Some CI/windowless setups render a blank/stale backbuffer even in Play Mode; treat an + // unobservable overlay as inconclusive rather than a failure (verified live in the PR). + if (!overlayObserved) + { + UnityEngine.Object.Destroy(overlayGo); + UnityEngine.Object.Destroy(camGo); + // ExitPlayModeIfNeeded ([UnityTearDown]) restores edit mode after this ignore. + Assert.Ignore("Overlay not observable in this environment's game-view capture; screen path still verified."); + } + + // Tone round-trip: the authored grays must read back near their values. The old AsTexture + // path double-encoded gamma in Linear projects (128 washed out to ~188). Orientation: the + // light half must be on top — CaptureScreenshotIntoRenderTexture returns GPU-UV-oriented + // content that the command must un-flip on D3D/Metal/Vulkan. (AUTHAPI-10 review) + Assert.That(stats.topLuma, Is.EqualTo((float)OverlayTopGray.r).Within(20f), + "top half should be the light gray at its authored tone (flip and/or gamma bug otherwise)"); + Assert.That(stats.bottomLuma, Is.EqualTo((float)OverlayBottomGray.r).Within(20f), + "bottom half should be the dark gray at its authored tone (flip and/or gamma bug otherwise)"); + + // Overlay is observable here: prove it shows in a screen capture but NOT a camera capture + // (the camera clears to solid blue and renders nothing, so it contains no neutral gray). + var cameraShot = CaptureCommands.CaptureGameView(64, 64, camera: "CLI199_ScreenCam", source: "camera"); + Assert.Less(GrayStats(cameraShot.Base64).fraction, 0.2f, "overlay UI must be absent from a camera capture"); + + UnityEngine.Object.Destroy(overlayGo); + UnityEngine.Object.Destroy(camGo); + + yield return new ExitPlayMode(); + } + + /// + /// Decode the PNG and measure its neutral-gray content: the fraction of pixels that are + /// near-neutral (r≈g≈b, away from black/white clip), plus the mean luminance of the image's + /// top and bottom halves. Brightness-agnostic so gamma distortion shows up in the luminance + /// values (not as a hidden overlay), and per-half so a vertical flip swaps them detectably. + /// + private static (float fraction, float topLuma, float bottomLuma) GrayStats(string base64) + { + var tex = new Texture2D(2, 2); + try + { + Assert.IsTrue(tex.LoadImage(Convert.FromBase64String(base64)), "capture PNG should decode"); + var width = tex.width; + var height = tex.height; + var pixels = tex.GetPixels32(); + var gray = 0; + long topSum = 0, bottomSum = 0; + int topCount = 0, bottomCount = 0; + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + var p = pixels[y * width + x]; + if (Mathf.Abs(p.r - p.g) <= 12 && Mathf.Abs(p.g - p.b) <= 12 && p.r >= 40 && p.r <= 240) + gray++; + + // GetPixels32 row 0 is the image's BOTTOM row. + var luma = (p.r + p.g + p.b) / 3; + if (y >= height / 2) { topSum += luma; topCount++; } + else { bottomSum += luma; bottomCount++; } + } + } + + return (pixels.Length == 0 ? 0f : (float)gray / pixels.Length, + topCount == 0 ? 0f : (float)topSum / topCount, + bottomCount == 0 ? 0f : (float)bottomSum / bottomCount); + } + finally + { + UnityEngine.Object.DestroyImmediate(tex); + } + } + + #endregion + } +} diff --git a/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Capture/CaptureCommandsTests.cs.meta b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/Capture/CaptureCommandsTests.cs.meta new file mode 100644 index 0000000..0ce465b --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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/AIClient/Packages/com.unity.pipeline/Tests~/Editor/CaptureVisualElementCommandsTests.cs b/AIClient/Packages/com.unity.pipeline/Tests~/Editor/CaptureVisualElementCommandsTests.cs new file mode 100644 index 0000000..b4afeee --- /dev/null +++ b/AIClient/Packages/com.unity.pipeline/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. + /// + 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