diff --git a/README.md b/README.md index 545f56d..b1b6aff 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,16 @@ Exposes project-grade tools to AI agents while defining capability contracts and Initial tool families: - `unity.project.*` — inspect project structure, packages, settings, scenes, scripts, and assets. +- `unity.audit.*` — generate hashed JSON/Markdown reports from persisted events and before/after evidence. - `unity.assets.*` — list project assets with GUIDs, paths, and main asset types. +- `unity.assets.import` — copy or download FBX/OBJ/DAE/3DS/DXF and package-backed glTF models, textures, and audio; verify hashes; configure importers; instantiate models; and save prefabs. +- `unity.assets.catalog.search` / `unity.assets.import_from_catalog` — search CC0-by-default asset catalogs and import entries with enforced license, provenance, byte size, and SHA-256 metadata. - `unity.asset.*` — inspect specific asset metadata and dependencies. - `unity.prefabs.*` / `unity.prefab.*` — list and inspect prefab assets. - `unity.scene.*` — inspect hierarchies and serialized component state, then create, duplicate, rename, reparent, delete, instantiate prefabs, add/remove components, and set serialized properties through atomic batches. +- `unity.gameplay.*` — turn existing objects into checkpointed doors, pickups, and multi-target proximity activators through high-level templates. - `unity.scenes.*` — list scenes discovered in the project and Build Settings. -- `unity.scripts.*` / `unity.assemblies.*` — inspect C# scripts and Unity script assemblies. +- `unity.scripts.*` / `unity.assemblies.*` — inspect C# scripts and assemblies, or author hash-confirmed runtime `MonoBehaviour` components with compile verification and rollback. - `unity.packages.*` — list and change registry packages through reload-safe jobs. - `unity.jobs.*` — inspect and cancel persistent long-running operations. - `unity.playmode.*` / `unity.compilation.*` — control Play Mode and wait for compilation/import plus console verification. @@ -50,7 +54,7 @@ Initial tool families: - `unity.meta_xr.*` — validate and configure Meta XR SDK, OpenXR, rigs, hands, passthrough, anchors, interactions, and Quest build requirements. - `unity.tests.*` — run Edit Mode/Play Mode tests and summarize failures. - `unity.build.*` — validate and execute builds, especially Android/Quest targets. -- `unity.assets.author` — create or edit shaders, materials, animation clips, generated WAV audio, and audio import settings. +- `unity.assets.author` — create or edit shaders, materials, animation clips, Animator Controllers, generated WAV audio, and audio import settings. - `unity.prefab.manage` — save prefab assets, create variants, edit prefab contents, and apply/revert overrides. ### Unity Editor plugin @@ -155,7 +159,7 @@ npm run setup:user -- --unity-project /path/to/UnityProject --opencode --claude- Apply the changes: ```bash -npm run setup:user -- --unity-project /path/to/UnityProject --opencode --claude-code --codex --build --write +npm run setup:user -- --unity-project /path/to/UnityProject --opencode --claude-code --codex --antigravity --build --write ``` What it changes: @@ -164,10 +168,11 @@ What it changes: - Adds or updates `mcp.unity-ai` in `~/.config/opencode/opencode.json` using an absolute path to `apps/mcp-server/dist/index.js`. - Configures Claude Code through `claude mcp add-json unity-ai ...`; the script does not edit Claude Code config files directly. - Adds or updates a generated `unity-ai` MCP server block in `~/.codex/config.toml`. +- Adds or updates `unity-ai` in Antigravity, Antigravity CLI, and Antigravity IDE MCP configs. - Creates `.bak-YYYYMMDDHHmmss` backups next to files before writing. - Generates and prints a local bridge token when applying with `--write` and an MCP host without `--bridge-token`; use that same token when starting the Unity local bridge. -After changing opencode or Codex config, restart the host so it reloads config. Claude Code is configured through its CLI. +After changing opencode, Codex, or Antigravity config, restart the host so it reloads config. Claude Code is configured through its CLI. See `docs/setup.md` for focused setup details and examples. @@ -185,7 +190,12 @@ Useful commands: npm ci npm run typecheck npm run build +npm run schemas:check +npm run verify:asset-catalog +npm run verify:bridge-retry npm run verify:unity-package ``` `npm run verify:unity-package` expects a local Unity installation. Set `UNITY_PATH` or pass the Unity executable path as the first argument if Unity is not discoverable in the default location. + +Run `npm run schemas:antigravity` after adding or changing MCP tools to refresh Antigravity, Antigravity CLI, and Antigravity IDE local `parameters` JSON instead of leaving stale `parameters: null` entries. diff --git a/apps/mcp-server/catalog-assets/starter-character.obj b/apps/mcp-server/catalog-assets/starter-character.obj new file mode 100644 index 0000000..bdc3318 --- /dev/null +++ b/apps/mcp-server/catalog-assets/starter-character.obj @@ -0,0 +1,91 @@ +# Unity AI Starter Catalog - CC0 1.0 +o StarterCharacterBody +v -0.3 0.8 -0.18 +v 0.3 0.8 -0.18 +v 0.3 1.55 -0.18 +v -0.3 1.55 -0.18 +v -0.3 0.8 0.18 +v 0.3 0.8 0.18 +v 0.3 1.55 0.18 +v -0.3 1.55 0.18 +f 1 2 3 4 +f 5 8 7 6 +f 1 5 6 2 +f 2 6 7 3 +f 3 7 8 4 +f 5 1 4 8 +o StarterCharacterHead +v -0.22 1.58 -0.22 +v 0.22 1.58 -0.22 +v 0.22 2.02 -0.22 +v -0.22 2.02 -0.22 +v -0.22 1.58 0.22 +v 0.22 1.58 0.22 +v 0.22 2.02 0.22 +v -0.22 2.02 0.22 +f 9 10 11 12 +f 13 16 15 14 +f 9 13 14 10 +f 10 14 15 11 +f 11 15 16 12 +f 13 9 12 16 +o StarterCharacterArmL +v -0.62 0.85 -0.12 +v -0.36 0.85 -0.12 +v -0.36 1.45 -0.12 +v -0.62 1.45 -0.12 +v -0.62 0.85 0.12 +v -0.36 0.85 0.12 +v -0.36 1.45 0.12 +v -0.62 1.45 0.12 +f 17 18 19 20 +f 21 24 23 22 +f 17 21 22 18 +f 18 22 23 19 +f 19 23 24 20 +f 21 17 20 24 +o StarterCharacterArmR +v 0.36 0.85 -0.12 +v 0.62 0.85 -0.12 +v 0.62 1.45 -0.12 +v 0.36 1.45 -0.12 +v 0.36 0.85 0.12 +v 0.62 0.85 0.12 +v 0.62 1.45 0.12 +v 0.36 1.45 0.12 +f 25 26 27 28 +f 29 32 31 30 +f 25 29 30 26 +f 26 30 31 27 +f 27 31 32 28 +f 29 25 28 32 +o StarterCharacterLegL +v -0.28 0.0 -0.13 +v -0.04 0.0 -0.13 +v -0.04 0.8 -0.13 +v -0.28 0.8 -0.13 +v -0.28 0.0 0.13 +v -0.04 0.0 0.13 +v -0.04 0.8 0.13 +v -0.28 0.8 0.13 +f 33 34 35 36 +f 37 40 39 38 +f 33 37 38 34 +f 34 38 39 35 +f 35 39 40 36 +f 37 33 36 40 +o StarterCharacterLegR +v 0.04 0.0 -0.13 +v 0.28 0.0 -0.13 +v 0.28 0.8 -0.13 +v 0.04 0.8 -0.13 +v 0.04 0.0 0.13 +v 0.28 0.0 0.13 +v 0.28 0.8 0.13 +v 0.04 0.8 0.13 +f 41 42 43 44 +f 45 48 47 46 +f 41 45 46 42 +f 42 46 47 43 +f 43 47 48 44 +f 45 41 44 48 diff --git a/apps/mcp-server/catalog-assets/starter-crate.obj b/apps/mcp-server/catalog-assets/starter-crate.obj new file mode 100644 index 0000000..85dae2e --- /dev/null +++ b/apps/mcp-server/catalog-assets/starter-crate.obj @@ -0,0 +1,16 @@ +# Unity AI Starter Catalog - CC0 1.0 +o StarterCrate +v -0.5 0.0 -0.5 +v 0.5 0.0 -0.5 +v 0.5 1.0 -0.5 +v -0.5 1.0 -0.5 +v -0.5 0.0 0.5 +v 0.5 0.0 0.5 +v 0.5 1.0 0.5 +v -0.5 1.0 0.5 +f 1 2 3 4 +f 5 8 7 6 +f 1 5 6 2 +f 2 6 7 3 +f 3 7 8 4 +f 5 1 4 8 diff --git a/apps/mcp-server/catalog-assets/starter-platform.obj b/apps/mcp-server/catalog-assets/starter-platform.obj new file mode 100644 index 0000000..56d682e --- /dev/null +++ b/apps/mcp-server/catalog-assets/starter-platform.obj @@ -0,0 +1,31 @@ +# Unity AI Starter Catalog - CC0 1.0 +o StarterPlatform +v -2.0 0.0 -2.0 +v 2.0 0.0 -2.0 +v 2.0 0.2 -2.0 +v -2.0 0.2 -2.0 +v -2.0 0.0 2.0 +v 2.0 0.0 2.0 +v 2.0 0.2 2.0 +v -2.0 0.2 2.0 +f 1 2 3 4 +f 5 8 7 6 +f 1 5 6 2 +f 2 6 7 3 +f 3 7 8 4 +f 5 1 4 8 +o StarterPlatformMarker +v -0.2 0.21 -0.2 +v 0.2 0.21 -0.2 +v 0.2 0.24 -0.2 +v -0.2 0.24 -0.2 +v -0.2 0.21 0.2 +v 0.2 0.21 0.2 +v 0.2 0.24 0.2 +v -0.2 0.24 0.2 +f 9 10 11 12 +f 13 16 15 14 +f 9 13 14 10 +f 10 14 15 11 +f 11 15 16 12 +f 13 9 12 16 diff --git a/apps/mcp-server/catalog-assets/starter-ramp.obj b/apps/mcp-server/catalog-assets/starter-ramp.obj new file mode 100644 index 0000000..c9512ff --- /dev/null +++ b/apps/mcp-server/catalog-assets/starter-ramp.obj @@ -0,0 +1,13 @@ +# Unity AI Starter Catalog - CC0 1.0 +o StarterRamp +v -1.0 0.0 -1.0 +v 1.0 0.0 -1.0 +v -1.0 0.0 1.0 +v 1.0 0.0 1.0 +v -1.0 1.0 1.0 +v 1.0 1.0 1.0 +f 1 2 4 3 +f 3 4 6 5 +f 1 3 5 +f 2 6 4 +f 1 5 6 2 diff --git a/apps/mcp-server/catalog-assets/starter-tree.obj b/apps/mcp-server/catalog-assets/starter-tree.obj new file mode 100644 index 0000000..164cc3b --- /dev/null +++ b/apps/mcp-server/catalog-assets/starter-tree.obj @@ -0,0 +1,27 @@ +# Unity AI Starter Catalog - CC0 1.0 +o StarterTreeTrunk +v -0.15 0.0 -0.15 +v 0.15 0.0 -0.15 +v 0.15 1.0 -0.15 +v -0.15 1.0 -0.15 +v -0.15 0.0 0.15 +v 0.15 0.0 0.15 +v 0.15 1.0 0.15 +v -0.15 1.0 0.15 +f 1 2 3 4 +f 5 8 7 6 +f 1 5 6 2 +f 2 6 7 3 +f 3 7 8 4 +f 5 1 4 8 +o StarterTreeCanopy +v 0.0 2.0 0.0 +v -0.75 0.95 -0.75 +v 0.75 0.95 -0.75 +v 0.75 0.95 0.75 +v -0.75 0.95 0.75 +f 10 11 12 13 +f 9 10 11 +f 9 11 12 +f 9 12 13 +f 9 13 10 diff --git a/apps/mcp-server/catalog-assets/starter-vehicle.obj b/apps/mcp-server/catalog-assets/starter-vehicle.obj new file mode 100644 index 0000000..ade310e --- /dev/null +++ b/apps/mcp-server/catalog-assets/starter-vehicle.obj @@ -0,0 +1,91 @@ +# Unity AI Starter Catalog - CC0 1.0 +o StarterVehicleBody +v -1.2 0.35 -0.7 +v 1.2 0.35 -0.7 +v 1.2 0.75 -0.7 +v -1.2 0.75 -0.7 +v -1.2 0.35 0.7 +v 1.2 0.35 0.7 +v 1.2 0.75 0.7 +v -1.2 0.75 0.7 +f 1 2 3 4 +f 5 8 7 6 +f 1 5 6 2 +f 2 6 7 3 +f 3 7 8 4 +f 5 1 4 8 +o StarterVehicleCabin +v -0.45 0.75 -0.35 +v 0.45 0.75 -0.35 +v 0.45 1.25 -0.35 +v -0.45 1.25 -0.35 +v -0.45 0.75 0.45 +v 0.45 0.75 0.45 +v 0.45 1.25 0.45 +v -0.45 1.25 0.45 +f 9 10 11 12 +f 13 16 15 14 +f 9 13 14 10 +f 10 14 15 11 +f 11 15 16 12 +f 13 9 12 16 +o StarterVehicleWheelFL +v -0.9 0.05 -0.85 +v -0.45 0.05 -0.85 +v -0.45 0.5 -0.85 +v -0.9 0.5 -0.85 +v -0.9 0.05 -0.65 +v -0.45 0.05 -0.65 +v -0.45 0.5 -0.65 +v -0.9 0.5 -0.65 +f 17 18 19 20 +f 21 24 23 22 +f 17 21 22 18 +f 18 22 23 19 +f 19 23 24 20 +f 21 17 20 24 +o StarterVehicleWheelFR +v 0.45 0.05 -0.85 +v 0.9 0.05 -0.85 +v 0.9 0.5 -0.85 +v 0.45 0.5 -0.85 +v 0.45 0.05 -0.65 +v 0.9 0.05 -0.65 +v 0.9 0.5 -0.65 +v 0.45 0.5 -0.65 +f 25 26 27 28 +f 29 32 31 30 +f 25 29 30 26 +f 26 30 31 27 +f 27 31 32 28 +f 29 25 28 32 +o StarterVehicleWheelBL +v -0.9 0.05 0.65 +v -0.45 0.05 0.65 +v -0.45 0.5 0.65 +v -0.9 0.5 0.65 +v -0.9 0.05 0.85 +v -0.45 0.05 0.85 +v -0.45 0.5 0.85 +v -0.9 0.5 0.85 +f 33 34 35 36 +f 37 40 39 38 +f 33 37 38 34 +f 34 38 39 35 +f 35 39 40 36 +f 37 33 36 40 +o StarterVehicleWheelBR +v 0.45 0.05 0.65 +v 0.9 0.05 0.65 +v 0.9 0.5 0.65 +v 0.45 0.5 0.65 +v 0.45 0.05 0.85 +v 0.9 0.05 0.85 +v 0.9 0.5 0.85 +v 0.45 0.5 0.85 +f 41 42 43 44 +f 45 48 47 46 +f 41 45 46 42 +f 42 46 47 43 +f 43 47 48 44 +f 45 41 44 48 diff --git a/apps/mcp-server/generated/tool-schemas.json b/apps/mcp-server/generated/tool-schemas.json new file mode 100644 index 0000000..5f5c50d --- /dev/null +++ b/apps/mcp-server/generated/tool-schemas.json @@ -0,0 +1,5066 @@ +{ + "schemaVersion": 1, + "server": "unity-ai-control-plane", + "tools": [ + { + "name": "unity.assemblies.list", + "description": "List Unity script assemblies and assembly definition metadata.", + "parameters": { + "type": "object", + "properties": { + "maxResults": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 500 + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.asset.dependencies", + "description": "Inspect direct or recursive dependencies for a Unity asset path.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "recursive": { + "type": "boolean", + "default": true + }, + "maxResults": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200 + } + }, + "required": [ + "path" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.assets.author", + "description": "Create or edit shaders, materials, animation clips, Animator Controllers, generated WAV audio, and audio import settings with checkpoints.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "kind": { + "type": "string", + "enum": [ + "shader", + "material", + "animation_clip", + "animator_controller", + "audio_tone", + "audio_import" + ] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "shaderSource": { + "type": "string", + "maxLength": 1048576 + }, + "shaderName": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "shaderPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "materialProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "kind": { + "type": "string", + "enum": [ + "float", + "int", + "color", + "vector", + "texture" + ] + }, + "numberValue": { + "type": "number" + }, + "integerValue": { + "type": "integer" + }, + "assetPath": { + "type": "string", + "maxLength": 512 + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + }, + "w": { + "type": "number" + } + }, + "required": [ + "name", + "kind" + ], + "additionalProperties": false + }, + "maxItems": 500, + "default": [] + }, + "enabledKeywords": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "maxItems": 200, + "default": [] + }, + "renderQueue": { + "type": "integer", + "minimum": -1, + "maximum": 5000, + "default": -1 + }, + "clearExistingCurves": { + "type": "boolean", + "default": false + }, + "frameRate": { + "type": "number", + "minimum": 1, + "maximum": 240, + "default": 60 + }, + "animationCurves": { + "type": "array", + "items": { + "type": "object", + "properties": { + "relativePath": { + "type": "string", + "maxLength": 512, + "default": "" + }, + "componentType": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "default": "UnityEngine.Transform" + }, + "propertyName": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "keyframes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "time": { + "type": "number" + }, + "value": { + "type": "number" + }, + "inTangent": { + "type": "number", + "default": 0 + }, + "outTangent": { + "type": "number", + "default": 0 + } + }, + "required": [ + "time", + "value" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 10000 + } + }, + "required": [ + "propertyName", + "keyframes" + ], + "additionalProperties": false + }, + "maxItems": 500, + "default": [] + }, + "clearExistingStates": { + "type": "boolean", + "default": true + }, + "defaultState": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "animatorParameters": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "type": { + "type": "string", + "enum": [ + "float", + "int", + "bool", + "trigger" + ], + "default": "float" + }, + "defaultFloat": { + "type": "number", + "default": 0 + }, + "defaultInt": { + "type": "integer", + "default": 0 + }, + "defaultBool": { + "type": "boolean", + "default": false + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "maxItems": 100, + "default": [] + }, + "animatorStates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "clipPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "clipName": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "speed": { + "type": "number", + "minimum": -100, + "maximum": 100, + "default": 1 + }, + "writeDefaultValues": { + "type": "boolean", + "default": true + }, + "positionX": { + "type": "number", + "default": 0 + }, + "positionY": { + "type": "number", + "default": 0 + } + }, + "required": [ + "name", + "clipPath" + ], + "additionalProperties": false + }, + "maxItems": 200, + "default": [] + }, + "animatorTransitions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fromState": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "toState": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "hasExitTime": { + "type": "boolean", + "default": true + }, + "exitTime": { + "type": "number", + "minimum": 0, + "maximum": 1000, + "default": 1 + }, + "duration": { + "type": "number", + "minimum": 0, + "maximum": 1000, + "default": 0.1 + }, + "hasFixedDuration": { + "type": "boolean", + "default": true + }, + "offset": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0 + }, + "canTransitionToSelf": { + "type": "boolean", + "default": false + }, + "conditions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "if", + "if_not", + "greater", + "less", + "equals", + "not_equal" + ], + "default": "if" + }, + "threshold": { + "type": "number", + "default": 0 + }, + "parameter": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "parameter" + ], + "additionalProperties": false + }, + "maxItems": 20, + "default": [] + } + }, + "required": [ + "fromState", + "toState" + ], + "additionalProperties": false + }, + "maxItems": 500, + "default": [] + }, + "audioTone": { + "type": "object", + "properties": { + "frequencyHz": { + "type": "number", + "minimum": 1, + "maximum": 86000, + "default": 440 + }, + "durationSeconds": { + "type": "number", + "minimum": 0.01, + "maximum": 300, + "default": 1 + }, + "sampleRate": { + "type": "integer", + "minimum": 8000, + "maximum": 192000, + "default": 44100 + }, + "channels": { + "type": "integer", + "minimum": 1, + "maximum": 2, + "default": 1 + }, + "amplitude": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.5 + } + }, + "additionalProperties": false + }, + "audioImport": { + "type": "object", + "properties": { + "forceToMono": { + "type": "boolean", + "default": false + }, + "loadInBackground": { + "type": "boolean", + "default": false + }, + "preloadAudioData": { + "type": "boolean", + "default": true + }, + "loadType": { + "type": "string", + "enum": [ + "decompress_on_load", + "compressed_in_memory", + "streaming" + ], + "default": "decompress_on_load" + }, + "compressionFormat": { + "type": "string", + "enum": [ + "vorbis", + "pcm", + "adpcm" + ], + "default": "vorbis" + }, + "quality": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.7 + }, + "sampleRateOverride": { + "type": "integer", + "minimum": 0, + "maximum": 192000, + "default": 0 + } + }, + "additionalProperties": false + } + }, + "required": [ + "kind", + "path" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.assets.catalog.search", + "description": "Search bundled and configured CDN asset catalogs whose entries include license, provenance, byte size, and SHA-256 metadata.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "maxLength": 200, + "default": "" + }, + "kind": { + "type": "string", + "enum": [ + "all", + "model", + "texture", + "audio" + ], + "default": "all" + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 20, + "default": [] + }, + "maxResults": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + }, + "refresh": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.assets.import", + "description": "Copy or download a model, texture, or audio file, apply importer settings, and optionally instantiate, normalize model pivots/axes, or save a prefab.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "sourceKind": { + "type": "string", + "enum": [ + "local", + "url" + ], + "default": "local" + }, + "sourcePath": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "url": { + "type": "string", + "format": "uri", + "maxLength": 4096 + }, + "destinationPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "overwrite": { + "type": "boolean", + "default": false + }, + "expectedSha256": { + "type": "string", + "pattern": "^[a-fA-F0-9]{64}$" + }, + "maxBytes": { + "type": "integer", + "minimum": 1, + "maximum": 2147483648, + "default": 268435456 + }, + "timeoutSeconds": { + "type": "integer", + "minimum": 5, + "maximum": 1800, + "default": 120 + }, + "allowInsecureLocalhost": { + "type": "boolean", + "default": false + }, + "model": { + "type": "object", + "properties": { + "globalScale": { + "type": "number", + "minimum": 0.0001, + "maximum": 10000, + "default": 1 + }, + "useFileScale": { + "type": "boolean", + "default": true + }, + "importBlendShapes": { + "type": "boolean", + "default": true + }, + "importVisibility": { + "type": "boolean", + "default": true + }, + "importCameras": { + "type": "boolean", + "default": false + }, + "importLights": { + "type": "boolean", + "default": false + }, + "addCollider": { + "type": "boolean", + "default": false + }, + "importAnimation": { + "type": "boolean", + "default": true + }, + "animationType": { + "type": "string", + "enum": [ + "none", + "legacy", + "generic", + "human" + ], + "default": "generic" + }, + "isReadable": { + "type": "boolean", + "default": false + }, + "meshCompression": { + "type": "string", + "enum": [ + "off", + "low", + "medium", + "high" + ], + "default": "off" + }, + "normalizeOnInstantiate": { + "type": "boolean", + "default": false + }, + "recenterPivot": { + "type": "boolean", + "default": false + }, + "pivotMode": { + "type": "string", + "enum": [ + "bounds_center", + "bounds_base" + ], + "default": "bounds_center" + }, + "alignToGround": { + "type": "boolean", + "default": false + }, + "forwardAxis": { + "type": "string", + "enum": [ + "keep", + "z", + "+z", + "-z", + "negative_z", + "x", + "+x", + "-x", + "negative_x" + ], + "default": "keep" + } + }, + "additionalProperties": false + }, + "texture": { + "type": "object", + "properties": { + "textureType": { + "type": "string", + "enum": [ + "default", + "normal_map", + "sprite", + "cursor", + "cookie", + "lightmap", + "single_channel" + ], + "default": "default" + }, + "sRgb": { + "type": "boolean", + "default": true + }, + "alphaIsTransparency": { + "type": "boolean", + "default": false + }, + "mipmapEnabled": { + "type": "boolean", + "default": true + }, + "isReadable": { + "type": "boolean", + "default": false + }, + "maxTextureSize": { + "type": "integer", + "minimum": 32, + "maximum": 16384, + "default": 2048 + }, + "compression": { + "type": "string", + "enum": [ + "uncompressed", + "compressed", + "compressed_hq", + "compressed_lq" + ], + "default": "compressed" + } + }, + "additionalProperties": false + }, + "audio": { + "type": "object", + "properties": { + "forceToMono": { + "type": "boolean", + "default": false + }, + "loadInBackground": { + "type": "boolean", + "default": false + }, + "preloadAudioData": { + "type": "boolean", + "default": true + }, + "loadType": { + "type": "string", + "enum": [ + "decompress_on_load", + "compressed_in_memory", + "streaming" + ], + "default": "decompress_on_load" + }, + "compressionFormat": { + "type": "string", + "enum": [ + "vorbis", + "pcm", + "adpcm" + ], + "default": "vorbis" + }, + "quality": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.7 + }, + "sampleRateOverride": { + "type": "integer", + "minimum": 0, + "maximum": 192000, + "default": 0 + } + }, + "additionalProperties": false + }, + "instantiate": { + "type": "boolean", + "default": false + }, + "objectName": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "parentPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "transform": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": [ + "x", + "y", + "z" + ], + "additionalProperties": false + }, + "rotationEuler": { + "$ref": "#/properties/transform/properties/position" + }, + "scale": { + "$ref": "#/properties/transform/properties/position" + } + }, + "additionalProperties": false + }, + "saveAsPrefabPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": [ + "destinationPath" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.assets.import_from_catalog", + "description": "Resolve a license-allowlisted catalog asset, enforce its declared SHA-256 and size, import it through Unity, optionally normalize models, and retain provenance in the audit result.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "catalogAssetId": { + "type": "string", + "minLength": 3, + "maxLength": 256 + }, + "expectedCatalogSha256": { + "type": "string", + "pattern": "^[a-fA-F0-9]{64}$" + }, + "refreshCatalog": { + "type": "boolean", + "default": false + }, + "destinationPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "overwrite": { + "type": "boolean", + "default": false + }, + "maxBytes": { + "type": "integer", + "minimum": 1, + "maximum": 2147483648, + "default": 268435456 + }, + "timeoutSeconds": { + "type": "integer", + "minimum": 5, + "maximum": 1800, + "default": 120 + }, + "model": { + "type": "object", + "properties": { + "globalScale": { + "type": "number", + "minimum": 0.0001, + "maximum": 10000, + "default": 1 + }, + "useFileScale": { + "type": "boolean", + "default": true + }, + "importBlendShapes": { + "type": "boolean", + "default": true + }, + "importVisibility": { + "type": "boolean", + "default": true + }, + "importCameras": { + "type": "boolean", + "default": false + }, + "importLights": { + "type": "boolean", + "default": false + }, + "addCollider": { + "type": "boolean", + "default": false + }, + "importAnimation": { + "type": "boolean", + "default": true + }, + "animationType": { + "type": "string", + "enum": [ + "none", + "legacy", + "generic", + "human" + ], + "default": "generic" + }, + "isReadable": { + "type": "boolean", + "default": false + }, + "meshCompression": { + "type": "string", + "enum": [ + "off", + "low", + "medium", + "high" + ], + "default": "off" + }, + "normalizeOnInstantiate": { + "type": "boolean", + "default": false + }, + "recenterPivot": { + "type": "boolean", + "default": false + }, + "pivotMode": { + "type": "string", + "enum": [ + "bounds_center", + "bounds_base" + ], + "default": "bounds_center" + }, + "alignToGround": { + "type": "boolean", + "default": false + }, + "forwardAxis": { + "type": "string", + "enum": [ + "keep", + "z", + "+z", + "-z", + "negative_z", + "x", + "+x", + "-x", + "negative_x" + ], + "default": "keep" + } + }, + "additionalProperties": false + }, + "texture": { + "type": "object", + "properties": { + "textureType": { + "type": "string", + "enum": [ + "default", + "normal_map", + "sprite", + "cursor", + "cookie", + "lightmap", + "single_channel" + ], + "default": "default" + }, + "sRgb": { + "type": "boolean", + "default": true + }, + "alphaIsTransparency": { + "type": "boolean", + "default": false + }, + "mipmapEnabled": { + "type": "boolean", + "default": true + }, + "isReadable": { + "type": "boolean", + "default": false + }, + "maxTextureSize": { + "type": "integer", + "minimum": 32, + "maximum": 16384, + "default": 2048 + }, + "compression": { + "type": "string", + "enum": [ + "uncompressed", + "compressed", + "compressed_hq", + "compressed_lq" + ], + "default": "compressed" + } + }, + "additionalProperties": false + }, + "audio": { + "type": "object", + "properties": { + "forceToMono": { + "type": "boolean", + "default": false + }, + "loadInBackground": { + "type": "boolean", + "default": false + }, + "preloadAudioData": { + "type": "boolean", + "default": true + }, + "loadType": { + "type": "string", + "enum": [ + "decompress_on_load", + "compressed_in_memory", + "streaming" + ], + "default": "decompress_on_load" + }, + "compressionFormat": { + "type": "string", + "enum": [ + "vorbis", + "pcm", + "adpcm" + ], + "default": "vorbis" + }, + "quality": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.7 + }, + "sampleRateOverride": { + "type": "integer", + "minimum": 0, + "maximum": 192000, + "default": 0 + } + }, + "additionalProperties": false + }, + "instantiate": { + "type": "boolean", + "default": false + }, + "objectName": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "parentPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "transform": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": [ + "x", + "y", + "z" + ], + "additionalProperties": false + }, + "rotationEuler": { + "$ref": "#/properties/transform/properties/position" + }, + "scale": { + "$ref": "#/properties/transform/properties/position" + } + }, + "additionalProperties": false + }, + "saveAsPrefabPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": [ + "catalogAssetId", + "destinationPath" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.assets.list", + "description": "List Unity project assets with paths, GUIDs, and main asset types.", + "parameters": { + "type": "object", + "properties": { + "folder": { + "type": "string", + "default": "Assets" + }, + "maxResults": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200 + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.audit.report", + "description": "Generate hashed JSON and Markdown audit reports from persisted events and project-relative before/after evidence.", + "parameters": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "default": "Unity AI verification report" + }, + "summary": { + "type": "string", + "maxLength": 4000 + }, + "requestIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "maxItems": 200, + "default": [] + }, + "correlationIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "maxItems": 200, + "default": [] + }, + "capabilities": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "maxItems": 200, + "default": [] + }, + "sinceUtc": { + "type": "string", + "format": "date-time" + }, + "untilUtc": { + "type": "string", + "format": "date-time" + }, + "maxEvents": { + "type": "integer", + "minimum": 1, + "maximum": 5000, + "default": 200 + }, + "maxEvidenceBytes": { + "type": "integer", + "minimum": 1, + "maximum": 10737418240, + "default": 2147483648 + }, + "requireBeforeAfter": { + "type": "boolean", + "default": false + }, + "evidence": { + "type": "array", + "items": { + "type": "object", + "properties": { + "phase": { + "type": "string", + "enum": [ + "before", + "after", + "supporting" + ], + "default": "supporting" + }, + "kind": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "default": "artifact" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "description": { + "type": "string", + "maxLength": 1000 + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "maxItems": 100, + "default": [] + }, + "verifications": { + "type": "array", + "items": { + "type": "object", + "properties": { + "signal": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "status": { + "type": "string", + "enum": [ + "passed", + "failed", + "inconclusive" + ] + }, + "summary": { + "type": "string", + "maxLength": 2000 + }, + "evidencePaths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "maxItems": 100, + "default": [] + } + }, + "required": [ + "signal", + "status" + ], + "additionalProperties": false + }, + "maxItems": 200, + "default": [] + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.build.android", + "description": "Generate a validated Android APK or AAB for Quest as a persistent job.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "default": "quest" + }, + "appBundle": { + "type": "boolean", + "default": false + }, + "development": { + "type": "boolean", + "default": false + }, + "allowDebugging": { + "type": "boolean", + "default": false + }, + "connectProfiler": { + "type": "boolean", + "default": false + }, + "cleanBuildCache": { + "type": "boolean", + "default": false + }, + "scenes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "maxItems": 200, + "default": [] + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.build.validate_android_quest", + "description": "Validate Android/Quest build support, scenes, player settings, architectures, and XR packages.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.capabilities.list", + "description": "List the Unity AI Control Plane capabilities known by the MCP server.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.checkpoints.create", + "description": "Create a durable, hashed checkpoint of selected Unity project paths.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "label": { + "type": "string", + "maxLength": 100 + }, + "paths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "maxItems": 500, + "default": [] + }, + "maxFiles": { + "type": "integer", + "minimum": 1, + "maximum": 100000, + "default": 20000 + }, + "maxBytes": { + "type": "integer", + "minimum": 1, + "maximum": 10737418240, + "default": 1073741824 + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.checkpoints.delete", + "description": "Delete one durable checkpoint after explicit confirmation.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "checkpointId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "checkpointId" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.checkpoints.list", + "description": "List durable Unity project checkpoints and their manifests.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.checkpoints.restore", + "description": "Restore and hash-verify a durable checkpoint, optionally creating a pre-restore safety checkpoint.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "checkpointId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "createSafetyCheckpoint": { + "type": "boolean", + "default": true + } + }, + "required": [ + "checkpointId" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.compilation.status", + "description": "Read Unity compilation/import state and current console error counts.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.compilation.wait", + "description": "Wait asynchronously for Unity compilation/import to settle and verify the console error count.", + "parameters": { + "type": "object", + "properties": { + "triggerRefresh": { + "type": "boolean", + "default": true + }, + "timeoutSeconds": { + "type": "integer", + "minimum": 5, + "maximum": 3600, + "default": 300 + }, + "maxErrorCount": { + "type": "integer", + "minimum": 0, + "maximum": 100000, + "default": 0 + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.console.apply_fix", + "description": "Apply a confirmed, checkpointed one-line replacement to a Unity C# script.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "targetFile": { + "type": "string", + "minLength": 1 + }, + "targetLine": { + "type": "integer", + "minimum": 1 + }, + "expectedOriginalLine": { + "type": "string" + }, + "replacementLine": { + "type": "string" + }, + "expectedDiagnosticCategory": { + "type": "string" + }, + "expectedMessageContains": { + "type": "string" + }, + "planId": { + "type": "string" + } + }, + "required": [ + "targetFile", + "targetLine", + "expectedOriginalLine", + "replacementLine" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.console.diagnose", + "description": "Classify Unity Console entries into blocking CompilationError and non-blocking runtime, bridge, import, or warning diagnostics.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.console.plan_fix", + "description": "Generate conservative read-only fix plans from Unity Console diagnostics.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.console.read", + "description": "Read Unity Console entries with strict compilation, runtime, bridge, and import classifications plus blocking status.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.editor.create_empty_game_object", + "description": "Create an empty GameObject in the active Unity scene through a controlled Editor operation.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "default": "Unity AI GameObject" + }, + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.editor.undo_last_operation", + "description": "Undo the last Unity Editor operation through a controlled rollback operation.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.gameplay.compose", + "description": "Turn existing scene objects into checkpointed proximity doors, pickups, and multi-target activators.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "templates": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "door" + }, + "targetPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "interactorPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "interactorTag": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "default": "Player" + }, + "activationDistance": { + "type": "number", + "minimum": 0.01, + "maximum": 1000, + "default": 2 + }, + "deactivationDistance": { + "type": "number", + "minimum": 0.01, + "maximum": 1000, + "default": 2.5 + }, + "openOffset": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": [ + "x", + "y", + "z" + ], + "additionalProperties": false, + "default": { + "x": 0, + "y": 2.5, + "z": 0 + } + }, + "speed": { + "type": "number", + "minimum": 0.01, + "maximum": 1000, + "default": 3 + }, + "startsOpen": { + "type": "boolean", + "default": false + }, + "closeWhenOutOfRange": { + "type": "boolean", + "default": true + } + }, + "required": [ + "kind", + "targetPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "pickup" + }, + "targetPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "interactorPath": { + "$ref": "#/properties/templates/items/anyOf/0/properties/interactorPath" + }, + "interactorTag": { + "$ref": "#/properties/templates/items/anyOf/0/properties/interactorTag" + }, + "activationDistance": { + "$ref": "#/properties/templates/items/anyOf/0/properties/activationDistance" + }, + "pickupId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "default": "pickup" + }, + "value": { + "type": "integer", + "minimum": 0, + "maximum": 1000000, + "default": 1 + }, + "collectAction": { + "type": "string", + "enum": [ + "deactivate", + "destroy" + ], + "default": "deactivate" + }, + "spinAxis": { + "$ref": "#/properties/templates/items/anyOf/0/properties/openOffset", + "default": { + "x": 0, + "y": 1, + "z": 0 + } + }, + "spinDegreesPerSecond": { + "type": "number", + "minimum": -10000, + "maximum": 10000, + "default": 90 + }, + "bobAmplitude": { + "type": "number", + "minimum": 0, + "maximum": 1000, + "default": 0.15 + }, + "bobFrequency": { + "type": "number", + "minimum": 0, + "maximum": 1000, + "default": 1 + } + }, + "required": [ + "kind", + "targetPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "activator" + }, + "targetPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "interactorPath": { + "$ref": "#/properties/templates/items/anyOf/0/properties/interactorPath" + }, + "interactorTag": { + "$ref": "#/properties/templates/items/anyOf/0/properties/interactorTag" + }, + "activationDistance": { + "$ref": "#/properties/templates/items/anyOf/0/properties/activationDistance" + }, + "affectedPaths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "minItems": 1, + "maxItems": 32 + }, + "action": { + "type": "string", + "enum": [ + "activate", + "deactivate", + "toggle" + ], + "default": "activate" + }, + "oneShot": { + "type": "boolean", + "default": true + }, + "revertOnExit": { + "type": "boolean", + "default": false + } + }, + "required": [ + "kind", + "targetPath", + "affectedPaths" + ], + "additionalProperties": false + } + ] + }, + "minItems": 1, + "maxItems": 20 + } + }, + "required": [ + "templates" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.jobs.cancel", + "description": "Request cancellation of a queued or running Unity operation job.", + "parameters": { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "pattern": "^[A-Za-z0-9]{32}$" + } + }, + "required": [ + "jobId" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.jobs.get", + "description": "Get one persistent Unity operation job and its result.", + "parameters": { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "pattern": "^[A-Za-z0-9]{32}$" + } + }, + "required": [ + "jobId" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.jobs.list", + "description": "List persistent Unity operation jobs, optionally filtered by status or kind.", + "parameters": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "queued", + "running", + "succeeded", + "failed", + "cancelled" + ] + }, + "kind": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxResults": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 100 + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.meta_xr.configure", + "description": "Install and configure Unity OpenXR/Meta OpenXR for Quest, including Android target, IL2CPP, ARM64, loader, and features.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "installPackages": { + "type": "boolean", + "default": true + }, + "switchToAndroid": { + "type": "boolean", + "default": true + }, + "installMetaOpenXr": { + "type": "boolean", + "default": true + }, + "androidMinSdk": { + "type": "integer", + "minimum": 29, + "maximum": 99, + "default": 29 + }, + "applicationIdentifier": { + "type": "string", + "minLength": 3, + "maxLength": 256 + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.meta_xr.validate_setup", + "description": "Run initial Meta XR readiness validation through the local Unity Editor bridge.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.packages.change", + "description": "Add or remove registry Unity packages as a durable asynchronous job.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "add": { + "type": "array", + "items": { + "type": "string", + "pattern": "^com\\.[A-Za-z0-9._-]+(?:@[A-Za-z0-9._-]+)?$" + }, + "maxItems": 50, + "default": [] + }, + "remove": { + "type": "array", + "items": { + "type": "string", + "pattern": "^com\\.[A-Za-z0-9._-]+$" + }, + "maxItems": 50, + "default": [] + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.packages.list", + "description": "List registered Unity packages for the current project.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.physics.inspect", + "description": "Inspect bounded 3D/2D physics state, collider alignment, penetrations, relative impact speeds, and sampled net-force estimates.", + "parameters": { + "type": "object", + "properties": { + "pathPrefix": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "includeInactive": { + "type": "boolean", + "default": false + }, + "dimension": { + "type": "string", + "enum": [ + "all", + "3d", + "2d" + ], + "default": "all" + }, + "withinRadius": { + "anyOf": [ + { + "type": "object", + "properties": { + "centerPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "radius": { + "type": "number", + "minimum": 0, + "maximum": 100000 + } + }, + "required": [ + "centerPath", + "radius" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "center": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": [ + "x", + "y", + "z" + ], + "additionalProperties": false + }, + "radius": { + "type": "number", + "minimum": 0, + "maximum": 100000 + } + }, + "required": [ + "center", + "radius" + ], + "additionalProperties": false + } + ] + }, + "includeOverlapDiagnostics": { + "type": "boolean", + "default": true + }, + "maxObjects": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 200 + }, + "maxOverlaps": { + "type": "integer", + "minimum": 0, + "maximum": 1000, + "default": 200 + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.playmode.control", + "description": "Enter, exit, pause, resume, or step Unity Play Mode as a persistent job.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "action": { + "type": "string", + "enum": [ + "enter", + "exit", + "pause", + "resume", + "step" + ] + } + }, + "required": [ + "action" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.playmode.status", + "description": "Read the current Unity Play Mode and pause state.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.prefab.inspect", + "description": "Inspect a prefab asset hierarchy and components at a high level.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "includeComponents": { + "type": "boolean", + "default": true + }, + "maxDepth": { + "type": "integer", + "minimum": 0, + "maximum": 10, + "default": 3 + }, + "maxGameObjects": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200 + } + }, + "required": [ + "path" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.prefab.manage", + "description": "Save prefab assets, create variants, edit prefab contents, and apply or revert instance overrides with checkpoints.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "action": { + "type": "string", + "enum": [ + "save_scene_object", + "create_variant", + "edit_asset", + "apply_overrides", + "revert_overrides" + ] + }, + "prefabPath": { + "type": "string", + "maxLength": 512, + "default": "" + }, + "targetPath": { + "type": "string", + "maxLength": 512, + "default": "" + }, + "sceneObjectPath": { + "type": "string", + "maxLength": 512, + "default": "" + }, + "connectToScene": { + "type": "boolean", + "default": false + }, + "operations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "create_child", + "delete", + "rename", + "set_active", + "add_component", + "remove_component", + "set_property" + ] + }, + "objectPath": { + "type": "string", + "maxLength": 512, + "default": "" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "active": { + "type": "boolean" + }, + "componentType": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "componentIndex": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "propertyPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "value": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "bool" + }, + "boolValue": { + "type": "boolean" + } + }, + "required": [ + "kind", + "boolValue" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "integer" + }, + "integerValue": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "kind", + "integerValue" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "number" + }, + "numberValue": { + "type": "number" + } + }, + "required": [ + "kind", + "numberValue" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "string" + }, + "stringValue": { + "type": "string" + } + }, + "required": [ + "kind", + "stringValue" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "color" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + }, + "w": { + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y", + "z", + "w" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "vector2" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "vector3" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y", + "z" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "vector4" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + }, + "w": { + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y", + "z", + "w" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "vector2_int" + }, + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + } + }, + "required": [ + "kind", + "x", + "y" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "vector3_int" + }, + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + }, + "z": { + "type": "integer" + } + }, + "required": [ + "kind", + "x", + "y", + "z" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "rect" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + }, + "w": { + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y", + "z", + "w" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "rect_int" + }, + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + }, + "z": { + "type": "integer" + }, + "w": { + "type": "integer" + } + }, + "required": [ + "kind", + "x", + "y", + "z", + "w" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "bounds" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + }, + "x2": { + "type": "number" + }, + "y2": { + "type": "number" + }, + "z2": { + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y", + "z", + "x2", + "y2", + "z2" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "bounds_int" + }, + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + }, + "z": { + "type": "integer" + }, + "x2": { + "type": "integer" + }, + "y2": { + "type": "integer" + }, + "z2": { + "type": "integer" + } + }, + "required": [ + "kind", + "x", + "y", + "z", + "x2", + "y2", + "z2" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "quaternion" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + }, + "w": { + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y", + "z", + "w" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "enum" + }, + "enumName": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": [ + "kind", + "enumName" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "enum" + }, + "enumIndex": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "kind", + "enumIndex" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "object_reference" + }, + "assetPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": [ + "kind", + "assetPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "null" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "array_size" + }, + "integerValue": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + } + }, + "required": [ + "kind", + "integerValue" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "character" + }, + "integerValue": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + }, + "required": [ + "kind", + "integerValue" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + "maxItems": 50, + "default": [] + } + }, + "required": [ + "action" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.prefabs.list", + "description": "List prefabs in the Unity project with paths, GUIDs, and root component summaries.", + "parameters": { + "type": "object", + "properties": { + "folder": { + "type": "string", + "default": "Assets" + }, + "maxResults": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200 + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.project.inspect", + "description": "Inspect the active Unity project through the local Unity Editor bridge.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.project.settings.inspect", + "description": "Inspect high-level Unity project, player settings, tags, layers, render pipeline, and input-system mode.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.project.settings.update", + "description": "Update selected Project Settings, Android Player Settings, tags, layers, Active Input Handling, and Build Settings with a durable checkpoint.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "companyName": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "productName": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "applicationIdentifier": { + "type": "string", + "minLength": 3, + "maxLength": 256 + }, + "colorSpace": { + "type": "string", + "enum": [ + "Gamma", + "Linear" + ] + }, + "scriptingBackend": { + "type": "string", + "enum": [ + "Mono2x", + "IL2CPP", + "WinRTDotNET" + ] + }, + "androidMinSdk": { + "type": "integer", + "minimum": 21, + "maximum": 99 + }, + "androidTargetSdk": { + "type": "integer", + "minimum": 0, + "maximum": 99 + }, + "androidArchitectures": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "ARMv7", + "ARM64", + "X86" + ] + }, + "minItems": 1, + "maxItems": 3 + }, + "buildAppBundle": { + "type": "boolean" + }, + "developmentBuild": { + "type": "boolean" + }, + "connectProfiler": { + "type": "boolean" + }, + "addTags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 200 + }, + "addLayers": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "maxItems": 24 + }, + "activeInputHandling": { + "type": "string", + "enum": [ + "legacy", + "old", + "input_manager", + "new", + "input_system", + "both" + ] + }, + "scenes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "enabled": { + "type": "boolean", + "default": true + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "maxItems": 200 + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.project.snapshot", + "description": "Capture a compact, sanitized, read-only Unity project context snapshot for planning next actions.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.runtime.telemetry", + "description": "Capture compact per-object runtime telemetry for transforms, renderer/collider bounds, rigidbodies, velocities, and scene time.", + "parameters": { + "type": "object", + "properties": { + "pathPrefix": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "includeInactive": { + "type": "boolean", + "default": false + }, + "withinRadius": { + "anyOf": [ + { + "type": "object", + "properties": { + "centerPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "radius": { + "type": "number", + "minimum": 0, + "maximum": 100000 + } + }, + "required": [ + "centerPath", + "radius" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "center": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": [ + "x", + "y", + "z" + ], + "additionalProperties": false + }, + "radius": { + "type": "number", + "minimum": 0, + "maximum": 100000 + } + }, + "required": [ + "center", + "radius" + ], + "additionalProperties": false + } + ] + }, + "includeRenderers": { + "type": "boolean", + "default": true + }, + "includeColliders": { + "type": "boolean", + "default": true + }, + "includeRigidbodies": { + "type": "boolean", + "default": true + }, + "maxObjects": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 200 + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.scene.batch", + "description": "Apply an atomic, undo-backed batch of hierarchy, prefab, component, serialized-property, and cross-object reference operations.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "operations": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "create" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "parentPath": { + "type": "string", + "maxLength": 512 + }, + "primitive": { + "type": "string", + "enum": [ + "empty", + "cube", + "sphere", + "capsule", + "cylinder", + "plane", + "quad" + ], + "default": "empty" + } + }, + "required": [ + "kind", + "name" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "instantiate_prefab" + }, + "prefabPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "parentPath": { + "type": "string", + "maxLength": 512 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + }, + "required": [ + "kind", + "prefabPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "delete" + }, + "targetPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": [ + "kind", + "targetPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "duplicate" + }, + "targetPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "parentPath": { + "type": "string", + "maxLength": 512 + }, + "worldPositionStays": { + "type": "boolean", + "default": true + } + }, + "required": [ + "kind", + "targetPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "rename" + }, + "targetPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + }, + "required": [ + "kind", + "targetPath", + "name" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "reparent" + }, + "targetPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "parentPath": { + "type": "string", + "maxLength": 512, + "default": "" + }, + "worldPositionStays": { + "type": "boolean", + "default": true + } + }, + "required": [ + "kind", + "targetPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "set_active" + }, + "targetPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "active": { + "type": "boolean" + } + }, + "required": [ + "kind", + "targetPath", + "active" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "add_component" + }, + "targetPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "componentType": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "kind", + "targetPath", + "componentType" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "remove_component" + }, + "targetPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "componentType": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "componentIndex": { + "type": "integer", + "minimum": 0, + "default": 0 + } + }, + "required": [ + "kind", + "targetPath", + "componentType" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "set_property" + }, + "targetPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "componentType": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "componentIndex": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "propertyPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "value": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "bool" + }, + "boolValue": { + "type": "boolean" + } + }, + "required": [ + "kind", + "boolValue" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "integer" + }, + "integerValue": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "kind", + "integerValue" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "number" + }, + "numberValue": { + "type": "number" + } + }, + "required": [ + "kind", + "numberValue" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "string" + }, + "stringValue": { + "type": "string" + } + }, + "required": [ + "kind", + "stringValue" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "color" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + }, + "w": { + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y", + "z", + "w" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "vector2" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "vector3" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y", + "z" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "vector4" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + }, + "w": { + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y", + "z", + "w" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "vector2_int" + }, + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + } + }, + "required": [ + "kind", + "x", + "y" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "vector3_int" + }, + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + }, + "z": { + "type": "integer" + } + }, + "required": [ + "kind", + "x", + "y", + "z" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "rect" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + }, + "w": { + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y", + "z", + "w" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "rect_int" + }, + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + }, + "z": { + "type": "integer" + }, + "w": { + "type": "integer" + } + }, + "required": [ + "kind", + "x", + "y", + "z", + "w" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "bounds" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + }, + "x2": { + "type": "number" + }, + "y2": { + "type": "number" + }, + "z2": { + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y", + "z", + "x2", + "y2", + "z2" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "bounds_int" + }, + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + }, + "z": { + "type": "integer" + }, + "x2": { + "type": "integer" + }, + "y2": { + "type": "integer" + }, + "z2": { + "type": "integer" + } + }, + "required": [ + "kind", + "x", + "y", + "z", + "x2", + "y2", + "z2" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "quaternion" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + }, + "w": { + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y", + "z", + "w" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "enum" + }, + "enumName": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": [ + "kind", + "enumName" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "enum" + }, + "enumIndex": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "kind", + "enumIndex" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "object_reference" + }, + "assetPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": [ + "kind", + "assetPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "null" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "array_size" + }, + "integerValue": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + } + }, + "required": [ + "kind", + "integerValue" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "character" + }, + "integerValue": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + }, + "required": [ + "kind", + "integerValue" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "game_object_reference" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": [ + "kind", + "path" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "component_reference" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "componentType": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "componentIndex": { + "type": "integer", + "minimum": 0, + "default": 0 + } + }, + "required": [ + "kind", + "path", + "componentType" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "kind", + "targetPath", + "componentType", + "propertyPath", + "value" + ], + "additionalProperties": false + } + ] + }, + "minItems": 1, + "maxItems": 50 + } + }, + "required": [ + "operations" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.scene.inspect", + "description": "Inspect a bounded, optionally filtered view of the active Unity scene hierarchy.", + "parameters": { + "type": "object", + "properties": { + "includeComponents": { + "type": "boolean", + "default": true + }, + "maxDepth": { + "type": "integer", + "minimum": 0, + "maximum": 10, + "default": 3 + }, + "maxGameObjects": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200 + }, + "filter": { + "type": "object", + "properties": { + "nameContains": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "pathPrefix": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "componentType": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "activeState": { + "type": "string", + "enum": [ + "any", + "active", + "inactive" + ], + "default": "any" + }, + "withinRadius": { + "anyOf": [ + { + "type": "object", + "properties": { + "centerPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "radius": { + "type": "number", + "minimum": 0, + "maximum": 100000 + } + }, + "required": [ + "centerPath", + "radius" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "center": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": [ + "x", + "y", + "z" + ], + "additionalProperties": false + }, + "radius": { + "type": "number", + "minimum": 0, + "maximum": 100000 + } + }, + "required": [ + "center", + "radius" + ], + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.scene.inspect_game_object", + "description": "Inspect one GameObject, its components, and bounded visible serialized properties.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "includeProperties": { + "type": "boolean", + "default": true + }, + "maxProperties": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "default": 500 + }, + "maxPropertyDepth": { + "type": "integer", + "minimum": 0, + "maximum": 20, + "default": 8 + } + }, + "required": [ + "path" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.scene.upsert_game_object", + "description": "Create or update a GameObject in the active Unity scene from a safe, schema-bound spec.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "primitive": { + "type": "string", + "enum": [ + "empty", + "cube", + "sphere", + "capsule", + "cylinder", + "plane", + "quad" + ], + "default": "empty" + }, + "transform": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": [ + "x", + "y", + "z" + ], + "additionalProperties": false + }, + "rotationEuler": { + "$ref": "#/properties/transform/properties/position" + }, + "scale": { + "$ref": "#/properties/transform/properties/position" + } + }, + "additionalProperties": false + }, + "tag": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "layer": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 31 + } + ] + }, + "active": { + "type": "boolean" + }, + "mode": { + "type": "string", + "enum": [ + "create", + "update", + "upsert" + ], + "default": "upsert" + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.scenes.list", + "description": "List Unity scenes in the project and Build Settings.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.scripts.author", + "description": "Validate and hash-confirm a runtime MonoBehaviour, then write, compile, optionally attach, verify, and roll back on failure.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "source": { + "type": "string", + "minLength": 1, + "maxLength": 262144 + }, + "expectedSourceSha256": { + "type": "string", + "pattern": "^[a-fA-F0-9]{64}$" + }, + "expectedClassName": { + "type": "string", + "pattern": "^[_\\p{L}][_\\p{L}\\p{N}]*$", + "maxLength": 128 + }, + "expectedNamespace": { + "type": "string", + "pattern": "^[_\\p{L}][_\\p{L}\\p{N}]*(?:\\.[_\\p{L}][_\\p{L}\\p{N}]*)*$", + "maxLength": 256 + }, + "overwrite": { + "type": "boolean", + "default": false + }, + "attachToObjectPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "autoRollbackOnCompileError": { + "type": "boolean", + "default": true + }, + "timeoutSeconds": { + "type": "integer", + "minimum": 10, + "maximum": 1800, + "default": 300 + } + }, + "required": [ + "path", + "source", + "expectedClassName" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.scripts.list", + "description": "List C# scripts visible to Unity with paths, class names, and namespaces when available.", + "parameters": { + "type": "object", + "properties": { + "includePackages": { + "type": "boolean", + "default": true + }, + "maxResults": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "default": 500 + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.tests.run", + "description": "Run Unity Edit Mode or Play Mode tests, optionally inject frame-timed new Input System events, and persist an XML result artifact.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "mode": { + "type": "string", + "enum": [ + "edit", + "play" + ], + "default": "edit" + }, + "testNames": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "maxItems": 200, + "default": [] + }, + "groupNames": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "maxItems": 200, + "default": [] + }, + "categoryNames": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "maxItems": 100, + "default": [] + }, + "assemblyNames": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "maxItems": 100, + "default": [] + }, + "runSynchronously": { + "type": "boolean", + "default": false + }, + "saveModifiedScenes": { + "type": "boolean", + "default": false + }, + "inputStartDelayFrames": { + "type": "integer", + "minimum": 0, + "maximum": 10000, + "default": 1 + }, + "inputEvents": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "valueType": { + "type": "string", + "const": "button" + }, + "frameOffset": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "targetTest": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "device": { + "type": "string", + "enum": [ + "keyboard", + "mouse", + "gamepad" + ] + }, + "control": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "action": { + "type": "string", + "enum": [ + "press", + "release" + ] + }, + "durationFrames": { + "type": "integer", + "minimum": 1, + "maximum": 100000, + "default": 1 + } + }, + "required": [ + "valueType", + "frameOffset", + "device", + "control", + "action" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "valueType": { + "type": "string", + "const": "axis" + }, + "frameOffset": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "targetTest": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "device": { + "type": "string", + "enum": [ + "mouse", + "gamepad" + ] + }, + "control": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "action": { + "type": "string", + "const": "set" + }, + "value": { + "type": "number", + "minimum": -100000, + "maximum": 100000 + } + }, + "required": [ + "valueType", + "frameOffset", + "device", + "control", + "action", + "value" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "valueType": { + "type": "string", + "const": "vector2" + }, + "frameOffset": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "targetTest": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "device": { + "type": "string", + "enum": [ + "mouse", + "gamepad" + ] + }, + "control": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "action": { + "type": "string", + "const": "set" + }, + "x": { + "type": "number", + "minimum": -100000, + "maximum": 100000 + }, + "y": { + "type": "number", + "minimum": -100000, + "maximum": 100000 + } + }, + "required": [ + "valueType", + "frameOffset", + "device", + "control", + "action", + "x", + "y" + ], + "additionalProperties": false + } + ] + }, + "maxItems": 500, + "default": [] + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.ui.audit", + "description": "Audit active-scene UI for Canvas, CanvasScaler, EventSystem/input modules, contrast, labels, button action markers, and layout quality.", + "parameters": { + "type": "object", + "properties": { + "pathPrefix": { + "type": "string", + "maxLength": 512, + "default": "" + }, + "includeInactive": { + "type": "boolean", + "default": true + }, + "maxElements": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200 + }, + "maxFindings": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200 + }, + "minContrastRatio": { + "type": "number", + "minimum": 1, + "maximum": 21, + "default": 4.5 + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.ui.compose", + "description": "Create or replace a production-quality Canvas UI screen from templates with responsive layout, readable contrast, EventSystem, action markers, audit, and rollback on quality failure.", + "parameters": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "default": true + }, + "confirm": { + "type": "boolean", + "default": false + }, + "mode": { + "type": "string", + "enum": [ + "create", + "upsert", + "replace" + ], + "default": "upsert" + }, + "template": { + "type": "string", + "enum": [ + "main_menu", + "pause_menu", + "hud", + "dialog", + "blank" + ], + "default": "main_menu" + }, + "canvasName": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "default": "Unity AI UI Canvas" + }, + "screenName": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "default": "Unity AI Screen" + }, + "screenId": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "default": "unity-ai-screen" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "default": "New Screen" + }, + "subtitle": { + "type": "string", + "maxLength": 400, + "default": "" + }, + "body": { + "type": "string", + "maxLength": 400, + "default": "" + }, + "referenceWidth": { + "type": "integer", + "minimum": 320, + "maximum": 8192, + "default": 1920 + }, + "referenceHeight": { + "type": "integer", + "minimum": 240, + "maximum": 8192, + "default": 1080 + }, + "matchWidthOrHeight": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.5 + }, + "ensureEventSystem": { + "type": "boolean", + "default": true + }, + "createActionMarkers": { + "type": "boolean", + "default": true + }, + "enforceReadableContrast": { + "type": "boolean", + "default": true + }, + "theme": { + "type": "object", + "properties": { + "background": { + "type": "string", + "minLength": 4, + "maxLength": 16, + "default": "#0F172AF2" + }, + "panel": { + "type": "string", + "minLength": 4, + "maxLength": 16, + "default": "#111827E6" + }, + "primary": { + "type": "string", + "minLength": 4, + "maxLength": 16, + "default": "#2563EBFF" + }, + "secondary": { + "type": "string", + "minLength": 4, + "maxLength": 16, + "default": "#334155FF" + }, + "accent": { + "type": "string", + "minLength": 4, + "maxLength": 16, + "default": "#F59E0BFF" + }, + "text": { + "type": "string", + "minLength": 4, + "maxLength": 16, + "default": "#F8FAFCFF" + }, + "mutedText": { + "type": "string", + "minLength": 4, + "maxLength": 16, + "default": "#CBD5E1FF" + }, + "danger": { + "type": "string", + "minLength": 4, + "maxLength": 16, + "default": "#DC2626FF" + }, + "baseFontSize": { + "type": "integer", + "minimum": 12, + "maximum": 120, + "default": 28 + }, + "titleFontSize": { + "type": "integer", + "minimum": 18, + "maximum": 180, + "default": 72 + }, + "buttonFontSize": { + "type": "integer", + "minimum": 12, + "maximum": 120, + "default": 30 + }, + "safeAreaMargin": { + "type": "number", + "minimum": 0, + "maximum": 320, + "default": 80 + } + }, + "additionalProperties": false, + "default": {} + }, + "buttons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "actionId": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "intent": { + "type": "string", + "maxLength": 400, + "default": "" + }, + "variant": { + "type": "string", + "enum": [ + "primary", + "secondary", + "accent", + "danger" + ], + "default": "primary" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + }, + "maxItems": 12, + "default": [] + }, + "stats": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "value": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + }, + "required": [ + "label", + "value" + ], + "additionalProperties": false + }, + "maxItems": 12, + "default": [] + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 400 + }, + "slot": { + "type": "string", + "enum": [ + "body", + "header", + "footer", + "aside" + ], + "default": "body" + }, + "fontSize": { + "type": "integer", + "minimum": 0, + "maximum": 120, + "default": 0 + } + }, + "required": [ + "text" + ], + "additionalProperties": false + }, + "maxItems": 24, + "default": [] + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.vision.capture", + "description": "Synchronously capture a Scene View or Game View screenshot and return only after the PNG is verified ready.", + "parameters": { + "type": "object", + "properties": { + "source": { + "type": "string", + "enum": [ + "scene", + "game" + ], + "default": "scene" + }, + "width": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "default": 640 + }, + "height": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "default": 360 + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 50 + }, + "cameraPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "unity.vision.compare", + "description": "Compare ready before/after screenshot artifacts, generate a diff image, and detect visual regressions.", + "parameters": { + "type": "object", + "properties": { + "beforePath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "afterPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "pixelThreshold": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.1 + }, + "maxChangedPixelRatio": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.01 + }, + "maxMeanAbsoluteError": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.02 + }, + "ignoreAlpha": { + "type": "boolean", + "default": true + }, + "generateDiff": { + "type": "boolean", + "default": true + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 50 + } + }, + "required": [ + "beforePath", + "afterPath" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } + ] +} diff --git a/apps/mcp-server/src/asset-catalog.ts b/apps/mcp-server/src/asset-catalog.ts new file mode 100644 index 0000000..4cfed10 --- /dev/null +++ b/apps/mcp-server/src/asset-catalog.ts @@ -0,0 +1,465 @@ +import { createHash } from "node:crypto"; +import { lookup } from "node:dns/promises"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { isIP } from "node:net"; +import { dirname, extname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { z } from "zod"; + +const supportedFormats = [ + "fbx", "obj", "dae", "3ds", "dxf", "glb", "gltf", + "png", "jpg", "jpeg", "tga", "tif", "tiff", "psd", "exr", "hdr", + "wav", "mp3", "ogg", "aif", "aiff" +] as const; +const supportedKinds = ["model", "texture", "audio"] as const; +const maximumManifestBytes = 2 * 1024 * 1024; +const maximumAssetBytes = 2 * 1024 * 1024 * 1024; +const cacheDurationMs = 5 * 60 * 1_000; +const queryAliasGroups = [ + ["vehicle", "car", "auto", "automobile", "truck", "van", "racing", "transport"], + ["character", "humanoid", "avatar", "player", "npc", "enemy", "person"], + ["environment", "level", "platform", "floor", "ground", "stage", "terrain"], + ["foliage", "tree", "plant", "vegetation", "nature"], + ["prop", "crate", "box", "pickup", "collectible", "item"], + ["audio", "sound", "sfx", "music"], + ["texture", "material", "surface", "albedo"] +] as const; +const httpsUrlSchema = z.string().url().max(4096).refine( + (value) => new URL(value).protocol === "https:", + "Expected an HTTPS URL." +); + +const licenseSchema = z.object({ + spdxId: z.string().min(1).max(64), + name: z.string().min(1).max(160), + url: httpsUrlSchema, + attribution: z.string().max(1000).optional() +}).strict(); + +const manifestSchema = z.object({ + schemaVersion: z.literal(1), + catalog: z.object({ + id: z.string().regex(/^[a-z0-9][a-z0-9._-]{1,63}$/), + name: z.string().min(1).max(160), + homepage: httpsUrlSchema + }).strict(), + assets: z.array(z.object({ + id: z.string().regex(/^[a-z0-9][a-z0-9._-]{1,127}$/), + name: z.string().min(1).max(160), + description: z.string().max(2000).default(""), + kind: z.enum(supportedKinds), + format: z.enum(supportedFormats), + tags: z.array(z.string().min(1).max(64)).max(50).default([]), + downloadUrl: z.string().url().max(4096), + sourceUrl: httpsUrlSchema, + sha256: z.string().regex(/^[a-fA-F0-9]{64}$/), + sizeBytes: z.number().int().min(1).max(maximumAssetBytes), + license: licenseSchema + }).strict()).max(10_000) +}).strict(); + +export interface AssetCatalogOptions { + readonly manifestUrls: readonly string[]; + readonly allowedLicenses: readonly string[]; + readonly allowInsecureLocalhost: boolean; +} + +export interface CatalogSearchInput { + readonly query?: string; + readonly kind?: "all" | typeof supportedKinds[number]; + readonly tags?: readonly string[]; + readonly maxResults?: number; + readonly refresh?: boolean; +} + +export interface PublicCatalogAsset { + readonly assetId: string; + readonly catalogId: string; + readonly catalogName: string; + readonly catalogHomepage: string; + readonly name: string; + readonly description: string; + readonly kind: typeof supportedKinds[number]; + readonly format: typeof supportedFormats[number]; + readonly tags: readonly string[]; + readonly sourceUrl: string; + readonly downloadUrl?: string; + readonly sha256: string; + readonly sizeBytes: number; + readonly license: { + readonly spdxId: string; + readonly name: string; + readonly url: string; + readonly attribution?: string; + }; +} + +export interface ResolvedCatalogAsset extends PublicCatalogAsset { + readonly source: + | { readonly kind: "local"; readonly path: string } + | { readonly kind: "url"; readonly url: string; readonly allowInsecureLocalhost: boolean }; +} + +interface CatalogLoadResult { + readonly assets: ResolvedCatalogAsset[]; + readonly warnings: string[]; +} + +interface CatalogCacheEntry { + readonly expiresAt: number; + readonly assets: ResolvedCatalogAsset[]; +} + +export class AssetCatalogService { + private readonly allowedLicenses: Set; + private readonly remoteCache = new Map(); + + constructor(private readonly options: AssetCatalogOptions) { + this.allowedLicenses = new Set(options.allowedLicenses.map((value) => value.trim()).filter(Boolean)); + } + + async search(input: CatalogSearchInput = {}) { + const loaded = await this.loadAll(input.refresh === true); + const queryGroups = expandQueryGroups(input.query); + const kind = input.kind ?? "all"; + const tags = (input.tags ?? []).map(normalize).filter(Boolean); + const maxResults = clampInteger(input.maxResults ?? 50, 1, 200); + const matching = loaded.assets.filter((asset) => { + if (kind !== "all" && asset.kind !== kind) { + return false; + } + + const searchable = normalize([ + asset.assetId, + asset.name, + asset.description, + asset.catalogName, + ...asset.tags + ].join(" ")); + if (queryGroups.length > 0 && !queryGroups.every((group) => group.some((term) => searchable.includes(term)))) { + return false; + } + + const assetTags = new Set(asset.tags.map(normalize)); + return tags.every((tag) => assetTags.has(tag)); + }); + + return { + totalFound: matching.length, + returned: Math.min(matching.length, maxResults), + truncated: matching.length > maxResults, + allowedLicenses: [...this.allowedLicenses].sort(), + warnings: loaded.warnings, + assets: matching.slice(0, maxResults).map(toPublicCatalogAsset) + }; + } + + async resolve(assetId: string, refresh = false): Promise { + const loaded = await this.loadAll(refresh); + const asset = loaded.assets.find((candidate) => candidate.assetId === assetId); + if (!asset) { + const warningSuffix = loaded.warnings.length > 0 ? ` Catalog warnings: ${loaded.warnings.join(" | ")}` : ""; + throw new Error(`Catalog asset '${assetId}' was not found.${warningSuffix}`); + } + + return asset; + } + + private async loadAll(refresh: boolean): Promise { + const assets = loadBundledAssets(); + const warnings: string[] = []; + + for (const manifestUrl of this.options.manifestUrls) { + try { + assets.push(...await this.loadRemoteManifest(manifestUrl, refresh)); + } catch (error) { + warnings.push(`${manifestUrl}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + const unique = new Map(); + for (const asset of assets) { + if (!this.allowedLicenses.has(asset.license.spdxId)) { + warnings.push(`${asset.assetId}: license ${asset.license.spdxId} is not in the allowlist.`); + continue; + } + + if (unique.has(asset.assetId)) { + warnings.push(`${asset.assetId}: duplicate catalog asset id was ignored.`); + continue; + } + + unique.set(asset.assetId, asset); + } + + return { + assets: [...unique.values()].sort((left, right) => left.assetId.localeCompare(right.assetId)), + warnings + }; + } + + private async loadRemoteManifest(manifestUrl: string, refresh: boolean): Promise { + const cached = this.remoteCache.get(manifestUrl); + if (!refresh && cached && cached.expiresAt > Date.now()) { + return cached.assets; + } + + const parsed = manifestSchema.parse(await fetchJsonManifest(manifestUrl, this.options.allowInsecureLocalhost)); + const assets = parsed.assets.map((asset): ResolvedCatalogAsset => ({ + assetId: `${parsed.catalog.id}:${asset.id}`, + catalogId: parsed.catalog.id, + catalogName: parsed.catalog.name, + catalogHomepage: parsed.catalog.homepage, + name: asset.name, + description: asset.description, + kind: asset.kind, + format: asset.format, + tags: asset.tags, + sourceUrl: asset.sourceUrl, + downloadUrl: validateDownloadUrl(asset.downloadUrl, this.options.allowInsecureLocalhost), + sha256: asset.sha256.toLowerCase(), + sizeBytes: asset.sizeBytes, + license: asset.license, + source: { + kind: "url", + url: validateDownloadUrl(asset.downloadUrl, this.options.allowInsecureLocalhost), + allowInsecureLocalhost: isLoopbackUrl(asset.downloadUrl) && this.options.allowInsecureLocalhost + } + })); + this.remoteCache.set(manifestUrl, { assets, expiresAt: Date.now() + cacheDurationMs }); + return assets; + } +} + +function loadBundledAssets(): ResolvedCatalogAsset[] { + const catalogRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../catalog-assets"); + return [ + bundledAsset(catalogRoot, { + id: "starter-crate", + name: "Starter Crate", + description: "A lightweight CC0 OBJ crate for validating model import, prefab, and gameplay workflows.", + fileName: "starter-crate.obj", + tags: ["crate", "prop", "prototype", "environment"] + }), + bundledAsset(catalogRoot, { + id: "starter-ramp", + name: "Starter Ramp", + description: "A lightweight CC0 OBJ ramp for level blockout and physics workflow validation.", + fileName: "starter-ramp.obj", + tags: ["ramp", "level", "prototype", "environment"] + }), + bundledAsset(catalogRoot, { + id: "starter-vehicle", + name: "Starter Vehicle", + description: "A lightweight CC0 low-poly vehicle blockout with body and wheels for racing, traffic, transport, and gameplay prototype workflows.", + fileName: "starter-vehicle.obj", + tags: ["vehicle", "car", "truck", "racing", "transport", "prototype", "blockout"] + }), + bundledAsset(catalogRoot, { + id: "starter-character", + name: "Starter Character", + description: "A lightweight CC0 humanoid character blockout for player, NPC, enemy, and animation placeholder workflows.", + fileName: "starter-character.obj", + tags: ["character", "humanoid", "avatar", "player", "npc", "enemy", "prototype", "blockout"] + }), + bundledAsset(catalogRoot, { + id: "starter-tree", + name: "Starter Tree", + description: "A lightweight CC0 tree and foliage blockout for environment dressing and level composition.", + fileName: "starter-tree.obj", + tags: ["tree", "plant", "foliage", "vegetation", "nature", "environment", "prototype"] + }), + bundledAsset(catalogRoot, { + id: "starter-platform", + name: "Starter Platform", + description: "A lightweight CC0 modular floor platform for level blockout, spawn areas, and obstacle layouts.", + fileName: "starter-platform.obj", + tags: ["platform", "floor", "ground", "level", "environment", "terrain", "prototype", "modular"] + }) + ]; +} + +function bundledAsset( + catalogRoot: string, + definition: { id: string; name: string; description: string; fileName: string; tags: string[] } +): ResolvedCatalogAsset { + const path = resolve(catalogRoot, definition.fileName); + if (!existsSync(path)) { + throw new Error(`Bundled catalog asset is missing: ${path}`); + } + + const bytes = readFileSync(path); + return { + assetId: `unity-ai:${definition.id}`, + catalogId: "unity-ai", + catalogName: "Unity AI Starter Catalog", + catalogHomepage: "https://github.com/marcosotomac/unity-ai", + name: definition.name, + description: definition.description, + kind: "model", + format: extname(definition.fileName).slice(1).toLowerCase() as "obj", + tags: definition.tags, + sourceUrl: `https://github.com/marcosotomac/unity-ai/tree/main/apps/mcp-server/catalog-assets/${definition.fileName}`, + sha256: createHash("sha256").update(bytes).digest("hex"), + sizeBytes: statSync(path).size, + license: { + spdxId: "CC0-1.0", + name: "CC0 1.0 Universal", + url: "https://creativecommons.org/publicdomain/zero/1.0/" + }, + source: { kind: "local", path } + }; +} + +function validateDownloadUrl(rawUrl: string, allowInsecureLocalhost: boolean): string { + const url = new URL(rawUrl); + if (url.username || url.password) { + throw new Error("Catalog asset download URLs cannot include credentials."); + } + + const loopback = isLoopbackUrl(url.toString()); + if (url.protocol !== "https:" && !(allowInsecureLocalhost && loopback && url.protocol === "http:")) { + throw new Error("Catalog asset downloads require HTTPS; HTTP is only allowed for explicit localhost testing."); + } + + return url.toString(); +} + +function expandQueryGroups(rawQuery: string | undefined): string[][] { + const normalized = normalize(rawQuery); + if (!normalized) { + return []; + } + + return normalized + .split(/[^a-z0-9._-]+/u) + .map((term) => term.trim()) + .filter(Boolean) + .map((term) => { + const group = queryAliasGroups.find((aliases) => (aliases as readonly string[]).includes(term)); + return [...new Set([term, ...(group ?? [])])]; + }); +} + +async function fetchJsonManifest(rawUrl: string, allowInsecureLocalhost: boolean): Promise { + let current = await validateManifestUrl(rawUrl, allowInsecureLocalhost); + + for (let redirect = 0; redirect <= 5; redirect += 1) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const response = await fetch(current, { + method: "GET", + redirect: "manual", + headers: { + accept: "application/json", + "user-agent": "UnityAI-ControlPlane/0.1 asset-catalog" + }, + signal: controller.signal + }); + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (!location || redirect === 5) { + throw new Error("Catalog manifest exceeded the redirect limit or returned an invalid redirect."); + } + + current = await validateManifestUrl(new URL(location, current).toString(), allowInsecureLocalhost); + continue; + } + + if (!response.ok) { + throw new Error(`Catalog manifest returned HTTP ${response.status}.`); + } + + const contentLength = Number(response.headers.get("content-length") ?? 0); + if (contentLength > maximumManifestBytes) { + throw new Error(`Catalog manifest exceeds ${maximumManifestBytes} bytes.`); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength === 0 || bytes.byteLength > maximumManifestBytes) { + throw new Error(`Catalog manifest size must be between 1 and ${maximumManifestBytes} bytes.`); + } + + return JSON.parse(new TextDecoder().decode(bytes)); + } finally { + clearTimeout(timeout); + } + } + + throw new Error("Catalog manifest could not be loaded."); +} + +async function validateManifestUrl(rawUrl: string, allowInsecureLocalhost: boolean): Promise { + const url = new URL(rawUrl); + if (url.username || url.password) { + throw new Error("Catalog manifest URLs cannot include credentials."); + } + + const loopback = isLoopbackUrl(url.toString()); + if (url.protocol !== "https:" && !(allowInsecureLocalhost && loopback && url.protocol === "http:")) { + throw new Error("Catalog manifests require HTTPS; HTTP is only allowed for explicit localhost testing."); + } + + if (!loopback) { + const addresses = await lookup(url.hostname, { all: true }); + if (addresses.length === 0 || addresses.some((entry) => isPrivateAddress(entry.address))) { + throw new Error("Catalog manifest host resolves to a private or link-local address."); + } + } + + return url.toString(); +} + +function isLoopbackUrl(rawUrl: string): boolean { + const url = new URL(rawUrl); + return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1"; +} + +function isPrivateAddress(address: string): boolean { + if (isIP(address) === 4) { + const parts = address.split(".").map(Number); + return parts[0] === 10 + || parts[0] === 127 + || (parts[0] === 169 && parts[1] === 254) + || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) + || (parts[0] === 192 && parts[1] === 168); + } + + const normalized = address.toLowerCase(); + return normalized === "::1" + || normalized.startsWith("fc") + || normalized.startsWith("fd") + || normalized.startsWith("fe8") + || normalized.startsWith("fe9") + || normalized.startsWith("fea") + || normalized.startsWith("feb") + || normalized.startsWith("::ffff:127."); +} + +export function toPublicCatalogAsset(asset: ResolvedCatalogAsset): PublicCatalogAsset { + const { source: _source, ...publicAsset } = asset; + return publicAsset; +} + +function normalize(value: string | undefined): string { + return (value ?? "").trim().toLowerCase(); +} + +function clampInteger(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, Math.floor(value))); +} + +export function parseCatalogList(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +export function parseAllowedLicenses(value: string | undefined): string[] { + const parsed = parseCatalogList(value); + return parsed.length > 0 ? parsed : ["CC0-1.0"]; +} diff --git a/apps/mcp-server/src/capabilities.ts b/apps/mcp-server/src/capabilities.ts index 9b67e66..d198603 100644 --- a/apps/mcp-server/src/capabilities.ts +++ b/apps/mcp-server/src/capabilities.ts @@ -15,16 +15,23 @@ export const initialCapabilities: CapabilityManifest[] = [ effects: ["report_only"], verification: ["structured_observation", "console_snapshot", "console_diagnostics"] }, + { + name: "unity.audit.report", + description: "Generate hashed JSON and Markdown reports from persisted audit events and before/after evidence artifacts.", + permissions: ["read_artifacts", "write_artifacts"], + effects: ["report_only", "write_artifacts", "write_audit_log"], + verification: ["structured_observation", "operation_audited", "audit_report_generated", "evidence_hash_verified"] + }, { name: "unity.console.read", - description: "Read Unity Console messages and summarize errors, warnings, and logs.", + description: "Read Unity Console entries with strict compilation, runtime, bridge, and import classifications plus blocking status.", permissions: ["read_console"], effects: ["report_only"], verification: ["console_snapshot"] }, { name: "unity.console.diagnose", - description: "Classify Unity Console entries and return safe, structured diagnostic guidance.", + description: "Classify Unity Console entries into blocking CompilationError and non-blocking runtime, bridge, import, or warning diagnostics.", permissions: ["read_console"], effects: ["report_only"], verification: ["console_diagnostics"] @@ -59,7 +66,7 @@ export const initialCapabilities: CapabilityManifest[] = [ }, { name: "unity.scene.inspect", - description: "Inspect the active scene hierarchy at a high level.", + description: "Inspect a bounded, optionally filtered view of the active scene hierarchy.", permissions: ["read_scenes"], effects: ["report_only"], verification: ["structured_observation"] @@ -71,6 +78,27 @@ export const initialCapabilities: CapabilityManifest[] = [ effects: ["report_only"], verification: ["structured_observation"] }, + { + name: "unity.physics.inspect", + description: "Inspect bounded 3D/2D physics state, collider alignment, penetrations, relative impact speeds, and sampled net-force estimates.", + permissions: ["read_scenes"], + effects: ["report_only"], + verification: ["structured_observation"] + }, + { + name: "unity.runtime.telemetry", + description: "Capture compact per-object runtime telemetry for transforms, render bounds, collider bounds, rigidbodies, velocities, and scene time.", + permissions: ["read_scenes"], + effects: ["report_only"], + verification: ["structured_observation", "runtime_telemetry_captured"] + }, + { + name: "unity.ui.audit", + description: "Audit active-scene UI for Canvas, scaler, EventSystem, contrast, layout, labels, and agent-action markers.", + permissions: ["read_scenes"], + effects: ["report_only"], + verification: ["structured_observation", "ui_audit_completed", "ui_quality_gate_passed"] + }, { name: "unity.scene.upsert_game_object", description: "Create or update a GameObject in the active scene from a safe, schema-bound spec.", @@ -80,11 +108,25 @@ export const initialCapabilities: CapabilityManifest[] = [ }, { name: "unity.scene.batch", - description: "Apply an atomic, undo-backed batch of scene hierarchy and serialized component operations.", + description: "Apply an atomic, undo-backed batch of scene hierarchy, serialized component, and cross-object reference operations.", permissions: ["read_assets", "modify_scenes"], effects: ["report_only", "write_audit_log", "scene_change"], verification: ["operation_audited", "structured_observation", "scene_mutation_verified", "batch_applied", "component_state_verified"] }, + { + name: "unity.ui.compose", + description: "Create or replace high-quality Canvas UI screens with responsive layout, EventSystem, readable contrast, semantic action markers, audit, and rollback.", + permissions: ["read_scenes", "modify_scenes"], + effects: ["report_only", "write_checkpoint", "write_audit_log", "scene_change"], + verification: ["operation_audited", "structured_observation", "checkpoint_created", "ui_screen_composed", "ui_audit_completed", "ui_quality_gate_passed", "scene_mutation_verified", "rollback_verified"] + }, + { + name: "unity.gameplay.compose", + description: "Configure reusable door, pickup, and multi-target activator gameplay templates on existing scene objects.", + permissions: ["read_scenes", "modify_scenes"], + effects: ["report_only", "write_checkpoint", "write_audit_log", "scene_change"], + verification: ["operation_audited", "structured_observation", "checkpoint_created", "gameplay_template_applied", "component_state_verified", "scene_mutation_verified", "rollback_verified"] + }, { name: "unity.prefabs.list", description: "List prefabs in the Unity project with paths, GUIDs, and root component summaries.", @@ -113,6 +155,13 @@ export const initialCapabilities: CapabilityManifest[] = [ effects: ["report_only"], verification: ["structured_observation"] }, + { + name: "unity.scripts.author", + description: "Validate, hash-confirm, write, compile, and optionally attach a checkpointed runtime MonoBehaviour with automatic rollback.", + permissions: ["read_project", "read_console", "modify_assets", "modify_scenes", "execute_editor_script"], + effects: ["write_checkpoint", "write_audit_log", "asset_change", "code_change", "scene_change"], + verification: ["script_source_validated", "checkpoint_created", "operation_audited", "compilation_completed", "console_snapshot", "script_compilation_verified", "component_state_verified", "scene_mutation_verified", "checkpoint_restored"] + }, { name: "unity.assemblies.list", description: "List Unity script assemblies and assembly definition metadata.", @@ -136,14 +185,14 @@ export const initialCapabilities: CapabilityManifest[] = [ }, { name: "unity.project.settings.inspect", - description: "Inspect high-level Unity project and player settings.", + description: "Inspect high-level Unity project, player settings, tags, layers, render pipeline, and input-system mode.", permissions: ["read_project_settings"], effects: ["report_only"], verification: ["structured_observation"] }, { name: "unity.project.settings.update", - description: "Update selected Project, Player, Android, and Build Settings with a durable checkpoint.", + description: "Update selected Project, Player, Android, tag, layer, input, and Build Settings with a durable checkpoint.", permissions: ["read_project_settings", "modify_project_settings"], effects: ["write_checkpoint", "project_setting_change"], verification: ["checkpoint_created", "project_settings_verified"] @@ -171,7 +220,7 @@ export const initialCapabilities: CapabilityManifest[] = [ }, { name: "unity.tests.run", - description: "Run Unity Edit Mode or Play Mode tests and persist XML results.", + description: "Run Unity Edit Mode or Play Mode tests, optionally inject frame-timed new Input System events, and persist XML results.", permissions: ["run_tests", "write_artifacts"], effects: ["test_execution", "write_artifacts"], verification: ["tests_passed", "test_results_available"] @@ -220,11 +269,32 @@ export const initialCapabilities: CapabilityManifest[] = [ }, { name: "unity.assets.author", - description: "Create or edit shaders, materials, animation clips, WAV audio, and audio import settings.", + description: "Create or edit shaders, materials, animation clips, Animator Controllers, WAV audio, and audio import settings.", permissions: ["read_assets", "modify_assets", "write_artifacts"], effects: ["write_checkpoint", "asset_change"], verification: ["checkpoint_created", "asset_mutation_verified"] }, + { + name: "unity.assets.import", + description: "Copy or download models, textures, and audio into Assets, configure Unity importers, and optionally instantiate, normalize, or save a prefab.", + permissions: ["read_external_files", "network_access", "read_assets", "modify_assets", "modify_scenes", "write_artifacts"], + effects: ["write_checkpoint", "write_audit_log", "asset_change", "scene_change"], + verification: ["checkpoint_created", "operation_audited", "asset_import_verified", "asset_normalized", "scene_mutation_verified", "prefab_mutation_verified"] + }, + { + name: "unity.assets.catalog.search", + description: "Search license-allowlisted asset catalogs with provenance, size, format, and SHA-256 metadata.", + permissions: ["network_access", "read_external_files"], + effects: ["report_only"], + verification: ["structured_observation", "asset_license_verified", "asset_hash_available"] + }, + { + name: "unity.assets.import_from_catalog", + description: "Resolve and import a catalog asset with enforced license, provenance, byte limit, SHA-256 verification, and optional model normalization.", + permissions: ["network_access", "read_external_files", "read_assets", "modify_assets", "modify_scenes", "write_artifacts"], + effects: ["write_checkpoint", "write_audit_log", "asset_change", "scene_change"], + verification: ["asset_license_verified", "asset_hash_verified", "checkpoint_created", "operation_audited", "asset_import_verified", "asset_normalized", "scene_mutation_verified", "prefab_mutation_verified"] + }, { name: "unity.prefab.manage", description: "Save, edit, variant, apply, and revert prefabs with durable checkpoints.", diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index e91bf55..87edc39 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -2,13 +2,25 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; +import { AssetCatalogService, parseAllowedLicenses, parseCatalogList, toPublicCatalogAsset } from "./asset-catalog.js"; import { initialCapabilities } from "./capabilities.js"; import { UnityBridgeClient } from "./unity-bridge-client.js"; const bridge = new UnityBridgeClient({ baseUrl: process.env.UNITY_AI_BRIDGE_URL ?? "http://127.0.0.1:39071", timeoutMs: parseTimeout(process.env.UNITY_AI_BRIDGE_TIMEOUT_MS), - token: process.env.UNITY_AI_BRIDGE_TOKEN + token: process.env.UNITY_AI_BRIDGE_TOKEN, + retry: { + maxAttempts: parsePositiveInteger(process.env.UNITY_AI_BRIDGE_RETRY_MAX_ATTEMPTS, 12), + maxElapsedMs: parsePositiveInteger(process.env.UNITY_AI_BRIDGE_RETRY_TIMEOUT_MS, 90_000), + baseDelayMs: parsePositiveInteger(process.env.UNITY_AI_BRIDGE_RETRY_BASE_DELAY_MS, 150), + maxDelayMs: parsePositiveInteger(process.env.UNITY_AI_BRIDGE_RETRY_MAX_DELAY_MS, 3_000) + } +}); +const assetCatalog = new AssetCatalogService({ + manifestUrls: parseCatalogList(process.env.UNITY_AI_ASSET_CATALOG_URLS), + allowedLicenses: parseAllowedLicenses(process.env.UNITY_AI_ASSET_CATALOG_ALLOWED_LICENSES), + allowInsecureLocalhost: process.env.UNITY_AI_CATALOG_ALLOW_INSECURE_LOCALHOST === "1" }); const server = new McpServer({ @@ -50,10 +62,46 @@ server.registerTool( async () => bridgeTool("unity.project.snapshot") ); +const auditEvidenceSchema = z.object({ + phase: z.enum(["before", "after", "supporting"]).default("supporting"), + kind: z.string().min(1).max(64).default("artifact"), + path: z.string().min(1).max(512), + description: z.string().max(1000).optional() +}).strict(); + +const auditVerificationSchema = z.object({ + signal: z.string().min(1).max(128), + status: z.enum(["passed", "failed", "inconclusive"]), + summary: z.string().max(2000).optional(), + evidencePaths: z.array(z.string().min(1).max(512)).max(100).default([]) +}).strict(); + +server.registerTool( + "unity.audit.report", + { + description: "Generate hashed JSON and Markdown audit reports from persisted events and project-relative before/after evidence.", + inputSchema: z.object({ + title: z.string().min(1).max(160).default("Unity AI verification report"), + summary: z.string().max(4000).optional(), + requestIds: z.array(z.string().min(1).max(256)).max(200).default([]), + correlationIds: z.array(z.string().min(1).max(256)).max(200).default([]), + capabilities: z.array(z.string().min(1).max(256)).max(200).default([]), + sinceUtc: z.string().datetime({ offset: true }).optional(), + untilUtc: z.string().datetime({ offset: true }).optional(), + maxEvents: z.number().int().min(1).max(5000).default(200), + maxEvidenceBytes: z.number().int().min(1).max(10_737_418_240).default(2_147_483_648), + requireBeforeAfter: z.boolean().default(false), + evidence: z.array(auditEvidenceSchema).max(100).default([]), + verifications: z.array(auditVerificationSchema).max(200).default([]) + }).strict() + }, + async (input) => bridgeTool("unity.audit.report", input) +); + server.registerTool( "unity.console.read", { - description: "Read Unity Console summary and recent log entries through the local Unity Editor bridge.", + description: "Read Unity Console entries with strict compilation, runtime, bridge, and import classifications plus blocking status.", inputSchema: z.object({}) }, async () => bridgeTool("unity.console.read") @@ -62,7 +110,7 @@ server.registerTool( server.registerTool( "unity.console.diagnose", { - description: "Diagnose Unity Console compiler/runtime issues as structured, read-only guidance.", + description: "Classify Unity Console entries into blocking CompilationError and non-blocking runtime, bridge, import, or warning diagnostics.", inputSchema: z.object({}) }, async () => bridgeTool("unity.console.diagnose") @@ -120,14 +168,34 @@ server.registerTool( server.registerTool( "unity.scene.inspect", { - description: "Inspect the active Unity scene hierarchy at a high level.", + description: "Inspect a bounded, optionally filtered view of the active Unity scene hierarchy.", inputSchema: z.object({ includeComponents: z.boolean().default(true), maxDepth: z.number().int().min(0).max(10).default(3), - maxGameObjects: z.number().int().min(1).max(1000).default(200) - }) + maxGameObjects: z.number().int().min(1).max(1000).default(200), + filter: z.object({ + nameContains: z.string().min(1).max(128).optional(), + pathPrefix: z.string().min(1).max(512).optional(), + componentType: z.string().min(1).max(256).optional(), + activeState: z.enum(["any", "active", "inactive"]).default("any"), + withinRadius: z.union([ + z.object({ + centerPath: z.string().min(1).max(512), + radius: z.number().finite().min(0).max(100000) + }).strict(), + z.object({ + center: z.object({ + x: z.number().finite(), + y: z.number().finite(), + z: z.number().finite() + }).strict(), + radius: z.number().finite().min(0).max(100000) + }).strict() + ]).optional() + }).strict().optional() + }).strict() }, - async ({ includeComponents, maxDepth, maxGameObjects }) => bridgeTool("unity.scene.inspect", { includeComponents, maxDepth, maxGameObjects }) + async (input) => bridgeTool("unity.scene.inspect", input) ); server.registerTool( @@ -144,12 +212,153 @@ server.registerTool( async (input) => bridgeTool("unity.scene.inspect_game_object", input) ); +server.registerTool( + "unity.physics.inspect", + { + description: "Inspect bounded 3D/2D physics state, collider alignment, penetrations, relative impact speeds, and sampled net-force estimates.", + inputSchema: z.object({ + pathPrefix: z.string().min(1).max(512).optional(), + includeInactive: z.boolean().default(false), + dimension: z.enum(["all", "3d", "2d"]).default("all"), + withinRadius: z.union([ + z.object({ + centerPath: z.string().min(1).max(512), + radius: z.number().finite().min(0).max(100000) + }).strict(), + z.object({ + center: z.object({ + x: z.number().finite(), + y: z.number().finite(), + z: z.number().finite() + }).strict(), + radius: z.number().finite().min(0).max(100000) + }).strict() + ]).optional(), + includeOverlapDiagnostics: z.boolean().default(true), + maxObjects: z.number().int().min(1).max(500).default(200), + maxOverlaps: z.number().int().min(0).max(1000).default(200) + }).strict() + }, + async (input) => bridgeTool("unity.physics.inspect", input) +); + +server.registerTool( + "unity.runtime.telemetry", + { + description: "Capture compact per-object runtime telemetry for transforms, renderer/collider bounds, rigidbodies, velocities, and scene time.", + inputSchema: z.object({ + pathPrefix: z.string().min(1).max(512).optional(), + includeInactive: z.boolean().default(false), + withinRadius: z.union([ + z.object({ + centerPath: z.string().min(1).max(512), + radius: z.number().finite().min(0).max(100000) + }).strict(), + z.object({ + center: z.object({ + x: z.number().finite(), + y: z.number().finite(), + z: z.number().finite() + }).strict(), + radius: z.number().finite().min(0).max(100000) + }).strict() + ]).optional(), + includeRenderers: z.boolean().default(true), + includeColliders: z.boolean().default(true), + includeRigidbodies: z.boolean().default(true), + maxObjects: z.number().int().min(1).max(500).default(200) + }).strict() + }, + async (input) => bridgeTool("unity.runtime.telemetry", input) +); + +server.registerTool( + "unity.ui.audit", + { + description: "Audit active-scene UI for Canvas, CanvasScaler, EventSystem/input modules, contrast, labels, button action markers, and layout quality.", + inputSchema: z.object({ + pathPrefix: z.string().max(512).default(""), + includeInactive: z.boolean().default(true), + maxElements: z.number().int().min(1).max(1000).default(200), + maxFindings: z.number().int().min(1).max(1000).default(200), + minContrastRatio: z.number().finite().min(1).max(21).default(4.5) + }).strict() + }, + async (input) => bridgeTool("unity.ui.audit", input) +); + const sceneVectorSchema = z.object({ x: z.number().finite(), y: z.number().finite(), z: z.number().finite() }).strict(); +const uiThemeSchema = z.object({ + background: z.string().min(4).max(16).default("#0F172AF2"), + panel: z.string().min(4).max(16).default("#111827E6"), + primary: z.string().min(4).max(16).default("#2563EBFF"), + secondary: z.string().min(4).max(16).default("#334155FF"), + accent: z.string().min(4).max(16).default("#F59E0BFF"), + text: z.string().min(4).max(16).default("#F8FAFCFF"), + mutedText: z.string().min(4).max(16).default("#CBD5E1FF"), + danger: z.string().min(4).max(16).default("#DC2626FF"), + baseFontSize: z.number().int().min(12).max(120).default(28), + titleFontSize: z.number().int().min(18).max(180).default(72), + buttonFontSize: z.number().int().min(12).max(120).default(30), + safeAreaMargin: z.number().finite().min(0).max(320).default(80) +}).strict(); + +const uiButtonSchema = z.object({ + name: z.string().min(1).max(80).optional(), + text: z.string().min(1).max(120), + actionId: z.string().min(1).max(120).optional(), + intent: z.string().max(400).default(""), + variant: z.enum(["primary", "secondary", "accent", "danger"]).default("primary") +}).strict(); + +const uiStatSchema = z.object({ + name: z.string().min(1).max(80).optional(), + label: z.string().min(1).max(80), + value: z.string().min(1).max(80) +}).strict(); + +const uiLabelSchema = z.object({ + name: z.string().min(1).max(80).optional(), + text: z.string().min(1).max(400), + slot: z.enum(["body", "header", "footer", "aside"]).default("body"), + fontSize: z.number().int().min(0).max(120).default(0) +}).strict(); + +server.registerTool( + "unity.ui.compose", + { + description: "Create or replace a production-quality Canvas UI screen from templates with responsive layout, readable contrast, EventSystem, action markers, audit, and rollback on quality failure.", + inputSchema: z.object({ + dryRun: z.boolean().default(true), + confirm: z.boolean().default(false), + mode: z.enum(["create", "upsert", "replace"]).default("upsert"), + template: z.enum(["main_menu", "pause_menu", "hud", "dialog", "blank"]).default("main_menu"), + canvasName: z.string().min(1).max(80).default("Unity AI UI Canvas"), + screenName: z.string().min(1).max(80).default("Unity AI Screen"), + screenId: z.string().min(1).max(120).default("unity-ai-screen"), + title: z.string().min(1).max(160).default("New Screen"), + subtitle: z.string().max(400).default(""), + body: z.string().max(400).default(""), + referenceWidth: z.number().int().min(320).max(8192).default(1920), + referenceHeight: z.number().int().min(240).max(8192).default(1080), + matchWidthOrHeight: z.number().finite().min(0).max(1).default(0.5), + ensureEventSystem: z.boolean().default(true), + createActionMarkers: z.boolean().default(true), + enforceReadableContrast: z.boolean().default(true), + theme: uiThemeSchema.default({}), + buttons: z.array(uiButtonSchema).max(12).default([]), + stats: z.array(uiStatSchema).max(12).default([]), + labels: z.array(uiLabelSchema).max(24).default([]) + }).strict() + }, + async (input) => bridgeTool("unity.ui.compose", input) +); + server.registerTool( "unity.scene.upsert_game_object", { @@ -174,7 +383,7 @@ server.registerTool( async (input) => bridgeTool("unity.scene.upsert_game_object", input) ); -const sceneSerializedValueSchema = z.union([ +const serializedValueSchema = z.union([ z.object({ kind: z.literal("bool"), boolValue: z.boolean() }).strict(), z.object({ kind: z.literal("integer"), integerValue: z.number().int().safe() }).strict(), z.object({ kind: z.literal("number"), numberValue: z.number().finite() }).strict(), @@ -214,6 +423,20 @@ const sceneSerializedValueSchema = z.union([ z.object({ kind: z.literal("character"), integerValue: z.number().int().min(0).max(65535) }).strict() ]); +const sceneSerializedValueSchema = z.union([ + serializedValueSchema, + z.object({ + kind: z.literal("game_object_reference"), + path: z.string().min(1).max(512) + }).strict(), + z.object({ + kind: z.literal("component_reference"), + path: z.string().min(1).max(512), + componentType: z.string().min(1).max(256), + componentIndex: z.number().int().min(0).default(0) + }).strict() +]); + const sceneBatchOperationSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("create"), @@ -275,7 +498,7 @@ const sceneBatchOperationSchema = z.discriminatedUnion("kind", [ server.registerTool( "unity.scene.batch", { - description: "Apply an atomic, undo-backed batch of hierarchy, prefab, component, and serialized-property scene operations.", + description: "Apply an atomic, undo-backed batch of hierarchy, prefab, component, serialized-property, and cross-object reference operations.", inputSchema: z.object({ dryRun: z.boolean().default(true), confirm: z.boolean().default(false), @@ -285,6 +508,59 @@ server.registerTool( async (input) => bridgeTool("unity.scene.batch", input) ); +const gameplayInteractorSchema = { + interactorPath: z.string().min(1).max(512).optional(), + interactorTag: z.string().min(1).max(80).default("Player"), + activationDistance: z.number().finite().min(0.01).max(1000).default(2) +}; + +const gameplayTemplateSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("door"), + targetPath: z.string().min(1).max(512), + ...gameplayInteractorSchema, + deactivationDistance: z.number().finite().min(0.01).max(1000).default(2.5), + openOffset: sceneVectorSchema.default({ x: 0, y: 2.5, z: 0 }), + speed: z.number().finite().min(0.01).max(1000).default(3), + startsOpen: z.boolean().default(false), + closeWhenOutOfRange: z.boolean().default(true) + }).strict(), + z.object({ + kind: z.literal("pickup"), + targetPath: z.string().min(1).max(512), + ...gameplayInteractorSchema, + pickupId: z.string().min(1).max(128).default("pickup"), + value: z.number().int().min(0).max(1_000_000).default(1), + collectAction: z.enum(["deactivate", "destroy"]).default("deactivate"), + spinAxis: sceneVectorSchema.default({ x: 0, y: 1, z: 0 }), + spinDegreesPerSecond: z.number().finite().min(-10_000).max(10_000).default(90), + bobAmplitude: z.number().finite().min(0).max(1000).default(0.15), + bobFrequency: z.number().finite().min(0).max(1000).default(1) + }).strict(), + z.object({ + kind: z.literal("activator"), + targetPath: z.string().min(1).max(512), + ...gameplayInteractorSchema, + affectedPaths: z.array(z.string().min(1).max(512)).min(1).max(32), + action: z.enum(["activate", "deactivate", "toggle"]).default("activate"), + oneShot: z.boolean().default(true), + revertOnExit: z.boolean().default(false) + }).strict() +]); + +server.registerTool( + "unity.gameplay.compose", + { + description: "Turn existing scene objects into checkpointed proximity doors, pickups, and multi-target activators.", + inputSchema: z.object({ + dryRun: z.boolean().default(true), + confirm: z.boolean().default(false), + templates: z.array(gameplayTemplateSchema).min(1).max(20) + }).strict() + }, + async (input) => bridgeTool("unity.gameplay.compose", input) +); + server.registerTool( "unity.prefabs.list", { @@ -336,6 +612,27 @@ server.registerTool( async ({ includePackages, maxResults }) => bridgeTool("unity.scripts.list", { includePackages, maxResults }) ); +server.registerTool( + "unity.scripts.author", + { + description: "Validate and hash-confirm a runtime MonoBehaviour, then write, compile, optionally attach, verify, and roll back on failure.", + inputSchema: z.object({ + dryRun: z.boolean().default(true), + confirm: z.boolean().default(false), + path: z.string().min(1).max(512), + source: z.string().min(1).max(262144), + expectedSourceSha256: z.string().regex(/^[a-fA-F0-9]{64}$/).optional(), + expectedClassName: z.string().regex(/^[_\p{L}][_\p{L}\p{N}]*$/u).max(128), + expectedNamespace: z.string().regex(/^[_\p{L}][_\p{L}\p{N}]*(?:\.[_\p{L}][_\p{L}\p{N}]*)*$/u).max(256).optional(), + overwrite: z.boolean().default(false), + attachToObjectPath: z.string().min(1).max(512).optional(), + autoRollbackOnCompileError: z.boolean().default(true), + timeoutSeconds: z.number().int().min(10).max(1800).default(300) + }).strict() + }, + async (input) => bridgeTool("unity.scripts.author", input) +); + server.registerTool( "unity.assemblies.list", { @@ -359,7 +656,7 @@ server.registerTool( server.registerTool( "unity.project.settings.inspect", { - description: "Inspect high-level Unity project and player settings.", + description: "Inspect high-level Unity project, player settings, tags, layers, render pipeline, and input-system mode.", inputSchema: z.object({}) }, async () => bridgeTool("unity.project.settings.inspect") @@ -368,7 +665,7 @@ server.registerTool( server.registerTool( "unity.project.settings.update", { - description: "Update selected Project Settings, Android Player Settings, and Build Settings with a durable checkpoint.", + description: "Update selected Project Settings, Android Player Settings, tags, layers, Active Input Handling, and Build Settings with a durable checkpoint.", inputSchema: z.object({ dryRun: z.boolean().default(true), confirm: z.boolean().default(false), @@ -383,6 +680,9 @@ server.registerTool( buildAppBundle: z.boolean().optional(), developmentBuild: z.boolean().optional(), connectProfiler: z.boolean().optional(), + addTags: z.array(z.string().min(1).max(64)).max(200).optional(), + addLayers: z.array(z.string().min(1).max(32)).max(24).optional(), + activeInputHandling: z.enum(["legacy", "old", "input_manager", "new", "input_system", "both"]).optional(), scenes: z.array(z.object({ path: z.string().min(1).max(512), enabled: z.boolean().default(true) @@ -444,7 +744,7 @@ server.registerTool( server.registerTool( "unity.tests.run", { - description: "Run Unity Edit Mode or Play Mode tests and persist an XML result artifact.", + description: "Run Unity Edit Mode or Play Mode tests, optionally inject frame-timed new Input System events, and persist an XML result artifact.", inputSchema: z.object({ dryRun: z.boolean().default(true), confirm: z.boolean().default(false), @@ -454,7 +754,38 @@ server.registerTool( categoryNames: z.array(z.string().min(1).max(256)).max(100).default([]), assemblyNames: z.array(z.string().min(1).max(256)).max(100).default([]), runSynchronously: z.boolean().default(false), - saveModifiedScenes: z.boolean().default(false) + saveModifiedScenes: z.boolean().default(false), + inputStartDelayFrames: z.number().int().min(0).max(10000).default(1), + inputEvents: z.array(z.discriminatedUnion("valueType", [ + z.object({ + valueType: z.literal("button"), + frameOffset: z.number().int().min(0).max(100000), + targetTest: z.string().min(1).max(512).optional(), + device: z.enum(["keyboard", "mouse", "gamepad"]), + control: z.string().min(1).max(128), + action: z.enum(["press", "release"]), + durationFrames: z.number().int().min(1).max(100000).default(1) + }).strict(), + z.object({ + valueType: z.literal("axis"), + frameOffset: z.number().int().min(0).max(100000), + targetTest: z.string().min(1).max(512).optional(), + device: z.enum(["mouse", "gamepad"]), + control: z.string().min(1).max(128), + action: z.literal("set"), + value: z.number().finite().min(-100000).max(100000) + }).strict(), + z.object({ + valueType: z.literal("vector2"), + frameOffset: z.number().int().min(0).max(100000), + targetTest: z.string().min(1).max(512).optional(), + device: z.enum(["mouse", "gamepad"]), + control: z.string().min(1).max(128), + action: z.literal("set"), + x: z.number().finite().min(-100000).max(100000), + y: z.number().finite().min(-100000).max(100000) + }).strict() + ])).max(500).default([]) }).strict() }, async (input) => bridgeTool("unity.tests.run", input) @@ -566,14 +897,50 @@ const audioImportSchema = z.object({ sampleRateOverride: z.number().int().min(0).max(192000).default(0) }).strict(); +const animatorParameterSchema = z.object({ + name: z.string().min(1).max(128), + type: z.enum(["float", "int", "bool", "trigger"]).default("float"), + defaultFloat: z.number().finite().default(0), + defaultInt: z.number().int().default(0), + defaultBool: z.boolean().default(false) +}).strict(); + +const animatorStateSchema = z.object({ + name: z.string().min(1).max(128), + clipPath: z.string().min(1).max(512), + clipName: z.string().min(1).max(128).optional(), + speed: z.number().finite().min(-100).max(100).default(1), + writeDefaultValues: z.boolean().default(true), + positionX: z.number().finite().default(0), + positionY: z.number().finite().default(0) +}).strict(); + +const animatorTransitionConditionSchema = z.object({ + mode: z.enum(["if", "if_not", "greater", "less", "equals", "not_equal"]).default("if"), + threshold: z.number().finite().default(0), + parameter: z.string().min(1).max(128) +}).strict(); + +const animatorTransitionSchema = z.object({ + fromState: z.string().min(1).max(128), + toState: z.string().min(1).max(128), + hasExitTime: z.boolean().default(true), + exitTime: z.number().finite().min(0).max(1000).default(1), + duration: z.number().finite().min(0).max(1000).default(0.1), + hasFixedDuration: z.boolean().default(true), + offset: z.number().finite().min(0).max(1).default(0), + canTransitionToSelf: z.boolean().default(false), + conditions: z.array(animatorTransitionConditionSchema).max(20).default([]) +}).strict(); + server.registerTool( "unity.assets.author", { - description: "Create or edit shaders, materials, animation clips, generated WAV audio, and audio import settings with checkpoints.", + description: "Create or edit shaders, materials, animation clips, Animator Controllers, generated WAV audio, and audio import settings with checkpoints.", inputSchema: z.object({ dryRun: z.boolean().default(true), confirm: z.boolean().default(false), - kind: z.enum(["shader", "material", "animation_clip", "audio_tone", "audio_import"]), + kind: z.enum(["shader", "material", "animation_clip", "animator_controller", "audio_tone", "audio_import"]), path: z.string().min(1).max(512), shaderSource: z.string().max(1_048_576).optional(), shaderName: z.string().min(1).max(256).optional(), @@ -584,6 +951,11 @@ server.registerTool( clearExistingCurves: z.boolean().default(false), frameRate: z.number().min(1).max(240).default(60), animationCurves: z.array(animationCurveSchema).max(500).default([]), + clearExistingStates: z.boolean().default(true), + defaultState: z.string().min(1).max(128).optional(), + animatorParameters: z.array(animatorParameterSchema).max(100).default([]), + animatorStates: z.array(animatorStateSchema).max(200).default([]), + animatorTransitions: z.array(animatorTransitionSchema).max(500).default([]), audioTone: z.object({ frequencyHz: z.number().min(1).max(86000).default(440), durationSeconds: z.number().min(0.01).max(300).default(1), @@ -597,6 +969,174 @@ server.registerTool( async (input) => bridgeTool("unity.assets.author", input) ); +const modelImportSettingsSchema = z.object({ + globalScale: z.number().finite().min(0.0001).max(10000).default(1), + useFileScale: z.boolean().default(true), + importBlendShapes: z.boolean().default(true), + importVisibility: z.boolean().default(true), + importCameras: z.boolean().default(false), + importLights: z.boolean().default(false), + addCollider: z.boolean().default(false), + importAnimation: z.boolean().default(true), + animationType: z.enum(["none", "legacy", "generic", "human"]).default("generic"), + isReadable: z.boolean().default(false), + meshCompression: z.enum(["off", "low", "medium", "high"]).default("off"), + normalizeOnInstantiate: z.boolean().default(false), + recenterPivot: z.boolean().default(false), + pivotMode: z.enum(["bounds_center", "bounds_base"]).default("bounds_center"), + alignToGround: z.boolean().default(false), + forwardAxis: z.enum(["keep", "z", "+z", "-z", "negative_z", "x", "+x", "-x", "negative_x"]).default("keep") +}).strict(); + +const textureImportSettingsSchema = z.object({ + textureType: z.enum(["default", "normal_map", "sprite", "cursor", "cookie", "lightmap", "single_channel"]).default("default"), + sRgb: z.boolean().default(true), + alphaIsTransparency: z.boolean().default(false), + mipmapEnabled: z.boolean().default(true), + isReadable: z.boolean().default(false), + maxTextureSize: z.number().int().min(32).max(16384).default(2048), + compression: z.enum(["uncompressed", "compressed", "compressed_hq", "compressed_lq"]).default("compressed") +}).strict(); + +server.registerTool( + "unity.assets.catalog.search", + { + description: "Search bundled and configured CDN asset catalogs whose entries include license, provenance, byte size, and SHA-256 metadata.", + inputSchema: z.object({ + query: z.string().max(200).default(""), + kind: z.enum(["all", "model", "texture", "audio"]).default("all"), + tags: z.array(z.string().min(1).max(64)).max(20).default([]), + maxResults: z.number().int().min(1).max(200).default(50), + refresh: z.boolean().default(false) + }).strict() + }, + async (input) => { + try { + return jsonToolResult(await assetCatalog.search(input)); + } catch (error) { + return errorToolResult(error); + } + } +); + +server.registerTool( + "unity.assets.import", + { + description: "Copy or download a model, texture, or audio file, apply importer settings, and optionally instantiate, normalize model pivots/axes, or save a prefab.", + inputSchema: z.object({ + dryRun: z.boolean().default(true), + confirm: z.boolean().default(false), + sourceKind: z.enum(["local", "url"]).default("local"), + sourcePath: z.string().min(1).max(4096).optional(), + url: z.string().url().max(4096).optional(), + destinationPath: z.string().min(1).max(512), + overwrite: z.boolean().default(false), + expectedSha256: z.string().regex(/^[a-fA-F0-9]{64}$/).optional(), + maxBytes: z.number().int().min(1).max(2_147_483_648).default(268_435_456), + timeoutSeconds: z.number().int().min(5).max(1800).default(120), + allowInsecureLocalhost: z.boolean().default(false), + model: modelImportSettingsSchema.optional(), + texture: textureImportSettingsSchema.optional(), + audio: audioImportSchema.optional(), + instantiate: z.boolean().default(false), + objectName: z.string().min(1).max(80).optional(), + parentPath: z.string().min(1).max(512).optional(), + transform: z.object({ + position: sceneVectorSchema.optional(), + rotationEuler: sceneVectorSchema.optional(), + scale: sceneVectorSchema.optional() + }).strict().optional(), + saveAsPrefabPath: z.string().min(1).max(512).optional() + }).strict() + }, + async (input) => bridgeTool("unity.assets.import", input) +); + +server.registerTool( + "unity.assets.import_from_catalog", + { + description: "Resolve a license-allowlisted catalog asset, enforce its declared SHA-256 and size, import it through Unity, optionally normalize models, and retain provenance in the audit result.", + inputSchema: z.object({ + dryRun: z.boolean().default(true), + confirm: z.boolean().default(false), + catalogAssetId: z.string().min(3).max(256), + expectedCatalogSha256: z.string().regex(/^[a-fA-F0-9]{64}$/).optional(), + refreshCatalog: z.boolean().default(false), + destinationPath: z.string().min(1).max(512), + overwrite: z.boolean().default(false), + maxBytes: z.number().int().min(1).max(2_147_483_648).default(268_435_456), + timeoutSeconds: z.number().int().min(5).max(1800).default(120), + model: modelImportSettingsSchema.optional(), + texture: textureImportSettingsSchema.optional(), + audio: audioImportSchema.optional(), + instantiate: z.boolean().default(false), + objectName: z.string().min(1).max(80).optional(), + parentPath: z.string().min(1).max(512).optional(), + transform: z.object({ + position: sceneVectorSchema.optional(), + rotationEuler: sceneVectorSchema.optional(), + scale: sceneVectorSchema.optional() + }).strict().optional(), + saveAsPrefabPath: z.string().min(1).max(512).optional() + }).strict() + }, + async (input) => { + try { + const asset = await assetCatalog.resolve(input.catalogAssetId, input.refreshCatalog); + if (input.expectedCatalogSha256 + && input.expectedCatalogSha256.toLowerCase() !== asset.sha256) { + return errorToolResult(new Error(`Catalog SHA-256 changed for '${asset.assetId}'. Search the catalog again before confirming the import.`)); + } + + if (input.maxBytes < asset.sizeBytes) { + return errorToolResult(new Error(`Catalog asset '${asset.assetId}' declares ${asset.sizeBytes} bytes, exceeding maxBytes=${input.maxBytes}.`)); + } + + const destinationFormat = input.destinationPath.split(".").pop()?.toLowerCase(); + if (destinationFormat !== asset.format) { + return errorToolResult(new Error(`destinationPath must end in .${asset.format} for catalog asset '${asset.assetId}'.`)); + } + + const { catalogAssetId: _catalogAssetId, expectedCatalogSha256: _expectedHash, refreshCatalog: _refresh, ...importInput } = input; + const sourceInput = asset.source.kind === "local" + ? { sourceKind: "local", sourcePath: asset.source.path } + : { + sourceKind: "url", + url: asset.source.url, + allowInsecureLocalhost: asset.source.allowInsecureLocalhost + }; + const response = await bridge.call("unity.assets.import_from_catalog", { + ...importInput, + ...sourceInput, + expectedSha256: asset.sha256, + catalogProvenance: { + catalogId: asset.catalogId, + catalogAssetId: asset.assetId, + catalogName: asset.catalogName, + catalogHomepage: asset.catalogHomepage, + sourceUrl: asset.sourceUrl, + licenseSpdxId: asset.license.spdxId, + licenseName: asset.license.name, + licenseUrl: asset.license.url, + attribution: asset.license.attribution ?? "" + } + }); + + if (!response.ok) { + return errorToolResult(new Error(response.error ?? "Unity catalog import failed.")); + } + + const result = parseJsonObject(response.resultJson); + return jsonToolResult({ + ...result, + catalogAsset: toPublicCatalogAsset(asset) + }); + } catch (error) { + return errorToolResult(error); + } + } +); + const prefabEditSchema = z.object({ kind: z.enum(["create_child", "delete", "rename", "set_active", "add_component", "remove_component", "set_property"]), objectPath: z.string().max(512).default(""), @@ -605,7 +1145,7 @@ const prefabEditSchema = z.object({ componentType: z.string().min(1).max(256).optional(), componentIndex: z.number().int().min(0).default(0), propertyPath: z.string().min(1).max(512).optional(), - value: sceneSerializedValueSchema.optional() + value: serializedValueSchema.optional() }).strict(); server.registerTool( @@ -787,9 +1327,47 @@ async function bridgeTool(capability: string, input: unknown = {}) { }; } +function jsonToolResult(value: unknown) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(value, null, 2) + } + ] + }; +} + +function errorToolResult(error: unknown) { + return { + isError: true, + content: [ + { + type: "text" as const, + text: error instanceof Error ? error.message : String(error) + } + ] + }; +} + +function parseJsonObject(value: string | undefined): Record { + if (!value) { + return {}; + } + + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : { result: parsed }; +} + function parseTimeout(value: string | undefined): number { - const parsed = Number(value ?? 10_000); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 10_000; + return parsePositiveInteger(value, 10_000); +} + +function parsePositiveInteger(value: string | undefined, fallback: number): number { + const parsed = Number(value ?? fallback); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; } async function main(): Promise { diff --git a/apps/mcp-server/src/unity-bridge-client.ts b/apps/mcp-server/src/unity-bridge-client.ts index 29f74ae..c51042e 100644 --- a/apps/mcp-server/src/unity-bridge-client.ts +++ b/apps/mcp-server/src/unity-bridge-client.ts @@ -4,6 +4,14 @@ export interface UnityBridgeOptions { readonly baseUrl: string; readonly timeoutMs: number; readonly token?: string; + readonly retry?: Partial; +} + +export interface UnityBridgeRetryOptions { + readonly maxAttempts: number; + readonly maxElapsedMs: number; + readonly baseDelayMs: number; + readonly maxDelayMs: number; } export interface UnityBridgeResponse { @@ -13,52 +21,187 @@ export interface UnityBridgeResponse { readonly correlationId?: string; readonly resultJson?: string; readonly error?: string; + readonly attempts?: number; + readonly recoveredAfterRetry?: boolean; +} + +interface BridgeAttemptResult { + readonly response: UnityBridgeResponse; + readonly retryable: boolean; + readonly retryAfterMs?: number; } +const defaultRetryOptions: UnityBridgeRetryOptions = { + maxAttempts: 12, + maxElapsedMs: 90_000, + baseDelayMs: 150, + maxDelayMs: 3_000 +}; + export class UnityBridgeClient { private readonly baseUrl: string; + private readonly retry: UnityBridgeRetryOptions; + private reconnecting?: Promise; constructor(private readonly options: UnityBridgeOptions) { this.baseUrl = options.baseUrl.replace(/\/+$/, ""); + this.retry = { + maxAttempts: positiveInteger(options.retry?.maxAttempts, defaultRetryOptions.maxAttempts), + maxElapsedMs: positiveInteger(options.retry?.maxElapsedMs, defaultRetryOptions.maxElapsedMs), + baseDelayMs: positiveInteger(options.retry?.baseDelayMs, defaultRetryOptions.baseDelayMs), + maxDelayMs: positiveInteger(options.retry?.maxDelayMs, defaultRetryOptions.maxDelayMs) + }; } async call(capability: string, input: unknown = {}): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs); const requestId = randomUUID(); const correlationId = randomUUID(); + const requestBody = JSON.stringify({ requestId, correlationId, input }); + const startedAt = Date.now(); + const deadline = startedAt + this.retry.maxElapsedMs; + let lastResponse: UnityBridgeResponse | undefined; + let attemptsMade = 0; + + for (let attempt = 1; attempt <= this.retry.maxAttempts && Date.now() <= deadline; attempt += 1) { + attemptsMade = attempt; + if (this.reconnecting && !(await this.reconnecting)) { + break; + } + + const result = await this.attemptCall(capability, requestId, correlationId, requestBody); + lastResponse = result.response; + + if (!result.retryable) { + return { + ...result.response, + requestId: result.response.requestId ?? requestId, + correlationId: result.response.correlationId ?? correlationId, + attempts: attempt, + recoveredAfterRetry: attempt > 1 + }; + } + + if (attempt >= this.retry.maxAttempts || Date.now() >= deadline) { + break; + } + + const available = await this.waitForReconnect(deadline, result.retryAfterMs); + if (!available) { + break; + } + } + + const elapsedMs = Date.now() - startedAt; + return { + ok: false, + capability, + requestId, + correlationId, + attempts: Math.max(1, attemptsMade), + recoveredAfterRetry: false, + error: `Unity bridge remained unavailable after ${Math.max(1, attemptsMade)} attempt(s) over ${elapsedMs}ms. ${lastResponse?.error ?? "No bridge response was received."}` + }; + } + + private async attemptCall( + capability: string, + requestId: string, + correlationId: string, + requestBody: string + ): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs); try { const response = await fetch(`${this.baseUrl}/capabilities/${encodeURIComponent(capability)}`, { method: "POST", headers: this.createHeaders(), - body: JSON.stringify({ requestId, correlationId, input }), + body: requestBody, signal: controller.signal }); - const text = await response.text(); const parsed = parseBridgeResponse(text, capability); + const bridgeResponse = response.ok + ? parsed + : { + ok: false, + capability, + requestId, + correlationId, + error: parsed.error ?? `Unity bridge returned HTTP ${response.status}`, + resultJson: parsed.resultJson + }; - if (!response.ok) { - return { + return { + response: bridgeResponse, + retryable: isRetryableStatus(response.status), + retryAfterMs: parseRetryAfter(response.headers.get("retry-after")) + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + response: { ok: false, capability, requestId, correlationId, - error: parsed.error ?? `Unity bridge returned HTTP ${response.status}`, - resultJson: parsed.resultJson - }; + error: message + }, + retryable: true + }; + } finally { + clearTimeout(timeout); + } + } + + private async waitForReconnect(deadline: number, minimumDelayMs = 0): Promise { + if (this.reconnecting) { + return this.reconnecting; + } + + const reconnecting = this.probeUntilAvailable(deadline, minimumDelayMs); + this.reconnecting = reconnecting; + try { + return await reconnecting; + } finally { + if (this.reconnecting === reconnecting) { + this.reconnecting = undefined; } + } + } - return parsed; - } catch (error) { - return { - ok: false, - capability, - requestId, - correlationId, - error: error instanceof Error ? error.message : String(error) - }; + private async probeUntilAvailable(deadline: number, minimumDelayMs: number): Promise { + let delayMs = Math.max(this.retry.baseDelayMs, minimumDelayMs); + + while (Date.now() < deadline) { + await delay(Math.min(delayMs, Math.max(0, deadline - Date.now()))); + if (Date.now() >= deadline) { + return false; + } + + if (await this.isHealthy()) { + return true; + } + + delayMs = Math.min(this.retry.maxDelayMs, Math.ceil(delayMs * 1.7)); + } + + return false; + } + + private async isHealthy(): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), Math.min(this.options.timeoutMs, 2_000)); + + try { + const response = await fetch(`${this.baseUrl}/health`, { + method: "GET", + headers: this.createHeaders(), + signal: controller.signal + }); + return response.ok; + } catch { + return false; } finally { clearTimeout(timeout); } @@ -75,6 +218,36 @@ export class UnityBridgeClient { } } +function isRetryableStatus(status: number): boolean { + return status === 408 || status === 425 || status === 429 || status === 502 || status === 503 || status === 504; +} + +function parseRetryAfter(value: string | null): number | undefined { + if (!value) { + return undefined; + } + + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) { + return Math.min(30_000, Math.ceil(seconds * 1_000)); + } + + const date = Date.parse(value); + if (Number.isNaN(date)) { + return undefined; + } + + return Math.min(30_000, Math.max(0, date - Date.now())); +} + +function positiveInteger(value: number | undefined, fallback: number): number { + return Number.isFinite(value) && Number(value) > 0 ? Math.floor(Number(value)) : fallback; +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + function isLocalBridgeUrl(value: string): boolean { try { const url = new URL(value); diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor.meta new file mode 100644 index 0000000..82a17d2 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4d50b1fcd95e54795a345def05202eb1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act.meta new file mode 100644 index 0000000..99011f6 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6f14f95de4a86413ab5439d568c54594 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/CreateEmptyGameObjectOperation.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/CreateEmptyGameObjectOperation.cs.meta new file mode 100644 index 0000000..94bda30 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/CreateEmptyGameObjectOperation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b7b95a8bd2f254828bfc407edddb780c \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/GameplayComposeOperation.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/GameplayComposeOperation.cs new file mode 100644 index 0000000..209aeb4 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/GameplayComposeOperation.cs @@ -0,0 +1,779 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityAI.ControlPlane.Runtime; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEditorInternal; +using UnityEngine; + +namespace UnityAI.ControlPlane.Editor +{ + [Serializable] + public sealed class GameplayVector3Input + { + public float x; + public float y; + public float z; + } + + [Serializable] + public sealed class GameplayTemplateInput + { + public string kind; + public string targetPath; + public string interactorPath; + public string interactorTag = "Player"; + public float activationDistance = 2f; + + public float deactivationDistance = 2.5f; + public GameplayVector3Input openOffset = new() { y = 2.5f }; + public float speed = 3f; + public bool startsOpen; + public bool closeWhenOutOfRange = true; + + public string pickupId = "pickup"; + public int value = 1; + public string collectAction = "deactivate"; + public GameplayVector3Input spinAxis = new() { y = 1f }; + public float spinDegreesPerSecond = 90f; + public float bobAmplitude = 0.15f; + public float bobFrequency = 1f; + + public string[] affectedPaths = Array.Empty(); + public string action = "activate"; + public bool oneShot = true; + public bool revertOnExit; + } + + [Serializable] + public sealed class GameplayComposeInput + { + public bool dryRun = true; + public bool confirm; + public GameplayTemplateInput[] templates = Array.Empty(); + } + + [Serializable] + public sealed class GameplayComposeRequest + { + public GameplayComposeInput input = new(); + } + + [Serializable] + public sealed class GameplayTemplateResult + { + public int index; + public string kind; + public string targetPath; + public string componentType; + public string interactorPath; + public string[] affectedPaths = Array.Empty(); + public bool applied; + public bool verified; + public string message; + } + + [Serializable] + public sealed class GameplayComposeResult + { + public bool dryRun; + public bool applied; + public bool refused; + public bool rolledBack; + public bool requiresConfirmation; + public string requestId; + public string correlationId; + public string scenePath; + public string checkpointId; + public int requestedTemplateCount; + public int appliedTemplateCount; + public GameplayTemplateResult[] templates = Array.Empty(); + public string message; + public string verificationStatus; + public string[] requiredPermissions = Array.Empty(); + public string[] verificationSignals = Array.Empty(); + public UnityAiAuditEvent[] auditEvents = Array.Empty(); + public bool auditPersisted; + public string auditLogPath; + public string timestampUtc; + } + + public static class GameplayComposeOperation + { + private const string Capability = "unity.gameplay.compose"; + private const int MaxTemplates = 20; + private const int MaxAffectedTargets = 32; + + public static GameplayComposeResult Execute(string requestBody) + { + var request = ParseRequest(requestBody); + var input = request.input ?? new GameplayComposeInput(); + var templates = input.templates ?? Array.Empty(); + var envelope = UnityAiJobStore.ParseEnvelope(requestBody); + var scene = EditorSceneManager.GetActiveScene(); + + if (!scene.IsValid() || string.IsNullOrWhiteSpace(scene.path)) + { + return BuildResult( + envelope, + input, + false, + true, + false, + false, + string.Empty, + Array.Empty(), + "The active scene must be saved before gameplay templates can create a durable checkpoint.", + "refused"); + } + + if (!ValidateTemplates(templates, out var refusal)) + { + return BuildResult( + envelope, + input, + false, + true, + false, + false, + string.Empty, + Array.Empty(), + refusal, + "refused"); + } + + if (input.dryRun) + { + var plans = templates.Select((template, index) => BuildPlan(template, index)).ToArray(); + return BuildResult( + envelope, + input, + false, + false, + false, + false, + string.Empty, + plans, + $"DRY RUN: validated {plans.Length} gameplay template(s).", + "passed"); + } + + if (!input.confirm) + { + return BuildResult( + envelope, + input, + false, + false, + false, + true, + string.Empty, + Array.Empty(), + $"CONFIRMATION REQUIRED: would apply {templates.Length} gameplay template(s) atomically.", + "needs_confirmation"); + } + + var checkpoint = DurableCheckpointStore.CreateInternal( + "gameplay-compose", + new[] { scene.path, scene.path + ".meta" }); + Undo.IncrementCurrentGroup(); + var undoGroup = Undo.GetCurrentGroup(); + Undo.SetCurrentGroupName("Unity AI Compose Gameplay"); + var results = new List(); + + try + { + for (var index = 0; index < templates.Length; index++) + { + results.Add(ApplyTemplate(templates[index], index)); + } + + EditorSceneManager.MarkSceneDirty(scene); + Undo.CollapseUndoOperations(undoGroup); + return BuildResult( + envelope, + input, + true, + false, + false, + false, + checkpoint.checkpointId, + results.ToArray(), + $"Applied and verified {results.Count} gameplay template(s).", + "passed"); + } + catch (Exception exception) + { + Undo.RevertAllDownToGroup(undoGroup); + return BuildResult( + envelope, + input, + false, + false, + true, + false, + checkpoint.checkpointId, + results.ToArray(), + $"Gameplay composition failed and all in-memory changes were rolled back: {exception.GetBaseException().Message}", + "failed"); + } + } + + private static GameplayTemplateResult ApplyTemplate(GameplayTemplateInput input, int index) + { + var kind = Normalize(input.kind); + var targetPath = NormalizePath(input.targetPath); + var target = RequireObject(targetPath); + var interactor = ResolveInteractor(input.interactorPath); + + switch (kind) + { + case "door": + return ApplyDoor(input, index, targetPath, target, interactor); + case "pickup": + return ApplyPickup(input, index, targetPath, target, interactor); + case "activator": + return ApplyActivator(input, index, targetPath, target, interactor); + default: + throw new InvalidOperationException($"Unsupported gameplay template '{kind}'."); + } + } + + private static GameplayTemplateResult ApplyDoor( + GameplayTemplateInput input, + int index, + string targetPath, + GameObject target, + Transform interactor) + { + var component = GetOrAddComponent(target); + component.interactor = interactor; + component.interactorTag = NormalizeTag(input.interactorTag); + component.activationDistance = input.activationDistance; + component.deactivationDistance = input.deactivationDistance; + component.openLocalOffset = ToVector(input.openOffset); + component.movementSpeed = input.speed; + component.startsOpen = input.startsOpen; + component.closeWhenOutOfRange = input.closeWhenOutOfRange; + EditorUtility.SetDirty(component); + + var verified = component.interactor == interactor + && component.interactorTag == NormalizeTag(input.interactorTag) + && Approximately(component.activationDistance, input.activationDistance) + && Approximately(component.deactivationDistance, input.deactivationDistance) + && Vector3.Distance(component.openLocalOffset, ToVector(input.openOffset)) <= 0.0001f + && Approximately(component.movementSpeed, input.speed) + && component.startsOpen == input.startsOpen + && component.closeWhenOutOfRange == input.closeWhenOutOfRange; + RequireVerified(verified, $"Door template on '{targetPath}' could not be verified."); + return AppliedResult(index, input, typeof(ProximityDoor), Array.Empty(), "Configured proximity door."); + } + + private static GameplayTemplateResult ApplyPickup( + GameplayTemplateInput input, + int index, + string targetPath, + GameObject target, + Transform interactor) + { + var component = GetOrAddComponent(target); + component.interactor = interactor; + component.interactorTag = NormalizeTag(input.interactorTag); + component.activationDistance = input.activationDistance; + component.pickupId = input.pickupId.Trim(); + component.value = input.value; + component.collectAction = ParseCollectAction(input.collectAction); + component.spinAxis = ToVector(input.spinAxis); + component.spinDegreesPerSecond = input.spinDegreesPerSecond; + component.bobAmplitude = input.bobAmplitude; + component.bobFrequency = input.bobFrequency; + EditorUtility.SetDirty(component); + + var verified = component.interactor == interactor + && component.interactorTag == NormalizeTag(input.interactorTag) + && Approximately(component.activationDistance, input.activationDistance) + && component.pickupId == input.pickupId.Trim() + && component.value == input.value + && component.collectAction == ParseCollectAction(input.collectAction) + && Vector3.Distance(component.spinAxis, ToVector(input.spinAxis)) <= 0.0001f + && Approximately(component.spinDegreesPerSecond, input.spinDegreesPerSecond) + && Approximately(component.bobAmplitude, input.bobAmplitude) + && Approximately(component.bobFrequency, input.bobFrequency); + RequireVerified(verified, $"Pickup template on '{targetPath}' could not be verified."); + return AppliedResult(index, input, typeof(ProximityPickup), Array.Empty(), "Configured proximity pickup."); + } + + private static GameplayTemplateResult ApplyActivator( + GameplayTemplateInput input, + int index, + string targetPath, + GameObject target, + Transform interactor) + { + var affectedPaths = input.affectedPaths.Select(NormalizePath).ToArray(); + var affectedObjects = affectedPaths.Select(RequireObject).ToArray(); + var component = GetOrAddComponent(target); + component.interactor = interactor; + component.interactorTag = NormalizeTag(input.interactorTag); + component.activationDistance = input.activationDistance; + component.action = ParseTargetAction(input.action); + component.oneShot = input.oneShot; + component.revertOnExit = input.revertOnExit; + var serializedObject = new SerializedObject(component); + serializedObject.Update(); + var targetsProperty = serializedObject.FindProperty("targets") + ?? throw new InvalidOperationException("ProximityActivator.targets is not serialized."); + targetsProperty.arraySize = affectedObjects.Length; + for (var targetIndex = 0; targetIndex < affectedObjects.Length; targetIndex++) + { + targetsProperty.GetArrayElementAtIndex(targetIndex).objectReferenceValue = affectedObjects[targetIndex]; + } + + serializedObject.ApplyModifiedProperties(); + serializedObject.Update(); + EditorUtility.SetDirty(component); + + var verified = component.interactor == interactor + && component.interactorTag == NormalizeTag(input.interactorTag) + && Approximately(component.activationDistance, input.activationDistance) + && component.targets.SequenceEqual(affectedObjects) + && component.action == ParseTargetAction(input.action) + && component.oneShot == input.oneShot + && component.revertOnExit == input.revertOnExit + && targetsProperty.arraySize == affectedObjects.Length + && Enumerable.Range(0, affectedObjects.Length).All(targetIndex => + targetsProperty.GetArrayElementAtIndex(targetIndex).objectReferenceValue == affectedObjects[targetIndex]); + RequireVerified(verified, $"Activator template on '{targetPath}' could not be verified."); + return AppliedResult(index, input, typeof(ProximityActivator), affectedPaths, "Configured proximity activator."); + } + + private static T GetOrAddComponent(GameObject target) where T : Component + { + var existing = target.GetComponent(); + if (existing != null) + { + Undo.RecordObject(existing, "Unity AI Update Gameplay Component"); + return existing; + } + + var created = Undo.AddComponent(target); + if (created == null) + { + throw new InvalidOperationException($"Unity could not add component '{typeof(T).FullName}' to '{GetPath(target)}'."); + } + + return created; + } + + private static bool ValidateTemplates(GameplayTemplateInput[] templates, out string refusal) + { + if (templates.Length == 0 || templates.Length > MaxTemplates) + { + refusal = $"templates must contain between 1 and {MaxTemplates} items."; + return false; + } + + var identities = new HashSet(StringComparer.Ordinal); + for (var index = 0; index < templates.Length; index++) + { + var template = templates[index]; + if (template == null) + { + refusal = $"templates[{index}] is null."; + return false; + } + + var kind = Normalize(template.kind); + if (kind != "door" && kind != "pickup" && kind != "activator") + { + refusal = $"templates[{index}].kind must be door, pickup, or activator."; + return false; + } + + var targetPath = NormalizePath(template.targetPath); + if (!IsSafePath(targetPath) || FindByPath(targetPath) == null) + { + refusal = $"templates[{index}].targetPath must identify an existing GameObject."; + return false; + } + + if (!identities.Add(kind + "\n" + targetPath)) + { + refusal = $"templates[{index}] duplicates the {kind} template for '{targetPath}'."; + return false; + } + + if (!string.IsNullOrWhiteSpace(template.interactorPath)) + { + var interactorPath = NormalizePath(template.interactorPath); + if (!IsSafePath(interactorPath) || FindByPath(interactorPath) == null) + { + refusal = $"templates[{index}].interactorPath must identify an existing GameObject."; + return false; + } + } + else if (!IsExistingTag(template.interactorTag)) + { + refusal = $"templates[{index}].interactorTag must be an existing Unity tag when interactorPath is omitted."; + return false; + } + + if (!IsFiniteInRange(template.activationDistance, 0.01f, 1000f)) + { + refusal = $"templates[{index}].activationDistance must be finite and between 0.01 and 1000."; + return false; + } + + if (kind == "door" && !ValidateDoor(template, index, out refusal)) + { + return false; + } + + if (kind == "pickup" && !ValidatePickup(template, index, out refusal)) + { + return false; + } + + if (kind == "activator" && !ValidateActivator(template, index, out refusal)) + { + return false; + } + } + + refusal = string.Empty; + return true; + } + + private static bool ValidateDoor(GameplayTemplateInput input, int index, out string refusal) + { + if (!IsFiniteInRange(input.deactivationDistance, 0.01f, 1000f) + || !IsFiniteInRange(input.speed, 0.01f, 1000f) + || !IsBoundedVector(input.openOffset, 10000f)) + { + refusal = $"templates[{index}] contains invalid door distance, speed, or openOffset values."; + return false; + } + + refusal = string.Empty; + return true; + } + + private static bool ValidatePickup(GameplayTemplateInput input, int index, out string refusal) + { + var pickupId = (input.pickupId ?? string.Empty).Trim(); + if (pickupId.Length == 0 || pickupId.Length > 128 + || input.value < 0 || input.value > 1000000 + || (Normalize(input.collectAction) != "deactivate" && Normalize(input.collectAction) != "destroy") + || !IsBoundedVector(input.spinAxis, 1000f) + || !IsFiniteInRange(input.spinDegreesPerSecond, -10000f, 10000f) + || !IsFiniteInRange(input.bobAmplitude, 0f, 1000f) + || !IsFiniteInRange(input.bobFrequency, 0f, 1000f)) + { + refusal = $"templates[{index}] contains invalid pickup id, value, action, spin, or bob settings."; + return false; + } + + refusal = string.Empty; + return true; + } + + private static bool ValidateActivator(GameplayTemplateInput input, int index, out string refusal) + { + var affectedPaths = input.affectedPaths ?? Array.Empty(); + var action = Normalize(input.action); + if (affectedPaths.Length == 0 || affectedPaths.Length > MaxAffectedTargets) + { + refusal = $"templates[{index}].affectedPaths must contain between 1 and {MaxAffectedTargets} targets."; + return false; + } + + if (action != "activate" && action != "deactivate" && action != "toggle") + { + refusal = $"templates[{index}].action must be activate, deactivate, or toggle."; + return false; + } + + foreach (var rawPath in affectedPaths) + { + var path = NormalizePath(rawPath); + if (!IsSafePath(path) || FindByPath(path) == null) + { + refusal = $"templates[{index}].affectedPaths contains a missing or invalid GameObject path."; + return false; + } + } + + refusal = string.Empty; + return true; + } + + private static GameplayTemplateResult BuildPlan(GameplayTemplateInput input, int index) + { + var kind = Normalize(input.kind); + var type = kind switch + { + "door" => typeof(ProximityDoor), + "pickup" => typeof(ProximityPickup), + _ => typeof(ProximityActivator) + }; + return new GameplayTemplateResult + { + index = index, + kind = kind, + targetPath = NormalizePath(input.targetPath), + componentType = type.FullName, + interactorPath = NormalizePath(input.interactorPath), + affectedPaths = kind == "activator" + ? (input.affectedPaths ?? Array.Empty()).Select(NormalizePath).ToArray() + : Array.Empty(), + message = $"Would configure {kind} gameplay on '{NormalizePath(input.targetPath)}'." + }; + } + + private static GameplayTemplateResult AppliedResult( + int index, + GameplayTemplateInput input, + Type componentType, + string[] affectedPaths, + string message) + { + return new GameplayTemplateResult + { + index = index, + kind = Normalize(input.kind), + targetPath = NormalizePath(input.targetPath), + componentType = componentType.FullName, + interactorPath = NormalizePath(input.interactorPath), + affectedPaths = affectedPaths, + applied = true, + verified = true, + message = message + }; + } + + private static GameplayComposeResult BuildResult( + UnityAiRequestEnvelope envelope, + GameplayComposeInput input, + bool applied, + bool refused, + bool rolledBack, + bool requiresConfirmation, + string checkpointId, + GameplayTemplateResult[] templateResults, + string message, + string verificationStatus) + { + var timestamp = DateTime.UtcNow.ToString("O"); + var effect = applied || rolledBack ? "scene_change" : "report_only"; + var auditEvent = new UnityAiAuditEvent + { + timestamp = timestamp, + capability = Capability, + requestId = envelope.requestId, + correlationId = envelope.correlationId, + message = message, + effects = applied || rolledBack + ? new[] { effect, "write_checkpoint", "write_audit_log" } + : new[] { effect, "write_audit_log" } + }; + var auditPersisted = PersistAudit(auditEvent); + var signals = new List { "operation_audited", "structured_observation" }; + if (!string.IsNullOrWhiteSpace(checkpointId)) + { + signals.Add("checkpoint_created"); + } + + if (applied) + { + signals.Add("gameplay_template_applied"); + signals.Add("component_state_verified"); + signals.Add("scene_mutation_verified"); + } + + if (rolledBack) + { + signals.Add("rollback_verified"); + } + + return new GameplayComposeResult + { + dryRun = input.dryRun, + applied = applied, + refused = refused, + rolledBack = rolledBack, + requiresConfirmation = requiresConfirmation, + requestId = envelope.requestId, + correlationId = envelope.correlationId, + scenePath = EditorSceneManager.GetActiveScene().path, + checkpointId = checkpointId, + requestedTemplateCount = input.templates?.Length ?? 0, + appliedTemplateCount = applied ? templateResults.Count(result => result.applied) : 0, + templates = templateResults, + message = message, + verificationStatus = verificationStatus, + requiredPermissions = new[] { "read_scenes", "modify_scenes" }, + verificationSignals = signals.ToArray(), + auditEvents = new[] { auditEvent }, + auditPersisted = auditPersisted, + auditLogPath = AuditLogStore.AuditLogRelativePath, + timestampUtc = timestamp + }; + } + + private static bool PersistAudit(UnityAiAuditEvent auditEvent) + { + try + { + AuditLogStore.Append(new[] { auditEvent }); + return true; + } + catch (Exception exception) + { + Debug.LogError($"Failed to persist gameplay composition audit event: {exception.Message}"); + return false; + } + } + + private static Transform ResolveInteractor(string path) + { + return string.IsNullOrWhiteSpace(path) ? null : RequireObject(NormalizePath(path)).transform; + } + + private static GameObject RequireObject(string path) + { + return FindByPath(path) + ?? throw new InvalidOperationException($"GameObject '{path}' was not found in the active scene."); + } + + private static GameObject FindByPath(string path) + { + var normalized = NormalizePath(path); + if (string.IsNullOrEmpty(normalized)) + { + return null; + } + + var segments = normalized.Split('/'); + var current = EditorSceneManager.GetActiveScene() + .GetRootGameObjects() + .FirstOrDefault(root => root.name == segments[0]); + for (var index = 1; current != null && index < segments.Length; index++) + { + var child = current.transform.Find(segments[index]); + current = child != null ? child.gameObject : null; + } + + return current; + } + + private static string GetPath(GameObject gameObject) + { + var names = new List(); + var current = gameObject.transform; + while (current != null) + { + names.Add(current.name); + current = current.parent; + } + + names.Reverse(); + return string.Join("/", names); + } + + private static bool IsSafePath(string path) + { + if (string.IsNullOrWhiteSpace(path) || path.Length > 512 || path.StartsWith("/", StringComparison.Ordinal) + || path.EndsWith("/", StringComparison.Ordinal) || path.Contains("//") || path.Contains("..")) + { + return false; + } + + return path.Split('/').All(segment => + !string.IsNullOrWhiteSpace(segment) + && segment.Length <= 80 + && !segment.Any(char.IsControl)); + } + + private static bool IsExistingTag(string tag) + { + var normalized = NormalizeTag(tag); + return normalized.Length > 0 && Array.IndexOf(InternalEditorUtility.tags, normalized) >= 0; + } + + private static bool IsBoundedVector(GameplayVector3Input vector, float maximum) + { + return vector != null + && IsFiniteInRange(vector.x, -maximum, maximum) + && IsFiniteInRange(vector.y, -maximum, maximum) + && IsFiniteInRange(vector.z, -maximum, maximum); + } + + private static bool IsFiniteInRange(float value, float minimum, float maximum) + { + return !float.IsNaN(value) && !float.IsInfinity(value) && value >= minimum && value <= maximum; + } + + private static Vector3 ToVector(GameplayVector3Input value) + { + return value == null ? Vector3.zero : new Vector3(value.x, value.y, value.z); + } + + private static PickupCollectAction ParseCollectAction(string value) + { + return Normalize(value) == "destroy" ? PickupCollectAction.Destroy : PickupCollectAction.Deactivate; + } + + private static ProximityTargetAction ParseTargetAction(string value) + { + return Normalize(value) switch + { + "deactivate" => ProximityTargetAction.Deactivate, + "toggle" => ProximityTargetAction.Toggle, + _ => ProximityTargetAction.Activate + }; + } + + private static string NormalizeTag(string value) + { + return (value ?? string.Empty).Trim(); + } + + private static string NormalizePath(string value) + { + return (value ?? string.Empty).Trim().Replace('\\', '/').Trim('/'); + } + + private static string Normalize(string value) + { + return (value ?? string.Empty).Trim().ToLowerInvariant(); + } + + private static bool Approximately(float left, float right) + { + return Mathf.Abs(left - right) <= 0.0001f; + } + + private static void RequireVerified(bool verified, string message) + { + if (!verified) + { + throw new InvalidOperationException(message); + } + } + + private static GameplayComposeRequest ParseRequest(string body) + { + try + { + return string.IsNullOrWhiteSpace(body) + ? new GameplayComposeRequest() + : JsonUtility.FromJson(body) ?? new GameplayComposeRequest(); + } + catch + { + return new GameplayComposeRequest(); + } + } + } +} diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/GameplayComposeOperation.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/GameplayComposeOperation.cs.meta new file mode 100644 index 0000000..3f7994f --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/GameplayComposeOperation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 64c798a4a0ecd49c292a2ea7189f1f56 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/ProjectSettingsUpdateOperation.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/ProjectSettingsUpdateOperation.cs index 55054e0..9b0bd22 100644 --- a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/ProjectSettingsUpdateOperation.cs +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/ProjectSettingsUpdateOperation.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Reflection; using UnityEditor; using UnityEngine; using UnityEngine.Rendering; @@ -36,6 +38,9 @@ public sealed class ProjectSettingsUpdateInput public bool buildAppBundle; public bool developmentBuild; public bool connectProfiler; + public string[] addTags = Array.Empty(); + public string[] addLayers = Array.Empty(); + public string activeInputHandling; public BuildSceneInput[] scenes = Array.Empty(); } @@ -132,6 +137,9 @@ private static void Apply(string body, ProjectSettingsUpdateInput input) if (HasField(body, "buildAppBundle")) EditorUserBuildSettings.buildAppBundle = input.buildAppBundle; if (HasField(body, "developmentBuild")) EditorUserBuildSettings.development = input.developmentBuild; if (HasField(body, "connectProfiler")) EditorUserBuildSettings.connectProfiler = input.connectProfiler; + if (HasField(body, "addTags")) AddTags(input.addTags); + if (HasField(body, "addLayers")) AddLayers(input.addLayers); + if (HasField(body, "activeInputHandling")) SetActiveInputHandling(input.activeInputHandling); if (HasField(body, "scenes")) { EditorBuildSettings.scenes = input.scenes.Select(scene => new EditorBuildSettingsScene(scene.path.Trim().Replace('\\', '/'), scene.enabled)).ToArray(); @@ -148,6 +156,9 @@ private static bool TryValidate(string body, ProjectSettingsUpdateInput input, o if (HasField(body, "androidMinSdk") && input.androidMinSdk < 21) return Error("androidMinSdk must be at least 21.", out error); if (HasField(body, "androidTargetSdk") && input.androidTargetSdk < 0) return Error("androidTargetSdk must be zero (automatic) or positive.", out error); if (HasField(body, "androidArchitectures") && ParseArchitectures(input.androidArchitectures) == 0) return Error("androidArchitectures must contain ARM64, ARMv7, or X86.", out error); + if (HasField(body, "addTags") && !ValidateNames(input.addTags, 64, "tag", out error)) return false; + if (HasField(body, "addLayers") && !ValidateNames(input.addLayers, 32, "layer", out error)) return false; + if (HasField(body, "activeInputHandling") && ParseInputHandling(input.activeInputHandling) < 0) return Error("activeInputHandling must be legacy, old, input_system, new, or both.", out error); if (HasField(body, "scenes") && input.scenes.Any(scene => scene == null || string.IsNullOrWhiteSpace(scene.path) || !scene.path.StartsWith("Assets/", StringComparison.Ordinal) || !scene.path.EndsWith(".unity", StringComparison.OrdinalIgnoreCase) || scene.path.Contains(".."))) return Error("Every build scene must be a safe Assets/*.unity path.", out error); error = string.Empty; return true; @@ -166,12 +177,181 @@ private static AndroidArchitecture ParseArchitectures(string[] values) private static IEnumerable GetChangedFields(string body) { - foreach (var field in new[] { "companyName", "productName", "applicationIdentifier", "colorSpace", "scriptingBackend", "androidMinSdk", "androidTargetSdk", "androidArchitectures", "buildAppBundle", "developmentBuild", "connectProfiler", "scenes" }) + foreach (var field in new[] { "companyName", "productName", "applicationIdentifier", "colorSpace", "scriptingBackend", "androidMinSdk", "androidTargetSdk", "androidArchitectures", "buildAppBundle", "developmentBuild", "connectProfiler", "addTags", "addLayers", "activeInputHandling", "scenes" }) { if (HasField(body, field)) yield return field; } } + private static void AddTags(string[] tags) + { + var manager = LoadTagManager(); + var property = manager.FindProperty("tags"); + foreach (var tag in NormalizeNames(tags).Distinct(StringComparer.Ordinal)) + { + if (ContainsString(property, tag)) + { + continue; + } + + property.InsertArrayElementAtIndex(property.arraySize); + property.GetArrayElementAtIndex(property.arraySize - 1).stringValue = tag; + } + + manager.ApplyModifiedPropertiesWithoutUndo(); + } + + private static void AddLayers(string[] layers) + { + var manager = LoadTagManager(); + var property = manager.FindProperty("layers"); + foreach (var layer in NormalizeNames(layers).Distinct(StringComparer.Ordinal)) + { + if (ContainsString(property, layer)) + { + continue; + } + + var slot = -1; + for (var index = 8; index < property.arraySize; index++) + { + if (string.IsNullOrWhiteSpace(property.GetArrayElementAtIndex(index).stringValue)) + { + slot = index; + break; + } + } + + if (slot < 0) + { + throw new InvalidOperationException("No free user layer slots are available."); + } + + property.GetArrayElementAtIndex(slot).stringValue = layer; + } + + manager.ApplyModifiedPropertiesWithoutUndo(); + } + + private static SerializedObject LoadTagManager() + { + var assets = AssetDatabase.LoadAllAssetsAtPath("ProjectSettings/TagManager.asset"); + if (assets == null || assets.Length == 0) + { + throw new InvalidOperationException("ProjectSettings/TagManager.asset could not be loaded."); + } + + return new SerializedObject(assets[0]); + } + + private static bool ContainsString(SerializedProperty property, string value) + { + for (var index = 0; index < property.arraySize; index++) + { + if (string.Equals(property.GetArrayElementAtIndex(index).stringValue, value, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static void SetActiveInputHandling(string value) + { + var code = ParseInputHandling(value); + if (code < 0) + { + throw new InvalidOperationException("Unsupported activeInputHandling value."); + } + + var property = typeof(PlayerSettings).GetProperty("activeInputHandling", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + if (property != null && property.CanWrite && property.PropertyType.IsEnum) + { + property.SetValue(null, Enum.ToObject(property.PropertyType, code)); + return; + } + + WriteProjectSettingsActiveInputHandler(code); + } + + private static void WriteProjectSettingsActiveInputHandler(int code) + { + var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; + var path = Path.Combine(projectRoot, "ProjectSettings", "ProjectSettings.asset"); + if (!File.Exists(path)) + { + throw new InvalidOperationException("ProjectSettings.asset could not be found."); + } + + var lines = File.ReadAllLines(path); + for (var index = 0; index < lines.Length; index++) + { + var trimmed = lines[index].TrimStart(); + if (!trimmed.StartsWith("activeInputHandler:", StringComparison.Ordinal)) + { + continue; + } + + var indentation = lines[index].Substring(0, lines[index].Length - trimmed.Length); + lines[index] = indentation + "activeInputHandler: " + code; + File.WriteAllLines(path, lines); + return; + } + + throw new InvalidOperationException("activeInputHandler was not found in ProjectSettings.asset."); + } + + private static int ParseInputHandling(string value) + { + switch ((value ?? string.Empty).Trim().ToLowerInvariant().Replace("-", "_").Replace(" ", "_")) + { + case "legacy": + case "old": + case "input_manager": + return 0; + case "new": + case "input_system": + case "inputsystem": + return 1; + case "both": + return 2; + default: + return -1; + } + } + + private static bool ValidateNames(string[] values, int maxLength, string label, out string error) + { + foreach (var name in NormalizeNames(values)) + { + if (name.Length > maxLength + || name.Contains("/") + || name.Contains("\\") + || name.Contains("..") + || name.Any(char.IsControl)) + { + error = $"Invalid {label} name '{name}'."; + return false; + } + } + + error = string.Empty; + return true; + } + + private static IEnumerable NormalizeNames(string[] values) + { + foreach (var value in values ?? Array.Empty()) + { + var normalized = (value ?? string.Empty).Trim(); + if (normalized.Length > 0) + { + yield return normalized; + } + } + } + private static T ParseEnum(string value) where T : struct { return Enum.TryParse(value, true, out var parsed) ? parsed : default; diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/ProjectSettingsUpdateOperation.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/ProjectSettingsUpdateOperation.cs.meta new file mode 100644 index 0000000..4faac71 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/ProjectSettingsUpdateOperation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a9a6b7936fd63415899b7bbe7ce12cd5 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/SceneBatchOperation.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/SceneBatchOperation.cs index 201d1f2..5e0e9f5 100644 --- a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/SceneBatchOperation.cs +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/SceneBatchOperation.cs @@ -24,6 +24,9 @@ public sealed class SceneSerializedValueInput public float y2; public float z2; public string assetPath; + public string path; + public string componentType; + public int componentIndex; public string enumName; public int enumIndex; } @@ -507,6 +510,12 @@ private static void ApplySerializedValue(SerializedProperty property, SceneSeria case "object_reference" when property.propertyType == SerializedPropertyType.ObjectReference: property.objectReferenceValue = LoadObjectReference(input.assetPath); return; + case "game_object_reference" when property.propertyType == SerializedPropertyType.ObjectReference: + property.objectReferenceValue = RequireTarget(NormalizePath(input.path)); + return; + case "component_reference" when property.propertyType == SerializedPropertyType.ObjectReference: + property.objectReferenceValue = ResolveSceneComponentReference(input); + return; case "null" when property.propertyType == SerializedPropertyType.ObjectReference: property.objectReferenceValue = null; return; @@ -563,6 +572,10 @@ private static bool SerializedValueMatches(SerializedProperty property, SceneSer return property.enumValueIndex == ResolveEnumIndex(property, input); case "object_reference": return property.objectReferenceValue == LoadObjectReference(input.assetPath); + case "game_object_reference": + return property.objectReferenceValue == RequireTarget(NormalizePath(input.path)); + case "component_reference": + return property.objectReferenceValue == ResolveSceneComponentReference(input); case "null": return property.objectReferenceValue == null; case "array_size": @@ -573,6 +586,14 @@ private static bool SerializedValueMatches(SerializedProperty property, SceneSer } } + private static Component ResolveSceneComponentReference(SceneSerializedValueInput input) + { + var path = NormalizePath(input.path); + var target = RequireTarget(path); + var type = ResolveComponentType(input.componentType); + return RequireComponent(target.GetComponents(type), input.componentIndex, type, path); + } + private static int ResolveEnumIndex(SerializedProperty property, SceneSerializedValueInput input) { if (!string.IsNullOrWhiteSpace(input.enumName)) @@ -685,6 +706,25 @@ private static bool TryValidateBatch(SceneBatchItemInput[] operations, out strin refusal = $"operations[{index}].prefabPath must be a safe Assets/ or Packages/ path."; return false; } + + if (kind == "set_property") + { + var value = operation.value ?? new SceneSerializedValueInput(); + var valueKind = Normalize(value.kind); + if ((valueKind == "game_object_reference" || valueKind == "component_reference") + && !IsSafeScenePath(value.path, false)) + { + refusal = $"operations[{index}].value.path is not a safe scene hierarchy path."; + return false; + } + + if (valueKind == "component_reference" + && (string.IsNullOrWhiteSpace(value.componentType) || value.componentIndex < 0)) + { + refusal = $"operations[{index}].value requires componentType and a non-negative componentIndex."; + return false; + } + } } return true; diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/SceneBatchOperation.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/SceneBatchOperation.cs.meta new file mode 100644 index 0000000..8dc2028 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/SceneBatchOperation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d56ca3a4b19f04b9994e7c8cf942a08e \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/SceneUpsertGameObjectOperation.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/SceneUpsertGameObjectOperation.cs.meta new file mode 100644 index 0000000..6f3a446 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/SceneUpsertGameObjectOperation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ea58a37a818b44871b011c93e3de1c36 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/UndoLastOperation.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/UndoLastOperation.cs.meta new file mode 100644 index 0000000..a5d6802 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/UndoLastOperation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 38e2b6ae9e6c04ac4b85dc8a4c40eed2 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets.meta new file mode 100644 index 0000000..4a1dbcc --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bf9a9bf1054254a23b5471461ae71078 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetAuthoringOperation.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetAuthoringOperation.cs index 6ebe0bd..27190d9 100644 --- a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetAuthoringOperation.cs +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetAuthoringOperation.cs @@ -6,6 +6,7 @@ using System.Security.Cryptography; using System.Text; using UnityEditor; +using UnityEditor.Animations; using UnityEngine; namespace UnityAI.ControlPlane.Editor @@ -32,6 +33,11 @@ public sealed class AssetAuthoringInput public bool clearExistingCurves; public float frameRate = 60f; public AnimationCurveInput[] animationCurves = Array.Empty(); + public bool clearExistingStates = true; + public string defaultState; + public AnimatorParameterInput[] animatorParameters = Array.Empty(); + public AnimatorStateInput[] animatorStates = Array.Empty(); + public AnimatorTransitionInput[] animatorTransitions = Array.Empty(); public AudioToneInput audioTone = new(); public AudioImportInput audioImport = new(); } @@ -68,6 +74,50 @@ public sealed class AnimationKeyframeInput public float outTangent; } + [Serializable] + public sealed class AnimatorParameterInput + { + public string name; + public string type = "float"; + public float defaultFloat; + public int defaultInt; + public bool defaultBool; + } + + [Serializable] + public sealed class AnimatorStateInput + { + public string name; + public string clipPath; + public string clipName; + public float speed = 1f; + public bool writeDefaultValues = true; + public float positionX; + public float positionY; + } + + [Serializable] + public sealed class AnimatorTransitionConditionInput + { + public string mode = "if"; + public float threshold; + public string parameter; + } + + [Serializable] + public sealed class AnimatorTransitionInput + { + public string fromState; + public string toState; + public bool hasExitTime = true; + public float exitTime = 1f; + public float duration = 0.1f; + public bool hasFixedDuration = true; + public float offset; + public bool canTransitionToSelf; + public AnimatorTransitionConditionInput[] conditions = Array.Empty(); + } + [Serializable] public sealed class AudioToneInput { @@ -166,6 +216,9 @@ public static AssetAuthoringResult Execute(string requestBody) case "animation_clip": AuthorAnimationClip(path, input); break; + case "animator_controller": + AuthorAnimatorController(path, input); + break; case "audio_tone": AuthorAudioTone(path, input); break; @@ -333,6 +386,192 @@ private static void AuthorAnimationClip(string path, AssetAuthoringInput input) EditorUtility.SetDirty(clip); } + private static void AuthorAnimatorController(string path, AssetAuthoringInput input) + { + var states = input.animatorStates ?? Array.Empty(); + if (states.Length == 0 || states.Length > 200) + { + throw new InvalidOperationException("animator_controller requires 1-200 states."); + } + + var stateNames = states + .Select(state => RequireAnimatorName(state?.name, "state")) + .ToArray(); + if (stateNames.Distinct(StringComparer.Ordinal).Count() != stateNames.Length) + { + throw new InvalidOperationException("Animator state names must be unique."); + } + + var controller = AssetDatabase.LoadAssetAtPath(path); + if (controller == null) + { + controller = AnimatorController.CreateAnimatorControllerAtPath(path); + } + + if (controller == null) + { + throw new InvalidOperationException($"Animator Controller '{path}' could not be created."); + } + + controller.parameters = BuildAnimatorParameters(input.animatorParameters); + var stateMachine = controller.layers[0].stateMachine; + if (input.clearExistingStates) + { + foreach (var child in stateMachine.states.ToArray()) + { + stateMachine.RemoveState(child.state); + } + + foreach (var transition in stateMachine.anyStateTransitions.ToArray()) + { + stateMachine.RemoveAnyStateTransition(transition); + } + } + + var createdStates = new Dictionary(StringComparer.Ordinal); + for (var index = 0; index < states.Length; index++) + { + var stateInput = states[index]; + var name = stateNames[index]; + var state = stateMachine.AddState(name, new Vector3(stateInput.positionX, stateInput.positionY, 0f)); + state.motion = ResolveAnimationClip(stateInput.clipPath, stateInput.clipName); + state.speed = Mathf.Clamp(stateInput.speed, -100f, 100f); + state.writeDefaultValues = stateInput.writeDefaultValues; + createdStates.Add(name, state); + } + + var defaultStateName = string.IsNullOrWhiteSpace(input.defaultState) + ? stateNames[0] + : input.defaultState.Trim(); + if (!createdStates.TryGetValue(defaultStateName, out var defaultState)) + { + throw new InvalidOperationException($"Default Animator state '{defaultStateName}' was not declared."); + } + + stateMachine.defaultState = defaultState; + foreach (var transitionInput in input.animatorTransitions ?? Array.Empty()) + { + if (transitionInput == null + || !createdStates.TryGetValue((transitionInput.fromState ?? string.Empty).Trim(), out var fromState) + || !createdStates.TryGetValue((transitionInput.toState ?? string.Empty).Trim(), out var toState)) + { + throw new InvalidOperationException("Each Animator transition must reference declared fromState and toState values."); + } + + var transition = fromState.AddTransition(toState); + transition.hasExitTime = transitionInput.hasExitTime; + transition.exitTime = Mathf.Clamp(transitionInput.exitTime, 0f, 1000f); + transition.duration = Mathf.Clamp(transitionInput.duration, 0f, 1000f); + transition.hasFixedDuration = transitionInput.hasFixedDuration; + transition.offset = Mathf.Clamp(transitionInput.offset, 0f, 1f); + transition.canTransitionToSelf = transitionInput.canTransitionToSelf; + foreach (var condition in transitionInput.conditions ?? Array.Empty()) + { + if (condition == null || !controller.parameters.Any(parameter => parameter.name == condition.parameter)) + { + throw new InvalidOperationException($"Animator transition condition parameter '{condition?.parameter}' was not declared."); + } + + transition.AddCondition(ParseConditionMode(condition.mode), condition.threshold, condition.parameter); + } + } + + EditorUtility.SetDirty(stateMachine); + EditorUtility.SetDirty(controller); + } + + private static AnimatorControllerParameter[] BuildAnimatorParameters(AnimatorParameterInput[] inputs) + { + var parameters = inputs ?? Array.Empty(); + if (parameters.Length > 100) + { + throw new InvalidOperationException("Animator Controller supports at most 100 declared parameters per request."); + } + + var result = new List(); + foreach (var input in parameters) + { + var name = RequireAnimatorName(input?.name, "parameter"); + if (result.Any(parameter => parameter.name == name)) + { + throw new InvalidOperationException($"Animator parameter '{name}' was declared more than once."); + } + + result.Add(new AnimatorControllerParameter + { + name = name, + type = ParseParameterType(input.type), + defaultBool = input.defaultBool, + defaultFloat = input.defaultFloat, + defaultInt = input.defaultInt + }); + } + + return result.ToArray(); + } + + private static AnimationClip ResolveAnimationClip(string rawPath, string clipName) + { + var path = NormalizeAssetPath(rawPath); + if (!IsSafeAssetPath(path)) + { + throw new InvalidOperationException("Animator state clipPath must remain under Assets."); + } + + var clips = AssetDatabase.LoadAllAssetsAtPath(path).OfType().ToArray(); + if (!string.IsNullOrWhiteSpace(clipName)) + { + var named = clips.FirstOrDefault(clip => string.Equals(clip.name, clipName.Trim(), StringComparison.Ordinal)); + if (named != null) + { + return named; + } + } + + var clip = clips.FirstOrDefault(candidate => !candidate.name.StartsWith("__preview__", StringComparison.OrdinalIgnoreCase)); + if (clip == null) + { + throw new InvalidOperationException($"Animation clip '{path}' was not found."); + } + + return clip; + } + + private static AnimatorControllerParameterType ParseParameterType(string value) + { + return (value ?? string.Empty).Trim().ToLowerInvariant() switch + { + "int" => AnimatorControllerParameterType.Int, + "bool" => AnimatorControllerParameterType.Bool, + "trigger" => AnimatorControllerParameterType.Trigger, + _ => AnimatorControllerParameterType.Float + }; + } + + private static AnimatorConditionMode ParseConditionMode(string value) + { + return (value ?? string.Empty).Trim().ToLowerInvariant() switch + { + "if_not" => AnimatorConditionMode.IfNot, + "greater" => AnimatorConditionMode.Greater, + "less" => AnimatorConditionMode.Less, + "equals" => AnimatorConditionMode.Equals, + "not_equal" => AnimatorConditionMode.NotEqual, + _ => AnimatorConditionMode.If + }; + } + + private static string RequireAnimatorName(string value, string kind) + { + var name = (value ?? string.Empty).Trim(); + if (name.Length == 0 || name.Length > 128 || name.Any(char.IsControl)) + { + throw new InvalidOperationException($"Animator {kind} name is invalid."); + } + + return name; + } + private static void AuthorAudioTone(string path, AssetAuthoringInput input) { var tone = input.audioTone ?? new AudioToneInput(); @@ -448,6 +687,7 @@ private static bool ValidateKindAndPath(string kind, string path, out string err ["shader"] = ".shader", ["material"] = ".mat", ["animation_clip"] = ".anim", + ["animator_controller"] = ".controller", ["audio_tone"] = ".wav" }; if (kind == "audio_import") @@ -464,7 +704,7 @@ private static bool ValidateKindAndPath(string kind, string path, out string err if (!extensions.TryGetValue(kind, out var extension)) { - error = "kind must be shader, material, animation_clip, audio_tone, or audio_import."; + error = "kind must be shader, material, animation_clip, animator_controller, audio_tone, or audio_import."; return false; } @@ -485,6 +725,7 @@ private static bool VerifyAsset(string kind, UnityEngine.Object asset) "shader" => asset is Shader, "material" => asset is Material, "animation_clip" => asset is AnimationClip, + "animator_controller" => asset is RuntimeAnimatorController, "audio_tone" => asset is AudioClip, "audio_import" => asset is AudioClip, _ => false diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetAuthoringOperation.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetAuthoringOperation.cs.meta new file mode 100644 index 0000000..c71a373 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetAuthoringOperation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8bad53bb65c5f4369a97233db97cd813 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetImportOperation.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetImportOperation.cs new file mode 100644 index 0000000..ae601ab --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetImportOperation.cs @@ -0,0 +1,1297 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace UnityAI.ControlPlane.Editor +{ + [Serializable] + public sealed class AssetImportRequest + { + public string requestId; + public string correlationId; + public AssetImportInput input = new(); + } + + [Serializable] + public sealed class AssetImportInput + { + public bool dryRun = true; + public bool confirm; + public string sourceKind = "local"; + public string sourcePath; + public string url; + public string destinationPath; + public bool overwrite; + public string expectedSha256; + public long maxBytes = 268435456; + public int timeoutSeconds = 120; + public bool allowInsecureLocalhost; + public AssetCatalogProvenanceInput catalogProvenance = new(); + public ModelImportSettingsInput model = new(); + public TextureImportSettingsInput texture = new(); + public AudioImportInput audio = new(); + public bool instantiate; + public string objectName; + public string parentPath; + public ImportedAssetTransformInput transform = new(); + public string saveAsPrefabPath; + } + + [Serializable] + public sealed class AssetCatalogProvenanceInput + { + public string catalogId; + public string catalogAssetId; + public string catalogName; + public string catalogHomepage; + public string sourceUrl; + public string licenseSpdxId; + public string licenseName; + public string licenseUrl; + public string attribution; + } + + [Serializable] + public sealed class ModelImportSettingsInput + { + public float globalScale = 1f; + public bool useFileScale = true; + public bool importBlendShapes = true; + public bool importVisibility = true; + public bool importCameras; + public bool importLights; + public bool addCollider; + public bool importAnimation = true; + public string animationType = "generic"; + public bool isReadable; + public string meshCompression = "off"; + public bool normalizeOnInstantiate; + public bool recenterPivot; + public string pivotMode = "bounds_center"; + public bool alignToGround; + public string forwardAxis = "keep"; + } + + [Serializable] + public sealed class TextureImportSettingsInput + { + public string textureType = "default"; + public bool sRgb = true; + public bool alphaIsTransparency; + public bool mipmapEnabled = true; + public bool isReadable; + public int maxTextureSize = 2048; + public string compression = "compressed"; + } + + [Serializable] + public sealed class ImportedAssetTransformInput + { + public SceneAuthoringVector3 position = new(); + public SceneAuthoringVector3 rotationEuler = new(); + public SceneAuthoringVector3 scale = new() { x = 1f, y = 1f, z = 1f }; + } + + [Serializable] + public sealed class AssetImportResult + { + public bool dryRun; + public bool imported; + public bool downloaded; + public bool copied; + public bool instantiated; + public bool prefabCreated; + public bool refused; + public bool requiresConfirmation; + public string requestId; + public string correlationId; + public string sourceKind; + public string destinationPath; + public string assetType; + public string importerType; + public string guid; + public long byteLength; + public string sha256; + public string checkpointId; + public string objectPath; + public string prefabPath; + public bool normalized; + public string normalization; + public AssetCatalogProvenanceInput catalogProvenance = new(); + public string message; + public string verificationStatus; + public string[] verificationSignals = Array.Empty(); + public bool auditPersisted; + public UnityAiAuditEvent auditEvent; + public string auditLogPath; + public string timestampUtc; + } + + public static class AssetImportOperation + { + private const string DefaultCapability = "unity.assets.import"; + private const string CatalogCapability = "unity.assets.import_from_catalog"; + private const long MaximumBytes = 2L * 1024 * 1024 * 1024; + private static readonly HashSet SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".fbx", ".obj", ".dae", ".3ds", ".dxf", ".glb", ".gltf", + ".png", ".jpg", ".jpeg", ".tga", ".tif", ".tiff", ".psd", ".exr", ".hdr", + ".wav", ".mp3", ".ogg", ".aif", ".aiff" + }; + + private sealed class ModelInstantiationResult + { + public GameObject gameObject; + public bool normalized; + public string normalization = string.Empty; + } + + public static AssetImportResult Execute(string requestBody, string capability = DefaultCapability) + { + var request = ParseRequest(requestBody); + var input = request.input ?? new AssetImportInput(); + var timestamp = DateTime.UtcNow.ToString("O"); + var sourceKind = Normalize(input.sourceKind); + var destinationPath = NormalizeAssetPath(input.destinationPath); + var prefabPath = NormalizeAssetPath(input.saveAsPrefabPath); + + if (!Validate(input, sourceKind, destinationPath, prefabPath, capability, out var validationError)) + { + return Refused(request, sourceKind, destinationPath, validationError, timestamp); + } + + if (input.dryRun) + { + return new AssetImportResult + { + dryRun = true, + requestId = request.requestId, + correlationId = request.correlationId, + sourceKind = sourceKind, + destinationPath = destinationPath, + prefabPath = prefabPath, + catalogProvenance = input.catalogProvenance ?? new AssetCatalogProvenanceInput(), + message = $"DRY RUN: would import {DescribeSource(input, sourceKind)} to '{destinationPath}'.", + verificationStatus = "passed", + verificationSignals = new[] { "structured_observation" }, + timestampUtc = timestamp + }; + } + + if (!input.confirm) + { + return new AssetImportResult + { + requestId = request.requestId, + correlationId = request.correlationId, + sourceKind = sourceKind, + destinationPath = destinationPath, + prefabPath = prefabPath, + catalogProvenance = input.catalogProvenance ?? new AssetCatalogProvenanceInput(), + requiresConfirmation = true, + message = "Asset import requires confirm=true.", + verificationStatus = "needs_confirmation", + timestampUtc = timestamp + }; + } + + var destinationAbsolutePath = ResolveAssetPath(destinationPath); + if (!input.overwrite && File.Exists(destinationAbsolutePath)) + { + return Refused(request, sourceKind, destinationPath, "Destination already exists and overwrite=false.", timestamp); + } + + var checkpointPaths = new List { destinationPath, destinationPath + ".meta" }; + if (!string.IsNullOrEmpty(prefabPath)) + { + checkpointPaths.Add(prefabPath); + checkpointPaths.Add(prefabPath + ".meta"); + } + + var activeScene = EditorSceneManager.GetActiveScene(); + if (input.instantiate && activeScene.IsValid() && !string.IsNullOrWhiteSpace(activeScene.path)) + { + checkpointPaths.Add(activeScene.path); + checkpointPaths.Add(activeScene.path + ".meta"); + } + + var undoGroup = -1; + try + { + var checkpoint = DurableCheckpointStore.CreateInternal("asset-import", checkpointPaths.Distinct().ToArray()); + if (input.instantiate) + { + Undo.IncrementCurrentGroup(); + undoGroup = Undo.GetCurrentGroup(); + Undo.SetCurrentGroupName("Unity AI Import And Instantiate Asset"); + } + + EnsureParentDirectory(destinationAbsolutePath); + var maxBytes = Math.Max(1, Math.Min(input.maxBytes, MaximumBytes)); + var stagedPath = CreateStagedPath(); + try + { + if (sourceKind == "url") + { + Download(input.url, stagedPath, maxBytes, Math.Max(5, Math.Min(input.timeoutSeconds, 1800)), input.allowInsecureLocalhost); + } + else + { + CopyLocal(input.sourcePath, stagedPath, maxBytes); + } + + var byteLength = new FileInfo(stagedPath).Length; + var sha256 = ComputeSha256(stagedPath); + var expectedHash = NormalizeHash(input.expectedSha256); + if (!string.IsNullOrEmpty(expectedHash) && !string.Equals(expectedHash, sha256, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Downloaded/copied asset SHA-256 '{sha256}' does not match expectedSha256."); + } + + File.Copy(stagedPath, destinationAbsolutePath, true); + AssetDatabase.ImportAsset(destinationPath, ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate); + ApplyImporterSettings(destinationPath, input); + AssetDatabase.ImportAsset(destinationPath, ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate); + + var asset = AssetDatabase.LoadMainAssetAtPath(destinationPath); + if (asset == null) + { + throw new InvalidOperationException($"Unity did not import a main asset at '{destinationPath}'."); + } + + var objectPath = string.Empty; + var instantiated = false; + var prefabCreated = false; + var normalized = false; + var normalization = string.Empty; + if (input.instantiate) + { + if (!(asset is GameObject model)) + { + throw new InvalidOperationException("instantiate=true requires a model asset whose main asset is a GameObject."); + } + + var instantiation = InstantiateModel(model, input); + var instance = instantiation.gameObject; + objectPath = GetGameObjectPath(instance); + instantiated = true; + normalized = instantiation.normalized; + normalization = instantiation.normalization; + if (!string.IsNullOrEmpty(prefabPath)) + { + EnsureParentDirectory(ResolveAssetPath(prefabPath)); + var saved = PrefabUtility.SaveAsPrefabAsset(instance, prefabPath); + prefabCreated = saved != null; + if (!prefabCreated) + { + throw new InvalidOperationException($"Unity did not create prefab '{prefabPath}'."); + } + } + + EditorSceneManager.MarkSceneDirty(activeScene); + } + + var importer = AssetImporter.GetAtPath(destinationPath); + var verifiedHash = ComputeSha256(destinationAbsolutePath); + var verified = asset != null + && string.Equals(sha256, verifiedHash, StringComparison.Ordinal) + && (!input.instantiate || FindByPath(objectPath) != null) + && (string.IsNullOrEmpty(prefabPath) || AssetDatabase.LoadAssetAtPath(prefabPath) != null); + var auditEvent = new UnityAiAuditEvent + { + timestamp = DateTime.UtcNow.ToString("O"), + capability = capability, + requestId = request.requestId, + correlationId = request.correlationId, + message = BuildAuditMessage(input, destinationPath, sourceKind, instantiated, prefabCreated), + effects = BuildEffects(instantiated, prefabCreated, true) + }; + var auditPersisted = PersistAudit(auditEvent); + if (!auditPersisted) + { + auditEvent.effects = BuildEffects(instantiated, prefabCreated, false); + } + + var signals = new List { "checkpoint_created", "asset_import_verified" }; + if (instantiated) + { + signals.Add("scene_mutation_verified"); + } + + if (prefabCreated) + { + signals.Add("prefab_mutation_verified"); + } + + if (normalized) + { + signals.Add("asset_normalized"); + } + + if (auditPersisted) + { + signals.Add("operation_audited"); + } + + if (string.Equals(capability, CatalogCapability, StringComparison.Ordinal)) + { + signals.Add("asset_license_verified"); + signals.Add("asset_hash_verified"); + } + + if (undoGroup >= 0) + { + Undo.CollapseUndoOperations(undoGroup); + } + + return new AssetImportResult + { + imported = verified, + downloaded = sourceKind == "url", + copied = sourceKind == "local", + instantiated = instantiated, + prefabCreated = prefabCreated, + requestId = request.requestId, + correlationId = request.correlationId, + sourceKind = sourceKind, + destinationPath = destinationPath, + assetType = asset.GetType().FullName, + importerType = importer != null ? importer.GetType().FullName : string.Empty, + guid = AssetDatabase.AssetPathToGUID(destinationPath), + byteLength = byteLength, + sha256 = verifiedHash, + checkpointId = checkpoint.checkpointId, + objectPath = objectPath, + prefabPath = prefabPath, + normalized = normalized, + normalization = normalization, + catalogProvenance = input.catalogProvenance ?? new AssetCatalogProvenanceInput(), + message = verified ? $"Imported and verified '{destinationPath}'." : $"Imported '{destinationPath}', but verification failed.", + verificationStatus = verified ? "passed" : "failed", + verificationSignals = signals.ToArray(), + auditPersisted = auditPersisted, + auditEvent = auditEvent, + auditLogPath = AuditLogStore.AuditLogRelativePath.Replace('\\', '/'), + timestampUtc = timestamp + }; + } + finally + { + if (File.Exists(stagedPath)) + { + File.Delete(stagedPath); + } + } + } + catch (Exception exception) + { + if (undoGroup >= 0) + { + Undo.RevertAllDownToGroup(undoGroup); + } + + return Refused(request, sourceKind, destinationPath, exception.GetBaseException().Message, timestamp); + } + } + + private static void ApplyImporterSettings(string assetPath, AssetImportInput input) + { + var importer = AssetImporter.GetAtPath(assetPath); + switch (importer) + { + case ModelImporter modelImporter: + ApplyModelSettings(modelImporter, input.model ?? new ModelImportSettingsInput()); + modelImporter.SaveAndReimport(); + return; + case TextureImporter textureImporter: + ApplyTextureSettings(textureImporter, input.texture ?? new TextureImportSettingsInput()); + textureImporter.SaveAndReimport(); + return; + case AudioImporter audioImporter: + ApplyAudioSettings(audioImporter, input.audio ?? new AudioImportInput()); + audioImporter.SaveAndReimport(); + return; + } + } + + private static void ApplyModelSettings(ModelImporter importer, ModelImportSettingsInput input) + { + importer.globalScale = Mathf.Clamp(input.globalScale, 0.0001f, 10000f); + importer.useFileScale = input.useFileScale; + importer.importBlendShapes = input.importBlendShapes; + importer.importVisibility = input.importVisibility; + importer.importCameras = input.importCameras; + importer.importLights = input.importLights; + importer.addCollider = input.addCollider; + importer.importAnimation = input.importAnimation; + importer.animationType = ParseAnimationType(input.animationType); + importer.isReadable = input.isReadable; + importer.meshCompression = ParseMeshCompression(input.meshCompression); + } + + private static void ApplyTextureSettings(TextureImporter importer, TextureImportSettingsInput input) + { + importer.textureType = ParseTextureType(input.textureType); + importer.sRGBTexture = input.sRgb; + importer.alphaIsTransparency = input.alphaIsTransparency; + importer.mipmapEnabled = input.mipmapEnabled; + importer.isReadable = input.isReadable; + importer.maxTextureSize = ClampTextureSize(input.maxTextureSize); + importer.textureCompression = ParseTextureCompression(input.compression); + } + + private static void ApplyAudioSettings(AudioImporter importer, AudioImportInput input) + { + importer.forceToMono = input.forceToMono; + importer.loadInBackground = input.loadInBackground; + var settings = importer.defaultSampleSettings; + settings.preloadAudioData = input.preloadAudioData; + settings.loadType = ParseAudioLoadType(input.loadType); + settings.compressionFormat = ParseAudioCompression(input.compressionFormat); + settings.quality = Mathf.Clamp01(input.quality); + if (input.sampleRateOverride > 0) + { + settings.sampleRateSetting = AudioSampleRateSetting.OverrideSampleRate; + settings.sampleRateOverride = (uint)Mathf.Clamp(input.sampleRateOverride, 8000, 192000); + } + else + { + settings.sampleRateSetting = AudioSampleRateSetting.PreserveSampleRate; + } + + importer.defaultSampleSettings = settings; + } + + private static ModelInstantiationResult InstantiateModel(GameObject model, AssetImportInput input) + { + var objectName = string.IsNullOrWhiteSpace(input.objectName) + ? Path.GetFileNameWithoutExtension(input.destinationPath) + : RequireSafeName(input.objectName); + var parent = FindByPath(NormalizeHierarchyPath(input.parentPath)); + if (!string.IsNullOrWhiteSpace(input.parentPath) && parent == null) + { + throw new InvalidOperationException($"Parent GameObject '{input.parentPath}' was not found."); + } + + var transform = input.transform ?? new ImportedAssetTransformInput(); + var modelSettings = input.model ?? new ModelImportSettingsInput(); + if (!ShouldNormalizeModel(modelSettings)) + { + var instance = InstantiateModelPrefab(model); + instance.name = objectName; + Undo.RegisterCreatedObjectUndo(instance, "Unity AI Import Asset"); + instance.transform.SetParent(parent != null ? parent.transform : null, false); + ApplyRequestedTransform(instance.transform, transform); + return new ModelInstantiationResult { gameObject = instance }; + } + + var root = new GameObject(objectName); + Undo.RegisterCreatedObjectUndo(root, "Unity AI Import Normalized Asset"); + root.transform.SetParent(parent != null ? parent.transform : null, false); + root.transform.localPosition = Vector3.zero; + root.transform.localRotation = Quaternion.identity; + root.transform.localScale = Vector3.one; + + var child = InstantiateModelPrefab(model); + child.name = objectName + "_Model"; + Undo.RegisterCreatedObjectUndo(child, "Unity AI Import Normalized Asset Model"); + child.transform.SetParent(root.transform, false); + child.transform.localPosition = Vector3.zero; + child.transform.localRotation = ParseForwardAxisCorrection(modelSettings.forwardAxis); + child.transform.localScale = Vector3.one; + + var normalization = ApplyModelNormalization(root.transform, child.transform, modelSettings); + ApplyRequestedTransform(root.transform, transform); + return new ModelInstantiationResult + { + gameObject = root, + normalized = true, + normalization = normalization + }; + } + + private static void CopyLocal(string sourcePath, string destinationPath, long maxBytes) + { + var source = (sourcePath ?? string.Empty).Trim(); + if (!Path.IsPathRooted(source) || !File.Exists(source)) + { + throw new InvalidOperationException("sourcePath must be an existing absolute file path."); + } + + var file = new FileInfo(source); + if (file.Length <= 0 || file.Length > maxBytes) + { + throw new InvalidOperationException($"Local source size must be between 1 and {maxBytes} bytes."); + } + + File.Copy(source, destinationPath, true); + } + + private static void Download(string rawUrl, string destinationPath, long maxBytes, int timeoutSeconds, bool allowInsecureLocalhost) + { + if (!Uri.TryCreate(rawUrl, UriKind.Absolute, out var current)) + { + throw new InvalidOperationException("url must be an absolute HTTPS URL."); + } + + using var handler = new HttpClientHandler { AllowAutoRedirect = false }; + using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(timeoutSeconds) }; + client.DefaultRequestHeaders.UserAgent.ParseAdd("UnityAI-ControlPlane/0.1"); + + for (var redirect = 0; redirect <= 5; redirect++) + { + ValidateDownloadUri(current, allowInsecureLocalhost); + using var request = new HttpRequestMessage(HttpMethod.Get, current); + using var response = client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult(); + if ((int)response.StatusCode >= 300 && (int)response.StatusCode < 400) + { + if (redirect == 5 || response.Headers.Location == null) + { + throw new InvalidOperationException("Asset download exceeded the redirect limit or returned an invalid redirect."); + } + + current = response.Headers.Location.IsAbsoluteUri + ? response.Headers.Location + : new Uri(current, response.Headers.Location); + continue; + } + + response.EnsureSuccessStatusCode(); + if (response.Content.Headers.ContentLength.HasValue && response.Content.Headers.ContentLength.Value > maxBytes) + { + throw new InvalidOperationException($"Remote asset exceeds maxBytes ({maxBytes})."); + } + + using var source = response.Content.ReadAsStreamAsync().GetAwaiter().GetResult(); + using var destination = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None); + var buffer = new byte[81920]; + long total = 0; + while (true) + { + var read = source.Read(buffer, 0, buffer.Length); + if (read <= 0) + { + break; + } + + total += read; + if (total > maxBytes) + { + throw new InvalidOperationException($"Remote asset exceeds maxBytes ({maxBytes})."); + } + + destination.Write(buffer, 0, read); + } + + if (total == 0) + { + throw new InvalidOperationException("Remote asset response was empty."); + } + + return; + } + } + + private static void ValidateDownloadUri(Uri uri, bool allowInsecureLocalhost) + { + var loopback = uri.IsLoopback + || string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase); + if (uri.Scheme == Uri.UriSchemeHttp && !(allowInsecureLocalhost && loopback)) + { + throw new InvalidOperationException("Remote imports require HTTPS; HTTP is only allowed for explicit localhost testing."); + } + + if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp) + { + throw new InvalidOperationException("Remote imports support only HTTPS URLs."); + } + + if (loopback) + { + if (!allowInsecureLocalhost) + { + throw new InvalidOperationException("Loopback asset URLs require allowInsecureLocalhost=true."); + } + + return; + } + + foreach (var address in Dns.GetHostAddresses(uri.DnsSafeHost)) + { + if (IsPrivateAddress(address)) + { + throw new InvalidOperationException("Remote asset URLs cannot resolve to private or link-local addresses."); + } + } + } + + private static bool IsPrivateAddress(IPAddress address) + { + if (IPAddress.IsLoopback(address)) + { + return true; + } + + var bytes = address.GetAddressBytes(); + if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + return bytes[0] == 10 + || bytes[0] == 127 + || (bytes[0] == 169 && bytes[1] == 254) + || (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) + || (bytes[0] == 192 && bytes[1] == 168); + } + + return address.IsIPv6LinkLocal + || address.IsIPv6SiteLocal + || address.Equals(IPAddress.IPv6Loopback) + || (bytes.Length == 16 && (bytes[0] & 0xfe) == 0xfc); + } + + private static bool Validate( + AssetImportInput input, + string sourceKind, + string destinationPath, + string prefabPath, + string capability, + out string error) + { + if (sourceKind != "local" && sourceKind != "url") + { + error = "sourceKind must be local or url."; + return false; + } + + var extension = Path.GetExtension(destinationPath); + if (!IsSafeAssetPath(destinationPath) || !SupportedExtensions.Contains(extension)) + { + error = "destinationPath must use a supported model, texture, or audio extension under Assets."; + return false; + } + + if (sourceKind == "local" && string.IsNullOrWhiteSpace(input.sourcePath)) + { + error = "sourcePath is required for local imports."; + return false; + } + + if (sourceKind == "url" && string.IsNullOrWhiteSpace(input.url)) + { + error = "url is required for remote imports."; + return false; + } + + var expectedHash = (input.expectedSha256 ?? string.Empty).Trim().ToLowerInvariant(); + if (!string.IsNullOrEmpty(expectedHash) && (expectedHash.Length != 64 || expectedHash.Any(character => !Uri.IsHexDigit(character)))) + { + error = "expectedSha256 must be a 64-character hexadecimal value."; + return false; + } + + if (string.Equals(capability, CatalogCapability, StringComparison.Ordinal) + && (!ValidateCatalogProvenance(input.catalogProvenance, out error) || string.IsNullOrEmpty(expectedHash))) + { + if (string.IsNullOrEmpty(error)) + { + error = "Catalog imports require an expected SHA-256."; + } + + return false; + } + + if (input.instantiate && !IsModelExtension(extension)) + { + error = "instantiate=true requires a supported 3D model destination extension."; + return false; + } + + if (!string.IsNullOrEmpty(prefabPath) && (!input.instantiate || !IsSafePrefabPath(prefabPath))) + { + error = "saveAsPrefabPath requires instantiate=true and a .prefab path under Assets."; + return false; + } + + if (!string.IsNullOrWhiteSpace(input.objectName) && !IsSafeName(input.objectName)) + { + error = "objectName is invalid."; + return false; + } + + if (!string.IsNullOrWhiteSpace(input.parentPath) && string.IsNullOrEmpty(NormalizeHierarchyPath(input.parentPath))) + { + error = "parentPath is invalid."; + return false; + } + + if (!ValidateModelNormalization(input.model ?? new ModelImportSettingsInput(), input.instantiate, out error)) + { + return false; + } + + error = string.Empty; + return true; + } + + private static GameObject InstantiateModelPrefab(GameObject model) + { + var instance = PrefabUtility.InstantiatePrefab(model) as GameObject; + return instance != null ? instance : UnityEngine.Object.Instantiate(model); + } + + private static void ApplyRequestedTransform(Transform target, ImportedAssetTransformInput transform) + { + target.localPosition = ToVector3(transform.position, Vector3.zero); + target.localEulerAngles = ToVector3(transform.rotationEuler, Vector3.zero); + target.localScale = ToVector3(transform.scale, Vector3.one); + } + + private static bool ShouldNormalizeModel(ModelImportSettingsInput input) + { + if (input == null) + { + return false; + } + + var axis = NormalizeAxis(input.forwardAxis); + return input.normalizeOnInstantiate + || input.recenterPivot + || input.alignToGround + || axis == "x" + || axis == "-x" + || axis == "-z"; + } + + private static string ApplyModelNormalization(Transform root, Transform child, ModelImportSettingsInput input) + { + var applied = new List(); + var axis = NormalizeAxis(input.forwardAxis); + if (axis == "x" || axis == "-x" || axis == "-z") + { + applied.Add($"forwardAxis={axis}->+z"); + } + + var recenterPivot = input.normalizeOnInstantiate || input.recenterPivot; + var alignToGround = input.normalizeOnInstantiate || input.alignToGround; + var pivotMode = NormalizePivotMode(input.pivotMode); + if (recenterPivot) + { + var bounds = TryGetRootLocalBounds(root, child.gameObject); + if (bounds.HasValue) + { + var pivot = bounds.Value.center; + if (pivotMode == "bounds_base") + { + pivot.y = bounds.Value.min.y; + } + + child.localPosition -= pivot; + applied.Add($"pivot={pivotMode}"); + } + else + { + applied.Add("pivot=bounds_unavailable"); + } + } + + if (alignToGround) + { + var bounds = TryGetRootLocalBounds(root, child.gameObject); + if (bounds.HasValue) + { + child.localPosition += new Vector3(0f, -bounds.Value.min.y, 0f); + applied.Add("ground=aligned"); + } + else + { + applied.Add("ground=bounds_unavailable"); + } + } + + if (applied.Count == 0) + { + applied.Add("normalized_root_created"); + } + + return string.Join("; ", applied.Distinct()); + } + + private static Bounds? TryGetRootLocalBounds(Transform root, GameObject target) + { + var hasBounds = false; + var result = new Bounds(Vector3.zero, Vector3.zero); + foreach (var renderer in target.GetComponentsInChildren(true)) + { + if (renderer == null) + { + continue; + } + + EncapsulateWorldBounds(root, renderer.bounds, ref hasBounds, ref result); + } + + foreach (var collider in target.GetComponentsInChildren(true)) + { + if (collider == null) + { + continue; + } + + EncapsulateWorldBounds(root, collider.bounds, ref hasBounds, ref result); + } + + return hasBounds ? result : null; + } + + private static void EncapsulateWorldBounds(Transform root, Bounds worldBounds, ref bool hasBounds, ref Bounds localBounds) + { + var min = worldBounds.min; + var max = worldBounds.max; + var corners = new[] + { + new Vector3(min.x, min.y, min.z), + new Vector3(min.x, min.y, max.z), + new Vector3(min.x, max.y, min.z), + new Vector3(min.x, max.y, max.z), + new Vector3(max.x, min.y, min.z), + new Vector3(max.x, min.y, max.z), + new Vector3(max.x, max.y, min.z), + new Vector3(max.x, max.y, max.z) + }; + + foreach (var corner in corners) + { + var local = root.InverseTransformPoint(corner); + if (!hasBounds) + { + localBounds = new Bounds(local, Vector3.zero); + hasBounds = true; + } + else + { + localBounds.Encapsulate(local); + } + } + } + + private static Quaternion ParseForwardAxisCorrection(string value) + { + return NormalizeAxis(value) switch + { + "x" => Quaternion.Euler(0f, -90f, 0f), + "-x" => Quaternion.Euler(0f, 90f, 0f), + "-z" => Quaternion.Euler(0f, 180f, 0f), + _ => Quaternion.identity + }; + } + + private static bool ValidateModelNormalization(ModelImportSettingsInput input, bool instantiate, out string error) + { + if (!IsSupportedPivotMode(input.pivotMode)) + { + error = "model.pivotMode must be bounds_center or bounds_base."; + return false; + } + + if (!IsSupportedForwardAxis(input.forwardAxis)) + { + error = "model.forwardAxis must be keep, z, +z, -z, negative_z, x, +x, -x, or negative_x."; + return false; + } + + if (ShouldNormalizeModel(input) && !instantiate) + { + error = "Model normalization fields require instantiate=true because Unity mesh pivots are represented by a normalized scene root."; + return false; + } + + error = string.Empty; + return true; + } + + private static bool IsSupportedPivotMode(string value) + { + var normalized = NormalizePivotMode(value); + return normalized == "bounds_center" || normalized == "bounds_base"; + } + + private static string NormalizePivotMode(string value) + { + var normalized = Normalize(value).Replace("-", "_"); + return string.IsNullOrEmpty(normalized) ? "bounds_center" : normalized; + } + + private static bool IsSupportedForwardAxis(string value) + { + var normalized = NormalizeAxis(value); + return normalized == "keep" + || normalized == "z" + || normalized == "-z" + || normalized == "x" + || normalized == "-x"; + } + + private static string NormalizeAxis(string value) + { + var normalized = Normalize(value).Replace("_", "").Replace(" ", ""); + return normalized switch + { + "" => "keep", + "keep" => "keep", + "z" => "z", + "+z" => "z", + "negativez" => "-z", + "-z" => "-z", + "x" => "x", + "+x" => "x", + "negativex" => "-x", + "-x" => "-x", + _ => normalized + }; + } + + private static bool ValidateCatalogProvenance(AssetCatalogProvenanceInput provenance, out string error) + { + if (provenance == null + || string.IsNullOrWhiteSpace(provenance.catalogId) + || string.IsNullOrWhiteSpace(provenance.catalogAssetId) + || string.IsNullOrWhiteSpace(provenance.sourceUrl) + || string.IsNullOrWhiteSpace(provenance.licenseSpdxId) + || string.IsNullOrWhiteSpace(provenance.licenseUrl)) + { + error = "Catalog imports require catalog id, asset id, source URL, SPDX license, and license URL provenance."; + return false; + } + + if (!Uri.TryCreate(provenance.sourceUrl, UriKind.Absolute, out var sourceUri) + || !Uri.TryCreate(provenance.licenseUrl, UriKind.Absolute, out var licenseUri) + || sourceUri.Scheme != Uri.UriSchemeHttps + || licenseUri.Scheme != Uri.UriSchemeHttps) + { + error = "Catalog source and license URLs must be absolute HTTPS URLs."; + return false; + } + + error = string.Empty; + return true; + } + + private static string[] BuildEffects(bool instantiated, bool prefabCreated, bool auditPersisted) + { + var effects = new List { "asset_change" }; + if (instantiated || prefabCreated) + { + effects.Add("scene_change"); + } + + if (auditPersisted) + { + effects.Add("write_audit_log"); + } + + return effects.Distinct().ToArray(); + } + + private static bool PersistAudit(UnityAiAuditEvent auditEvent) + { + try + { + AuditLogStore.Append(new[] { auditEvent }); + return true; + } + catch (Exception exception) + { + Debug.LogError($"Failed to persist asset import audit event: {exception.Message}"); + return false; + } + } + + private static ModelImporterAnimationType ParseAnimationType(string value) + { + return Normalize(value) switch + { + "none" => ModelImporterAnimationType.None, + "legacy" => ModelImporterAnimationType.Legacy, + "human" => ModelImporterAnimationType.Human, + _ => ModelImporterAnimationType.Generic + }; + } + + private static ModelImporterMeshCompression ParseMeshCompression(string value) + { + return Normalize(value) switch + { + "low" => ModelImporterMeshCompression.Low, + "medium" => ModelImporterMeshCompression.Medium, + "high" => ModelImporterMeshCompression.High, + _ => ModelImporterMeshCompression.Off + }; + } + + private static TextureImporterType ParseTextureType(string value) + { + return Normalize(value) switch + { + "normal_map" => TextureImporterType.NormalMap, + "sprite" => TextureImporterType.Sprite, + "cursor" => TextureImporterType.Cursor, + "cookie" => TextureImporterType.Cookie, + "lightmap" => TextureImporterType.Lightmap, + "single_channel" => TextureImporterType.SingleChannel, + _ => TextureImporterType.Default + }; + } + + private static TextureImporterCompression ParseTextureCompression(string value) + { + return Normalize(value) switch + { + "uncompressed" => TextureImporterCompression.Uncompressed, + "compressed_hq" => TextureImporterCompression.CompressedHQ, + "compressed_lq" => TextureImporterCompression.CompressedLQ, + _ => TextureImporterCompression.Compressed + }; + } + + private static AudioClipLoadType ParseAudioLoadType(string value) + { + return Normalize(value) switch + { + "compressed_in_memory" => AudioClipLoadType.CompressedInMemory, + "streaming" => AudioClipLoadType.Streaming, + _ => AudioClipLoadType.DecompressOnLoad + }; + } + + private static AudioCompressionFormat ParseAudioCompression(string value) + { + return Normalize(value) switch + { + "pcm" => AudioCompressionFormat.PCM, + "adpcm" => AudioCompressionFormat.ADPCM, + _ => AudioCompressionFormat.Vorbis + }; + } + + private static int ClampTextureSize(int value) + { + var sizes = new[] { 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384 }; + var requested = Math.Max(32, Math.Min(value, 16384)); + return sizes.OrderBy(size => Math.Abs(size - requested)).First(); + } + + private static Vector3 ToVector3(SceneAuthoringVector3 input, Vector3 fallback) + { + return input == null ? fallback : new Vector3(input.x, input.y, input.z); + } + + private static string RequireSafeName(string value) + { + if (!IsSafeName(value)) + { + throw new InvalidOperationException("objectName is invalid."); + } + + return value.Trim(); + } + + private static bool IsSafeName(string value) + { + var name = (value ?? string.Empty).Trim(); + return name.Length > 0 + && name.Length <= 80 + && !name.Contains("/") + && !name.Contains("\\") + && !name.Contains("..") + && name.All(character => !char.IsControl(character)); + } + + private static bool IsSafeAssetPath(string path) + { + return !string.IsNullOrWhiteSpace(path) + && path.StartsWith("Assets/", StringComparison.Ordinal) + && !path.Contains("..") + && !Path.IsPathRooted(path) + && path.Length <= 512; + } + + private static bool IsSafePrefabPath(string path) + { + return IsSafeAssetPath(path) && path.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsModelExtension(string extension) + { + return new[] { ".fbx", ".obj", ".dae", ".3ds", ".dxf", ".glb", ".gltf" } + .Contains(extension, StringComparer.OrdinalIgnoreCase); + } + + private static string NormalizeAssetPath(string value) + { + return (value ?? string.Empty).Trim().Replace('\\', '/'); + } + + private static string NormalizeHierarchyPath(string value) + { + var path = (value ?? string.Empty).Trim().Replace('\\', '/').Trim('/'); + return path.Length <= 512 && !path.Contains("..") && !path.Contains("//") ? path : string.Empty; + } + + private static string Normalize(string value) + { + return (value ?? string.Empty).Trim().ToLowerInvariant(); + } + + private static string NormalizeHash(string value) + { + return (value ?? string.Empty).Trim().ToLowerInvariant(); + } + + private static string ResolveAssetPath(string path) + { + var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; + var absolute = Path.GetFullPath(Path.Combine(projectRoot, path.Replace('/', Path.DirectorySeparatorChar))); + var assetsRoot = Path.GetFullPath(Application.dataPath).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + var comparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!absolute.StartsWith(assetsRoot, comparison)) + { + throw new InvalidOperationException("Asset path escapes Assets."); + } + + return absolute; + } + + private static void EnsureParentDirectory(string absolutePath) + { + var parent = Path.GetDirectoryName(absolutePath); + if (!string.IsNullOrWhiteSpace(parent)) + { + Directory.CreateDirectory(parent); + } + } + + private static string CreateStagedPath() + { + var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; + var directory = Path.Combine(projectRoot, "Library", "UnityAIControlPlane", "Downloads"); + Directory.CreateDirectory(directory); + return Path.Combine(directory, Guid.NewGuid().ToString("N") + ".tmp"); + } + + private static string ComputeSha256(string path) + { + using var stream = File.OpenRead(path); + using var hash = SHA256.Create(); + return string.Concat(hash.ComputeHash(stream).Select(value => value.ToString("x2"))); + } + + private static GameObject FindByPath(string path) + { + var normalized = NormalizeHierarchyPath(path); + if (string.IsNullOrEmpty(normalized)) + { + return null; + } + + foreach (var root in EditorSceneManager.GetActiveScene().GetRootGameObjects()) + { + if (root.name == normalized) + { + return root; + } + + if (normalized.StartsWith(root.name + "/", StringComparison.Ordinal)) + { + var child = root.transform.Find(normalized.Substring(root.name.Length + 1)); + if (child != null) + { + return child.gameObject; + } + } + } + + return null; + } + + private static string GetGameObjectPath(GameObject gameObject) + { + var path = gameObject.name; + var parent = gameObject.transform.parent; + while (parent != null) + { + path = parent.name + "/" + path; + parent = parent.parent; + } + + return path; + } + + private static string DescribeSource(AssetImportInput input, string sourceKind) + { + if (sourceKind == "url") + { + return "remote asset"; + } + + return string.IsNullOrWhiteSpace(input.sourcePath) + ? "local asset" + : $"local file '{Path.GetFileName(input.sourcePath)}'"; + } + + private static string BuildAuditMessage( + AssetImportInput input, + string destinationPath, + string sourceKind, + bool instantiated, + bool prefabCreated) + { + var provenance = input.catalogProvenance; + var catalog = provenance != null && !string.IsNullOrWhiteSpace(provenance.catalogAssetId) + ? $"; catalog={provenance.catalogAssetId}; license={provenance.licenseSpdxId}" + : string.Empty; + return $"Imported '{destinationPath}' from {sourceKind}{catalog}; instantiated={instantiated}; prefabCreated={prefabCreated}."; + } + + private static AssetImportResult Refused( + AssetImportRequest request, + string sourceKind, + string destinationPath, + string message, + string timestamp) + { + return new AssetImportResult + { + dryRun = request.input != null && request.input.dryRun, + refused = true, + requestId = request.requestId, + correlationId = request.correlationId, + sourceKind = sourceKind, + destinationPath = destinationPath, + catalogProvenance = request.input?.catalogProvenance ?? new AssetCatalogProvenanceInput(), + message = message, + verificationStatus = "failed", + timestampUtc = timestamp + }; + } + + private static AssetImportRequest ParseRequest(string body) + { + try + { + return string.IsNullOrWhiteSpace(body) + ? new AssetImportRequest() + : JsonUtility.FromJson(body) ?? new AssetImportRequest(); + } + catch + { + return new AssetImportRequest(); + } + } + } +} diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetImportOperation.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetImportOperation.cs.meta new file mode 100644 index 0000000..f1752ab --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetImportOperation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 667363aa690514ff9ad001a82fe1eaf8 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit.meta new file mode 100644 index 0000000..0f016c0 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 757762cfe892541b197484d08071c037 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit/AuditLogStore.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit/AuditLogStore.cs index e4618f4..f4caff7 100644 --- a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit/AuditLogStore.cs +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit/AuditLogStore.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using UnityEngine; @@ -35,6 +36,42 @@ public static void Append(UnityAiAuditEvent[] events) } } + public static UnityAiAuditEvent[] ReadAll(out int malformedLineCount) + { + malformedLineCount = 0; + if (!File.Exists(AuditLogPath)) + { + return Array.Empty(); + } + + var events = new List(); + foreach (var line in File.ReadLines(AuditLogPath)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + try + { + var auditEvent = JsonUtility.FromJson(line); + if (auditEvent == null || string.IsNullOrWhiteSpace(auditEvent.capability)) + { + malformedLineCount++; + continue; + } + + events.Add(auditEvent); + } + catch + { + malformedLineCount++; + } + } + + return events.ToArray(); + } + private static string GetProjectRoot() { var parent = Directory.GetParent(Application.dataPath); diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit/AuditLogStore.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit/AuditLogStore.cs.meta new file mode 100644 index 0000000..bdea716 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit/AuditLogStore.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 322c590f2b07e46ce8e23bcb1c57c1db \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit/AuditReportGenerator.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit/AuditReportGenerator.cs new file mode 100644 index 0000000..4c87feb --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit/AuditReportGenerator.cs @@ -0,0 +1,690 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using UnityEngine; + +namespace UnityAI.ControlPlane.Editor +{ + [Serializable] + public sealed class AuditReportEvidenceInput + { + public string phase = "supporting"; + public string kind = "artifact"; + public string path; + public string description; + } + + [Serializable] + public sealed class AuditReportVerificationInput + { + public string signal; + public string status = "inconclusive"; + public string summary; + public string[] evidencePaths = Array.Empty(); + } + + [Serializable] + public sealed class AuditReportInput + { + public string title = "Unity AI verification report"; + public string summary; + public string[] requestIds = Array.Empty(); + public string[] correlationIds = Array.Empty(); + public string[] capabilities = Array.Empty(); + public string sinceUtc; + public string untilUtc; + public int maxEvents = 200; + public long maxEvidenceBytes = 2147483648; + public bool requireBeforeAfter; + public AuditReportEvidenceInput[] evidence = Array.Empty(); + public AuditReportVerificationInput[] verifications = Array.Empty(); + } + + [Serializable] + public sealed class AuditReportRequest + { + public string requestId; + public string correlationId; + public AuditReportInput input = new(); + } + + [Serializable] + public sealed class AuditReportEvidence + { + public string phase; + public string kind; + public string path; + public string description; + public bool available; + public long byteLength; + public string sha256; + public string lastWriteTimeUtc; + public string error; + } + + [Serializable] + public sealed class AuditReportVerification + { + public string signal; + public string status; + public string summary; + public string[] evidencePaths = Array.Empty(); + public bool evidenceReferencesValid; + } + + [Serializable] + public sealed class AuditReportDocument + { + public string reportId; + public string generatedAtUtc; + public string generatedByRequestId; + public string generatedByCorrelationId; + public string title; + public string summary; + public string status; + public bool requireBeforeAfter; + public bool beforeAfterComplete; + public int totalAuditEvents; + public int matchedAuditEvents; + public int malformedAuditLines; + public bool truncated; + public string[] requestIds = Array.Empty(); + public string[] correlationIds = Array.Empty(); + public string[] capabilities = Array.Empty(); + public string sinceUtc; + public string untilUtc; + public UnityAiAuditEvent[] auditEvents = Array.Empty(); + public AuditReportEvidence[] evidence = Array.Empty(); + public AuditReportVerification[] verifications = Array.Empty(); + } + + [Serializable] + public sealed class AuditReportResult + { + public bool generated; + public bool refused; + public string reportId; + public string requestId; + public string correlationId; + public string title; + public string status; + public string message; + public int totalAuditEvents; + public int matchedAuditEvents; + public int malformedAuditLines; + public bool truncated; + public bool requireBeforeAfter; + public bool beforeAfterComplete; + public AuditReportEvidence[] evidence = Array.Empty(); + public AuditReportVerification[] verifications = Array.Empty(); + public UnityAiAuditEvent[] auditEvents = Array.Empty(); + public string reportJsonPath; + public string reportMarkdownPath; + public string reportJsonSha256; + public string reportMarkdownSha256; + public bool reportFilesVerified; + public bool auditPersisted; + public UnityAiAuditEvent auditEvent; + public string auditLogPath; + public string[] verificationSignals = Array.Empty(); + public string generatedAtUtc; + } + + public static class AuditReportGenerator + { + private const string ArtifactRoot = "UnityAIArtifacts"; + private const string ReportDirectory = "UnityAIArtifacts/Audit/Reports"; + + public static AuditReportResult Generate(string requestBody) + { + var request = ParseRequest(requestBody); + var input = request.input ?? new AuditReportInput(); + var generatedAt = DateTime.UtcNow.ToString("O"); + var requestIds = NormalizeValues(input.requestIds, 200); + var correlationIds = NormalizeValues(input.correlationIds, 200); + var capabilities = NormalizeValues(input.capabilities, 200); + var maxEvents = Math.Max(1, Math.Min(input.maxEvents, 5000)); + var maxEvidenceBytes = Math.Max(1, Math.Min(input.maxEvidenceBytes, 10L * 1024 * 1024 * 1024)); + + if (!TryParseTimestamp(input.sinceUtc, out var sinceUtc, out var normalizedSince, out var sinceError)) + { + return Refused(request, sinceError, generatedAt); + } + + if (!TryParseTimestamp(input.untilUtc, out var untilUtc, out var normalizedUntil, out var untilError)) + { + return Refused(request, untilError, generatedAt); + } + + if (sinceUtc.HasValue && untilUtc.HasValue && sinceUtc.Value > untilUtc.Value) + { + return Refused(request, "sinceUtc must be earlier than or equal to untilUtc.", generatedAt); + } + + var allEvents = AuditLogStore.ReadAll(out var malformedLineCount); + var matched = allEvents + .Where(auditEvent => MatchesFilters(auditEvent, requestIds, correlationIds, capabilities, sinceUtc, untilUtc)) + .ToArray(); + var truncated = matched.Length > maxEvents; + var selectedEvents = matched + .Skip(Math.Max(0, matched.Length - maxEvents)) + .ToArray(); + + var evidence = (input.evidence ?? Array.Empty()) + .Take(100) + .Select(item => InspectEvidence(item, maxEvidenceBytes)) + .ToArray(); + var availableEvidencePaths = new HashSet( + evidence.Where(item => item.available).Select(item => item.path), + StringComparer.Ordinal); + var verifications = (input.verifications ?? Array.Empty()) + .Take(200) + .Select(item => NormalizeVerification(item, availableEvidencePaths)) + .ToArray(); + var beforeAfterComplete = evidence.Any(item => item.available && item.phase == "before") + && evidence.Any(item => item.available && item.phase == "after"); + var hasFilters = requestIds.Length > 0 || correlationIds.Length > 0 || capabilities.Length > 0 + || sinceUtc.HasValue || untilUtc.HasValue; + var status = DetermineStatus( + evidence, + verifications, + input.requireBeforeAfter, + beforeAfterComplete, + hasFilters, + matched.Length); + var reportId = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss-fff") + "-" + Guid.NewGuid().ToString("N").Substring(0, 8); + var title = SanitizeText(input.title, 160, "Unity AI verification report"); + var summary = SanitizeText(input.summary, 4000, string.Empty); + var document = new AuditReportDocument + { + reportId = reportId, + generatedAtUtc = generatedAt, + generatedByRequestId = request.requestId, + generatedByCorrelationId = request.correlationId, + title = title, + summary = summary, + status = status, + requireBeforeAfter = input.requireBeforeAfter, + beforeAfterComplete = beforeAfterComplete, + totalAuditEvents = allEvents.Length, + matchedAuditEvents = matched.Length, + malformedAuditLines = malformedLineCount, + truncated = truncated, + requestIds = requestIds, + correlationIds = correlationIds, + capabilities = capabilities, + sinceUtc = normalizedSince, + untilUtc = normalizedUntil, + auditEvents = selectedEvents, + evidence = evidence, + verifications = verifications + }; + + try + { + var relativeJsonPath = $"{ReportDirectory}/{reportId}.json"; + var relativeMarkdownPath = $"{ReportDirectory}/{reportId}.md"; + var absoluteJsonPath = ResolveArtifactPath(relativeJsonPath, true); + var absoluteMarkdownPath = ResolveArtifactPath(relativeMarkdownPath, true); + File.WriteAllText(absoluteJsonPath, JsonUtility.ToJson(document, true), new UTF8Encoding(false)); + File.WriteAllText(absoluteMarkdownPath, BuildMarkdown(document), new UTF8Encoding(false)); + + var jsonHash = ComputeSha256(absoluteJsonPath); + var markdownHash = ComputeSha256(absoluteMarkdownPath); + var filesVerified = File.Exists(absoluteJsonPath) + && File.Exists(absoluteMarkdownPath) + && jsonHash == ComputeSha256(absoluteJsonPath) + && markdownHash == ComputeSha256(absoluteMarkdownPath); + var generationAuditEvent = new UnityAiAuditEvent + { + timestamp = DateTime.UtcNow.ToString("O"), + capability = "unity.audit.report", + requestId = request.requestId, + correlationId = request.correlationId, + message = $"Generated audit report '{reportId}' with {matched.Length} matched event(s) and status '{status}'.", + effects = new[] { "write_artifacts", "write_audit_log" } + }; + var auditPersisted = PersistAudit(generationAuditEvent); + var responseAuditEvent = auditPersisted + ? generationAuditEvent + : new UnityAiAuditEvent + { + timestamp = generationAuditEvent.timestamp, + capability = generationAuditEvent.capability, + requestId = generationAuditEvent.requestId, + correlationId = generationAuditEvent.correlationId, + message = generationAuditEvent.message, + effects = new[] { "write_artifacts" } + }; + var signals = new List { "audit_report_generated", "structured_observation" }; + if (evidence.Length > 0 && evidence.All(item => item.available && !string.IsNullOrEmpty(item.sha256))) + { + signals.Add("evidence_hash_verified"); + } + + if (auditPersisted) + { + signals.Add("operation_audited"); + } + + return new AuditReportResult + { + generated = true, + reportId = reportId, + requestId = request.requestId, + correlationId = request.correlationId, + title = title, + status = status, + message = $"Generated JSON and Markdown audit report artifacts with {matched.Length} matched event(s).", + totalAuditEvents = allEvents.Length, + matchedAuditEvents = matched.Length, + malformedAuditLines = malformedLineCount, + truncated = truncated, + requireBeforeAfter = input.requireBeforeAfter, + beforeAfterComplete = beforeAfterComplete, + evidence = evidence, + verifications = verifications, + auditEvents = selectedEvents, + reportJsonPath = relativeJsonPath, + reportMarkdownPath = relativeMarkdownPath, + reportJsonSha256 = jsonHash, + reportMarkdownSha256 = markdownHash, + reportFilesVerified = filesVerified, + auditPersisted = auditPersisted, + auditEvent = responseAuditEvent, + auditLogPath = AuditLogStore.AuditLogRelativePath.Replace('\\', '/'), + verificationSignals = signals.ToArray(), + generatedAtUtc = generatedAt + }; + } + catch (Exception exception) + { + return Refused(request, $"Audit report generation failed: {exception.Message}", generatedAt); + } + } + + private static bool MatchesFilters( + UnityAiAuditEvent auditEvent, + string[] requestIds, + string[] correlationIds, + string[] capabilities, + DateTime? sinceUtc, + DateTime? untilUtc) + { + if (requestIds.Length > 0 && !requestIds.Contains(auditEvent.requestId, StringComparer.Ordinal)) + { + return false; + } + + if (correlationIds.Length > 0 && !correlationIds.Contains(auditEvent.correlationId, StringComparer.Ordinal)) + { + return false; + } + + if (capabilities.Length > 0 && !capabilities.Contains(auditEvent.capability, StringComparer.Ordinal)) + { + return false; + } + + if (!sinceUtc.HasValue && !untilUtc.HasValue) + { + return true; + } + + if (!DateTime.TryParse( + auditEvent.timestamp, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var eventTimestamp)) + { + return false; + } + + return (!sinceUtc.HasValue || eventTimestamp >= sinceUtc.Value) + && (!untilUtc.HasValue || eventTimestamp <= untilUtc.Value); + } + + private static AuditReportEvidence InspectEvidence(AuditReportEvidenceInput input, long maxEvidenceBytes) + { + input ??= new AuditReportEvidenceInput(); + var phase = NormalizePhase(input.phase); + var kind = SanitizeToken(input.kind, 64, "artifact"); + var path = NormalizeArtifactPath(input.path); + var result = new AuditReportEvidence + { + phase = phase, + kind = kind, + path = path, + description = SanitizeText(input.description, 1000, string.Empty) + }; + + if (string.IsNullOrEmpty(path)) + { + result.error = $"Evidence paths must be project-relative files under {ArtifactRoot}."; + return result; + } + + try + { + var absolutePath = ResolveArtifactPath(path, false); + if (!File.Exists(absolutePath)) + { + result.error = "Evidence file does not exist."; + return result; + } + + var file = new FileInfo(absolutePath); + result.byteLength = file.Length; + result.lastWriteTimeUtc = file.LastWriteTimeUtc.ToString("O"); + if (file.Length > maxEvidenceBytes) + { + result.error = $"Evidence file exceeds maxEvidenceBytes ({maxEvidenceBytes})."; + return result; + } + + result.sha256 = ComputeSha256(absolutePath); + result.available = !string.IsNullOrEmpty(result.sha256); + return result; + } + catch (Exception exception) + { + result.error = exception.Message; + return result; + } + } + + private static AuditReportVerification NormalizeVerification( + AuditReportVerificationInput input, + HashSet availableEvidencePaths) + { + input ??= new AuditReportVerificationInput(); + var requestedEvidencePaths = NormalizeValues(input.evidencePaths, 100); + var evidencePaths = requestedEvidencePaths + .Select(NormalizeArtifactPath) + .Where(path => !string.IsNullOrEmpty(path)) + .ToArray(); + return new AuditReportVerification + { + signal = SanitizeToken(input.signal, 128, "unspecified"), + status = NormalizeStatus(input.status), + summary = SanitizeText(input.summary, 2000, string.Empty), + evidencePaths = evidencePaths, + evidenceReferencesValid = requestedEvidencePaths.Length == evidencePaths.Length + && evidencePaths.All(availableEvidencePaths.Contains) + }; + } + + private static string DetermineStatus( + AuditReportEvidence[] evidence, + AuditReportVerification[] verifications, + bool requireBeforeAfter, + bool beforeAfterComplete, + bool hasFilters, + int matchedEventCount) + { + if (evidence.Any(item => !item.available) + || verifications.Any(item => item.status == "failed" || !item.evidenceReferencesValid) + || (hasFilters && matchedEventCount == 0) + || (requireBeforeAfter && !beforeAfterComplete)) + { + return "failed"; + } + + if (evidence.Length == 0 + || verifications.Length == 0 + || verifications.Any(item => item.status == "inconclusive")) + { + return "inconclusive"; + } + + return "passed"; + } + + private static string BuildMarkdown(AuditReportDocument report) + { + var builder = new StringBuilder(); + builder.AppendLine("# " + MarkdownText(report.title)); + builder.AppendLine(); + builder.AppendLine($"- Report ID: `{MarkdownText(report.reportId)}`"); + builder.AppendLine($"- Generated: `{MarkdownText(report.generatedAtUtc)}`"); + builder.AppendLine($"- Status: **{MarkdownText(report.status)}**"); + builder.AppendLine($"- Audit events: {report.matchedAuditEvents} matched, {report.auditEvents.Length} included"); + builder.AppendLine($"- Before/after complete: {report.beforeAfterComplete.ToString().ToLowerInvariant()}"); + if (!string.IsNullOrEmpty(report.summary)) + { + builder.AppendLine(); + builder.AppendLine("## Summary"); + builder.AppendLine(); + builder.AppendLine(MarkdownText(report.summary)); + } + + builder.AppendLine(); + builder.AppendLine("## Verification"); + builder.AppendLine(); + builder.AppendLine("| Status | Signal | Summary | Evidence |"); + builder.AppendLine("|---|---|---|---|"); + foreach (var verification in report.verifications) + { + builder.AppendLine($"| {MarkdownCell(verification.status)} | `{MarkdownCell(verification.signal)}` | {MarkdownCell(verification.summary)} | {MarkdownCell(string.Join(", ", verification.evidencePaths))} |"); + } + + if (report.verifications.Length == 0) + { + builder.AppendLine("| inconclusive | `none` | No verification claims supplied. | |"); + } + + builder.AppendLine(); + builder.AppendLine("## Evidence"); + builder.AppendLine(); + builder.AppendLine("| Phase | Kind | Path | Available | Bytes | SHA-256 | Description |"); + builder.AppendLine("|---|---|---|---:|---:|---|---|"); + foreach (var evidence in report.evidence) + { + builder.AppendLine($"| {MarkdownCell(evidence.phase)} | {MarkdownCell(evidence.kind)} | `{MarkdownCell(evidence.path)}` | {evidence.available.ToString().ToLowerInvariant()} | {evidence.byteLength} | `{MarkdownCell(evidence.sha256)}` | {MarkdownCell(string.IsNullOrEmpty(evidence.error) ? evidence.description : evidence.error)} |"); + } + + if (report.evidence.Length == 0) + { + builder.AppendLine("| supporting | artifact | | false | 0 | | No evidence supplied. |"); + } + + builder.AppendLine(); + builder.AppendLine("## Audit Events"); + builder.AppendLine(); + builder.AppendLine("| Timestamp | Capability | Request ID | Correlation ID | Effects | Message |"); + builder.AppendLine("|---|---|---|---|---|---|"); + foreach (var auditEvent in report.auditEvents) + { + builder.AppendLine($"| `{MarkdownCell(auditEvent.timestamp)}` | `{MarkdownCell(auditEvent.capability)}` | `{MarkdownCell(auditEvent.requestId)}` | `{MarkdownCell(auditEvent.correlationId)}` | {MarkdownCell(string.Join(", ", auditEvent.effects ?? Array.Empty()))} | {MarkdownCell(auditEvent.message)} |"); + } + + if (report.auditEvents.Length == 0) + { + builder.AppendLine("| | | | | | No matching audit events. |"); + } + + return builder.ToString(); + } + + private static bool TryParseTimestamp( + string value, + out DateTime? timestamp, + out string normalized, + out string error) + { + timestamp = null; + normalized = string.Empty; + error = string.Empty; + if (string.IsNullOrWhiteSpace(value)) + { + return true; + } + + if (!DateTime.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var parsed)) + { + error = $"Invalid UTC timestamp '{SanitizeText(value, 100, string.Empty)}'."; + return false; + } + + timestamp = parsed; + normalized = parsed.ToString("O"); + return true; + } + + private static string ResolveArtifactPath(string relativePath, bool createParent) + { + var normalized = NormalizeArtifactPath(relativePath); + if (string.IsNullOrEmpty(normalized)) + { + throw new InvalidOperationException($"Artifact path must stay under {ArtifactRoot}."); + } + + var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; + var artifactRoot = Path.GetFullPath(Path.Combine(projectRoot, ArtifactRoot)); + var absolutePath = Path.GetFullPath(Path.Combine(projectRoot, normalized.Replace('/', Path.DirectorySeparatorChar))); + var prefix = artifactRoot.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + var comparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!absolutePath.StartsWith(prefix, comparison)) + { + throw new InvalidOperationException("Artifact path escapes UnityAIArtifacts."); + } + + if (createParent) + { + Directory.CreateDirectory(Path.GetDirectoryName(absolutePath) ?? artifactRoot); + } + + return absolutePath; + } + + private static string NormalizeArtifactPath(string value) + { + var normalized = (value ?? string.Empty).Trim().Replace('\\', '/'); + if (string.IsNullOrEmpty(normalized) + || Path.IsPathRooted(normalized) + || normalized.EndsWith("/", StringComparison.Ordinal) + || !normalized.StartsWith(ArtifactRoot + "/", StringComparison.Ordinal) + || normalized.Split('/').Any(segment => segment == ".." || segment.Length == 0)) + { + return string.Empty; + } + + return normalized; + } + + private static bool PersistAudit(UnityAiAuditEvent auditEvent) + { + try + { + AuditLogStore.Append(new[] { auditEvent }); + return true; + } + catch (Exception exception) + { + Debug.LogError($"Failed to persist audit report event: {exception.Message}"); + return false; + } + } + + private static AuditReportResult Refused(AuditReportRequest request, string message, string generatedAt) + { + return new AuditReportResult + { + refused = true, + requestId = request.requestId, + correlationId = request.correlationId, + status = "failed", + message = message, + auditLogPath = AuditLogStore.AuditLogRelativePath.Replace('\\', '/'), + generatedAtUtc = generatedAt + }; + } + + private static string ComputeSha256(string path) + { + using var stream = File.OpenRead(path); + using var sha256 = SHA256.Create(); + return BitConverter.ToString(sha256.ComputeHash(stream)).Replace("-", string.Empty).ToLowerInvariant(); + } + + private static string[] NormalizeValues(string[] values, int maxResults) + { + return (values ?? Array.Empty()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => SanitizeText(value, 256, string.Empty)) + .Where(value => !string.IsNullOrEmpty(value)) + .Distinct(StringComparer.Ordinal) + .Take(maxResults) + .ToArray(); + } + + private static string NormalizePhase(string value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return normalized == "before" || normalized == "after" ? normalized : "supporting"; + } + + private static string NormalizeStatus(string value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return normalized == "passed" || normalized == "failed" ? normalized : "inconclusive"; + } + + private static string SanitizeToken(string value, int maxLength, string fallback) + { + var sanitized = new string((value ?? string.Empty) + .Trim() + .Take(maxLength) + .Select(character => char.IsLetterOrDigit(character) || character == '_' || character == '-' || character == '.' ? character : '_') + .ToArray()) + .Trim('_'); + return string.IsNullOrEmpty(sanitized) ? fallback : sanitized; + } + + private static string SanitizeText(string value, int maxLength, string fallback) + { + var sanitized = new string((value ?? string.Empty) + .Take(maxLength) + .Select(character => char.IsControl(character) && character != '\n' && character != '\t' ? ' ' : character) + .ToArray()) + .Trim(); + return string.IsNullOrEmpty(sanitized) ? fallback : sanitized; + } + + private static string MarkdownText(string value) + { + return (value ?? string.Empty).Replace("\r", string.Empty).Replace("\n", " \n"); + } + + private static string MarkdownCell(string value) + { + return (value ?? string.Empty) + .Replace("\\", "\\\\") + .Replace("|", "\\|") + .Replace("\r", " ") + .Replace("\n", " "); + } + + private static AuditReportRequest ParseRequest(string body) + { + try + { + return string.IsNullOrWhiteSpace(body) + ? new AuditReportRequest() + : JsonUtility.FromJson(body) ?? new AuditReportRequest(); + } + catch + { + return new AuditReportRequest(); + } + } + } +} diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit/AuditReportGenerator.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit/AuditReportGenerator.cs.meta new file mode 100644 index 0000000..bb7f5b1 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit/AuditReportGenerator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7317412ec8d5f43cfa4626581856f241 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge.meta new file mode 100644 index 0000000..9b91023 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c7a262678013e499287709fcb874681b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge/UnityAiBridgeBatchRunner.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge/UnityAiBridgeBatchRunner.cs.meta new file mode 100644 index 0000000..42dc569 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge/UnityAiBridgeBatchRunner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1a313461d49ed42fdb2be14d577a22eb \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge/UnityAiBridgeServer.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge/UnityAiBridgeServer.cs index 231ba23..4269e5f 100644 --- a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge/UnityAiBridgeServer.cs +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge/UnityAiBridgeServer.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net; using System.Text; using System.Threading; @@ -33,22 +35,37 @@ internal sealed class BridgeWorkItem { public string capability; public string requestBody; + public string cacheKey; + public bool persistResponse; public TaskCompletionSource completion; } + [Serializable] + internal sealed class BridgeCachedResponse + { + public string cachedAtUtc; + public BridgeResponse response; + } + [InitializeOnLoad] public static class UnityAiBridgeServer { public const int DefaultPort = 39071; private const string SessionEnabledKey = "UnityAI.ControlPlane.BridgeEnabled"; private const string SessionTokenKey = "UnityAI.ControlPlane.BridgeToken"; + private const int MaximumMemoryCacheEntries = 1024; + private const int MaximumPersistentCacheEntries = 2048; private static readonly ConcurrentQueue WorkQueue = new(); + private static readonly ConcurrentDictionary> InFlightRequests = new(); + private static readonly ConcurrentDictionary CompletedResponses = new(); private static HttpListener _listener; private static CancellationTokenSource _cancellation; private static Task _serverTask; private static string _bridgeToken = string.Empty; private static int _restoreAttempts; + private static double _nextRestoreAttemptAt; + private static double _restoreDeadlineAt; public static bool IsRunning => _listener != null && _listener.IsListening; public static string Url => $"http://127.0.0.1:{DefaultPort}/"; @@ -92,6 +109,7 @@ private static void StartInternal(string bridgeToken, bool persistSession) } _serverTask = Task.Run(() => ListenLoop(_cancellation.Token)); + CleanupPersistentResponseCache(); if (persistSession) { SessionState.SetBool(SessionEnabledKey, true); @@ -99,6 +117,9 @@ private static void StartInternal(string bridgeToken, bool persistSession) } _restoreAttempts = 0; + _nextRestoreAttemptAt = 0; + _restoreDeadlineAt = 0; + EditorApplication.update -= RetryRestoreWhenReady; Debug.Log($"Unity AI bridge listening on {Url}"); } @@ -125,6 +146,7 @@ private static void StopInternal(bool clearSession) _serverTask = null; _cancellation = null; _bridgeToken = string.Empty; + EditorApplication.update -= RetryRestoreWhenReady; if (clearSession) { SessionState.EraseBool(SessionEnabledKey); @@ -134,29 +156,82 @@ private static void StopInternal(bool clearSession) private static void RestoreAfterDomainReload() { - if (IsRunning || !SessionState.GetBool(SessionEnabledKey, false)) + if (IsRunning) { + EditorApplication.update -= RetryRestoreWhenReady; return; } + var sessionEnabled = SessionState.GetBool(SessionEnabledKey, false); + var token = sessionEnabled + ? SessionState.GetString(SessionTokenKey, string.Empty) + : ReadDefaultDesktopToken(); + if (!sessionEnabled && string.IsNullOrWhiteSpace(token)) + { + EditorApplication.update -= RetryRestoreWhenReady; + return; + } + + if (_restoreDeadlineAt <= 0) + { + _restoreDeadlineAt = EditorApplication.timeSinceStartup + 90; + } + try { - StartInternal(SessionState.GetString(SessionTokenKey, string.Empty), false); + StartInternal(token, !sessionEnabled); } - catch (HttpListenerException exception) + catch (Exception exception) { _restoreAttempts++; - if (_restoreAttempts < 20) + if (EditorApplication.timeSinceStartup < _restoreDeadlineAt) { - EditorApplication.delayCall += RestoreAfterDomainReload; + var backoffSeconds = Math.Min(3, 0.1 * Math.Pow(1.5, Math.Min(_restoreAttempts, 12))); + _nextRestoreAttemptAt = EditorApplication.timeSinceStartup + backoffSeconds; + EditorApplication.update -= RetryRestoreWhenReady; + EditorApplication.update += RetryRestoreWhenReady; return; } - Debug.LogError($"Unity AI bridge could not resume after domain reload: {exception.Message}"); + Debug.LogError($"Unity AI bridge could not resume within 90 seconds after domain reload: {exception.Message}"); StopInternal(true); } } + private static string ReadDefaultDesktopToken() + { + if (Application.isBatchMode) + { + return string.Empty; + } + + try + { + var tokenPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config", + "unity-ai", + "bridge-token"); + return File.Exists(tokenPath) ? File.ReadAllText(tokenPath).Trim() : string.Empty; + } + catch (Exception exception) + { + Debug.LogWarning($"Unity AI bridge could not read the desktop token: {exception.Message}"); + return string.Empty; + } + } + + private static void RetryRestoreWhenReady() + { + if (EditorApplication.timeSinceStartup < _nextRestoreAttemptAt) + { + return; + } + + EditorApplication.update -= RetryRestoreWhenReady; + RestoreAfterDomainReload(); + } + private static async Task ListenLoop(CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested && _listener != null) @@ -232,37 +307,250 @@ private static async Task HandleContext(HttpListenerContext context) var body = await ReadRequestBody(context.Request); var envelope = ParseEnvelope(body); - var completion = new TaskCompletionSource(); + var cacheKey = CreateCacheKey(capability, envelope.requestId); + if (TryGetCompletedResponse(cacheKey, envelope.requestId, capability, out var completed)) + { + await WriteResponse(context, completed.ok ? 200 : 500, completed); + return; + } + + var response = await EnqueueOrJoin(capability, body, cacheKey, IsMutatingCapability(capability)); + await WriteResponse(context, response.ok ? 200 : 500, response); + } + + private static Task EnqueueOrJoin(string capability, string requestBody, string cacheKey, bool persistResponse) + { + if (string.IsNullOrEmpty(cacheKey)) + { + return Enqueue(capability, requestBody, string.Empty, false); + } + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (InFlightRequests.TryGetValue(cacheKey, out var existing)) + { + return existing; + } + + if (!InFlightRequests.TryAdd(cacheKey, completion.Task)) + { + return InFlightRequests.TryGetValue(cacheKey, out existing) + ? existing + : EnqueueOrJoin(capability, requestBody, cacheKey, persistResponse); + } WorkQueue.Enqueue(new BridgeWorkItem { capability = capability, - requestBody = body, + requestBody = requestBody, + cacheKey = cacheKey, + persistResponse = persistResponse, completion = completion }); + return completion.Task; + } - var response = await completion.Task; - await WriteResponse(context, response.ok ? 200 : 500, response); + private static Task Enqueue(string capability, string requestBody, string cacheKey, bool persistResponse) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + WorkQueue.Enqueue(new BridgeWorkItem + { + capability = capability, + requestBody = requestBody, + cacheKey = cacheKey, + persistResponse = persistResponse, + completion = completion + }); + return completion.Task; } private static void ProcessQueuedWork() { while (WorkQueue.TryDequeue(out var item)) { + BridgeResponse response; try { - item.completion.SetResult(ExecuteCapability(item.capability, item.requestBody)); + response = ExecuteCapability(item.capability, item.requestBody); } catch (Exception exception) { - item.completion.SetResult(new BridgeResponse + var envelope = ParseEnvelope(item.requestBody); + response = new BridgeResponse { ok = false, capability = item.capability, + requestId = envelope.requestId, + correlationId = envelope.correlationId, error = exception.Message - }); + }; } + + if (!string.IsNullOrEmpty(item.cacheKey)) + { + CompletedResponses[item.cacheKey] = response; + TrimMemoryCache(); + if (item.persistResponse) + { + PersistCompletedResponse(response); + } + + InFlightRequests.TryRemove(item.cacheKey, out _); + } + + item.completion.TrySetResult(response); + } + } + + private static bool TryGetCompletedResponse(string cacheKey, string requestId, string capability, out BridgeResponse response) + { + response = null; + if (string.IsNullOrEmpty(cacheKey)) + { + return false; + } + + if (CompletedResponses.TryGetValue(cacheKey, out response)) + { + return true; + } + + if (!IsMutatingCapability(capability) || !TryLoadPersistentResponse(requestId, capability, out response)) + { + response = null; + return false; } + + CompletedResponses[cacheKey] = response; + TrimMemoryCache(); + return true; + } + + private static string CreateCacheKey(string capability, string requestId) + { + return IsSafeRequestId(requestId) ? capability + "|" + requestId : string.Empty; + } + + private static bool IsSafeRequestId(string requestId) + { + if (string.IsNullOrWhiteSpace(requestId) || requestId.Length > 128) + { + return false; + } + + return requestId.All(character => char.IsLetterOrDigit(character) || character == '-' || character == '_'); + } + + private static void PersistCompletedResponse(BridgeResponse response) + { + if (response == null || !IsSafeRequestId(response.requestId)) + { + return; + } + + try + { + var directory = GetPersistentResponseCacheDirectory(); + Directory.CreateDirectory(directory); + var record = new BridgeCachedResponse + { + cachedAtUtc = DateTime.UtcNow.ToString("O"), + response = response + }; + var path = Path.Combine(directory, response.requestId + ".json"); + var temporaryPath = path + ".tmp"; + File.WriteAllText(temporaryPath, JsonUtility.ToJson(record, true), Encoding.UTF8); + if (File.Exists(path)) + { + File.Delete(path); + } + + File.Move(temporaryPath, path); + } + catch (Exception exception) + { + Debug.LogWarning($"Unity AI bridge could not persist idempotency response: {exception.Message}"); + } + } + + private static bool TryLoadPersistentResponse(string requestId, string capability, out BridgeResponse response) + { + response = null; + if (!IsSafeRequestId(requestId)) + { + return false; + } + + try + { + var path = Path.Combine(GetPersistentResponseCacheDirectory(), requestId + ".json"); + if (!File.Exists(path) || DateTime.UtcNow - File.GetLastWriteTimeUtc(path) > TimeSpan.FromDays(1)) + { + return false; + } + + var record = JsonUtility.FromJson(File.ReadAllText(path, Encoding.UTF8)); + if (record?.response == null + || !string.Equals(record.response.requestId, requestId, StringComparison.Ordinal) + || !string.Equals(record.response.capability, capability, StringComparison.Ordinal)) + { + return false; + } + + response = record.response; + return true; + } + catch (Exception exception) + { + Debug.LogWarning($"Unity AI bridge could not read idempotency response: {exception.Message}"); + return false; + } + } + + private static void TrimMemoryCache() + { + if (CompletedResponses.Count <= MaximumMemoryCacheEntries) + { + return; + } + + foreach (var key in CompletedResponses.Keys.Take(CompletedResponses.Count - MaximumMemoryCacheEntries)) + { + CompletedResponses.TryRemove(key, out _); + } + } + + private static void CleanupPersistentResponseCache() + { + try + { + var directory = GetPersistentResponseCacheDirectory(); + if (!Directory.Exists(directory)) + { + return; + } + + var files = new DirectoryInfo(directory) + .GetFiles("*.json") + .OrderByDescending(file => file.LastWriteTimeUtc) + .ToArray(); + for (var index = 0; index < files.Length; index += 1) + { + if (index >= MaximumPersistentCacheEntries || DateTime.UtcNow - files[index].LastWriteTimeUtc > TimeSpan.FromDays(1)) + { + files[index].Delete(); + } + } + } + catch (Exception exception) + { + Debug.LogWarning($"Unity AI bridge could not clean idempotency responses: {exception.Message}"); + } + } + + private static string GetPersistentResponseCacheDirectory() + { + var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; + return Path.Combine(projectRoot, "Library", "UnityAIControlPlane", "BridgeResponses"); } private static BridgeResponse ExecuteCapability(string capability, string requestBody) @@ -275,6 +563,8 @@ private static BridgeResponse ExecuteCapability(string capability, string reques return JsonResult(capability, envelope, ProjectInspector.InspectActiveProject()); case "unity.project.snapshot": return JsonResult(capability, envelope, ProjectSnapshotObserver.Capture()); + case "unity.audit.report": + return JsonResult(capability, envelope, AuditReportGenerator.Generate(requestBody)); case "unity.console.read": return JsonResult(capability, envelope, ConsoleLogBridge.GetSummary()); case "unity.console.diagnose": @@ -291,10 +581,20 @@ private static BridgeResponse ExecuteCapability(string capability, string reques return JsonResult(capability, envelope, SceneInspector.InspectActiveScene(requestBody)); case "unity.scene.inspect_game_object": return JsonResult(capability, envelope, GameObjectInspector.Inspect(requestBody)); + case "unity.physics.inspect": + return JsonResult(capability, envelope, PhysicsInspector.Inspect(requestBody)); + case "unity.runtime.telemetry": + return JsonResult(capability, envelope, RuntimeTelemetryObserver.Capture(requestBody)); + case "unity.ui.audit": + return JsonResult(capability, envelope, UiComposeOperation.Audit(requestBody)); case "unity.scene.upsert_game_object": return JsonResult(capability, envelope, SceneUpsertGameObjectOperation.Execute(requestBody)); case "unity.scene.batch": return JsonResult(capability, envelope, SceneBatchOperation.Execute(requestBody)); + case "unity.ui.compose": + return JsonResult(capability, envelope, UiComposeOperation.Compose(requestBody)); + case "unity.gameplay.compose": + return JsonResult(capability, envelope, GameplayComposeOperation.Execute(requestBody)); case "unity.prefabs.list": return JsonResult(capability, envelope, PrefabObserver.ListPrefabs(requestBody)); case "unity.prefab.inspect": @@ -303,6 +603,8 @@ private static BridgeResponse ExecuteCapability(string capability, string reques return JsonResult(capability, envelope, AssetDependencyObserver.InspectDependencies(requestBody)); case "unity.scripts.list": return JsonResult(capability, envelope, ScriptAndAssemblyObserver.ListScripts(requestBody)); + case "unity.scripts.author": + return JsonResult(capability, envelope, ScriptAuthoringOperation.Start(requestBody)); case "unity.assemblies.list": return JsonResult(capability, envelope, ScriptAndAssemblyObserver.ListAssemblies(requestBody)); case "unity.packages.list": @@ -335,6 +637,10 @@ private static BridgeResponse ExecuteCapability(string capability, string reques return JsonResult(capability, envelope, BuildOperations.StartAndroidBuild(requestBody)); case "unity.assets.author": return JsonResult(capability, envelope, AssetAuthoringOperation.Execute(requestBody)); + case "unity.assets.import": + return JsonResult(capability, envelope, AssetImportOperation.Execute(requestBody)); + case "unity.assets.import_from_catalog": + return JsonResult(capability, envelope, AssetImportOperation.Execute(requestBody, capability)); case "unity.prefab.manage": return JsonResult(capability, envelope, PrefabAssetOperation.Execute(requestBody)); case "unity.checkpoints.create": @@ -415,6 +721,8 @@ private static bool IsMutatingCapability(string capability) case "unity.console.apply_fix": case "unity.scene.upsert_game_object": case "unity.scene.batch": + case "unity.ui.compose": + case "unity.gameplay.compose": case "unity.project.settings.update": case "unity.packages.change": case "unity.jobs.cancel": @@ -423,6 +731,9 @@ private static bool IsMutatingCapability(string capability) case "unity.compilation.wait": case "unity.build.android": case "unity.assets.author": + case "unity.assets.import": + case "unity.assets.import_from_catalog": + case "unity.scripts.author": case "unity.prefab.manage": case "unity.checkpoints.create": case "unity.checkpoints.restore": diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge/UnityAiBridgeServer.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge/UnityAiBridgeServer.cs.meta new file mode 100644 index 0000000..43844d8 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge/UnityAiBridgeServer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7ac3d5abeb35a46d5885721c2f1ce583 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Build.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Build.meta new file mode 100644 index 0000000..3d21e80 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Build.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c69c023a91f054e98be3d96a844795b2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Build/BuildOperations.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Build/BuildOperations.cs.meta new file mode 100644 index 0000000..4418998 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Build/BuildOperations.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3858d39d8b86f4dd08aeeec87ea54053 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Checkpoints.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Checkpoints.meta new file mode 100644 index 0000000..6fb4811 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Checkpoints.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 65d178b3dd91e43dfbb0778e282c6ec8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Checkpoints/DurableCheckpointStore.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Checkpoints/DurableCheckpointStore.cs.meta new file mode 100644 index 0000000..9a8b787 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Checkpoints/DurableCheckpointStore.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b2cb4c366a49b4759a2c578c32a3fffa \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem.meta new file mode 100644 index 0000000..769380e --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d9190b1e3eae44219b6cd23ee0c0caf3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem/InputSystemSimulationBackend.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem/InputSystemSimulationBackend.cs new file mode 100644 index 0000000..cbac197 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem/InputSystemSimulationBackend.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.InputSystem.LowLevel; + +namespace UnityAI.ControlPlane.Editor.InputSystemSupport +{ + public static class InputSystemSimulationBackend + { + private static readonly Dictionary Devices = new(StringComparer.Ordinal); + private static readonly Dictionary TouchedControls = new(); + + public static bool IsAvailable() + { + return true; + } + + public static string Queue(string deviceName, string controlPath, string valueType, float value, float x, float y) + { + try + { + var device = GetOrCreateDevice(deviceName); + var control = device[controlPath]; + switch (valueType) + { + case "button": + case "axis": + if (!(control is InputControl scalarControl)) + { + return $"Control '{deviceName}/{controlPath}' does not accept a scalar value."; + } + + QueueValue(scalarControl, value); + TouchedControls[control] = "scalar"; + break; + case "vector2": + if (!(control is InputControl vectorControl)) + { + return $"Control '{deviceName}/{controlPath}' does not accept a Vector2 value."; + } + + QueueValue(vectorControl, new Vector2(x, y)); + TouchedControls[control] = "vector2"; + break; + default: + return $"Unsupported input valueType '{valueType}'."; + } + + return string.Empty; + } + catch (Exception exception) + { + return exception.GetBaseException().Message; + } + } + + public static string Reset() + { + try + { + foreach (var pair in TouchedControls) + { + if (pair.Key == null || pair.Key.device == null || !pair.Key.device.added) + { + continue; + } + + if (pair.Value == "vector2" && pair.Key is InputControl vectorControl) + { + QueueValue(vectorControl, Vector2.zero); + } + else if (pair.Key is InputControl scalarControl) + { + QueueValue(scalarControl, 0f); + } + } + + InputSystem.Update(); + foreach (var device in Devices.Values) + { + if (device != null && device.added) + { + InputSystem.RemoveDevice(device); + } + } + + TouchedControls.Clear(); + Devices.Clear(); + return string.Empty; + } + catch (Exception exception) + { + TouchedControls.Clear(); + Devices.Clear(); + return exception.GetBaseException().Message; + } + } + + private static InputDevice GetOrCreateDevice(string rawDeviceName) + { + var deviceName = (rawDeviceName ?? string.Empty).Trim().ToLowerInvariant(); + if (Devices.TryGetValue(deviceName, out var existing) && existing != null && existing.added) + { + return existing; + } + + var layout = deviceName switch + { + "keyboard" => "Keyboard", + "mouse" => "Mouse", + "gamepad" => "Gamepad", + _ => throw new InvalidOperationException($"Unsupported input device '{rawDeviceName}'.") + }; + var device = InputSystem.AddDevice(layout, "UnityAI" + layout); + Devices[deviceName] = device; + return device; + } + + private static unsafe void QueueValue(InputControl control, TValue value) + where TValue : struct + { + using (DeltaStateEvent.From(control, out var eventPtr)) + { + control.WriteValueIntoEvent(value, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + } + } +} diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem/InputSystemSimulationBackend.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem/InputSystemSimulationBackend.cs.meta new file mode 100644 index 0000000..9ba9cf2 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem/InputSystemSimulationBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 65f30fb395d1432288bf262e466dc54b diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem/Unity.AI.ControlPlane.Editor.InputSystem.asmdef b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem/Unity.AI.ControlPlane.Editor.InputSystem.asmdef new file mode 100644 index 0000000..1bcab50 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem/Unity.AI.ControlPlane.Editor.InputSystem.asmdef @@ -0,0 +1,27 @@ +{ + "name": "Unity.AI.ControlPlane.Editor.InputSystem", + "rootNamespace": "UnityAI.ControlPlane.Editor.InputSystemSupport", + "references": [ + "Unity.AI.ControlPlane.Editor", + "Unity.InputSystem" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": true, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [ + "UNITY_AI_INPUT_SYSTEM" + ], + "versionDefines": [ + { + "name": "com.unity.inputsystem", + "expression": "", + "define": "UNITY_AI_INPUT_SYSTEM" + } + ], + "noEngineReferences": false +} diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem/Unity.AI.ControlPlane.Editor.InputSystem.asmdef.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem/Unity.AI.ControlPlane.Editor.InputSystem.asmdef.meta new file mode 100644 index 0000000..35148f8 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem/Unity.AI.ControlPlane.Editor.InputSystem.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 12f5eed7eca24d58a7e5da5a503116a1 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs.meta new file mode 100644 index 0000000..819a972 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 62fcb98fee7ad4a8badc4eb1d27ff722 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/CompilationController.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/CompilationController.cs index 6c34a16..63aa5e2 100644 --- a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/CompilationController.cs +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/CompilationController.cs @@ -26,7 +26,9 @@ public sealed class CompilationStatusReport public bool isCompiling; public bool isUpdating; public int errorCount; + public int rawErrorCount; public int warningCount; + public int nonBlockingIssueCount; public bool clean; public string capturedAtUtc; } @@ -54,9 +56,11 @@ public static CompilationStatusReport GetStatus() { isCompiling = EditorApplication.isCompiling, isUpdating = EditorApplication.isUpdating, - errorCount = console.errorCount, + errorCount = console.compilationErrorCount, + rawErrorCount = console.errorCount, warningCount = console.warningCount, - clean = !EditorApplication.isCompiling && !EditorApplication.isUpdating && console.errorCount == 0, + nonBlockingIssueCount = console.nonBlockingIssueCount, + clean = !EditorApplication.isCompiling && !EditorApplication.isUpdating && console.compilationErrorCount == 0, capturedAtUtc = DateTime.UtcNow.ToString("O") }; } diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/CompilationController.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/CompilationController.cs.meta new file mode 100644 index 0000000..673aa05 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/CompilationController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b7f10f95e51aa4bc183c24c5f70f9fd4 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/PackageOperations.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/PackageOperations.cs.meta new file mode 100644 index 0000000..e62c3ac --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/PackageOperations.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e5c34443dbf48405691d6079167a78c7 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/PlayModeController.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/PlayModeController.cs.meta new file mode 100644 index 0000000..89f9bce --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/PlayModeController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 91a2276b651ee45a380ab8097d72a3b5 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/UnityAiJobStore.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/UnityAiJobStore.cs.meta new file mode 100644 index 0000000..6b796b7 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/UnityAiJobStore.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c1795dbd0eafa44cca912f294c5129e1 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/MetaXR.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/MetaXR.meta new file mode 100644 index 0000000..8f487e7 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/MetaXR.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d8aeb6fd1f20b40d6af5c508d931bfc2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/MetaXR/MetaXrConfigurationController.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/MetaXR/MetaXrConfigurationController.cs.meta new file mode 100644 index 0000000..6954321 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/MetaXR/MetaXrConfigurationController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 824557dbd5e0f4b41a353e93f969aaf7 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/MetaXR/MetaXrValidator.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/MetaXR/MetaXrValidator.cs.meta new file mode 100644 index 0000000..f2cfa76 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/MetaXR/MetaXrValidator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3d0c0fc897c1d465190bea4a6f799f02 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe.meta new file mode 100644 index 0000000..f1a4646 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 96bca9c6ceff24611b416b831e8c05b4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/AssetDependencyObserver.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/AssetDependencyObserver.cs.meta new file mode 100644 index 0000000..2ed0ada --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/AssetDependencyObserver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a33d3a4a5c21948b5acc4160cf515c85 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/AssetListObserver.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/AssetListObserver.cs.meta new file mode 100644 index 0000000..bf729db --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/AssetListObserver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a4b9ab0076e5e48d182cae50aa96cbb7 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ConsoleLogBridge.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ConsoleLogBridge.cs index 7b75254..a6a5923 100644 --- a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ConsoleLogBridge.cs +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ConsoleLogBridge.cs @@ -14,6 +14,17 @@ public sealed class ConsoleLogSummary public int errorCount; public int warningCount; public int logCount; + public int compilationErrorCount; + public int compilationWarningCount; + public int runtimeErrorCount; + public int runtimeWarningCount; + public int bridgeErrorCount; + public int bridgeWarningCount; + public int importErrorCount; + public int importWarningCount; + public int blockingErrorCount; + public int nonBlockingIssueCount; + public bool hasBlockingErrors; public List recentEntries = new(); } @@ -23,6 +34,12 @@ public sealed class ConsoleLogEntry public string type; public string condition; public string stackTrace; + public string file; + public int line; + public string category; + public string classification; + public string severity; + public bool blocking; } [Serializable] @@ -34,7 +51,18 @@ public sealed class ConsoleDiagnosticReport public int logCount; public int totalEntries; public int diagnosticCount; + public int compilationErrorCount; + public int compilationWarningCount; + public int runtimeErrorCount; + public int runtimeWarningCount; + public int bridgeErrorCount; + public int bridgeWarningCount; + public int importErrorCount; + public int importWarningCount; + public int blockingErrorCount; + public int nonBlockingIssueCount; public bool hasErrors; + public bool hasBlockingErrors; public List diagnostics = new(); } @@ -42,7 +70,9 @@ public sealed class ConsoleDiagnosticReport public sealed class ConsoleDiagnosticEntry { public string category; + public string classification; public string severity; + public bool blocking; public string message; public string file; public int line; @@ -140,6 +170,8 @@ public static class ConsoleLogBridge private const int MaxEntries = 100; private static readonly int ErrorModeMask = ResolveLogMessageFlags("Error", "Fatal", "Assert", "AssetImportError", "ScriptingError", "ScriptingException", "ScriptCompileError", "ScriptingAssertion", "GraphError", "VisualScriptingError"); private static readonly int WarningModeMask = ResolveLogMessageFlags("Warning", "AssetImportWarning", "ScriptingWarning", "ScriptCompileWarning"); + private static readonly int CompilerErrorModeMask = ResolveLogMessageFlags("ScriptCompileError"); + private static readonly int CompilerWarningModeMask = ResolveLogMessageFlags("ScriptCompileWarning"); private static readonly List RecentEntries = new(); static ConsoleLogBridge() @@ -152,29 +184,22 @@ public static ConsoleLogSummary GetSummary() { var summary = new ConsoleLogSummary(); TryReadEditorConsoleCounts(summary); - summary.recentEntries = new List(RecentEntries); + foreach (var entry in CollectEntries()) + { + var logEntry = BuildLogEntry(entry); + summary.recentEntries.Add(logEntry); + IncrementClassificationCounts(summary, logEntry.classification, logEntry.severity, logEntry.blocking); + } + + summary.hasBlockingErrors = summary.blockingErrorCount > 0; return summary; } public static ConsoleDiagnosticReport Diagnose() { - var summary = GetSummary(); - var entries = TryReadEditorConsoleEntries(); - - foreach (var recentEntry in RecentEntries) - { - var recentSnapshot = new ConsoleEntrySnapshot - { - type = recentEntry.type, - message = recentEntry.condition, - stackTrace = recentEntry.stackTrace - }; - - if (!ContainsEquivalentEntry(entries, recentSnapshot)) - { - entries.Add(recentSnapshot); - } - } + var summary = new ConsoleLogSummary(); + TryReadEditorConsoleCounts(summary); + var entries = CollectEntries(); var report = new ConsoleDiagnosticReport { @@ -189,6 +214,7 @@ public static ConsoleDiagnosticReport Diagnose() { var diagnostic = BuildDiagnostic(entry); report.diagnostics.Add(diagnostic); + IncrementClassificationCounts(report, diagnostic.classification, diagnostic.severity, diagnostic.blocking); if (diagnostic.severity == "error") { @@ -197,6 +223,7 @@ public static ConsoleDiagnosticReport Diagnose() } report.diagnosticCount = report.diagnostics.Count; + report.hasBlockingErrors = report.blockingErrorCount > 0; return report; } @@ -647,12 +674,12 @@ private static ConsoleApplyFixRequest ParseApplyFixRequest(string requestBody) private static void OnLogMessageReceived(string condition, string stackTrace, LogType type) { - RecentEntries.Add(new ConsoleLogEntry + RecentEntries.Add(BuildLogEntry(new ConsoleEntrySnapshot { type = type.ToString(), - condition = condition, + message = condition, stackTrace = stackTrace - }); + })); if (RecentEntries.Count > MaxEntries) { @@ -660,6 +687,56 @@ private static void OnLogMessageReceived(string condition, string stackTrace, Lo } } + private static List CollectEntries() + { + var entries = TryReadEditorConsoleEntries(); + foreach (var recentEntry in RecentEntries) + { + var recentSnapshot = new ConsoleEntrySnapshot + { + type = recentEntry.type, + message = recentEntry.condition, + stackTrace = recentEntry.stackTrace, + file = recentEntry.file, + line = recentEntry.line + }; + + if (!ContainsEquivalentEntry(entries, recentSnapshot)) + { + entries.Add(recentSnapshot); + } + } + + if (entries.Count > MaxEntries) + { + entries.RemoveRange(0, entries.Count - MaxEntries); + } + + return entries; + } + + private static ConsoleLogEntry BuildLogEntry(ConsoleEntrySnapshot entry) + { + var message = SanitizeDiagnosticText(entry.message); + var stackTrace = SanitizeDiagnosticText(entry.stackTrace); + var severity = DetermineSeverity(entry, message); + var classification = ClassifyEntry(entry, severity, message, stackTrace); + var file = !string.IsNullOrWhiteSpace(entry.file) ? entry.file : ExtractUnityPath(message + "\n" + stackTrace); + + return new ConsoleLogEntry + { + type = entry.type, + condition = message, + stackTrace = stackTrace, + file = file, + line = entry.line > 0 ? entry.line : ExtractLine(message + "\n" + stackTrace), + category = ToLegacyCategory(classification), + classification = classification, + severity = severity, + blocking = IsBlockingClassification(classification) + }; + } + private static void TryReadEditorConsoleCounts(ConsoleLogSummary summary) { var logEntriesType = Type.GetType("UnityEditor.LogEntries,UnityEditor.dll"); @@ -748,12 +825,15 @@ private static ConsoleDiagnosticEntry BuildDiagnostic(ConsoleEntrySnapshot entry var file = !string.IsNullOrWhiteSpace(entry.file) ? entry.file : ExtractUnityPath(message + "\n" + stackTrace); var line = entry.line > 0 ? entry.line : ExtractLine(message + "\n" + stackTrace); var severity = DetermineSeverity(entry, message); - var category = ClassifyEntry(severity, message, stackTrace); + var classification = ClassifyEntry(entry, severity, message, stackTrace); + var category = ToLegacyCategory(classification); return new ConsoleDiagnosticEntry { category = category, + classification = classification, severity = severity, + blocking = IsBlockingClassification(classification), message = message, file = file, line = line, @@ -980,31 +1060,182 @@ private static int ResolveLogMessageFlags(params string[] names) return mask; } - private static string ClassifyEntry(string severity, string message, string stackTrace) + private static string ClassifyEntry(ConsoleEntrySnapshot entry, string severity, string message, string stackTrace) { var text = (message + "\n" + stackTrace).Trim(); - if (text.Contains("error CS", StringComparison.OrdinalIgnoreCase) || text.Contains("Compilation failed", StringComparison.OrdinalIgnoreCase)) + if ((entry.mode & CompilerErrorModeMask) != 0 + || text.Contains("error CS", StringComparison.OrdinalIgnoreCase) + || text.Contains("Compilation failed", StringComparison.OrdinalIgnoreCase) + || text.Contains("Scripts have compiler errors", StringComparison.OrdinalIgnoreCase)) { - return "compiler_error"; + return "CompilationError"; } - if (text.Contains("Importer", StringComparison.OrdinalIgnoreCase) || text.Contains("failed to import", StringComparison.OrdinalIgnoreCase) || text.Contains("Asset import", StringComparison.OrdinalIgnoreCase)) + if ((entry.mode & CompilerWarningModeMask) != 0 + || text.Contains("warning CS", StringComparison.OrdinalIgnoreCase)) { - return "import_error"; + return "CompilationWarning"; } - if (text.Contains("Exception", StringComparison.OrdinalIgnoreCase) || text.Contains("NullReferenceException", StringComparison.OrdinalIgnoreCase)) + if (IsBridgeMessage(text)) { - return "runtime_exception"; + return severity == "error" + ? "BridgeError" + : severity == "warning" ? "BridgeWarning" : "Info"; + } + + if (text.Contains("Importer", StringComparison.OrdinalIgnoreCase) + || text.Contains("failed to import", StringComparison.OrdinalIgnoreCase) + || text.Contains("Asset import", StringComparison.OrdinalIgnoreCase)) + { + return severity == "error" + ? "ImportError" + : severity == "warning" ? "ImportWarning" : "Info"; } if (severity == "warning") { - return "warning"; + return "RuntimeWarning"; } - return "unknown"; + if (severity == "error" + || text.Contains("Exception", StringComparison.OrdinalIgnoreCase) + || text.Contains("NullReferenceException", StringComparison.OrdinalIgnoreCase)) + { + return "RuntimeError"; + } + + return "Info"; + } + + private static bool IsBridgeMessage(string text) + { + return text.Contains("Unity AI bridge", StringComparison.OrdinalIgnoreCase) + || text.Contains("UnityAiBridge", StringComparison.OrdinalIgnoreCase) + || text.Contains("UnityAI.ControlPlane", StringComparison.OrdinalIgnoreCase) + || text.Contains("bridge request", StringComparison.OrdinalIgnoreCase) + || text.Contains("bridge server", StringComparison.OrdinalIgnoreCase) + || text.Contains("fetch failed", StringComparison.OrdinalIgnoreCase) + && (text.Contains("localhost", StringComparison.OrdinalIgnoreCase) + || text.Contains("127.0.0.1", StringComparison.OrdinalIgnoreCase) + || text.Contains("bridge", StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsBlockingClassification(string classification) + { + return string.Equals(classification, "CompilationError", StringComparison.Ordinal); + } + + private static string ToLegacyCategory(string classification) + { + switch (classification) + { + case "CompilationError": + return "compiler_error"; + case "ImportError": + case "ImportWarning": + return "import_error"; + case "RuntimeError": + return "runtime_exception"; + case "RuntimeWarning": + case "CompilationWarning": + return "warning"; + case "BridgeError": + case "BridgeWarning": + return "bridge_error"; + default: + return "unknown"; + } + } + + private static void IncrementClassificationCounts(ConsoleLogSummary summary, string classification, string severity, bool blocking) + { + IncrementClassificationCounts( + classification, + severity, + blocking, + value => summary.compilationErrorCount += value, + value => summary.compilationWarningCount += value, + value => summary.runtimeErrorCount += value, + value => summary.runtimeWarningCount += value, + value => summary.bridgeErrorCount += value, + value => summary.bridgeWarningCount += value, + value => summary.importErrorCount += value, + value => summary.importWarningCount += value, + value => summary.blockingErrorCount += value, + value => summary.nonBlockingIssueCount += value); + } + + private static void IncrementClassificationCounts(ConsoleDiagnosticReport report, string classification, string severity, bool blocking) + { + IncrementClassificationCounts( + classification, + severity, + blocking, + value => report.compilationErrorCount += value, + value => report.compilationWarningCount += value, + value => report.runtimeErrorCount += value, + value => report.runtimeWarningCount += value, + value => report.bridgeErrorCount += value, + value => report.bridgeWarningCount += value, + value => report.importErrorCount += value, + value => report.importWarningCount += value, + value => report.blockingErrorCount += value, + value => report.nonBlockingIssueCount += value); + } + + private static void IncrementClassificationCounts( + string classification, + string severity, + bool blocking, + Action compilationError, + Action compilationWarning, + Action runtimeError, + Action runtimeWarning, + Action bridgeError, + Action bridgeWarning, + Action importError, + Action importWarning, + Action blockingError, + Action nonBlockingIssue) + { + switch (classification) + { + case "CompilationError": + compilationError(1); + break; + case "CompilationWarning": + compilationWarning(1); + break; + case "RuntimeError": + runtimeError(1); + break; + case "RuntimeWarning": + runtimeWarning(1); + break; + case "BridgeError": + bridgeError(1); + break; + case "BridgeWarning": + bridgeWarning(1); + break; + case "ImportError": + importError(1); + break; + case "ImportWarning": + importWarning(1); + break; + } + + if (blocking) + { + blockingError(1); + } + else if (severity == "error" || severity == "warning") + { + nonBlockingIssue(1); + } } private static string SummarizeRootCause(string category, string message, string file, int line) diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ConsoleLogBridge.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ConsoleLogBridge.cs.meta new file mode 100644 index 0000000..ae0c63e --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ConsoleLogBridge.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c51e1e121a31843e694e4ad366d9a570 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/GameObjectInspector.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/GameObjectInspector.cs index a393209..771a7ea 100644 --- a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/GameObjectInspector.cs +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/GameObjectInspector.cs @@ -32,7 +32,10 @@ public sealed class SerializedPropertyInfo public bool isArray; public bool editable; public string value; + public string objectReferenceKind; public string objectReferencePath; + public string objectReferenceComponentType; + public int objectReferenceComponentIndex; } [Serializable] @@ -190,7 +193,7 @@ private static ComponentInspectInfo InspectComponent(Component component, int in private static SerializedPropertyInfo ToPropertyInfo(SerializedProperty property) { - return new SerializedPropertyInfo + var info = new SerializedPropertyInfo { path = property.propertyPath, displayName = property.displayName, @@ -199,8 +202,14 @@ private static SerializedPropertyInfo ToPropertyInfo(SerializedProperty property isArray = property.isArray, editable = property.editable, value = GetPropertyValue(property), - objectReferencePath = GetObjectReferencePath(property) + objectReferenceKind = "none", + objectReferencePath = string.Empty, + objectReferenceComponentType = string.Empty, + objectReferenceComponentIndex = -1 }; + + PopulateObjectReferenceInfo(property, info); + return info; } private static string GetPropertyValue(SerializedProperty property) @@ -267,30 +276,37 @@ private static string GetPropertyValue(SerializedProperty property) } } - private static string GetObjectReferencePath(SerializedProperty property) + private static void PopulateObjectReferenceInfo(SerializedProperty property, SerializedPropertyInfo info) { if (property.propertyType != SerializedPropertyType.ObjectReference || property.objectReferenceValue == null) { - return string.Empty; + return; } var assetPath = AssetDatabase.GetAssetPath(property.objectReferenceValue); if (!string.IsNullOrEmpty(assetPath)) { - return assetPath; + info.objectReferenceKind = "asset"; + info.objectReferencePath = assetPath; + return; } if (property.objectReferenceValue is GameObject gameObject) { - return GetGameObjectPath(gameObject); + info.objectReferenceKind = "game_object"; + info.objectReferencePath = GetGameObjectPath(gameObject); + return; } if (property.objectReferenceValue is Component component) { - return GetGameObjectPath(component.gameObject) + "#" + component.GetType().FullName; + var type = component.GetType(); + var components = component.gameObject.GetComponents(type); + info.objectReferenceKind = "component"; + info.objectReferencePath = GetGameObjectPath(component.gameObject); + info.objectReferenceComponentType = type.FullName ?? type.Name; + info.objectReferenceComponentIndex = Array.IndexOf(components, component); } - - return string.Empty; } private static string GetScriptPath(Component component) diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/GameObjectInspector.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/GameObjectInspector.cs.meta new file mode 100644 index 0000000..8fbc7fa --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/GameObjectInspector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f756ee1985a4942abb8d0776d3d1116b \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PackageListObserver.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PackageListObserver.cs.meta new file mode 100644 index 0000000..fbfd640 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PackageListObserver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6e7b6200b0f8e4d138e6f40fd5bb64a8 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PhysicsInspector.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PhysicsInspector.cs new file mode 100644 index 0000000..f25412c --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PhysicsInspector.cs @@ -0,0 +1,814 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace UnityAI.ControlPlane.Editor +{ + [Serializable] + public sealed class PhysicsInspectRequest + { + public PhysicsInspectInput input = new(); + } + + [Serializable] + public sealed class PhysicsInspectInput + { + public string pathPrefix; + public bool includeInactive; + public string dimension = "all"; + public SceneRadiusFilterInput withinRadius; + public bool includeOverlapDiagnostics = true; + public int maxObjects = 200; + public int maxOverlaps = 200; + } + + [Serializable] + public sealed class PhysicsBodyInfo + { + public string dimension; + public string bodyType; + public float mass; + public bool isKinematic; + public bool useGravity; + public float gravityScale; + public bool sleeping; + public SceneVector3 velocity; + public SceneVector3 angularVelocity; + public SceneVector3 centerOfMass; + public string collisionDetectionMode; + public string interpolation; + public string constraints; + public bool forceEstimateAvailable; + public float forceSampleDeltaSeconds; + public SceneVector3 estimatedAcceleration; + public SceneVector3 estimatedNetForce; + } + + [Serializable] + public sealed class PhysicsColliderInfo + { + public string dimension; + public string type; + public bool enabled; + public bool isTrigger; + public string material; + public SceneVector3 boundsCenter; + public SceneVector3 boundsSize; + public SceneVector3 localCenter; + public SceneVector3 shapeSize; + public float radius; + public float height; + public string direction; + public bool convex; + public string mesh; + public string attachedRigidbodyPath; + } + + [Serializable] + public sealed class PhysicsObjectInfo + { + public string name; + public string path; + public bool activeInHierarchy; + public int layer; + public string layerName; + public SceneVector3 worldPosition; + public bool hasBody3D; + public bool hasBody2D; + public PhysicsBodyInfo body3D; + public PhysicsBodyInfo body2D; + public PhysicsColliderInfo[] colliders3D; + public PhysicsColliderInfo[] colliders2D; + public string[] issues; + } + + [Serializable] + public sealed class PhysicsOverlapInfo + { + public string dimension; + public string pathA; + public string colliderTypeA; + public string pathB; + public string colliderTypeB; + public bool triggerOnly; + public float penetrationDepth; + public SceneVector3 separationDirection; + public SceneVector3 relativeVelocity; + public float relativeNormalSpeed; + } + + [Serializable] + public sealed class PhysicsInspectReport + { + public string scenePath; + public string sceneName; + public bool isPlaying; + public SceneVector3 gravity3D; + public SceneVector3 gravity2D; + public int scannedGameObjectCount; + public int matchedPhysicsObjectCount; + public int returnedPhysicsObjectCount; + public bool truncatedByObjectCount; + public int detectedOverlapCount; + public int returnedOverlapCount; + public bool truncatedByOverlapCount; + public bool forceEstimatesAreSampled; + public string forceEstimateCaveat; + public PhysicsObjectInfo[] objects; + public PhysicsOverlapInfo[] overlaps; + public string capturedAtUtc; + } + + public static class PhysicsInspector + { + private sealed class BodySample + { + public double timestamp; + public Vector3 velocity; + } + + private sealed class Collider3DEntry + { + public string path; + public Collider collider; + } + + private sealed class Collider2DEntry + { + public string path; + public Collider2D collider; + } + + private static readonly Dictionary BodySamples3D = new(); + private static readonly Dictionary BodySamples2D = new(); + + public static PhysicsInspectReport Inspect(string requestBody) + { + var input = ParseRequest(requestBody).input ?? new PhysicsInspectInput(); + var maxObjects = input.maxObjects <= 0 ? 200 : Math.Min(input.maxObjects, 500); + var maxOverlaps = Math.Max(0, Math.Min(input.maxOverlaps, 1000)); + var dimension = NormalizeDimension(input.dimension); + var scene = EditorSceneManager.GetActiveScene(); + var radiusCenter = ResolveRadiusCenter(input.withinRadius); + var objects = new List(); + var colliders3D = new List(); + var colliders2D = new List(); + var scannedCount = 0; + var matchedCount = 0; + + if (scene.IsValid()) + { + foreach (var root in scene.GetRootGameObjects()) + { + InspectHierarchy( + root, + root.name, + input, + dimension, + radiusCenter, + maxObjects, + objects, + colliders3D, + colliders2D, + ref scannedCount, + ref matchedCount); + } + } + + var overlaps = new List(); + var detectedOverlapCount = 0; + if (input.includeOverlapDiagnostics && maxOverlaps >= 0) + { + if (dimension != "2d") + { + Add3DOverlaps(colliders3D, maxOverlaps, overlaps, ref detectedOverlapCount); + } + + if (dimension != "3d") + { + Add2DOverlaps(colliders2D, maxOverlaps, overlaps, ref detectedOverlapCount); + } + } + + PruneSamples(BodySamples3D); + PruneSamples(BodySamples2D); + + return new PhysicsInspectReport + { + scenePath = scene.path, + sceneName = scene.name, + isPlaying = EditorApplication.isPlaying, + gravity3D = ToSceneVector3(Physics.gravity), + gravity2D = ToSceneVector3(Physics2D.gravity), + scannedGameObjectCount = scannedCount, + matchedPhysicsObjectCount = matchedCount, + returnedPhysicsObjectCount = objects.Count, + truncatedByObjectCount = matchedCount > objects.Count, + detectedOverlapCount = detectedOverlapCount, + returnedOverlapCount = overlaps.Count, + truncatedByOverlapCount = detectedOverlapCount > overlaps.Count, + forceEstimatesAreSampled = true, + forceEstimateCaveat = "Estimated net force is mass * velocity delta / sample delta between calls; it includes gravity, collisions, constraints, and scripted velocity changes, not only AddForce calls.", + objects = objects.ToArray(), + overlaps = overlaps.ToArray(), + capturedAtUtc = DateTime.UtcNow.ToString("O") + }; + } + + private static void InspectHierarchy( + GameObject gameObject, + string path, + PhysicsInspectInput input, + string dimension, + Vector3? radiusCenter, + int maxObjects, + List output, + List colliderEntries3D, + List colliderEntries2D, + ref int scannedCount, + ref int matchedCount) + { + scannedCount++; + var activeMatches = input.includeInactive || gameObject.activeInHierarchy; + var pathMatches = string.IsNullOrWhiteSpace(input.pathPrefix) + || PathMatchesPrefix(path, input.pathPrefix); + var radiusMatches = !radiusCenter.HasValue + || Vector3.Distance(gameObject.transform.position, radiusCenter.Value) <= Math.Max(0f, input.withinRadius.radius); + + if (activeMatches && pathMatches && radiusMatches) + { + var body3D = dimension == "2d" ? null : gameObject.GetComponent(); + var body2D = dimension == "3d" ? null : gameObject.GetComponent(); + var colliders3D = dimension == "2d" ? Array.Empty() : gameObject.GetComponents(); + var colliders2D = dimension == "3d" ? Array.Empty() : gameObject.GetComponents(); + + if (body3D != null || body2D != null || colliders3D.Length > 0 || colliders2D.Length > 0) + { + matchedCount++; + if (output.Count < maxObjects) + { + var issues = new List(); + var colliderInfos3D = BuildColliderInfos3D(path, colliders3D, colliderEntries3D, issues); + var colliderInfos2D = BuildColliderInfos2D(path, colliders2D, colliderEntries2D, issues); + AddBodyIssues(gameObject, body3D, body2D, colliders3D, colliders2D, issues); + + output.Add(new PhysicsObjectInfo + { + name = gameObject.name, + path = path, + activeInHierarchy = gameObject.activeInHierarchy, + layer = gameObject.layer, + layerName = LayerMask.LayerToName(gameObject.layer), + worldPosition = ToSceneVector3(gameObject.transform.position), + hasBody3D = body3D != null, + hasBody2D = body2D != null, + body3D = BuildBodyInfo(body3D), + body2D = BuildBodyInfo(body2D), + colliders3D = colliderInfos3D, + colliders2D = colliderInfos2D, + issues = issues.ToArray() + }); + } + } + } + + for (var index = 0; index < gameObject.transform.childCount; index++) + { + var child = gameObject.transform.GetChild(index).gameObject; + InspectHierarchy( + child, + path + "/" + child.name, + input, + dimension, + radiusCenter, + maxObjects, + output, + colliderEntries3D, + colliderEntries2D, + ref scannedCount, + ref matchedCount); + } + } + + private static PhysicsBodyInfo BuildBodyInfo(Rigidbody body) + { + if (body == null) + { + return null; + } + + var velocity = body.linearVelocity; + var estimate = SampleForce(BodySamples3D, body.GetInstanceID(), velocity, body.mass, !body.isKinematic); + return new PhysicsBodyInfo + { + dimension = "3d", + bodyType = body.isKinematic ? "kinematic" : "dynamic", + mass = body.mass, + isKinematic = body.isKinematic, + useGravity = body.useGravity, + gravityScale = body.useGravity ? 1f : 0f, + sleeping = body.IsSleeping(), + velocity = ToSceneVector3(velocity), + angularVelocity = ToSceneVector3(body.angularVelocity), + centerOfMass = ToSceneVector3(body.worldCenterOfMass), + collisionDetectionMode = body.collisionDetectionMode.ToString(), + interpolation = body.interpolation.ToString(), + constraints = body.constraints.ToString(), + forceEstimateAvailable = estimate.available, + forceSampleDeltaSeconds = estimate.deltaSeconds, + estimatedAcceleration = ToSceneVector3(estimate.acceleration), + estimatedNetForce = ToSceneVector3(estimate.netForce) + }; + } + + private static PhysicsBodyInfo BuildBodyInfo(Rigidbody2D body) + { + if (body == null) + { + return null; + } + + var velocity = new Vector3(body.linearVelocity.x, body.linearVelocity.y, 0f); + var estimate = SampleForce(BodySamples2D, body.GetInstanceID(), velocity, body.mass, body.bodyType == RigidbodyType2D.Dynamic); + return new PhysicsBodyInfo + { + dimension = "2d", + bodyType = body.bodyType.ToString().ToLowerInvariant(), + mass = body.mass, + isKinematic = body.bodyType == RigidbodyType2D.Kinematic, + useGravity = Math.Abs(body.gravityScale) > 0.0001f, + gravityScale = body.gravityScale, + sleeping = body.IsSleeping(), + velocity = ToSceneVector3(velocity), + angularVelocity = ToSceneVector3(new Vector3(0f, 0f, body.angularVelocity)), + centerOfMass = ToSceneVector3(body.worldCenterOfMass), + collisionDetectionMode = body.collisionDetectionMode.ToString(), + interpolation = body.interpolation.ToString(), + constraints = body.constraints.ToString(), + forceEstimateAvailable = estimate.available, + forceSampleDeltaSeconds = estimate.deltaSeconds, + estimatedAcceleration = ToSceneVector3(estimate.acceleration), + estimatedNetForce = ToSceneVector3(estimate.netForce) + }; + } + + private static (bool available, float deltaSeconds, Vector3 acceleration, Vector3 netForce) SampleForce( + Dictionary samples, + int instanceId, + Vector3 velocity, + float mass, + bool dynamicBody) + { + var now = EditorApplication.timeSinceStartup; + var available = false; + var deltaSeconds = 0f; + var acceleration = Vector3.zero; + var netForce = Vector3.zero; + + if (dynamicBody && samples.TryGetValue(instanceId, out var previous)) + { + var delta = now - previous.timestamp; + if (delta >= 0.001 && delta <= 10) + { + deltaSeconds = (float)delta; + acceleration = (velocity - previous.velocity) / deltaSeconds; + netForce = acceleration * mass; + available = true; + } + } + + samples[instanceId] = new BodySample { timestamp = now, velocity = velocity }; + return (available, deltaSeconds, acceleration, netForce); + } + + private static PhysicsColliderInfo[] BuildColliderInfos3D( + string path, + Collider[] colliders, + List entries, + List issues) + { + var result = new List(); + foreach (var collider in colliders) + { + if (collider == null) + { + continue; + } + + var info = new PhysicsColliderInfo + { + dimension = "3d", + type = collider.GetType().Name, + enabled = collider.enabled, + isTrigger = collider.isTrigger, + material = collider.sharedMaterial != null ? collider.sharedMaterial.name : string.Empty, + boundsCenter = ToSceneVector3(collider.bounds.center), + boundsSize = ToSceneVector3(collider.bounds.size), + localCenter = ToSceneVector3(Vector3.zero), + shapeSize = ToSceneVector3(Vector3.zero), + direction = string.Empty, + mesh = string.Empty, + attachedRigidbodyPath = collider.attachedRigidbody != null ? GetGameObjectPath(collider.attachedRigidbody.gameObject) : string.Empty + }; + + if (collider is BoxCollider box) + { + info.localCenter = ToSceneVector3(box.center); + info.shapeSize = ToSceneVector3(box.size); + } + else if (collider is SphereCollider sphere) + { + info.localCenter = ToSceneVector3(sphere.center); + info.radius = sphere.radius; + } + else if (collider is CapsuleCollider capsule) + { + info.localCenter = ToSceneVector3(capsule.center); + info.radius = capsule.radius; + info.height = capsule.height; + info.direction = capsule.direction == 0 ? "x" : capsule.direction == 1 ? "y" : "z"; + } + else if (collider is MeshCollider mesh) + { + info.convex = mesh.convex; + info.mesh = mesh.sharedMesh != null ? mesh.sharedMesh.name : string.Empty; + if (mesh.attachedRigidbody != null && !mesh.attachedRigidbody.isKinematic && !mesh.convex) + { + issues.Add("dynamic_rigidbody_has_non_convex_mesh_collider"); + } + } + + if (!collider.enabled) + { + issues.Add("disabled_3d_collider"); + } + + entries.Add(new Collider3DEntry { path = path, collider = collider }); + result.Add(info); + } + + return result.ToArray(); + } + + private static PhysicsColliderInfo[] BuildColliderInfos2D( + string path, + Collider2D[] colliders, + List entries, + List issues) + { + var result = new List(); + foreach (var collider in colliders) + { + if (collider == null) + { + continue; + } + + var info = new PhysicsColliderInfo + { + dimension = "2d", + type = collider.GetType().Name, + enabled = collider.enabled, + isTrigger = collider.isTrigger, + material = collider.sharedMaterial != null ? collider.sharedMaterial.name : string.Empty, + boundsCenter = ToSceneVector3(collider.bounds.center), + boundsSize = ToSceneVector3(collider.bounds.size), + localCenter = ToSceneVector3(new Vector3(collider.offset.x, collider.offset.y, 0f)), + shapeSize = ToSceneVector3(Vector3.zero), + direction = string.Empty, + mesh = string.Empty, + attachedRigidbodyPath = collider.attachedRigidbody != null ? GetGameObjectPath(collider.attachedRigidbody.gameObject) : string.Empty + }; + + if (collider is BoxCollider2D box) + { + info.shapeSize = ToSceneVector3(new Vector3(box.size.x, box.size.y, 0f)); + } + else if (collider is CircleCollider2D circle) + { + info.radius = circle.radius; + } + else if (collider is CapsuleCollider2D capsule) + { + info.shapeSize = ToSceneVector3(new Vector3(capsule.size.x, capsule.size.y, 0f)); + info.direction = capsule.direction.ToString(); + } + + if (!collider.enabled) + { + issues.Add("disabled_2d_collider"); + } + + entries.Add(new Collider2DEntry { path = path, collider = collider }); + result.Add(info); + } + + return result.ToArray(); + } + + private static void AddBodyIssues( + GameObject gameObject, + Rigidbody body3D, + Rigidbody2D body2D, + Collider[] colliders3D, + Collider2D[] colliders2D, + List issues) + { + if (body3D != null && !body3D.isKinematic && colliders3D.Length == 0 && gameObject.GetComponentInChildren() == null) + { + issues.Add("dynamic_3d_body_without_collider"); + } + + if (body2D != null && body2D.bodyType == RigidbodyType2D.Dynamic && colliders2D.Length == 0 && gameObject.GetComponentInChildren() == null) + { + issues.Add("dynamic_2d_body_without_collider"); + } + + var renderer = gameObject.GetComponent(); + if (renderer == null) + { + return; + } + + foreach (var collider in colliders3D) + { + if (collider.enabled && !collider.bounds.Intersects(renderer.bounds)) + { + issues.Add("3d_collider_does_not_overlap_renderer_bounds"); + break; + } + } + + foreach (var collider in colliders2D) + { + if (collider.enabled && !collider.bounds.Intersects(renderer.bounds)) + { + issues.Add("2d_collider_does_not_overlap_renderer_bounds"); + break; + } + } + } + + private static void Add3DOverlaps( + List entries, + int maxOverlaps, + List output, + ref int detectedCount) + { + for (var leftIndex = 0; leftIndex < entries.Count; leftIndex++) + { + var left = entries[leftIndex]; + if (!IsUsable(left.collider)) + { + continue; + } + + for (var rightIndex = leftIndex + 1; rightIndex < entries.Count; rightIndex++) + { + var right = entries[rightIndex]; + if (!IsUsable(right.collider) + || left.collider.attachedRigidbody != null + && left.collider.attachedRigidbody == right.collider.attachedRigidbody + || !left.collider.bounds.Intersects(right.collider.bounds)) + { + continue; + } + + if (!Physics.ComputePenetration( + left.collider, + left.collider.transform.position, + left.collider.transform.rotation, + right.collider, + right.collider.transform.position, + right.collider.transform.rotation, + out var direction, + out var distance)) + { + continue; + } + + detectedCount++; + if (output.Count >= maxOverlaps) + { + continue; + } + + var relativeVelocity = GetVelocity(right.collider.attachedRigidbody) - GetVelocity(left.collider.attachedRigidbody); + output.Add(new PhysicsOverlapInfo + { + dimension = "3d", + pathA = left.path, + colliderTypeA = left.collider.GetType().Name, + pathB = right.path, + colliderTypeB = right.collider.GetType().Name, + triggerOnly = left.collider.isTrigger || right.collider.isTrigger, + penetrationDepth = distance, + separationDirection = ToSceneVector3(direction), + relativeVelocity = ToSceneVector3(relativeVelocity), + relativeNormalSpeed = Math.Abs(Vector3.Dot(relativeVelocity, direction)) + }); + } + } + } + + private static void Add2DOverlaps( + List entries, + int maxOverlaps, + List output, + ref int detectedCount) + { + for (var leftIndex = 0; leftIndex < entries.Count; leftIndex++) + { + var left = entries[leftIndex]; + if (!IsUsable(left.collider)) + { + continue; + } + + for (var rightIndex = leftIndex + 1; rightIndex < entries.Count; rightIndex++) + { + var right = entries[rightIndex]; + if (!IsUsable(right.collider) + || left.collider.attachedRigidbody != null + && left.collider.attachedRigidbody == right.collider.attachedRigidbody + || !left.collider.bounds.Intersects(right.collider.bounds)) + { + continue; + } + + var distance = Physics2D.Distance(left.collider, right.collider); + if (!distance.isOverlapped) + { + continue; + } + + detectedCount++; + if (output.Count >= maxOverlaps) + { + continue; + } + + var direction = new Vector3(distance.normal.x, distance.normal.y, 0f); + var relativeVelocity = GetVelocity(right.collider.attachedRigidbody) - GetVelocity(left.collider.attachedRigidbody); + output.Add(new PhysicsOverlapInfo + { + dimension = "2d", + pathA = left.path, + colliderTypeA = left.collider.GetType().Name, + pathB = right.path, + colliderTypeB = right.collider.GetType().Name, + triggerOnly = left.collider.isTrigger || right.collider.isTrigger, + penetrationDepth = Math.Abs(distance.distance), + separationDirection = ToSceneVector3(direction), + relativeVelocity = ToSceneVector3(relativeVelocity), + relativeNormalSpeed = Math.Abs(Vector3.Dot(relativeVelocity, direction)) + }); + } + } + } + + private static bool IsUsable(Collider collider) + { + return collider != null && collider.enabled && collider.gameObject.activeInHierarchy; + } + + private static bool IsUsable(Collider2D collider) + { + return collider != null && collider.enabled && collider.gameObject.activeInHierarchy; + } + + private static Vector3 GetVelocity(Rigidbody body) + { + return body != null ? body.linearVelocity : Vector3.zero; + } + + private static Vector3 GetVelocity(Rigidbody2D body) + { + return body != null ? new Vector3(body.linearVelocity.x, body.linearVelocity.y, 0f) : Vector3.zero; + } + + private static Vector3? ResolveRadiusCenter(SceneRadiusFilterInput filter) + { + if (filter == null + || string.IsNullOrWhiteSpace(filter.centerPath) + && filter.center == null) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(filter.centerPath)) + { + var target = FindByPath(filter.centerPath.Trim().Replace('\\', '/')); + if (target == null) + { + throw new InvalidOperationException($"Physics radius centerPath was not found: {filter.centerPath}"); + } + + return target.transform.position; + } + + return new Vector3(filter.center.x, filter.center.y, filter.center.z); + } + + private static bool PathMatchesPrefix(string path, string rawPrefix) + { + var prefix = rawPrefix.Trim().TrimEnd('/').Replace('\\', '/'); + return string.Equals(path, prefix, StringComparison.OrdinalIgnoreCase) + || path.StartsWith(prefix + "/", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeDimension(string value) + { + var normalized = (value ?? "all").Trim().ToLowerInvariant(); + return normalized == "3d" || normalized == "2d" ? normalized : "all"; + } + + private static void PruneSamples(Dictionary samples) + { + var threshold = EditorApplication.timeSinceStartup - 30; + var stale = new List(); + foreach (var pair in samples) + { + if (pair.Value.timestamp < threshold) + { + stale.Add(pair.Key); + } + } + + foreach (var key in stale) + { + samples.Remove(key); + } + } + + private static GameObject FindByPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var segments = path.Split('/'); + foreach (var root in EditorSceneManager.GetActiveScene().GetRootGameObjects()) + { + if (!string.Equals(root.name, segments[0], StringComparison.Ordinal)) + { + continue; + } + + var current = root.transform; + for (var index = 1; index < segments.Length && current != null; index++) + { + current = current.Find(segments[index]); + } + + return current != null ? current.gameObject : null; + } + + return null; + } + + private static string GetGameObjectPath(GameObject gameObject) + { + var names = new List(); + var current = gameObject.transform; + while (current != null) + { + names.Add(current.name); + current = current.parent; + } + + names.Reverse(); + return string.Join("/", names); + } + + private static SceneVector3 ToSceneVector3(Vector2 value) + { + return ToSceneVector3(new Vector3(value.x, value.y, 0f)); + } + + private static SceneVector3 ToSceneVector3(Vector3 value) + { + return new SceneVector3 { x = value.x, y = value.y, z = value.z }; + } + + private static PhysicsInspectRequest ParseRequest(string requestBody) + { + if (string.IsNullOrWhiteSpace(requestBody)) + { + return new PhysicsInspectRequest(); + } + + try + { + return JsonUtility.FromJson(requestBody) ?? new PhysicsInspectRequest(); + } + catch + { + return new PhysicsInspectRequest(); + } + } + } +} diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PhysicsInspector.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PhysicsInspector.cs.meta new file mode 100644 index 0000000..74fd7c5 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PhysicsInspector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b3dc5b3680354de3977b31093ee2444a diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PrefabObserver.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PrefabObserver.cs.meta new file mode 100644 index 0000000..3e29c91 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PrefabObserver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a74446953bc1843d7af17bcb716a4842 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectInspector.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectInspector.cs index 547d095..7fd0936 100644 --- a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectInspector.cs +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectInspector.cs @@ -1,8 +1,11 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; +using UnityEngine.Rendering; namespace UnityAI.ControlPlane.Editor { @@ -15,9 +18,47 @@ public sealed class ProjectSnapshot public string activeBuildTarget; public int rootGameObjectCount; public string[] rootGameObjectNames; + public RenderPipelineEnvironment renderPipeline = new(); + public InputSystemEnvironment inputSystem = new(); + public string[] compatibilityWarnings = Array.Empty(); public string capturedAtUtc; } + [Serializable] + public sealed class ProjectEnvironmentReport + { + public RenderPipelineEnvironment renderPipeline = new(); + public InputSystemEnvironment inputSystem = new(); + public string[] compatibilityWarnings = Array.Empty(); + } + + [Serializable] + public sealed class RenderPipelineEnvironment + { + public string kind = "unknown"; + public bool isScriptableRenderPipeline; + public string assetName = string.Empty; + public string assetType = string.Empty; + public string assetPath = string.Empty; + public string effectiveAssetSource = "none"; + public int qualityLevel; + public string qualityLevelName = string.Empty; + public string recommendedShader = string.Empty; + } + + [Serializable] + public sealed class InputSystemEnvironment + { + public string mode = "unknown"; + public int activeInputHandler = -1; + public bool legacyInputManagerEnabled; + public bool inputSystemPackageEnabled; + public bool inputSystemPackageInstalled; + public string inputSystemPackageVersion = string.Empty; + public string recommendedApi = string.Empty; + public string detectionSource = "ProjectSettings/ProjectSettings.asset"; + } + public static class ProjectInspector { public static ProjectSnapshot InspectActiveProject() @@ -31,6 +72,7 @@ public static ProjectSnapshot InspectActiveProject() rootGameObjectNames[index] = rootGameObjects[index].name; } + var environment = ProjectEnvironmentInspector.Inspect(); return new ProjectSnapshot { projectPath = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath, @@ -39,8 +81,186 @@ public static ProjectSnapshot InspectActiveProject() activeBuildTarget = EditorUserBuildSettings.activeBuildTarget.ToString(), rootGameObjectCount = rootGameObjects.Length, rootGameObjectNames = rootGameObjectNames, + renderPipeline = environment.renderPipeline, + inputSystem = environment.inputSystem, + compatibilityWarnings = environment.compatibilityWarnings, capturedAtUtc = DateTime.UtcNow.ToString("O") }; } } + + public static class ProjectEnvironmentInspector + { + private const string InputSystemPackageName = "com.unity.inputsystem"; + + public static ProjectEnvironmentReport Inspect() + { + var warnings = new List(); + var renderPipeline = InspectRenderPipeline(warnings); + var inputSystem = InspectInputSystem(warnings); + return new ProjectEnvironmentReport + { + renderPipeline = renderPipeline, + inputSystem = inputSystem, + compatibilityWarnings = warnings.ToArray() + }; + } + + private static RenderPipelineEnvironment InspectRenderPipeline(List warnings) + { + var qualityAsset = QualitySettings.renderPipeline; +#pragma warning disable CS0618 + var defaultAsset = GraphicsSettings.renderPipelineAsset; +#pragma warning restore CS0618 + var effectiveAsset = qualityAsset != null ? qualityAsset : defaultAsset; + var kind = ClassifyRenderPipeline(effectiveAsset); + if (kind == "custom") + { + warnings.Add("Custom Scriptable Render Pipeline detected; verify shader compatibility before authoring materials."); + } + + return new RenderPipelineEnvironment + { + kind = kind, + isScriptableRenderPipeline = effectiveAsset != null, + assetName = effectiveAsset != null ? effectiveAsset.name : string.Empty, + assetType = effectiveAsset != null ? effectiveAsset.GetType().FullName : string.Empty, + assetPath = effectiveAsset != null ? AssetDatabase.GetAssetPath(effectiveAsset) : string.Empty, + effectiveAssetSource = qualityAsset != null ? "quality" : defaultAsset != null ? "graphics" : "none", + qualityLevel = QualitySettings.GetQualityLevel(), + qualityLevelName = QualitySettings.names.ElementAtOrDefault(QualitySettings.GetQualityLevel()) ?? string.Empty, + recommendedShader = RecommendedShader(kind) + }; + } + + private static InputSystemEnvironment InspectInputSystem(List warnings) + { + var activeInputHandler = ReadActiveInputHandler(); + var packageVersion = FindRegisteredPackageVersion(InputSystemPackageName); + var packageInstalled = !string.IsNullOrEmpty(packageVersion); + var mode = activeInputHandler switch + { + 0 => "legacy", + 1 => "input_system", + 2 => "both", + _ => "unknown" + }; + var legacyEnabled = activeInputHandler == 0 || activeInputHandler == 2; + var inputSystemEnabled = activeInputHandler == 1 || activeInputHandler == 2; + + if (inputSystemEnabled && !packageInstalled) + { + warnings.Add("The Input System is enabled in Player Settings but com.unity.inputsystem is not registered."); + } + else if (packageInstalled && activeInputHandler == 0) + { + warnings.Add("com.unity.inputsystem is installed but Player Settings currently use only the legacy Input Manager."); + } + else if (activeInputHandler < 0) + { + warnings.Add("Unable to determine Active Input Handling; do not generate input code until the setting is verified."); + } + + return new InputSystemEnvironment + { + mode = mode, + activeInputHandler = activeInputHandler, + legacyInputManagerEnabled = legacyEnabled, + inputSystemPackageEnabled = inputSystemEnabled, + inputSystemPackageInstalled = packageInstalled, + inputSystemPackageVersion = packageVersion, + recommendedApi = RecommendedInputApi(mode, packageInstalled) + }; + } + + private static string ClassifyRenderPipeline(RenderPipelineAsset asset) + { + if (asset == null) + { + return "built_in"; + } + + var typeName = asset.GetType().FullName ?? asset.GetType().Name; + if (typeName.IndexOf("UniversalRenderPipelineAsset", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "urp"; + } + + if (typeName.IndexOf("HDRenderPipelineAsset", StringComparison.OrdinalIgnoreCase) >= 0 + || typeName.IndexOf("HighDefinitionRenderPipelineAsset", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "hdrp"; + } + + return "custom"; + } + + private static string RecommendedShader(string kind) + { + return kind switch + { + "built_in" => "Standard", + "urp" => "Universal Render Pipeline/Lit", + "hdrp" => "HDRP/Lit", + _ => "Verify against the active RenderPipelineAsset" + }; + } + + private static string RecommendedInputApi(string mode, bool packageInstalled) + { + return mode switch + { + "legacy" => "UnityEngine.Input", + "input_system" => packageInstalled ? "UnityEngine.InputSystem" : "Install com.unity.inputsystem before generating input code", + "both" => packageInstalled ? "UnityEngine.InputSystem (preferred); UnityEngine.Input is also enabled" : "UnityEngine.Input", + _ => "Inspect Active Input Handling before generating input code" + }; + } + + private static int ReadActiveInputHandler() + { + try + { + var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; + var path = Path.Combine(projectRoot, "ProjectSettings", "ProjectSettings.asset"); + if (!File.Exists(path)) + { + return -1; + } + + foreach (var line in File.ReadLines(path)) + { + var trimmed = line.Trim(); + if (!trimmed.StartsWith("activeInputHandler:", StringComparison.Ordinal)) + { + continue; + } + + var rawValue = trimmed.Substring("activeInputHandler:".Length).Trim(); + return int.TryParse(rawValue, out var value) ? value : -1; + } + } + catch + { + return -1; + } + + return -1; + } + + private static string FindRegisteredPackageVersion(string packageName) + { + try + { + var package = UnityEditor.PackageManager.PackageInfo + .GetAllRegisteredPackages() + .FirstOrDefault(candidate => string.Equals(candidate.name, packageName, StringComparison.Ordinal)); + return package?.version ?? string.Empty; + } + catch + { + return string.Empty; + } + } + } } diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectInspector.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectInspector.cs.meta new file mode 100644 index 0000000..db1db1c --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectInspector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a57ba01b78dd34a8c8cb2519f75be0a3 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSettingsInspector.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSettingsInspector.cs index a9cbadd..8d984d2 100644 --- a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSettingsInspector.cs +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSettingsInspector.cs @@ -1,5 +1,6 @@ using System; using UnityEditor; +using UnityEditorInternal; using UnityEngine; using UnityEngine.Rendering; @@ -18,6 +19,11 @@ public sealed class ProjectSettingsReport public string apiCompatibilityLevel; public bool developmentBuild; public bool connectProfiler; + public string[] tags = Array.Empty(); + public string[] layers = Array.Empty(); + public RenderPipelineEnvironment renderPipeline = new(); + public InputSystemEnvironment inputSystem = new(); + public string[] compatibilityWarnings = Array.Empty(); public string capturedAtUtc; } @@ -27,6 +33,7 @@ public static ProjectSettingsReport Inspect() { var buildTarget = EditorUserBuildSettings.activeBuildTarget; var buildTargetGroup = BuildPipeline.GetBuildTargetGroup(buildTarget); + var environment = ProjectEnvironmentInspector.Inspect(); return new ProjectSettingsReport { @@ -40,6 +47,11 @@ public static ProjectSettingsReport Inspect() apiCompatibilityLevel = SafeGetApiCompatibilityLevel(buildTargetGroup), developmentBuild = EditorUserBuildSettings.development, connectProfiler = EditorUserBuildSettings.connectProfiler, + tags = SafeGetTags(), + layers = SafeGetLayers(), + renderPipeline = environment.renderPipeline, + inputSystem = environment.inputSystem, + compatibilityWarnings = environment.compatibilityWarnings, capturedAtUtc = DateTime.UtcNow.ToString("O") }; } @@ -79,5 +91,29 @@ private static string SafeGetApiCompatibilityLevel(BuildTargetGroup group) return string.Empty; } } + + private static string[] SafeGetTags() + { + try + { + return InternalEditorUtility.tags ?? Array.Empty(); + } + catch + { + return Array.Empty(); + } + } + + private static string[] SafeGetLayers() + { + try + { + return InternalEditorUtility.layers ?? Array.Empty(); + } + catch + { + return Array.Empty(); + } + } } } diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSettingsInspector.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSettingsInspector.cs.meta new file mode 100644 index 0000000..6cf71d6 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSettingsInspector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3c45f783f32fe4059bc101525a7611a0 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSnapshotObserver.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSnapshotObserver.cs index c50835e..884eeac 100644 --- a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSnapshotObserver.cs +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSnapshotObserver.cs @@ -18,6 +18,7 @@ public sealed class ProjectContextSnapshot public ProjectSnapshotAssemblies assemblies = new(); public ProjectSnapshotPackages packages = new(); public ProjectSettingsReport settings = new(); + public ProjectEnvironmentReport environment = new(); public ProjectSnapshotMetaXr metaXr = new(); public ProjectSnapshotArtifacts artifacts = new(); public ProjectSnapshotCapability[] capabilities = Array.Empty(); @@ -45,7 +46,10 @@ public sealed class ProjectSnapshotConsole public int warningCount; public int logCount; public int diagnosticCount; + public int compilationErrorCount; + public int nonBlockingIssueCount; public bool hasErrors; + public bool hasBlockingErrors; public ConsoleDiagnosticEntry[] topDiagnostics = Array.Empty(); } @@ -149,6 +153,7 @@ public static ProjectContextSnapshot Capture() TryCapture("assemblies", failures, () => snapshot.assemblies = CaptureAssemblies()); TryCapture("packages", failures, () => snapshot.packages = CapturePackages()); TryCapture("settings", failures, () => snapshot.settings = ProjectSettingsInspector.Inspect()); + TryCapture("environment", failures, () => snapshot.environment = ProjectEnvironmentInspector.Inspect()); TryCapture("meta_xr", failures, () => snapshot.metaXr = CaptureMetaXr()); TryCapture("artifacts", failures, () => snapshot.artifacts = CaptureArtifacts()); @@ -182,7 +187,10 @@ private static ProjectSnapshotConsole CaptureConsole() warningCount = report.warningCount, logCount = report.logCount, diagnosticCount = report.diagnosticCount, + compilationErrorCount = report.compilationErrorCount, + nonBlockingIssueCount = report.nonBlockingIssueCount, hasErrors = report.hasErrors, + hasBlockingErrors = report.hasBlockingErrors, topDiagnostics = SelectTopDiagnostics(report.diagnostics).ToArray() }; } @@ -193,6 +201,7 @@ private static List SelectTopDiagnostics(List BuildRiskFlags(ProjectContextSnapshot snapshot) { var flags = new List { "missing_bridge_token_not_relevant" }; - if (snapshot.console.hasErrors) + if (snapshot.console.hasBlockingErrors) { flags.Add("compiler_errors_present"); } @@ -426,6 +439,16 @@ private static List BuildRiskFlags(ProjectContextSnapshot snapshot) flags.Add("partial_snapshot"); } + if (snapshot.environment.renderPipeline.kind == "custom") + { + flags.Add("custom_render_pipeline"); + } + + if (snapshot.environment.inputSystem.inputSystemPackageEnabled && !snapshot.environment.inputSystem.inputSystemPackageInstalled) + { + flags.Add("input_system_package_missing"); + } + return flags; } @@ -433,7 +456,7 @@ private static List BuildRecommendedNextActions(ProjectContextSnapshot s { var actions = new List(); - if (snapshot.console.hasErrors) + if (snapshot.console.hasBlockingErrors) { actions.Add("run unity.console.diagnose"); actions.Add("run unity.console.plan_fix"); @@ -449,6 +472,11 @@ private static List BuildRecommendedNextActions(ProjectContextSnapshot s actions.Add("run unity.meta_xr.validate_setup"); } + if (snapshot.environment.inputSystem.mode == "unknown") + { + actions.Add("verify Active Input Handling before generating runtime input code"); + } + if (actions.Count == 0) { actions.Add("inspect active scene before acting"); @@ -459,9 +487,9 @@ private static List BuildRecommendedNextActions(ProjectContextSnapshot s private static List BuildVerificationSignals(ProjectContextSnapshot snapshot) { - var signals = new List { "structured_observation", "console_snapshot", "console_diagnostics" }; + var signals = new List { "structured_observation", "console_snapshot", "console_diagnostics", "environment_introspected" }; - if (snapshot.console.errorCount == 0) + if (snapshot.console.compilationErrorCount == 0) { signals.Add("console_clean"); } diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSnapshotObserver.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSnapshotObserver.cs.meta new file mode 100644 index 0000000..71412a1 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSnapshotObserver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c5c2ab8af5eb41628dec242069ef3ad \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/RuntimeTelemetryObserver.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/RuntimeTelemetryObserver.cs new file mode 100644 index 0000000..22c553c --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/RuntimeTelemetryObserver.cs @@ -0,0 +1,421 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace UnityAI.ControlPlane.Editor +{ + [Serializable] + public sealed class RuntimeTelemetryRequest + { + public RuntimeTelemetryInput input = new(); + } + + [Serializable] + public sealed class RuntimeTelemetryInput + { + public string pathPrefix; + public bool includeInactive; + public SceneRadiusFilterInput withinRadius; + public bool includeRenderers = true; + public bool includeColliders = true; + public bool includeRigidbodies = true; + public int maxObjects = 200; + } + + [Serializable] + public sealed class RuntimeBoundsTelemetry + { + public SceneVector3 center; + public SceneVector3 size; + public SceneVector3 min; + public SceneVector3 max; + } + + [Serializable] + public sealed class RuntimeBodyTelemetry + { + public string dimension; + public string bodyType; + public float mass; + public bool isKinematic; + public bool useGravity; + public float gravityScale; + public bool sleeping; + public SceneVector3 velocity; + public float speed; + public SceneVector3 angularVelocity; + public float angularSpeed; + } + + [Serializable] + public sealed class RuntimeObjectTelemetry + { + public string name; + public string path; + public string tag; + public int layer; + public string layerName; + public bool activeSelf; + public bool activeInHierarchy; + public SceneVector3 position; + public SceneVector3 localPosition; + public SceneVector3 rotationEuler; + public SceneVector3 scale; + public SceneVector3 lossyScale; + public RuntimeBoundsTelemetry rendererBounds; + public RuntimeBoundsTelemetry colliderBounds; + public RuntimeBodyTelemetry body3D; + public RuntimeBodyTelemetry body2D; + public string[] components = Array.Empty(); + } + + [Serializable] + public sealed class RuntimeTelemetryReport + { + public string scenePath; + public string sceneName; + public bool isPlaying; + public bool isPaused; + public int frameCount; + public float time; + public float fixedTime; + public float deltaTime; + public float fixedDeltaTime; + public float timeScale; + public int scannedGameObjectCount; + public int matchedGameObjectCount; + public int returnedGameObjectCount; + public bool truncated; + public RuntimeObjectTelemetry[] objects = Array.Empty(); + public string[] verificationSignals = Array.Empty(); + public string capturedAtUtc; + } + + public static class RuntimeTelemetryObserver + { + public static RuntimeTelemetryReport Capture(string requestBody) + { + var input = ParseRequest(requestBody).input ?? new RuntimeTelemetryInput(); + var maxObjects = input.maxObjects <= 0 ? 200 : Math.Min(input.maxObjects, 500); + var scene = EditorSceneManager.GetActiveScene(); + var radiusCenter = ResolveRadiusCenter(input.withinRadius); + var objects = new List(); + var scannedCount = 0; + var matchedCount = 0; + + if (scene.IsValid()) + { + foreach (var root in scene.GetRootGameObjects()) + { + CaptureHierarchy(root, root.name, input, radiusCenter, maxObjects, objects, ref scannedCount, ref matchedCount); + } + } + + return new RuntimeTelemetryReport + { + scenePath = scene.path, + sceneName = scene.name, + isPlaying = EditorApplication.isPlaying, + isPaused = EditorApplication.isPaused, + frameCount = Time.frameCount, + time = Time.time, + fixedTime = Time.fixedTime, + deltaTime = Time.deltaTime, + fixedDeltaTime = Time.fixedDeltaTime, + timeScale = Time.timeScale, + scannedGameObjectCount = scannedCount, + matchedGameObjectCount = matchedCount, + returnedGameObjectCount = objects.Count, + truncated = matchedCount > objects.Count, + objects = objects.ToArray(), + verificationSignals = new[] { "structured_observation", "runtime_telemetry_captured" }, + capturedAtUtc = DateTime.UtcNow.ToString("O") + }; + } + + private static void CaptureHierarchy( + GameObject gameObject, + string path, + RuntimeTelemetryInput input, + Vector3? radiusCenter, + int maxObjects, + List output, + ref int scannedCount, + ref int matchedCount) + { + scannedCount++; + if (Matches(gameObject, path, input, radiusCenter)) + { + matchedCount++; + if (output.Count < maxObjects) + { + output.Add(BuildObjectTelemetry(gameObject, path, input)); + } + } + + for (var index = 0; index < gameObject.transform.childCount; index++) + { + var child = gameObject.transform.GetChild(index).gameObject; + CaptureHierarchy(child, path + "/" + child.name, input, radiusCenter, maxObjects, output, ref scannedCount, ref matchedCount); + } + } + + private static bool Matches(GameObject gameObject, string path, RuntimeTelemetryInput input, Vector3? radiusCenter) + { + if (!input.includeInactive && !gameObject.activeInHierarchy) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(input.pathPrefix) && !PathMatchesPrefix(path, input.pathPrefix)) + { + return false; + } + + return !radiusCenter.HasValue + || Vector3.Distance(gameObject.transform.position, radiusCenter.Value) <= Math.Max(0f, input.withinRadius.radius); + } + + private static RuntimeObjectTelemetry BuildObjectTelemetry(GameObject gameObject, string path, RuntimeTelemetryInput input) + { + var transform = gameObject.transform; + return new RuntimeObjectTelemetry + { + name = gameObject.name, + path = path, + tag = SafeGetTag(gameObject), + layer = gameObject.layer, + layerName = LayerMask.LayerToName(gameObject.layer), + activeSelf = gameObject.activeSelf, + activeInHierarchy = gameObject.activeInHierarchy, + position = ToSceneVector3(transform.position), + localPosition = ToSceneVector3(transform.localPosition), + rotationEuler = ToSceneVector3(transform.eulerAngles), + scale = ToSceneVector3(transform.localScale), + lossyScale = ToSceneVector3(transform.lossyScale), + rendererBounds = input.includeRenderers ? ToBoundsTelemetry(GetCombinedRendererBounds(gameObject)) : null, + colliderBounds = input.includeColliders ? ToBoundsTelemetry(GetCombinedColliderBounds(gameObject)) : null, + body3D = input.includeRigidbodies ? ToBodyTelemetry(gameObject.GetComponent()) : null, + body2D = input.includeRigidbodies ? ToBodyTelemetry(gameObject.GetComponent()) : null, + components = gameObject.GetComponents() + .Where(component => component != null) + .Select(component => component.GetType().FullName ?? component.GetType().Name) + .Take(32) + .ToArray() + }; + } + + private static RuntimeBodyTelemetry ToBodyTelemetry(Rigidbody body) + { + if (body == null) + { + return null; + } + + var velocity = body.linearVelocity; + return new RuntimeBodyTelemetry + { + dimension = "3d", + bodyType = body.isKinematic ? "kinematic" : "dynamic", + mass = body.mass, + isKinematic = body.isKinematic, + useGravity = body.useGravity, + gravityScale = body.useGravity ? 1f : 0f, + sleeping = body.IsSleeping(), + velocity = ToSceneVector3(velocity), + speed = velocity.magnitude, + angularVelocity = ToSceneVector3(body.angularVelocity), + angularSpeed = body.angularVelocity.magnitude + }; + } + + private static RuntimeBodyTelemetry ToBodyTelemetry(Rigidbody2D body) + { + if (body == null) + { + return null; + } + + var velocity = new Vector3(body.linearVelocity.x, body.linearVelocity.y, 0f); + var angularVelocity = new Vector3(0f, 0f, body.angularVelocity); + return new RuntimeBodyTelemetry + { + dimension = "2d", + bodyType = body.bodyType.ToString().ToLowerInvariant(), + mass = body.mass, + isKinematic = body.bodyType == RigidbodyType2D.Kinematic, + useGravity = Math.Abs(body.gravityScale) > 0.0001f, + gravityScale = body.gravityScale, + sleeping = body.IsSleeping(), + velocity = ToSceneVector3(velocity), + speed = velocity.magnitude, + angularVelocity = ToSceneVector3(angularVelocity), + angularSpeed = Math.Abs(body.angularVelocity) + }; + } + + private static Bounds? GetCombinedRendererBounds(GameObject gameObject) + { + var hasBounds = false; + var bounds = new Bounds(Vector3.zero, Vector3.zero); + foreach (var renderer in gameObject.GetComponentsInChildren(true)) + { + if (renderer == null) + { + continue; + } + + if (!hasBounds) + { + bounds = renderer.bounds; + hasBounds = true; + } + else + { + bounds.Encapsulate(renderer.bounds); + } + } + + return hasBounds ? bounds : null; + } + + private static Bounds? GetCombinedColliderBounds(GameObject gameObject) + { + var hasBounds = false; + var bounds = new Bounds(Vector3.zero, Vector3.zero); + foreach (var collider in gameObject.GetComponentsInChildren(true).Cast() + .Concat(gameObject.GetComponentsInChildren(true))) + { + if (collider == null) + { + continue; + } + + var colliderBounds = collider is Collider collider3D + ? collider3D.bounds + : ((Collider2D)collider).bounds; + if (!hasBounds) + { + bounds = colliderBounds; + hasBounds = true; + } + else + { + bounds.Encapsulate(colliderBounds); + } + } + + return hasBounds ? bounds : null; + } + + private static RuntimeBoundsTelemetry ToBoundsTelemetry(Bounds? bounds) + { + if (!bounds.HasValue) + { + return null; + } + + return new RuntimeBoundsTelemetry + { + center = ToSceneVector3(bounds.Value.center), + size = ToSceneVector3(bounds.Value.size), + min = ToSceneVector3(bounds.Value.min), + max = ToSceneVector3(bounds.Value.max) + }; + } + + private static Vector3? ResolveRadiusCenter(SceneRadiusFilterInput filter) + { + if (filter == null + || string.IsNullOrWhiteSpace(filter.centerPath) + && filter.center == null) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(filter.centerPath)) + { + var target = FindByPath(filter.centerPath.Trim().Replace('\\', '/')); + if (target == null) + { + throw new InvalidOperationException($"Runtime telemetry radius centerPath was not found: {filter.centerPath}"); + } + + return target.transform.position; + } + + return new Vector3(filter.center.x, filter.center.y, filter.center.z); + } + + private static bool PathMatchesPrefix(string path, string rawPrefix) + { + var prefix = rawPrefix.Trim().TrimEnd('/').Replace('\\', '/'); + return string.Equals(path, prefix, StringComparison.OrdinalIgnoreCase) + || path.StartsWith(prefix + "/", StringComparison.OrdinalIgnoreCase); + } + + private static GameObject FindByPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var segments = path.Split('/'); + foreach (var root in EditorSceneManager.GetActiveScene().GetRootGameObjects()) + { + if (!string.Equals(root.name, segments[0], StringComparison.Ordinal)) + { + continue; + } + + var current = root.transform; + for (var index = 1; index < segments.Length && current != null; index++) + { + current = current.Find(segments[index]); + } + + return current != null ? current.gameObject : null; + } + + return null; + } + + private static string SafeGetTag(GameObject gameObject) + { + try + { + return gameObject.tag; + } + catch + { + return string.Empty; + } + } + + private static SceneVector3 ToSceneVector3(Vector3 value) + { + return new SceneVector3 { x = value.x, y = value.y, z = value.z }; + } + + private static RuntimeTelemetryRequest ParseRequest(string requestBody) + { + if (string.IsNullOrWhiteSpace(requestBody)) + { + return new RuntimeTelemetryRequest(); + } + + try + { + return JsonUtility.FromJson(requestBody) ?? new RuntimeTelemetryRequest(); + } + catch + { + return new RuntimeTelemetryRequest(); + } + } + } +} diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/RuntimeTelemetryObserver.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/RuntimeTelemetryObserver.cs.meta new file mode 100644 index 0000000..e7112e0 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/RuntimeTelemetryObserver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7e3f5b6c9a2542c48dddc7fe90d45a71 diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/SceneInspector.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/SceneInspector.cs index 14336ea..db8ffe8 100644 --- a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/SceneInspector.cs +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/SceneInspector.cs @@ -17,6 +17,25 @@ public sealed class SceneInspectInput public bool includeComponents = true; public int maxDepth = 3; public int maxGameObjects = 200; + public SceneInspectFilterInput filter; + } + + [Serializable] + public sealed class SceneInspectFilterInput + { + public string nameContains; + public string pathPrefix; + public string componentType; + public string activeState = "any"; + public SceneRadiusFilterInput withinRadius; + } + + [Serializable] + public sealed class SceneRadiusFilterInput + { + public string centerPath; + public SceneVector3 center; + public float radius; } [Serializable] @@ -26,8 +45,10 @@ public sealed class SceneGameObjectInfo public string path; public bool activeSelf; public SceneVector3 position; + public SceneVector3 worldPosition; public SceneVector3 rotationEuler; public SceneVector3 scale; + public float distanceFromFilterCenter; public int childCount; public string[] components; } @@ -48,7 +69,10 @@ public sealed class SceneInspectReport public bool isDirty; public bool isLoaded; public int rootGameObjectCount; + public int visitedGameObjectCount; + public int matchedGameObjectCount; public int returnedGameObjectCount; + public bool filtered; public bool truncated; public bool truncatedByDepth; public bool truncatedByCount; @@ -67,19 +91,29 @@ public static SceneInspectReport InspectActiveScene(string requestBody) var roots = scene.IsValid() ? scene.GetRootGameObjects() : Array.Empty(); var gameObjects = new List(); var truncatedByDepth = false; - var truncatedByCount = false; + var visitedGameObjectCount = 0; + var matchedGameObjectCount = 0; + var filter = NormalizeFilter(input.filter); + var radiusCenter = ResolveRadiusCenter(filter); foreach (var root in roots) { - AddGameObject(root, root.name, 0, maxDepth, maxGameObjects, input.includeComponents, gameObjects, ref truncatedByDepth, ref truncatedByCount); - - if (gameObjects.Count >= maxGameObjects) - { - truncatedByCount = true; - break; - } + AddGameObject( + root, + root.name, + 0, + maxDepth, + maxGameObjects, + input.includeComponents, + filter, + radiusCenter, + gameObjects, + ref visitedGameObjectCount, + ref matchedGameObjectCount, + ref truncatedByDepth); } + var truncatedByCount = matchedGameObjectCount > gameObjects.Count; return new SceneInspectReport { scenePath = scene.path, @@ -87,7 +121,10 @@ public static SceneInspectReport InspectActiveScene(string requestBody) isDirty = scene.isDirty, isLoaded = scene.isLoaded, rootGameObjectCount = roots.Length, + visitedGameObjectCount = visitedGameObjectCount, + matchedGameObjectCount = matchedGameObjectCount, returnedGameObjectCount = gameObjects.Count, + filtered = filter != null, truncated = truncatedByDepth || truncatedByCount, truncatedByDepth = truncatedByDepth, truncatedByCount = truncatedByCount, @@ -96,25 +133,45 @@ public static SceneInspectReport InspectActiveScene(string requestBody) }; } - private static void AddGameObject(GameObject gameObject, string path, int depth, int maxDepth, int maxGameObjects, bool includeComponents, List output, ref bool truncatedByDepth, ref bool truncatedByCount) + private static void AddGameObject( + GameObject gameObject, + string path, + int depth, + int maxDepth, + int maxGameObjects, + bool includeComponents, + SceneInspectFilterInput filter, + Vector3? radiusCenter, + List output, + ref int visitedGameObjectCount, + ref int matchedGameObjectCount, + ref bool truncatedByDepth) { - if (output.Count >= maxGameObjects) - { - truncatedByCount = true; - return; - } + visitedGameObjectCount++; + var distanceFromFilterCenter = radiusCenter.HasValue + ? Vector3.Distance(gameObject.transform.position, radiusCenter.Value) + : -1f; - output.Add(new SceneGameObjectInfo + if (MatchesFilter(gameObject, path, filter, distanceFromFilterCenter)) { - name = gameObject.name, - path = path, - activeSelf = gameObject.activeSelf, - position = ToSceneVector3(gameObject.transform.localPosition), - rotationEuler = ToSceneVector3(gameObject.transform.localEulerAngles), - scale = ToSceneVector3(gameObject.transform.localScale), - childCount = gameObject.transform.childCount, - components = includeComponents ? GetComponentNames(gameObject) : Array.Empty() - }); + matchedGameObjectCount++; + if (output.Count < maxGameObjects) + { + output.Add(new SceneGameObjectInfo + { + name = gameObject.name, + path = path, + activeSelf = gameObject.activeSelf, + position = ToSceneVector3(gameObject.transform.localPosition), + worldPosition = ToSceneVector3(gameObject.transform.position), + rotationEuler = ToSceneVector3(gameObject.transform.localEulerAngles), + scale = ToSceneVector3(gameObject.transform.localScale), + distanceFromFilterCenter = distanceFromFilterCenter, + childCount = gameObject.transform.childCount, + components = includeComponents ? GetComponentNames(gameObject) : Array.Empty() + }); + } + } if (depth >= maxDepth) { @@ -129,10 +186,128 @@ private static void AddGameObject(GameObject gameObject, string path, int depth, for (var index = 0; index < gameObject.transform.childCount; index++) { var child = gameObject.transform.GetChild(index).gameObject; - AddGameObject(child, $"{path}/{child.name}", depth + 1, maxDepth, maxGameObjects, includeComponents, output, ref truncatedByDepth, ref truncatedByCount); + AddGameObject( + child, + $"{path}/{child.name}", + depth + 1, + maxDepth, + maxGameObjects, + includeComponents, + filter, + radiusCenter, + output, + ref visitedGameObjectCount, + ref matchedGameObjectCount, + ref truncatedByDepth); } } + private static SceneInspectFilterInput NormalizeFilter(SceneInspectFilterInput filter) + { + if (filter == null) + { + return null; + } + + var hasName = !string.IsNullOrWhiteSpace(filter.nameContains); + var hasPath = !string.IsNullOrWhiteSpace(filter.pathPrefix); + var hasComponent = !string.IsNullOrWhiteSpace(filter.componentType); + var activeState = (filter.activeState ?? "any").Trim().ToLowerInvariant(); + var hasActiveState = activeState == "active" || activeState == "inactive"; + filter.activeState = hasActiveState ? activeState : "any"; + + return hasName || hasPath || hasComponent || hasActiveState || HasRadiusFilter(filter.withinRadius) + ? filter + : null; + } + + private static Vector3? ResolveRadiusCenter(SceneInspectFilterInput filter) + { + var radiusFilter = filter?.withinRadius; + if (!HasRadiusFilter(radiusFilter)) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(radiusFilter.centerPath)) + { + var centerObject = FindByPath(radiusFilter.centerPath.Trim().Replace('\\', '/')); + if (centerObject == null) + { + throw new InvalidOperationException($"Radius filter centerPath was not found: {radiusFilter.centerPath}"); + } + + return centerObject.transform.position; + } + + return new Vector3(radiusFilter.center.x, radiusFilter.center.y, radiusFilter.center.z); + } + + private static bool HasRadiusFilter(SceneRadiusFilterInput radiusFilter) + { + return radiusFilter != null + && (!string.IsNullOrWhiteSpace(radiusFilter.centerPath) || radiusFilter.center != null); + } + + private static bool MatchesFilter(GameObject gameObject, string path, SceneInspectFilterInput filter, float distanceFromFilterCenter) + { + if (filter == null) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(filter.nameContains) + && gameObject.name.IndexOf(filter.nameContains.Trim(), StringComparison.OrdinalIgnoreCase) < 0) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(filter.pathPrefix) + && !path.StartsWith(filter.pathPrefix.Trim().TrimEnd('/'), StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(filter.componentType) && !HasComponentType(gameObject, filter.componentType)) + { + return false; + } + + if (filter.activeState == "active" && !gameObject.activeInHierarchy) + { + return false; + } + + if (filter.activeState == "inactive" && gameObject.activeInHierarchy) + { + return false; + } + + return !HasRadiusFilter(filter.withinRadius) + || distanceFromFilterCenter <= Math.Max(0f, filter.withinRadius.radius); + } + + private static bool HasComponentType(GameObject gameObject, string requestedType) + { + var normalized = requestedType.Trim(); + foreach (var component in gameObject.GetComponents()) + { + if (component == null) + { + continue; + } + + var type = component.GetType(); + if (string.Equals(type.Name, normalized, StringComparison.OrdinalIgnoreCase) + || string.Equals(type.FullName, normalized, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + private static string[] GetComponentNames(GameObject gameObject) { var components = gameObject.GetComponents(); @@ -156,6 +331,37 @@ private static SceneVector3 ToSceneVector3(Vector3 value) }; } + private static GameObject FindByPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var segments = path.Split('/'); + var scene = EditorSceneManager.GetActiveScene(); + foreach (var root in scene.GetRootGameObjects()) + { + if (!string.Equals(root.name, segments[0], StringComparison.Ordinal)) + { + continue; + } + + var current = root.transform; + for (var index = 1; index < segments.Length && current != null; index++) + { + current = current.Find(segments[index]); + } + + if (current != null) + { + return current.gameObject; + } + } + + return null; + } + private static SceneInspectRequest ParseRequest(string requestBody) { if (string.IsNullOrWhiteSpace(requestBody)) diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/SceneInspector.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/SceneInspector.cs.meta new file mode 100644 index 0000000..953414c --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/SceneInspector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3a9f6c481da324faeba7a92507c841cf \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/SceneListObserver.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/SceneListObserver.cs.meta new file mode 100644 index 0000000..2dc857c --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/SceneListObserver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cf8cc0c333f014e90aec2f75354d5c7d \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ScreenshotCapture.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ScreenshotCapture.cs.meta new file mode 100644 index 0000000..36c538e --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ScreenshotCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: eecb5a960278241909a9b387a2fa37dc \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ScriptAndAssemblyObserver.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ScriptAndAssemblyObserver.cs index f97665a..3eb59a4 100644 --- a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ScriptAndAssemblyObserver.cs +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ScriptAndAssemblyObserver.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using UnityEditor; using UnityEditor.Compilation; @@ -76,15 +77,10 @@ public static ScriptListReport ListScripts(string requestBody) var maxResults = input.maxResults <= 0 ? 500 : Math.Min(input.maxResults, 2000); var folders = input.includePackages ? new[] { "Assets", "Packages" } : new[] { "Assets" }; var guids = AssetDatabase.FindAssets("t:MonoScript", folders); - var scripts = new List(); + var scripts = new List(guids.Length); foreach (var guid in guids) { - if (scripts.Count >= maxResults) - { - break; - } - var path = AssetDatabase.GUIDToAssetPath(guid); var script = AssetDatabase.LoadAssetAtPath(path); var scriptClass = script != null ? script.GetClass() : null; @@ -98,13 +94,18 @@ public static ScriptListReport ListScripts(string requestBody) }); } + var returnedScripts = scripts + .OrderBy(script => script.path, StringComparer.Ordinal) + .Take(maxResults) + .ToArray(); + return new ScriptListReport { totalFound = guids.Length, - returned = scripts.Count, + returned = returnedScripts.Length, includePackages = input.includePackages, - truncated = guids.Length > scripts.Count, - scripts = scripts.ToArray(), + truncated = guids.Length > returnedScripts.Length, + scripts = returnedScripts, capturedAtUtc = DateTime.UtcNow.ToString("O") }; } diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ScriptAndAssemblyObserver.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ScriptAndAssemblyObserver.cs.meta new file mode 100644 index 0000000..a2eeac9 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ScriptAndAssemblyObserver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bf60a4c2cf36f44ff8610bab41529282 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/VisualComparison.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/VisualComparison.cs.meta new file mode 100644 index 0000000..1e4839e --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/VisualComparison.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 70e6b766da0de4f5482699999bb5439c \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Prefabs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Prefabs.meta new file mode 100644 index 0000000..ebe6498 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Prefabs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b8422bcacc9dd4eac839d97dc35aef49 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Prefabs/PrefabAssetOperation.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Prefabs/PrefabAssetOperation.cs.meta new file mode 100644 index 0000000..c6d2212 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Prefabs/PrefabAssetOperation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d8a39d57989554da88528cd9cc474800 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Scripts.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Scripts.meta new file mode 100644 index 0000000..4c89eff --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 217df646ccb914c38b63af8118388f1e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Scripts/ScriptAuthoringOperation.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Scripts/ScriptAuthoringOperation.cs new file mode 100644 index 0000000..4218d88 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Scripts/ScriptAuthoringOperation.cs @@ -0,0 +1,782 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using UnityEditor; +using UnityEditor.Compilation; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace UnityAI.ControlPlane.Editor +{ + [Serializable] + public sealed class ScriptAuthoringInput + { + public bool dryRun = true; + public bool confirm; + public string path; + public string source; + public string expectedSourceSha256; + public string expectedClassName; + public string expectedNamespace; + public bool overwrite; + public string attachToObjectPath; + public bool autoRollbackOnCompileError = true; + public int timeoutSeconds = 300; + } + + [Serializable] + public sealed class ScriptAuthoringState + { + public string checkpointId; + public string sourceSha256; + public string fullTypeName; + public bool rollbackStarted; + public string failureMessage; + public int targetCompilerErrorCount; + } + + [Serializable] + public sealed class ScriptAuthoringRequest + { + public string requestId; + public string correlationId; + public ScriptAuthoringInput input = new(); + public ScriptAuthoringState state = new(); + } + + [Serializable] + public sealed class ScriptAuthoringStartResult + { + public bool accepted; + public bool dryRun; + public bool refused; + public bool requiresConfirmation; + public string jobId; + public string path; + public string sourceSha256; + public string expectedClassName; + public string fullTypeName; + public string message; + public string[] validationRules = Array.Empty(); + public string[] requiredPermissions = Array.Empty(); + public string[] verificationSignals = Array.Empty(); + public string timestampUtc; + } + + [Serializable] + public sealed class ScriptAuthoringJobResult + { + public bool written; + public bool compiled; + public bool attached; + public bool rolledBack; + public string path; + public string sourceSha256; + public string className; + public string namespaceName; + public string fullTypeName; + public string assemblyName; + public string checkpointId; + public string attachedObjectPath; + public int targetCompilerErrorCount; + public string message; + public string timestampUtc; + } + + [InitializeOnLoad] + public static class ScriptAuthoringOperation + { + private const string Capability = "unity.scripts.author"; + private const string JobKind = "script_authoring"; + private const int MaxSourceBytes = 262144; + private static readonly Dictionary StableFrames = new(); + private static readonly string[] ForbiddenPatterns = + { + @"\busing\s+UnityEditor\b", + @"\bUnityEditor\.", + @"\bInitializeOnLoad\b", + @"\bInitializeOnLoadMethod\b", + @"\bAssetPostprocessor\b", + @"\bMenuItem\b", + @"\bCustomEditor\b", + @"\bExecuteAlways\b", + @"\bExecuteInEditMode\b", + @"\bOnValidate\s*\(", + @"\bReset\s*\(", + @"\bOnEnable\s*\(", + @"\bOnDisable\s*\(", + @"\bOnDestroy\s*\(", + @"\bSystem\.Diagnostics\.Process\b", + @"\bProcess\.Start\s*\(", + @"\bDllImport\b", + @"\bLibraryImport\b", + @"\bunsafe\b", + @"\bstackalloc\b", + @"\bSystem\.Reflection\b", + @"\bAssembly\.Load", + @"\bActivator\.CreateInstance\b", + @"\bSystem\.Net\b", + @"\bHttpClient\b", + @"\bUnityWebRequest\b", + @"\bWebRequest\b", + @"\bSocket\b", + @"\bSystem\.IO\b", + @"\bFile\.", + @"\bDirectory\.", + @"\bFileStream\b", + @"\bApplication\.Quit\s*\(", + @"\bEnvironment\.Exit\s*\(", + @"^\s*#\s*(r|load)\b", + @"\bModuleInitializer\b" + }; + + static ScriptAuthoringOperation() + { + EditorApplication.update -= Tick; + EditorApplication.update += Tick; + CompilationPipeline.compilationStarted -= OnCompilationStarted; + CompilationPipeline.compilationStarted += OnCompilationStarted; + } + + public static ScriptAuthoringStartResult Start(string requestBody) + { + var request = ParseRequest(requestBody); + var input = request.input ?? new ScriptAuthoringInput(); + var path = NormalizeAssetPath(input.path); + var source = NormalizeSource(input.source); + var sourceSha256 = ComputeSha256(source); + var timestamp = DateTime.UtcNow.ToString("O"); + + if (!ValidateInput(input, path, source, sourceSha256, out var fullTypeName, out var error)) + { + return new ScriptAuthoringStartResult + { + dryRun = input.dryRun, + refused = true, + path = path, + sourceSha256 = sourceSha256, + expectedClassName = input.expectedClassName, + fullTypeName = fullTypeName, + message = error, + validationRules = ValidationRules(), + timestampUtc = timestamp + }; + } + + if (input.dryRun) + { + return new ScriptAuthoringStartResult + { + dryRun = true, + path = path, + sourceSha256 = sourceSha256, + expectedClassName = input.expectedClassName, + fullTypeName = fullTypeName, + message = $"DRY RUN: validated runtime MonoBehaviour source for '{path}'. Confirm this exact SHA-256 to write and compile it.", + validationRules = ValidationRules(), + requiredPermissions = new[] { "modify_assets", "execute_editor_script" }, + verificationSignals = new[] { "script_source_validated", "structured_observation" }, + timestampUtc = timestamp + }; + } + + if (!input.confirm) + { + return new ScriptAuthoringStartResult + { + requiresConfirmation = true, + path = path, + sourceSha256 = sourceSha256, + expectedClassName = input.expectedClassName, + fullTypeName = fullTypeName, + message = "Script authoring requires confirm=true and expectedSourceSha256 from the validated dry run.", + validationRules = ValidationRules(), + requiredPermissions = new[] { "modify_assets", "execute_editor_script" }, + timestampUtc = timestamp + }; + } + + try + { + var checkpointPaths = new List { path, path + ".meta" }; + var scene = EditorSceneManager.GetActiveScene(); + if (!string.IsNullOrWhiteSpace(input.attachToObjectPath) && scene.IsValid() && !string.IsNullOrWhiteSpace(scene.path)) + { + checkpointPaths.Add(scene.path); + checkpointPaths.Add(scene.path + ".meta"); + } + + var checkpoint = DurableCheckpointStore.CreateInternal("script-authoring", checkpointPaths.Distinct().ToArray()); + var absolutePath = ResolveAssetPath(path); + Directory.CreateDirectory(Path.GetDirectoryName(absolutePath) ?? Application.dataPath); + File.WriteAllText(absolutePath, source, new UTF8Encoding(false)); + + request.input.path = path; + request.input.source = source; + request.input.timeoutSeconds = Math.Max(10, Math.Min(input.timeoutSeconds, 1800)); + request.state = new ScriptAuthoringState + { + checkpointId = checkpoint.checkpointId, + sourceSha256 = sourceSha256, + fullTypeName = fullTypeName + }; + + var envelope = new UnityAiRequestEnvelope + { + requestId = request.requestId, + correlationId = request.correlationId + }; + var job = UnityAiJobStore.Create( + Capability, + JobKind, + envelope, + JsonUtility.ToJson(request), + $"Writing and compiling runtime component '{fullTypeName}'."); + UnityAiJobStore.MarkRunning(job.jobId, "importing_script", "Importing generated runtime component.", 0.2f); + + var auditEvent = new UnityAiAuditEvent + { + timestamp = DateTime.UtcNow.ToString("O"), + capability = Capability, + requestId = request.requestId, + correlationId = request.correlationId, + message = $"Wrote runtime component source '{path}' with SHA-256 {sourceSha256}; compilation job '{job.jobId}' started.", + effects = new[] { "code_change", "asset_change", "write_checkpoint", "write_audit_log" } + }; + PersistAudit(auditEvent); + AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate); + CompilationPipeline.RequestScriptCompilation(); + + return new ScriptAuthoringStartResult + { + accepted = true, + jobId = job.jobId, + path = path, + sourceSha256 = sourceSha256, + expectedClassName = input.expectedClassName, + fullTypeName = fullTypeName, + message = "Script source written; persistent compilation and attachment verification started.", + requiredPermissions = string.IsNullOrWhiteSpace(input.attachToObjectPath) + ? new[] { "modify_assets", "execute_editor_script" } + : new[] { "modify_assets", "modify_scenes", "execute_editor_script" }, + verificationSignals = new[] { "script_source_validated", "checkpoint_created", "operation_audited" }, + timestampUtc = timestamp + }; + } + catch (Exception exception) + { + return new ScriptAuthoringStartResult + { + refused = true, + path = path, + sourceSha256 = sourceSha256, + expectedClassName = input.expectedClassName, + fullTypeName = fullTypeName, + message = exception.GetBaseException().Message, + validationRules = ValidationRules(), + timestampUtc = timestamp + }; + } + } + + private static void Tick() + { + foreach (var job in UnityAiJobStore.List("running", JobKind, 100)) + { + var request = ParseRequest(job.requestJson); + var input = request.input ?? new ScriptAuthoringInput(); + var state = request.state ?? new ScriptAuthoringState(); + + if (job.cancelRequested) + { + RollbackOrFail(job, request, "Script authoring was cancelled."); + continue; + } + + if (IsTimedOut(job, input.timeoutSeconds)) + { + RollbackOrFail(job, request, $"Script authoring did not settle within {input.timeoutSeconds} seconds."); + continue; + } + + if (EditorApplication.isCompiling || EditorApplication.isUpdating) + { + StableFrames[job.jobId] = 0; + UnityAiJobStore.UpdateProgress(job.jobId, EditorApplication.isCompiling ? "compiling" : "importing", "Unity is compiling or importing the generated script.", 0.55f); + continue; + } + + StableFrames.TryGetValue(job.jobId, out var stableFrames); + stableFrames++; + StableFrames[job.jobId] = stableFrames; + if (stableFrames < 3) + { + continue; + } + + if (state.rollbackStarted) + { + var rollbackResult = new ScriptAuthoringJobResult + { + written = false, + compiled = false, + rolledBack = true, + path = input.path, + sourceSha256 = state.sourceSha256, + className = input.expectedClassName, + namespaceName = input.expectedNamespace, + fullTypeName = state.fullTypeName, + checkpointId = state.checkpointId, + targetCompilerErrorCount = state.targetCompilerErrorCount, + message = state.failureMessage, + timestampUtc = DateTime.UtcNow.ToString("O") + }; + UnityAiJobStore.Fail( + job.jobId, + state.failureMessage, + rollbackResult, + "script_source_validated", + "checkpoint_created", + "checkpoint_restored", + "compilation_completed", + "console_snapshot"); + StableFrames.Remove(job.jobId); + continue; + } + + VerifyCompiledJob(job, request); + StableFrames.Remove(job.jobId); + } + } + + private static void VerifyCompiledJob(UnityAiJobRecord job, ScriptAuthoringRequest request) + { + var input = request.input ?? new ScriptAuthoringInput(); + var state = request.state ?? new ScriptAuthoringState(); + var targetErrors = CountTargetCompilerErrors(input.path); + var script = AssetDatabase.LoadAssetAtPath(input.path); + var scriptClass = script != null ? script.GetClass() : null; + var validClass = scriptClass != null + && scriptClass.Name == input.expectedClassName + && string.Equals(scriptClass.Namespace ?? string.Empty, input.expectedNamespace ?? string.Empty, StringComparison.Ordinal) + && typeof(MonoBehaviour).IsAssignableFrom(scriptClass) + && !scriptClass.IsAbstract; + + if (targetErrors > 0 || !validClass) + { + var reason = targetErrors > 0 + ? $"Generated script '{input.path}' produced {targetErrors} compiler error(s)." + : $"Generated script '{input.path}' did not resolve to concrete MonoBehaviour '{state.fullTypeName}'."; + state.targetCompilerErrorCount = targetErrors; + request.state = state; + RollbackOrFail(job, request, reason); + return; + } + + var attached = false; + var attachedPath = string.Empty; + if (!string.IsNullOrWhiteSpace(input.attachToObjectPath)) + { + var target = FindByPath(input.attachToObjectPath); + if (target == null) + { + RollbackOrFail(job, request, $"Attachment target '{input.attachToObjectPath}' was not found after compilation."); + return; + } + + var existing = target.GetComponent(scriptClass); + var component = existing ?? Undo.AddComponent(target, scriptClass); + attached = component != null && target.GetComponent(scriptClass) != null; + attachedPath = GetGameObjectPath(target); + if (!attached) + { + RollbackOrFail(job, request, $"Compiled component '{state.fullTypeName}' could not be attached to '{attachedPath}'."); + return; + } + + EditorUtility.SetDirty(component); + EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene()); + } + + var result = new ScriptAuthoringJobResult + { + written = File.Exists(ResolveAssetPath(input.path)), + compiled = true, + attached = attached, + path = input.path, + sourceSha256 = state.sourceSha256, + className = scriptClass.Name, + namespaceName = scriptClass.Namespace ?? string.Empty, + fullTypeName = scriptClass.FullName ?? scriptClass.Name, + assemblyName = scriptClass.Assembly.GetName().Name, + checkpointId = state.checkpointId, + attachedObjectPath = attachedPath, + targetCompilerErrorCount = targetErrors, + message = attached + ? $"Compiled and attached runtime component '{scriptClass.FullName}'." + : $"Compiled runtime component '{scriptClass.FullName}'.", + timestampUtc = DateTime.UtcNow.ToString("O") + }; + var signals = new List + { + "script_source_validated", + "checkpoint_created", + "compilation_completed", + "console_snapshot", + "script_compilation_verified", + "operation_audited" + }; + if (attached) + { + signals.Add("component_state_verified"); + signals.Add("scene_mutation_verified"); + } + + UnityAiJobStore.Complete(job.jobId, result, result.message, signals.ToArray()); + } + + private static void RollbackOrFail(UnityAiJobRecord job, ScriptAuthoringRequest request, string failureMessage) + { + var input = request.input ?? new ScriptAuthoringInput(); + var state = request.state ?? new ScriptAuthoringState(); + if (!input.autoRollbackOnCompileError || string.IsNullOrWhiteSpace(state.checkpointId)) + { + var result = new ScriptAuthoringJobResult + { + written = File.Exists(ResolveAssetPath(input.path)), + compiled = false, + path = input.path, + sourceSha256 = state.sourceSha256, + className = input.expectedClassName, + namespaceName = input.expectedNamespace, + fullTypeName = state.fullTypeName, + checkpointId = state.checkpointId, + targetCompilerErrorCount = CountTargetCompilerErrors(input.path), + message = failureMessage, + timestampUtc = DateTime.UtcNow.ToString("O") + }; + UnityAiJobStore.Fail(job.jobId, failureMessage, result, "script_source_validated", "checkpoint_created", "compilation_completed", "console_snapshot"); + StableFrames.Remove(job.jobId); + return; + } + + state.rollbackStarted = true; + state.failureMessage = failureMessage; + state.targetCompilerErrorCount = Math.Max(state.targetCompilerErrorCount, CountTargetCompilerErrors(input.path)); + request.state = state; + job.requestJson = JsonUtility.ToJson(request); + job.stage = "verifying_rollback"; + job.message = "Compilation failed; restoring the pre-write checkpoint."; + job.progress = 0.9f; + UnityAiJobStore.Save(job); + + var restoreRequest = new CheckpointRestoreRequest + { + input = new CheckpointRestoreInput + { + dryRun = false, + confirm = true, + checkpointId = state.checkpointId, + createSafetyCheckpoint = false + } + }; + var restore = DurableCheckpointStore.Restore(JsonUtility.ToJson(restoreRequest)); + if (!restore.restored) + { + UnityAiJobStore.Fail( + job.jobId, + failureMessage + " Automatic checkpoint restoration also failed: " + restore.message, + restore, + "script_source_validated", + "checkpoint_created", + "compilation_completed", + "console_snapshot"); + } + } + + private static bool ValidateInput( + ScriptAuthoringInput input, + string path, + string source, + string sourceSha256, + out string fullTypeName, + out string error) + { + var className = (input.expectedClassName ?? string.Empty).Trim(); + var namespaceName = (input.expectedNamespace ?? string.Empty).Trim(); + fullTypeName = string.IsNullOrEmpty(namespaceName) ? className : namespaceName + "." + className; + + if (!IsSafeScriptPath(path)) + { + error = "path must be a .cs file under Assets and cannot contain parent traversal."; + return false; + } + + if (!IsIdentifier(className) || Path.GetFileNameWithoutExtension(path) != className) + { + error = "expectedClassName must be a valid C# identifier and match the script filename."; + return false; + } + + if (!string.IsNullOrEmpty(namespaceName) && !namespaceName.Split('.').All(IsIdentifier)) + { + error = "expectedNamespace must contain only valid dot-separated C# identifiers."; + return false; + } + + var sourceBytes = Encoding.UTF8.GetByteCount(source); + if (sourceBytes == 0 || sourceBytes > MaxSourceBytes) + { + error = $"source must contain between 1 and {MaxSourceBytes} UTF-8 bytes."; + return false; + } + + if (!Regex.IsMatch(source, $@"\bclass\s+{Regex.Escape(className)}\b[^{{:]*:\s*(?:[\w.]+\s*,\s*)*(?:UnityEngine\.)?MonoBehaviour\b")) + { + error = $"source must declare class '{className}' deriving from MonoBehaviour."; + return false; + } + + if (!string.IsNullOrEmpty(namespaceName) + && !Regex.IsMatch(source, $@"\bnamespace\s+{Regex.Escape(namespaceName)}\b")) + { + error = $"source does not declare expectedNamespace '{namespaceName}'."; + return false; + } + + if (Regex.IsMatch(source, $@"\bstatic\s+{Regex.Escape(className)}\s*\(")) + { + error = "Static constructors are not allowed in generated runtime components."; + return false; + } + + foreach (var pattern in ForbiddenPatterns) + { + if (Regex.IsMatch(source, pattern, RegexOptions.IgnoreCase | RegexOptions.Multiline)) + { + error = $"source contains a blocked API or lifecycle pattern: {pattern}"; + return false; + } + } + + var absolutePath = ResolveAssetPath(path); + if (!input.overwrite && File.Exists(absolutePath)) + { + error = "Target script already exists and overwrite=false."; + return false; + } + + if (!string.IsNullOrWhiteSpace(input.attachToObjectPath) + && string.IsNullOrEmpty(NormalizeHierarchyPath(input.attachToObjectPath))) + { + error = "attachToObjectPath is invalid."; + return false; + } + + if (!input.dryRun) + { + var expectedHash = (input.expectedSourceSha256 ?? string.Empty).Trim().ToLowerInvariant(); + if (expectedHash.Length != 64 || expectedHash.Any(character => !Uri.IsHexDigit(character))) + { + error = "Real script authoring requires expectedSourceSha256 from the dry-run validation."; + return false; + } + + if (!string.Equals(expectedHash, sourceSha256, StringComparison.Ordinal)) + { + error = "expectedSourceSha256 does not match the exact normalized source being written."; + return false; + } + } + + error = string.Empty; + return true; + } + + private static int CountTargetCompilerErrors(string path) + { + var normalizedPath = NormalizeAssetPath(path); + return ConsoleLogBridge.Diagnose().diagnostics.Count(diagnostic => + diagnostic.severity == "error" + && diagnostic.category == "compiler_error" + && IsSameAssetPath(diagnostic.file, normalizedPath)); + } + + private static bool IsSameAssetPath(string diagnosticPath, string assetPath) + { + var normalizedDiagnostic = NormalizeAssetPath(diagnosticPath); + if (string.Equals(normalizedDiagnostic, assetPath, StringComparison.Ordinal)) + { + return true; + } + + try + { + return string.Equals( + Path.GetFullPath(normalizedDiagnostic), + ResolveAssetPath(assetPath), + Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + catch + { + return false; + } + } + + private static bool IsTimedOut(UnityAiJobRecord job, int timeoutSeconds) + { + return DateTime.TryParse(job.createdAtUtc, out var created) + && DateTime.UtcNow > created.ToUniversalTime().AddSeconds(Math.Max(10, Math.Min(timeoutSeconds, 1800))); + } + + private static void OnCompilationStarted(object context) + { + foreach (var job in UnityAiJobStore.List("running", JobKind, 100)) + { + StableFrames[job.jobId] = 0; + UnityAiJobStore.UpdateProgress(job.jobId, "compiling", "Unity compilation started for the generated runtime component.", 0.5f); + } + } + + private static bool PersistAudit(UnityAiAuditEvent auditEvent) + { + try + { + AuditLogStore.Append(new[] { auditEvent }); + return true; + } + catch (Exception exception) + { + Debug.LogError($"Failed to persist script authoring audit event: {exception.Message}"); + return false; + } + } + + private static string[] ValidationRules() + { + return new[] + { + "Runtime MonoBehaviour only; filename must match class name.", + "Exact source hash confirmation is required before writing.", + "Editor APIs, edit-time callbacks, process, network, file, reflection, native interop, and unsafe code are blocked.", + "A durable checkpoint is created before writing.", + "Compilation and optional component attachment are verified by a persistent job.", + "Compilation or attachment failure restores the checkpoint by default." + }; + } + + private static bool IsSafeScriptPath(string path) + { + return !string.IsNullOrWhiteSpace(path) + && path.StartsWith("Assets/", StringComparison.Ordinal) + && path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) + && !path.Contains("..") + && !Path.IsPathRooted(path) + && path.Length <= 512; + } + + private static bool IsIdentifier(string value) + { + return !string.IsNullOrWhiteSpace(value) + && Regex.IsMatch(value, @"^[_\p{L}][_\p{L}\p{Nd}]*$"); + } + + private static string NormalizeSource(string value) + { + return (value ?? string.Empty).Replace("\r\n", "\n").Replace('\r', '\n'); + } + + private static string NormalizeAssetPath(string value) + { + return (value ?? string.Empty).Trim().Replace('\\', '/'); + } + + private static string NormalizeHierarchyPath(string value) + { + var path = (value ?? string.Empty).Trim().Replace('\\', '/').Trim('/'); + return path.Length > 0 && path.Length <= 512 && !path.Contains("..") && !path.Contains("//") + ? path + : string.Empty; + } + + private static string ResolveAssetPath(string path) + { + var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; + var absolute = Path.GetFullPath(Path.Combine(projectRoot, NormalizeAssetPath(path).Replace('/', Path.DirectorySeparatorChar))); + var assetsRoot = Path.GetFullPath(Application.dataPath).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + var comparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!absolute.StartsWith(assetsRoot, comparison)) + { + throw new InvalidOperationException("Script path escapes Assets."); + } + + return absolute; + } + + private static string ComputeSha256(string value) + { + using var hash = SHA256.Create(); + return string.Concat(hash.ComputeHash(Encoding.UTF8.GetBytes(value)).Select(item => item.ToString("x2"))); + } + + private static GameObject FindByPath(string path) + { + var normalized = NormalizeHierarchyPath(path); + if (string.IsNullOrEmpty(normalized)) + { + return null; + } + + foreach (var root in EditorSceneManager.GetActiveScene().GetRootGameObjects()) + { + if (root.name == normalized) + { + return root; + } + + if (normalized.StartsWith(root.name + "/", StringComparison.Ordinal)) + { + var child = root.transform.Find(normalized.Substring(root.name.Length + 1)); + if (child != null) + { + return child.gameObject; + } + } + } + + return null; + } + + private static string GetGameObjectPath(GameObject gameObject) + { + var names = new List(); + var current = gameObject.transform; + while (current != null) + { + names.Add(current.name); + current = current.parent; + } + + names.Reverse(); + return string.Join("/", names); + } + + private static ScriptAuthoringRequest ParseRequest(string body) + { + try + { + return string.IsNullOrWhiteSpace(body) + ? new ScriptAuthoringRequest() + : JsonUtility.FromJson(body) ?? new ScriptAuthoringRequest(); + } + catch + { + return new ScriptAuthoringRequest(); + } + } + } +} diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Scripts/ScriptAuthoringOperation.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Scripts/ScriptAuthoringOperation.cs.meta new file mode 100644 index 0000000..48b946e --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Scripts/ScriptAuthoringOperation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ef34fd2a56a0e448684b68bd9cd4a44d \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/TestRunner.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/TestRunner.meta new file mode 100644 index 0000000..929987f --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/TestRunner.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b5f1a9166724a4945bd0120b7fd0e678 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/TestRunner/Unity.AI.ControlPlane.Editor.TestRunner.asmdef.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/TestRunner/Unity.AI.ControlPlane.Editor.TestRunner.asmdef.meta new file mode 100644 index 0000000..5824729 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/TestRunner/Unity.AI.ControlPlane.Editor.TestRunner.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 434b21edac5a44a7c93d60114c4646bc +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/TestRunner/UnityAiTestRunnerAdapter.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/TestRunner/UnityAiTestRunnerAdapter.cs index c1643f6..26c2958 100644 --- a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/TestRunner/UnityAiTestRunnerAdapter.cs +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/TestRunner/UnityAiTestRunnerAdapter.cs @@ -30,6 +30,9 @@ public sealed class TestRunResult public double durationSeconds; public string resultState; public string resultPath; + public int requestedInputEventCount; + public int executedInputActionCount; + public string inputSimulationError; public TestFailureInfo[] failures = Array.Empty(); public string completedAtUtc; } @@ -154,14 +157,21 @@ public void RunFinished(ITestResultAdaptor result) failures = failures.ToArray(), completedAtUtc = DateTime.UtcNow.ToString("O") }; + var inputReport = InputSimulationScheduler.Finish(_jobId); + summary.requestedInputEventCount = inputReport.requestedEventCount; + summary.executedInputActionCount = inputReport.executedActionCount; + summary.inputSimulationError = inputReport.error; - if (result.FailCount == 0) + if (result.FailCount == 0 && string.IsNullOrWhiteSpace(summary.inputSimulationError)) { UnityAiJobStore.Complete(_jobId, summary, $"{_mode} mode tests passed.", "tests_passed", "test_results_available"); } else { - UnityAiJobStore.Fail(_jobId, $"{result.FailCount} test(s) failed.", summary, "test_results_available"); + var message = !string.IsNullOrWhiteSpace(summary.inputSimulationError) + ? $"Input simulation failed: {summary.inputSimulationError}" + : $"{result.FailCount} test(s) failed."; + UnityAiJobStore.Fail(_jobId, message, summary, "test_results_available"); } } catch (Exception exception) @@ -170,6 +180,7 @@ public void RunFinished(ITestResultAdaptor result) } finally { + InputSimulationScheduler.Cancel(_jobId); _api.UnregisterCallbacks(this); UnityEngine.Object.DestroyImmediate(_api); Active.Remove(_jobId); @@ -178,11 +189,20 @@ public void RunFinished(ITestResultAdaptor result) public void TestStarted(ITestAdaptor test) { + if (!test.IsSuite) + { + InputSimulationScheduler.ActivateForTest(_jobId, test.FullName); + } + UnityAiJobStore.UpdateProgress(_jobId, "running", $"Running {test.FullName}.", 0.5f); } public void TestFinished(ITestResultAdaptor result) { + if (!result.HasChildren) + { + InputSimulationScheduler.CompleteTest(_jobId, result.FullName); + } } private static void CollectFailures(ITestResultAdaptor result, List output, int limit) diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/TestRunner/UnityAiTestRunnerAdapter.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/TestRunner/UnityAiTestRunnerAdapter.cs.meta new file mode 100644 index 0000000..a86928e --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/TestRunner/UnityAiTestRunnerAdapter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c182ad384909e4cde9c35775c41264e7 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests.meta new file mode 100644 index 0000000..870d379 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 19ebebcbca52a4fe08607bc0dc63a421 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests/InputSimulationScheduler.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests/InputSimulationScheduler.cs new file mode 100644 index 0000000..8aaffbf --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests/InputSimulationScheduler.cs @@ -0,0 +1,462 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using UnityEditor; +using UnityEngine; + +namespace UnityAI.ControlPlane.Editor +{ + [Serializable] + public sealed class InputSimulationEventInput + { + public string valueType; + public int frameOffset; + public string targetTest; + public string device; + public string control; + public string action; + public int durationFrames = 1; + public float value; + public float x; + public float y; + } + + [Serializable] + public sealed class InputSimulationAction + { + public string valueType; + public int frameOffset; + public string device; + public string control; + public string action; + public float value; + public float x; + public float y; + public bool executed; + } + + [Serializable] + public sealed class InputSimulationSchedule + { + public string jobId; + public int startDelayFrames; + public int requestedEventCount; + public int executedActionCount; + public string activeTest; + public int activationFrame; + public string error; + public InputSimulationEventInput[] sourceEvents = Array.Empty(); + public InputSimulationAction[] pendingActions = Array.Empty(); + } + + [Serializable] + public sealed class InputSimulationReport + { + public int requestedEventCount; + public int executedActionCount; + public string error; + } + + [InitializeOnLoad] + public static class InputSimulationScheduler + { + private const string BackendTypeName = "UnityAI.ControlPlane.Editor.InputSystemSupport.InputSystemSimulationBackend, Unity.AI.ControlPlane.Editor.InputSystem"; + private static readonly Dictionary Schedules = new(); + private static Type _backendType; + + static InputSimulationScheduler() + { + LoadSchedules(); + EditorApplication.update -= Tick; + EditorApplication.update += Tick; + EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + } + + public static bool IsAvailable() + { + var backend = ResolveBackendType(); + var method = backend?.GetMethod("IsAvailable", BindingFlags.Public | BindingFlags.Static); + return method != null && method.Invoke(null, Array.Empty()) is bool available && available; + } + + public static bool Validate(InputSimulationEventInput input, out string error) + { + if (input == null) + { + error = "event cannot be null."; + return false; + } + + var valueType = Normalize(input.valueType); + var device = Normalize(input.device); + var action = Normalize(input.action); + if (valueType != "button" && valueType != "axis" && valueType != "vector2") + { + error = "valueType must be button, axis, or vector2."; + return false; + } + + if (device != "keyboard" && device != "mouse" && device != "gamepad") + { + error = "device must be keyboard, mouse, or gamepad."; + return false; + } + + if (string.IsNullOrWhiteSpace(input.control) + || input.control.Length > 128 + || input.control.Any(char.IsControl)) + { + error = "control must be a bounded Input System control path."; + return false; + } + + if (input.frameOffset < 0 || input.frameOffset > 100000) + { + error = "frameOffset must be between 0 and 100000."; + return false; + } + + if (!string.IsNullOrWhiteSpace(input.targetTest) && input.targetTest.Length > 512) + { + error = "targetTest cannot exceed 512 characters."; + return false; + } + + if (valueType == "button") + { + if (action != "press" && action != "release") + { + error = "button events require action press or release."; + return false; + } + + if (input.durationFrames < 1 || input.durationFrames > 100000) + { + error = "durationFrames must be between 1 and 100000."; + return false; + } + } + else + { + if (action != "set") + { + error = "axis and vector2 events require action set."; + return false; + } + + if (device == "keyboard") + { + error = "keyboard events support button controls only."; + return false; + } + } + + error = string.Empty; + return true; + } + + public static void Register(string jobId, InputSimulationEventInput[] events, int startDelayFrames) + { + var schedule = new InputSimulationSchedule + { + jobId = jobId, + startDelayFrames = Math.Max(0, Math.Min(startDelayFrames, 10000)), + requestedEventCount = events?.Length ?? 0, + sourceEvents = events?.Select(CloneEvent).ToArray() ?? Array.Empty() + }; + Schedules[jobId] = schedule; + Save(schedule); + } + + public static void ActivateForTest(string jobId, string testName) + { + if (!EditorApplication.isPlaying || !Schedules.TryGetValue(jobId, out var schedule)) + { + return; + } + + var selected = schedule.sourceEvents + .Where(input => string.IsNullOrWhiteSpace(input.targetTest) + || string.Equals(input.targetTest.Trim(), testName, StringComparison.Ordinal)) + .ToArray(); + var actions = new List(); + foreach (var input in selected) + { + var action = ToAction(input, schedule.startDelayFrames); + actions.Add(action); + if (Normalize(input.valueType) == "button" && Normalize(input.action) == "press") + { + var release = ToAction(input, schedule.startDelayFrames); + release.action = "release"; + release.value = 0f; + release.frameOffset += Math.Max(1, input.durationFrames); + actions.Add(release); + } + } + + schedule.activeTest = testName ?? string.Empty; + schedule.activationFrame = Time.frameCount; + schedule.pendingActions = actions.OrderBy(action => action.frameOffset).ToArray(); + Save(schedule); + } + + public static void CompleteTest(string jobId, string testName) + { + if (!Schedules.TryGetValue(jobId, out var schedule) + || !string.Equals(schedule.activeTest, testName, StringComparison.Ordinal)) + { + return; + } + + var resetError = InvokeBackend("Reset"); + if (string.IsNullOrWhiteSpace(schedule.error) && !string.IsNullOrWhiteSpace(resetError)) + { + schedule.error = resetError; + } + + schedule.activeTest = string.Empty; + schedule.pendingActions = Array.Empty(); + Save(schedule); + } + + public static InputSimulationReport Finish(string jobId) + { + if (!Schedules.TryGetValue(jobId, out var schedule)) + { + return new InputSimulationReport(); + } + + var resetError = InvokeBackend("Reset"); + if (string.IsNullOrWhiteSpace(schedule.error) && !string.IsNullOrWhiteSpace(resetError)) + { + schedule.error = resetError; + } + + var report = new InputSimulationReport + { + requestedEventCount = schedule.requestedEventCount, + executedActionCount = schedule.executedActionCount, + error = schedule.error ?? string.Empty + }; + Remove(jobId); + return report; + } + + public static void Cancel(string jobId) + { + if (Schedules.ContainsKey(jobId)) + { + InvokeBackend("Reset"); + Remove(jobId); + } + } + + private static void Tick() + { + if (!EditorApplication.isPlaying) + { + return; + } + + foreach (var schedule in Schedules.Values.ToArray()) + { + if (string.IsNullOrWhiteSpace(schedule.activeTest) + || schedule.pendingActions == null + || schedule.pendingActions.Length == 0 + || !string.IsNullOrWhiteSpace(schedule.error)) + { + continue; + } + + var elapsedFrames = Math.Max(0, Time.frameCount - schedule.activationFrame); + var changed = false; + foreach (var action in schedule.pendingActions) + { + if (action.executed || action.frameOffset > elapsedFrames) + { + continue; + } + + var error = InvokeBackend( + "Queue", + action.device, + action.control, + action.valueType, + action.value, + action.x, + action.y); + if (!string.IsNullOrWhiteSpace(error)) + { + schedule.error = error; + changed = true; + break; + } + + action.executed = true; + schedule.executedActionCount++; + changed = true; + } + + if (changed) + { + Save(schedule); + } + } + } + + private static void OnPlayModeStateChanged(PlayModeStateChange state) + { + if (state != PlayModeStateChange.ExitingPlayMode) + { + return; + } + + var resetError = InvokeBackend("Reset"); + foreach (var schedule in Schedules.Values) + { + if (string.IsNullOrWhiteSpace(schedule.error) && !string.IsNullOrWhiteSpace(resetError)) + { + schedule.error = resetError; + } + + schedule.activeTest = string.Empty; + schedule.pendingActions = Array.Empty(); + Save(schedule); + } + } + + private static InputSimulationEventInput CloneEvent(InputSimulationEventInput input) + { + return new InputSimulationEventInput + { + valueType = Normalize(input.valueType), + frameOffset = input.frameOffset, + targetTest = input.targetTest?.Trim() ?? string.Empty, + device = Normalize(input.device), + control = input.control?.Trim() ?? string.Empty, + action = Normalize(input.action), + durationFrames = input.durationFrames, + value = input.value, + x = input.x, + y = input.y + }; + } + + private static InputSimulationAction ToAction(InputSimulationEventInput input, int startDelayFrames) + { + var action = Normalize(input.action); + return new InputSimulationAction + { + valueType = Normalize(input.valueType), + frameOffset = checked(input.frameOffset + startDelayFrames), + device = Normalize(input.device), + control = input.control?.Trim() ?? string.Empty, + action = action, + value = action == "press" ? 1f : action == "release" ? 0f : input.value, + x = input.x, + y = input.y + }; + } + + private static string InvokeBackend(string methodName, params object[] arguments) + { + try + { + var backend = ResolveBackendType(); + var method = backend?.GetMethod(methodName, BindingFlags.Public | BindingFlags.Static); + if (method == null) + { + return "New Input System simulation backend is unavailable."; + } + + return method.Invoke(null, arguments) as string ?? string.Empty; + } + catch (Exception exception) + { + return exception.GetBaseException().Message; + } + } + + private static Type ResolveBackendType() + { + if (_backendType != null) + { + return _backendType; + } + + _backendType = Type.GetType(BackendTypeName, false); + if (_backendType != null) + { + return _backendType; + } + + try + { + var assembly = Assembly.Load("Unity.AI.ControlPlane.Editor.InputSystem"); + _backendType = assembly.GetType("UnityAI.ControlPlane.Editor.InputSystemSupport.InputSystemSimulationBackend", false); + } + catch + { + _backendType = null; + } + + return _backendType; + } + + private static void LoadSchedules() + { + var directory = GetScheduleDirectory(); + if (!Directory.Exists(directory)) + { + return; + } + + foreach (var path in Directory.GetFiles(directory, "*.json")) + { + try + { + var schedule = JsonUtility.FromJson(File.ReadAllText(path)); + if (schedule != null && !string.IsNullOrWhiteSpace(schedule.jobId)) + { + Schedules[schedule.jobId] = schedule; + } + } + catch + { + // Ignore malformed stale schedules; they cannot safely drive input. + } + } + } + + private static void Save(InputSimulationSchedule schedule) + { + var directory = GetScheduleDirectory(); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, schedule.jobId + ".json"), JsonUtility.ToJson(schedule, true)); + } + + private static void Remove(string jobId) + { + Schedules.Remove(jobId); + var path = Path.Combine(GetScheduleDirectory(), jobId + ".json"); + if (File.Exists(path)) + { + File.Delete(path); + } + } + + private static string GetScheduleDirectory() + { + var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; + return Path.Combine(projectRoot, "Library", "UnityAIControlPlane", "InputSchedules"); + } + + private static string Normalize(string value) + { + return (value ?? string.Empty).Trim().ToLowerInvariant(); + } + } +} diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests/InputSimulationScheduler.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests/InputSimulationScheduler.cs.meta new file mode 100644 index 0000000..130c6a9 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests/InputSimulationScheduler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5683df2b89554d0abb9a4941fb94be12 diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests/TestOperation.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests/TestOperation.cs index 9c2e563..af77e6c 100644 --- a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests/TestOperation.cs +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests/TestOperation.cs @@ -25,6 +25,8 @@ public sealed class TestRunInput public string[] assemblyNames = Array.Empty(); public bool runSynchronously; public bool saveModifiedScenes; + public int inputStartDelayFrames = 1; + public InputSimulationEventInput[] inputEvents = Array.Empty(); } public static class TestOperation @@ -41,6 +43,11 @@ public static UnityAiJobStartResult Start(string requestBody) return Rejected(input.dryRun, "mode must be edit or play."); } + if (!ValidateInputSimulation(input, mode, out var inputError)) + { + return Rejected(input.dryRun, inputError); + } + if (input.dryRun) { return new UnityAiJobStartResult @@ -100,15 +107,22 @@ public static UnityAiJobStartResult Start(string requestBody) var job = UnityAiJobStore.Create(Capability, "tests", envelope, requestBody, $"Queued {mode} mode test run."); try { + if (input.inputEvents.Length > 0) + { + InputSimulationScheduler.Register(job.jobId, input.inputEvents, input.inputStartDelayFrames); + } + var started = startMethod.Invoke(null, new object[] { job.jobId, requestBody }) is bool result && result; if (!started) { + InputSimulationScheduler.Cancel(job.jobId); UnityAiJobStore.Fail(job.jobId, "Unity Test Framework rejected the test run."); return Rejected(false, "Unity Test Framework rejected the test run."); } } catch (Exception exception) { + InputSimulationScheduler.Cancel(job.jobId); UnityAiJobStore.Fail(job.jobId, exception.GetBaseException().Message); return Rejected(false, exception.GetBaseException().Message); } @@ -126,11 +140,54 @@ public static UnityAiJobStartResult Start(string requestBody) public static bool Cancel(string jobId) { + InputSimulationScheduler.Cancel(jobId); var adapterType = Type.GetType(AdapterTypeName, false); var method = adapterType?.GetMethod("Cancel", BindingFlags.Public | BindingFlags.Static); return method != null && method.Invoke(null, new object[] { jobId }) is bool result && result; } + private static bool ValidateInputSimulation(TestRunInput input, string mode, out string error) + { + var events = input.inputEvents ?? Array.Empty(); + input.inputEvents = events; + if (events.Length == 0) + { + error = string.Empty; + return true; + } + + if (mode != "play") + { + error = "inputEvents are supported only for Play Mode tests."; + return false; + } + + if (events.Length > 500) + { + error = "inputEvents cannot contain more than 500 events."; + return false; + } + + if (!InputSimulationScheduler.IsAvailable()) + { + error = "New Input System simulation is unavailable. Install com.unity.inputsystem and recompile."; + return false; + } + + input.inputStartDelayFrames = Math.Max(0, Math.Min(input.inputStartDelayFrames, 10000)); + for (var index = 0; index < events.Length; index++) + { + if (!InputSimulationScheduler.Validate(events[index], out error)) + { + error = $"inputEvents[{index}]: {error}"; + return false; + } + } + + error = string.Empty; + return true; + } + private static UnityAiJobStartResult Rejected(bool dryRun, string message) { return new UnityAiJobStartResult diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests/TestOperation.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests/TestOperation.cs.meta new file mode 100644 index 0000000..ce35c05 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests/TestOperation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e7fd58180a5dd484886c9c180d62f78c \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/UI.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/UI.meta new file mode 100644 index 0000000..077b356 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/UI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e9d7a82b08b8462194c78cc42c8df7d2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/UI/UiComposeOperation.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/UI/UiComposeOperation.cs new file mode 100644 index 0000000..23c930d --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/UI/UiComposeOperation.cs @@ -0,0 +1,1316 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityAI.ControlPlane.Runtime; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +namespace UnityAI.ControlPlane.Editor +{ + [Serializable] + public sealed class UiThemeInput + { + public string background = "#0F172AF2"; + public string panel = "#111827E6"; + public string primary = "#2563EBFF"; + public string secondary = "#334155FF"; + public string accent = "#F59E0BFF"; + public string text = "#F8FAFCFF"; + public string mutedText = "#CBD5E1FF"; + public string danger = "#DC2626FF"; + public int baseFontSize = 28; + public int titleFontSize = 72; + public int buttonFontSize = 30; + public float safeAreaMargin = 80f; + } + + [Serializable] + public sealed class UiButtonInput + { + public string name; + public string text; + public string actionId; + public string intent; + public string variant = "primary"; + } + + [Serializable] + public sealed class UiStatInput + { + public string name; + public string label; + public string value; + } + + [Serializable] + public sealed class UiLabelInput + { + public string name; + public string text; + public string slot = "body"; + public int fontSize; + } + + [Serializable] + public sealed class UiComposeInput + { + public bool dryRun = true; + public bool confirm; + public string mode = "upsert"; + public string template = "main_menu"; + public string canvasName = "Unity AI UI Canvas"; + public string screenName = "Unity AI Screen"; + public string screenId = "unity-ai-screen"; + public string title = "New Screen"; + public string subtitle = string.Empty; + public string body = string.Empty; + public int referenceWidth = 1920; + public int referenceHeight = 1080; + public float matchWidthOrHeight = 0.5f; + public bool ensureEventSystem = true; + public bool createActionMarkers = true; + public bool enforceReadableContrast = true; + public UiThemeInput theme = new(); + public UiButtonInput[] buttons = Array.Empty(); + public UiStatInput[] stats = Array.Empty(); + public UiLabelInput[] labels = Array.Empty(); + } + + [Serializable] + public sealed class UiComposeRequest + { + public UiComposeInput input = new(); + } + + [Serializable] + public sealed class UiAuditInput + { + public string pathPrefix = string.Empty; + public bool includeInactive = true; + public int maxElements = 200; + public int maxFindings = 200; + public float minContrastRatio = 4.5f; + } + + [Serializable] + public sealed class UiAuditRequest + { + public UiAuditInput input = new(); + } + + [Serializable] + public sealed class UiFinding + { + public string severity; + public string code; + public string path; + public string message; + public string fixHint; + } + + [Serializable] + public sealed class UiCanvasInfo + { + public string path; + public string renderMode; + public bool hasCanvasScaler; + public string canvasScalerMode; + public int referenceWidth; + public int referenceHeight; + public bool hasGraphicRaycaster; + public int uiElementCount; + } + + [Serializable] + public sealed class UiElementInfo + { + public string path; + public string kind; + public string text; + public string actionId; + public bool activeInHierarchy; + public bool interactable; + public float width; + public float height; + public string color; + } + + [Serializable] + public sealed class UiAuditReport + { + public string scenePath; + public string sceneName; + public int canvasCount; + public int eventSystemCount; + public int inputModuleCount; + public int uiElementCount; + public int interactiveElementCount; + public int textElementCount; + public int markerCount; + public int findingCount; + public int errorCount; + public int warningCount; + public int infoCount; + public int qualityScore; + public bool truncated; + public UiCanvasInfo[] canvases = Array.Empty(); + public UiElementInfo[] elements = Array.Empty(); + public UiFinding[] findings = Array.Empty(); + public string[] recommendedActions = Array.Empty(); + public string verificationStatus; + public string[] verificationSignals = Array.Empty(); + public string capturedAtUtc; + } + + [Serializable] + public sealed class UiComposeResult + { + public bool dryRun; + public bool applied; + public bool refused; + public bool rolledBack; + public bool requiresConfirmation; + public string requestId; + public string correlationId; + public string scenePath; + public string canvasPath; + public string screenPath; + public string checkpointId; + public string template; + public string mode; + public string message; + public string verificationStatus; + public string[] verificationSignals = Array.Empty(); + public string[] requiredPermissions = Array.Empty(); + public string[] warnings = Array.Empty(); + public UiAuditReport audit; + public UnityAiAuditEvent[] auditEvents = Array.Empty(); + public bool auditPersisted; + public string auditLogPath; + public string timestampUtc; + } + + public static class UiComposeOperation + { + private const string ComposeCapability = "unity.ui.compose"; + private const int MaxButtons = 12; + private const int MaxStats = 12; + private const int MaxLabels = 24; + private const int MaxNameLength = 80; + private const int MaxTextLength = 400; + + private static readonly HashSet AllowedTemplates = new(StringComparer.Ordinal) + { + "main_menu", + "pause_menu", + "hud", + "dialog", + "blank" + }; + + private static readonly HashSet AllowedModes = new(StringComparer.Ordinal) + { + "create", + "upsert", + "replace" + }; + + public static UiComposeResult Compose(string requestBody) + { + var request = ParseComposeRequest(requestBody); + var input = NormalizeInput(request.input ?? new UiComposeInput()); + var envelope = UnityAiJobStore.ParseEnvelope(requestBody); + var scene = EditorSceneManager.GetActiveScene(); + var warnings = new List(); + + if (!scene.IsValid()) + { + return BuildComposeResult(envelope, input, false, true, false, false, string.Empty, string.Empty, string.Empty, "No valid active scene is available.", "refused", warnings, null); + } + + if (!ValidateInput(input, out var refusal)) + { + return BuildComposeResult(envelope, input, false, true, false, false, string.Empty, string.Empty, string.Empty, refusal, "refused", warnings, null); + } + + var canvasPath = input.canvasName; + var screenPath = canvasPath + "/" + input.screenName; + + if (input.dryRun) + { + warnings.AddRange(PreviewWarnings(input)); + var auditPreview = AuditInternal(new UiAuditInput { pathPrefix = canvasPath, includeInactive = true, maxElements = 100, maxFindings = 100, minContrastRatio = 4.5f }); + return BuildComposeResult(envelope, input, false, false, false, false, canvasPath, screenPath, string.Empty, $"DRY RUN: validated UI {input.template} screen '{screenPath}'.", "passed", warnings, auditPreview); + } + + if (!input.confirm) + { + return BuildComposeResult(envelope, input, false, false, false, true, canvasPath, screenPath, string.Empty, $"CONFIRMATION REQUIRED: would compose UI screen '{screenPath}'.", "needs_confirmation", warnings, null); + } + + var checkpointId = string.Empty; + if (!string.IsNullOrWhiteSpace(scene.path)) + { + checkpointId = DurableCheckpointStore.CreateInternal("ui-compose", new[] { scene.path, scene.path + ".meta" }).checkpointId; + } + else + { + warnings.Add("Active scene is unsaved; Unity Undo rollback is available, but no durable scene checkpoint was created."); + } + + Undo.IncrementCurrentGroup(); + var undoGroup = Undo.GetCurrentGroup(); + Undo.SetCurrentGroupName("Unity AI Compose UI"); + + try + { + var canvas = EnsureCanvas(input); + var existingScreen = FindChild(canvas.gameObject, input.screenName); + if (input.mode == "create" && existingScreen != null) + { + throw new InvalidOperationException($"UI screen '{screenPath}' already exists and mode=create was requested."); + } + + if (existingScreen != null && input.mode == "replace") + { + Undo.DestroyObjectImmediate(existingScreen); + existingScreen = null; + } + + var screen = existingScreen != null + ? existingScreen + : CreateUiObject(input.screenName, canvas.transform); + screen.layer = ResolveUiLayer(); + ClearChildren(screen.transform); + ConfigureScreen(screen, input); + + if (input.ensureEventSystem && HasInteractiveContent(input)) + { + EnsureEventSystem(warnings); + } + + EditorSceneManager.MarkSceneDirty(scene); + var audit = AuditInternal(new UiAuditInput + { + pathPrefix = GetGameObjectPath(screen), + includeInactive = true, + maxElements = 200, + maxFindings = 200, + minContrastRatio = 4.5f + }); + + if (audit.errorCount > 0) + { + Undo.RevertAllDownToGroup(undoGroup); + return BuildComposeResult(envelope, input, false, false, true, false, canvasPath, screenPath, checkpointId, "UI composition failed quality gate and was rolled back.", "failed", warnings, audit); + } + + Undo.CollapseUndoOperations(undoGroup); + return BuildComposeResult(envelope, input, true, false, false, false, GetGameObjectPath(canvas.gameObject), GetGameObjectPath(screen), checkpointId, $"Composed and verified UI {input.template} screen '{GetGameObjectPath(screen)}'.", audit.warningCount > 0 ? "warning" : "passed", warnings, audit); + } + catch (Exception exception) + { + Undo.RevertAllDownToGroup(undoGroup); + return BuildComposeResult(envelope, input, false, false, true, false, canvasPath, screenPath, checkpointId, $"UI composition failed and was rolled back: {exception.GetBaseException().Message}", "failed", warnings, null); + } + } + + public static UiAuditReport Audit(string requestBody) + { + return AuditInternal(ParseAuditRequest(requestBody).input ?? new UiAuditInput()); + } + + private static void ConfigureScreen(GameObject screen, UiComposeInput input) + { + var theme = input.theme ?? new UiThemeInput(); + var background = ParseColor(theme.background, "#0F172AF2"); + var panel = ParseColor(theme.panel, "#111827E6"); + var primary = ParseColor(theme.primary, "#2563EBFF"); + var secondary = ParseColor(theme.secondary, "#334155FF"); + var accent = ParseColor(theme.accent, "#F59E0BFF"); + var text = ParseColor(theme.text, "#F8FAFCFF"); + var muted = ParseColor(theme.mutedText, "#CBD5E1FF"); + var danger = ParseColor(theme.danger, "#DC2626FF"); + if (input.enforceReadableContrast) + { + text = EnsureReadable(text, panel, 4.5f); + muted = EnsureReadable(muted, panel, 3f); + } + + var screenRect = screen.GetComponent(); + Stretch(screenRect, Vector2.zero, Vector2.one, Vector2.zero, Vector2.zero); + var screenImage = GetOrAddComponent(screen); + screenImage.color = background; + screenImage.raycastTarget = true; + + var marker = GetOrAddComponent(screen); + marker.screenId = input.screenId; + marker.template = input.template; + marker.title = input.title; + marker.qualityProfile = "production"; + EditorUtility.SetDirty(marker); + + var safeArea = CreateUiObject("SafeArea", screen.transform); + Stretch(safeArea.GetComponent(), Vector2.zero, Vector2.one, new Vector2(theme.safeAreaMargin, theme.safeAreaMargin), new Vector2(-theme.safeAreaMargin, -theme.safeAreaMargin)); + + switch (input.template) + { + case "hud": + BuildHud(safeArea.transform, input, text, muted, panel, accent); + break; + case "dialog": + BuildDialog(safeArea.transform, input, text, muted, panel, primary, secondary, danger); + break; + case "pause_menu": + BuildMenu(safeArea.transform, input, text, muted, panel, primary, secondary, danger, true); + break; + case "blank": + BuildBlank(safeArea.transform, input, text, muted, panel); + break; + default: + BuildMenu(safeArea.transform, input, text, muted, panel, primary, secondary, danger, false); + break; + } + } + + private static void BuildMenu(Transform parent, UiComposeInput input, Color text, Color muted, Color panel, Color primary, Color secondary, Color danger, bool pause) + { + var card = CreatePanel("MenuCard", parent, panel, new Vector2(0.5f, 0.5f), new Vector2(900, pause ? 680 : 760), new Vector2(0, 0)); + var layout = GetOrAddComponent(card); + layout.childAlignment = TextAnchor.MiddleCenter; + layout.spacing = 24; + layout.padding = new RectOffset(64, 64, 56, 56); + layout.childControlWidth = true; + layout.childForceExpandWidth = true; + layout.childForceExpandHeight = false; + + CreateText("Title", card.transform, NonEmpty(input.title, pause ? "Paused" : "Main Menu"), input.theme.titleFontSize, FontStyle.Bold, text, TextAnchor.MiddleCenter, 110); + if (!string.IsNullOrWhiteSpace(input.subtitle)) + { + CreateText("Subtitle", card.transform, TrimText(input.subtitle), input.theme.baseFontSize, FontStyle.Normal, muted, TextAnchor.MiddleCenter, 70); + } + + var buttons = NormalizeButtons(input, pause ? DefaultPauseButtons() : DefaultMenuButtons()); + foreach (var button in buttons) + { + CreateButton(button, card.transform, input, ButtonColor(button, primary, secondary, danger), text); + } + + CreateExtraLabels(card.transform, input, muted); + } + + private static void BuildHud(Transform parent, UiComposeInput input, Color text, Color muted, Color panel, Color accent) + { + var topBar = CreatePanel("TopBar", parent, panel, new Vector2(0.5f, 1f), new Vector2(0, 96), new Vector2(0, -48)); + Stretch(topBar.GetComponent(), new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, -96), Vector2.zero); + var horizontal = GetOrAddComponent(topBar); + horizontal.childAlignment = TextAnchor.MiddleCenter; + horizontal.spacing = 32; + horizontal.padding = new RectOffset(32, 32, 8, 8); + horizontal.childControlWidth = true; + horizontal.childForceExpandWidth = false; + + var title = CreateText("HudTitle", topBar.transform, NonEmpty(input.title, "HUD"), input.theme.baseFontSize, FontStyle.Bold, text, TextAnchor.MiddleLeft, 80); + GetOrAddComponent(title).preferredWidth = 420; + + var stats = NormalizeStats(input); + foreach (var stat in stats) + { + var statText = CreateText(SafeName(NonEmpty(stat.name, stat.label), "Stat"), topBar.transform, $"{TrimText(stat.label)}: {TrimText(stat.value)}", input.theme.baseFontSize - 4, FontStyle.Bold, accent, TextAnchor.MiddleCenter, 80); + GetOrAddComponent(statText).preferredWidth = 260; + } + + if (!string.IsNullOrWhiteSpace(input.body)) + { + var objective = CreatePanel("ObjectivePanel", parent, panel, new Vector2(0.5f, 0f), new Vector2(920, 92), new Vector2(0, 70)); + CreateText("ObjectiveText", objective.transform, TrimText(input.body), input.theme.baseFontSize - 2, FontStyle.Normal, muted, TextAnchor.MiddleCenter, 70); + } + + CreateExtraLabels(parent, input, muted); + } + + private static void BuildDialog(Transform parent, UiComposeInput input, Color text, Color muted, Color panel, Color primary, Color secondary, Color danger) + { + var card = CreatePanel("DialogCard", parent, panel, new Vector2(0.5f, 0.5f), new Vector2(980, 560), Vector2.zero); + var layout = GetOrAddComponent(card); + layout.childAlignment = TextAnchor.MiddleCenter; + layout.spacing = 22; + layout.padding = new RectOffset(60, 60, 48, 48); + layout.childControlWidth = true; + layout.childForceExpandWidth = true; + layout.childForceExpandHeight = false; + + CreateText("DialogTitle", card.transform, NonEmpty(input.title, "Dialog"), input.theme.titleFontSize - 18, FontStyle.Bold, text, TextAnchor.MiddleCenter, 90); + CreateText("DialogBody", card.transform, NonEmpty(input.body, input.subtitle), input.theme.baseFontSize, FontStyle.Normal, muted, TextAnchor.MiddleCenter, 170); + + var row = CreateUiObject("Actions", card.transform); + var rowRect = row.GetComponent(); + rowRect.sizeDelta = new Vector2(860, 96); + var rowLayout = GetOrAddComponent(row); + rowLayout.spacing = 24; + rowLayout.childControlWidth = true; + rowLayout.childForceExpandWidth = true; + rowLayout.childControlHeight = true; + rowLayout.childForceExpandHeight = true; + + foreach (var button in NormalizeButtons(input, DefaultDialogButtons())) + { + CreateButton(button, row.transform, input, ButtonColor(button, primary, secondary, danger), text); + } + + CreateExtraLabels(card.transform, input, muted); + } + + private static void BuildBlank(Transform parent, UiComposeInput input, Color text, Color muted, Color panel) + { + var card = CreatePanel("ContentPanel", parent, panel, new Vector2(0.5f, 0.5f), new Vector2(1000, 640), Vector2.zero); + var layout = GetOrAddComponent(card); + layout.childAlignment = TextAnchor.MiddleCenter; + layout.spacing = 18; + layout.padding = new RectOffset(64, 64, 56, 56); + layout.childControlWidth = true; + layout.childForceExpandWidth = true; + layout.childForceExpandHeight = false; + CreateText("Title", card.transform, NonEmpty(input.title, "UI Screen"), input.theme.titleFontSize - 10, FontStyle.Bold, text, TextAnchor.MiddleCenter, 100); + if (!string.IsNullOrWhiteSpace(input.body) || !string.IsNullOrWhiteSpace(input.subtitle)) + { + CreateText("Body", card.transform, NonEmpty(input.body, input.subtitle), input.theme.baseFontSize, FontStyle.Normal, muted, TextAnchor.MiddleCenter, 240); + } + + CreateExtraLabels(card.transform, input, muted); + } + + private static GameObject CreatePanel(string name, Transform parent, Color color, Vector2 anchor, Vector2 size, Vector2 anchoredPosition) + { + var panel = CreateUiObject(name, parent); + var rect = panel.GetComponent(); + rect.anchorMin = anchor; + rect.anchorMax = anchor; + rect.pivot = anchor; + rect.sizeDelta = size; + rect.anchoredPosition = anchoredPosition; + var image = GetOrAddComponent(panel); + image.color = color; + image.raycastTarget = true; + return panel; + } + + private static GameObject CreateText(string name, Transform parent, string value, int fontSize, FontStyle style, Color color, TextAnchor alignment, float preferredHeight) + { + var textObject = CreateUiObject(name, parent); + var text = GetOrAddComponent(textObject); + text.text = TrimText(value); + text.font = Resources.GetBuiltinResource("Arial.ttf"); + text.fontSize = Mathf.Clamp(fontSize, 12, 160); + text.fontStyle = style; + text.alignment = alignment; + text.color = color; + text.horizontalOverflow = HorizontalWrapMode.Wrap; + text.verticalOverflow = VerticalWrapMode.Truncate; + text.raycastTarget = false; + var layout = GetOrAddComponent(textObject); + layout.preferredHeight = preferredHeight; + layout.minHeight = Mathf.Min(preferredHeight, 48); + return textObject; + } + + private static GameObject CreateButton(UiButtonInput input, Transform parent, UiComposeInput composeInput, Color background, Color textColor) + { + var buttonObject = CreateUiObject(SafeName(NonEmpty(input.name, input.text), "Button"), parent); + var image = GetOrAddComponent(buttonObject); + image.color = background; + image.raycastTarget = true; + + var button = GetOrAddComponent