From 421e4044a52b93d9c2e875e39be3ee8d9069d934 Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:12:45 -0500 Subject: [PATCH 01/28] feat(unity): expand content import and audit reporting --- .../Editor/Assets/AssetAuthoringOperation.cs | 243 ++++- .../Editor/Assets/AssetImportOperation.cs | 943 ++++++++++++++++++ .../Editor/Audit/AuditLogStore.cs | 37 + .../Editor/Audit/AuditReportGenerator.cs | 690 +++++++++++++ 4 files changed, 1912 insertions(+), 1 deletion(-) create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetImportOperation.cs create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit/AuditReportGenerator.cs 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/AssetImportOperation.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetImportOperation.cs new file mode 100644 index 0000000..8d3cfc1 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetImportOperation.cs @@ -0,0 +1,943 @@ +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 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 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"; + } + + [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 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 Capability = "unity.assets.import"; + 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" + }; + + public static AssetImportResult Execute(string requestBody) + { + 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, 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, + 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, + 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; + if (input.instantiate) + { + if (!(asset is GameObject model)) + { + throw new InvalidOperationException("instantiate=true requires a model asset whose main asset is a GameObject."); + } + + var instance = InstantiateModel(model, input); + objectPath = GetGameObjectPath(instance); + instantiated = true; + 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 = $"Imported '{destinationPath}' from {sourceKind}; instantiated={instantiated}; prefabCreated={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 (auditPersisted) + { + signals.Add("operation_audited"); + } + + 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, + 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 GameObject InstantiateModel(GameObject model, AssetImportInput input) + { + var instance = PrefabUtility.InstantiatePrefab(model) as GameObject; + if (instance == null) + { + instance = UnityEngine.Object.Instantiate(model); + } + + instance.name = string.IsNullOrWhiteSpace(input.objectName) + ? Path.GetFileNameWithoutExtension(input.destinationPath) + : RequireSafeName(input.objectName); + Undo.RegisterCreatedObjectUndo(instance, "Unity AI Import Asset"); + var parent = FindByPath(NormalizeHierarchyPath(input.parentPath)); + if (!string.IsNullOrWhiteSpace(input.parentPath) && parent == null) + { + throw new InvalidOperationException($"Parent GameObject '{input.parentPath}' was not found."); + } + + instance.transform.SetParent(parent != null ? parent.transform : null, false); + var transform = input.transform ?? new ImportedAssetTransformInput(); + instance.transform.localPosition = ToVector3(transform.position, Vector3.zero); + instance.transform.localEulerAngles = ToVector3(transform.rotationEuler, Vector3.zero); + instance.transform.localScale = ToVector3(transform.scale, Vector3.one); + return instance; + } + + 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, 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 (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; + } + + 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 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, + 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/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/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(); + } + } + } +} From f1b034c494c801835bece5fab3a9d81d700447e0 Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:12:50 -0500 Subject: [PATCH 02/28] feat(unity): add guarded runtime script authoring --- .../Scripts/ScriptAuthoringOperation.cs | 782 ++++++++++++++++++ .../Runtime/BobbingMotion.cs | 26 + .../Runtime/ContinuousRotation.cs | 20 + .../Runtime/PulseScale.cs | 24 + .../Unity.AI.ControlPlane.Runtime.asmdef | 14 + 5 files changed, 866 insertions(+) create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Scripts/ScriptAuthoringOperation.cs create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/BobbingMotion.cs create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ContinuousRotation.cs create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/PulseScale.cs create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/Unity.AI.ControlPlane.Runtime.asmdef 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/Runtime/BobbingMotion.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/BobbingMotion.cs new file mode 100644 index 0000000..95bc709 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/BobbingMotion.cs @@ -0,0 +1,26 @@ +using UnityEngine; + +namespace UnityAI.ControlPlane.Runtime +{ + public sealed class BobbingMotion : MonoBehaviour + { + public Vector3 axis = Vector3.up; + public float amplitude = 0.25f; + public float frequency = 1f; + public float phase; + + private Vector3 _origin; + + private void OnEnable() + { + _origin = transform.localPosition; + } + + private void Update() + { + var normalizedAxis = axis.sqrMagnitude > 0.000001f ? axis.normalized : Vector3.up; + var offset = Mathf.Sin((Time.time + phase) * Mathf.PI * 2f * frequency) * amplitude; + transform.localPosition = _origin + normalizedAxis * offset; + } + } +} diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ContinuousRotation.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ContinuousRotation.cs new file mode 100644 index 0000000..ab7deae --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ContinuousRotation.cs @@ -0,0 +1,20 @@ +using UnityEngine; + +namespace UnityAI.ControlPlane.Runtime +{ + public sealed class ContinuousRotation : MonoBehaviour + { + public Vector3 axis = Vector3.up; + public float degreesPerSecond = 45f; + public bool localSpace = true; + + private void Update() + { + var normalizedAxis = axis.sqrMagnitude > 0.000001f ? axis.normalized : Vector3.up; + transform.Rotate( + normalizedAxis, + degreesPerSecond * Time.deltaTime, + localSpace ? Space.Self : Space.World); + } + } +} diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/PulseScale.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/PulseScale.cs new file mode 100644 index 0000000..078eb21 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/PulseScale.cs @@ -0,0 +1,24 @@ +using UnityEngine; + +namespace UnityAI.ControlPlane.Runtime +{ + public sealed class PulseScale : MonoBehaviour + { + public Vector3 multiplier = Vector3.one * 0.1f; + public float frequency = 1f; + public float phase; + + private Vector3 _baseScale; + + private void OnEnable() + { + _baseScale = transform.localScale; + } + + private void Update() + { + var amount = Mathf.Sin((Time.time + phase) * Mathf.PI * 2f * frequency); + transform.localScale = _baseScale + Vector3.Scale(_baseScale, multiplier) * amount; + } + } +} diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/Unity.AI.ControlPlane.Runtime.asmdef b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/Unity.AI.ControlPlane.Runtime.asmdef new file mode 100644 index 0000000..f4ac1b6 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/Unity.AI.ControlPlane.Runtime.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Unity.AI.ControlPlane.Runtime", + "rootNamespace": "UnityAI.ControlPlane.Runtime", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} From 78ad88e4da4545ebf26284cf12444ed75a157267 Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:12:55 -0500 Subject: [PATCH 03/28] feat(unity): compose reusable gameplay templates --- .../Editor/Act/GameplayComposeOperation.cs | 779 ++++++++++++++++++ .../Unity.AI.ControlPlane.Editor.asmdef | 4 +- .../Runtime/ProximityActivator.cs | 93 +++ .../Runtime/ProximityDoor.cs | 66 ++ .../Runtime/ProximityInteractorResolver.cs | 53 ++ .../Runtime/ProximityPickup.cs | 82 ++ 6 files changed, 1076 insertions(+), 1 deletion(-) create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/GameplayComposeOperation.cs create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityActivator.cs create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityDoor.cs create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityInteractorResolver.cs create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityPickup.cs 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/Unity.AI.ControlPlane.Editor.asmdef b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Unity.AI.ControlPlane.Editor.asmdef index 2e395c0..173e0f9 100644 --- a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Unity.AI.ControlPlane.Editor.asmdef +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Unity.AI.ControlPlane.Editor.asmdef @@ -1,7 +1,9 @@ { "name": "Unity.AI.ControlPlane.Editor", "rootNamespace": "UnityAI.ControlPlane.Editor", - "references": [], + "references": [ + "Unity.AI.ControlPlane.Runtime" + ], "includePlatforms": [ "Editor" ], diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityActivator.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityActivator.cs new file mode 100644 index 0000000..0e96662 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityActivator.cs @@ -0,0 +1,93 @@ +using System; +using UnityEngine; + +namespace UnityAI.ControlPlane.Runtime +{ + public enum ProximityTargetAction + { + Activate, + Deactivate, + Toggle + } + + public sealed class ProximityActivator : MonoBehaviour + { + public Transform interactor; + public string interactorTag = "Player"; + public float activationDistance = 2f; + public GameObject[] targets = Array.Empty(); + public ProximityTargetAction action = ProximityTargetAction.Activate; + public bool oneShot = true; + public bool revertOnExit; + + private Transform _cachedInteractor; + private bool _inside; + private bool _triggered; + + public bool Triggered => _triggered; + + private void OnEnable() + { + _inside = false; + _triggered = false; + } + + private void Update() + { + var resolvedInteractor = ProximityInteractorResolver.Resolve( + interactor, + interactorTag, + transform.position, + ref _cachedInteractor); + var inside = resolvedInteractor != null + && Vector3.Distance(transform.position, resolvedInteractor.position) <= Mathf.Max(0.01f, activationDistance); + + if (inside && !_inside && (!_triggered || !oneShot)) + { + Apply(action); + _triggered = true; + } + else if (!inside && _inside && revertOnExit && !oneShot) + { + Apply(Inverse(action)); + _triggered = false; + } + + _inside = inside; + } + + private void Apply(ProximityTargetAction targetAction) + { + foreach (var target in targets ?? Array.Empty()) + { + if (target == null) + { + continue; + } + + switch (targetAction) + { + case ProximityTargetAction.Activate: + target.SetActive(true); + break; + case ProximityTargetAction.Deactivate: + target.SetActive(false); + break; + case ProximityTargetAction.Toggle: + target.SetActive(!target.activeSelf); + break; + } + } + } + + private static ProximityTargetAction Inverse(ProximityTargetAction targetAction) + { + return targetAction switch + { + ProximityTargetAction.Activate => ProximityTargetAction.Deactivate, + ProximityTargetAction.Deactivate => ProximityTargetAction.Activate, + _ => ProximityTargetAction.Toggle + }; + } + } +} diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityDoor.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityDoor.cs new file mode 100644 index 0000000..dec3949 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityDoor.cs @@ -0,0 +1,66 @@ +using UnityEngine; + +namespace UnityAI.ControlPlane.Runtime +{ + public sealed class ProximityDoor : MonoBehaviour + { + public Transform interactor; + public string interactorTag = "Player"; + public float activationDistance = 2f; + public float deactivationDistance = 2.5f; + public Vector3 openLocalOffset = new(0f, 2.5f, 0f); + public float movementSpeed = 3f; + public bool startsOpen; + public bool closeWhenOutOfRange = true; + + private Transform _cachedInteractor; + private Vector3 _closedLocalPosition; + private bool _isOpen; + + public bool IsOpen => _isOpen; + + private void OnEnable() + { + _closedLocalPosition = transform.localPosition; + _isOpen = startsOpen; + if (startsOpen) + { + transform.localPosition = _closedLocalPosition + openLocalOffset; + } + } + + private void Update() + { + var resolvedInteractor = ProximityInteractorResolver.Resolve( + interactor, + interactorTag, + transform.position, + ref _cachedInteractor); + if (resolvedInteractor != null) + { + var distance = Vector3.Distance(transform.position, resolvedInteractor.position); + if (_isOpen) + { + if (closeWhenOutOfRange && distance > Mathf.Max(activationDistance, deactivationDistance)) + { + _isOpen = false; + } + } + else if (distance <= Mathf.Max(0.01f, activationDistance)) + { + _isOpen = true; + } + } + else if (closeWhenOutOfRange) + { + _isOpen = false; + } + + var targetPosition = _closedLocalPosition + (_isOpen ? openLocalOffset : Vector3.zero); + transform.localPosition = Vector3.MoveTowards( + transform.localPosition, + targetPosition, + Mathf.Max(0.01f, movementSpeed) * Time.deltaTime); + } + } +} diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityInteractorResolver.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityInteractorResolver.cs new file mode 100644 index 0000000..bc0e53a --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityInteractorResolver.cs @@ -0,0 +1,53 @@ +using UnityEngine; + +namespace UnityAI.ControlPlane.Runtime +{ + internal static class ProximityInteractorResolver + { + public static Transform Resolve( + Transform explicitInteractor, + string tag, + Vector3 origin, + ref Transform cachedInteractor) + { + if (explicitInteractor != null && explicitInteractor.gameObject.activeInHierarchy) + { + return explicitInteractor; + } + + if (cachedInteractor != null && cachedInteractor.gameObject.activeInHierarchy) + { + return cachedInteractor; + } + + if (string.IsNullOrWhiteSpace(tag)) + { + return null; + } + + try + { + Transform nearest = null; + var nearestDistance = float.PositiveInfinity; + foreach (var candidate in GameObject.FindGameObjectsWithTag(tag.Trim())) + { + var distance = (candidate.transform.position - origin).sqrMagnitude; + if (distance >= nearestDistance) + { + continue; + } + + nearest = candidate.transform; + nearestDistance = distance; + } + + cachedInteractor = nearest; + return nearest; + } + catch (UnityException) + { + return null; + } + } + } +} diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityPickup.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityPickup.cs new file mode 100644 index 0000000..6843e6e --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityPickup.cs @@ -0,0 +1,82 @@ +using System; +using UnityEngine; + +namespace UnityAI.ControlPlane.Runtime +{ + public enum PickupCollectAction + { + Deactivate, + Destroy + } + + public sealed class ProximityPickup : MonoBehaviour + { + public Transform interactor; + public string interactorTag = "Player"; + public float activationDistance = 1.25f; + public string pickupId = "pickup"; + public int value = 1; + public PickupCollectAction collectAction = PickupCollectAction.Deactivate; + public Vector3 spinAxis = Vector3.up; + public float spinDegreesPerSecond = 90f; + public float bobAmplitude = 0.15f; + public float bobFrequency = 1f; + + private Transform _cachedInteractor; + private Vector3 _origin; + private bool _collected; + + public bool Collected => _collected; + public static event Action CollectedGlobally; + + private void OnEnable() + { + _origin = transform.localPosition; + _collected = false; + } + + private void Update() + { + if (_collected) + { + return; + } + + var normalizedAxis = spinAxis.sqrMagnitude > 0.000001f ? spinAxis.normalized : Vector3.up; + transform.Rotate(normalizedAxis, spinDegreesPerSecond * Time.deltaTime, Space.Self); + transform.localPosition = _origin + Vector3.up * ( + Mathf.Sin(Time.time * Mathf.PI * 2f * Mathf.Max(0f, bobFrequency)) + * Mathf.Max(0f, bobAmplitude)); + + var resolvedInteractor = ProximityInteractorResolver.Resolve( + interactor, + interactorTag, + transform.position, + ref _cachedInteractor); + if (resolvedInteractor != null + && Vector3.Distance(transform.position, resolvedInteractor.position) <= Mathf.Max(0.01f, activationDistance)) + { + Collect(); + } + } + + public void Collect() + { + if (_collected) + { + return; + } + + _collected = true; + CollectedGlobally?.Invoke(this); + if (collectAction == PickupCollectAction.Destroy) + { + Destroy(gameObject); + } + else + { + gameObject.SetActive(false); + } + } + } +} From fddc33df70b3c5bec3b5b0f819b5c536985afeb0 Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:12:59 -0500 Subject: [PATCH 04/28] feat(mcp): expose content and gameplay capabilities --- apps/mcp-server/src/capabilities.ts | 30 ++- apps/mcp-server/src/index.ts | 212 +++++++++++++++++- .../Editor/Bridge/UnityAiBridgeServer.cs | 11 + packages/core-protocol/src/index.ts | 10 +- 4 files changed, 259 insertions(+), 4 deletions(-) diff --git a/apps/mcp-server/src/capabilities.ts b/apps/mcp-server/src/capabilities.ts index 9b67e66..3a9b905 100644 --- a/apps/mcp-server/src/capabilities.ts +++ b/apps/mcp-server/src/capabilities.ts @@ -15,6 +15,13 @@ 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.", @@ -85,6 +92,13 @@ export const initialCapabilities: CapabilityManifest[] = [ effects: ["report_only", "write_audit_log", "scene_change"], verification: ["operation_audited", "structured_observation", "scene_mutation_verified", "batch_applied", "component_state_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 +127,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.", @@ -220,11 +241,18 @@ 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 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", "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..e75cc26 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -50,6 +50,42 @@ 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", { @@ -285,6 +321,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 +425,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", { @@ -566,14 +676,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 +730,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 +748,63 @@ 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") +}).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.import", + { + description: "Copy or download a model, texture, or audio file, apply importer settings, and optionally instantiate it 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) +); + 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(""), 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..b570afa 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 @@ -275,6 +275,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": @@ -295,6 +297,8 @@ private static BridgeResponse ExecuteCapability(string capability, string reques return JsonResult(capability, envelope, SceneUpsertGameObjectOperation.Execute(requestBody)); case "unity.scene.batch": return JsonResult(capability, envelope, SceneBatchOperation.Execute(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 +307,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 +341,8 @@ 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.prefab.manage": return JsonResult(capability, envelope, PrefabAssetOperation.Execute(requestBody)); case "unity.checkpoints.create": @@ -415,6 +423,7 @@ private static bool IsMutatingCapability(string capability) case "unity.console.apply_fix": case "unity.scene.upsert_game_object": case "unity.scene.batch": + case "unity.gameplay.compose": case "unity.project.settings.update": case "unity.packages.change": case "unity.jobs.cancel": @@ -423,6 +432,8 @@ private static bool IsMutatingCapability(string capability) case "unity.compilation.wait": case "unity.build.android": case "unity.assets.author": + case "unity.assets.import": + case "unity.scripts.author": case "unity.prefab.manage": case "unity.checkpoints.create": case "unity.checkpoints.restore": diff --git a/packages/core-protocol/src/index.ts b/packages/core-protocol/src/index.ts index 50c4c43..f05acf2 100644 --- a/packages/core-protocol/src/index.ts +++ b/packages/core-protocol/src/index.ts @@ -8,6 +8,8 @@ export type CapabilityPermission = | "read_assets" | "read_console" | "read_artifacts" + | "read_external_files" + | "network_access" | "capture_screenshots" | "write_artifacts" | "modify_scenes" @@ -65,7 +67,13 @@ export type VerificationSignal = | "packages_resolved" | "asset_mutation_verified" | "prefab_mutation_verified" - | "meta_xr_configured"; + | "meta_xr_configured" + | "audit_report_generated" + | "evidence_hash_verified" + | "asset_import_verified" + | "script_source_validated" + | "script_compilation_verified" + | "gameplay_template_applied"; export interface CapabilityManifest { readonly name: CapabilityName; From 619b46e0504c11a3da7e54bc22714742f96e53fa Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:13:02 -0500 Subject: [PATCH 05/28] test(e2e): verify advanced content workflows --- scripts/verify-mcp-unity-e2e.mjs | 817 ++++++++++++++++++++++++++++++- 1 file changed, 813 insertions(+), 4 deletions(-) diff --git a/scripts/verify-mcp-unity-e2e.mjs b/scripts/verify-mcp-unity-e2e.mjs index 0f43fb5..660b868 100644 --- a/scripts/verify-mcp-unity-e2e.mjs +++ b/scripts/verify-mcp-unity-e2e.mjs @@ -4,7 +4,8 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { existsSync, rmSync, writeFileSync, mkdirSync, cpSync, readFileSync, symlinkSync } from "node:fs"; import { join, resolve } from "node:path"; import { spawn } from "node:child_process"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; +import { createServer } from "node:http"; const repoRoot = resolve(new URL("..", import.meta.url).pathname); const packageSource = join(repoRoot, "apps/unity-plugin/Packages/com.unity-ai.control-plane"); @@ -17,6 +18,8 @@ const tempProject = join(repoRoot, ".unity-ai/e2e-project"); const packagesDir = join(tempProject, "Packages"); const assetsDir = join(tempProject, "Assets"); const projectSettingsDir = join(tempProject, "ProjectSettings"); +const externalAssetsDir = join(tempProject, "ExternalAssets"); +const localModelSourcePath = join(externalAssetsDir, "local-triangle.obj"); const artifactsDir = join(repoRoot, "artifacts/unity-verification"); const readyFile = join(artifactsDir, "bridge-ready.txt"); const tokenFile = join(artifactsDir, "bridge-token.txt"); @@ -27,6 +30,18 @@ const diagnosticExceptionMarker = "UNITY_AI_E2E_DIAGNOSTIC_EXCEPTION"; const applyFixFile = "Assets/UnityAiE2EApplyFixFixture.cs"; const applyFixOriginalLine = " public const string Marker = \"before\";"; const applyFixReplacementLine = " public const string Marker = \"after\";"; +const localObjSource = `o LocalTriangle +v 0 0 0 +v 1 0 0 +v 0 1 0 +f 1 2 3 +`; +const remoteObjSource = `o RemoteTriangle +v 0 0 0 +v 0 0 1 +v 0 1 0 +f 1 2 3 +`; if (!unityPath || !existsSync(unityPath)) { fail(`Unity executable not found. Set UNITY_PATH or pass it as the first argument.`); @@ -46,6 +61,7 @@ mkdirSync(join(assetsDir, "Editor"), { recursive: true }); mkdirSync(join(assetsDir, "Tests/EditMode"), { recursive: true }); mkdirSync(join(assetsDir, "Tests/PlayMode"), { recursive: true }); mkdirSync(projectSettingsDir, { recursive: true }); +mkdirSync(externalAssetsDir, { recursive: true }); mkdirSync(artifactsDir, { recursive: true }); rmSync(join(tempProject, "Library/UnityAIControlPlane"), { recursive: true, force: true }); rmSync(join(tempProject, "UnityAIArtifacts"), { recursive: true, force: true }); @@ -74,6 +90,7 @@ writeFileSync( ); writeFileSync(join(projectSettingsDir, "ProjectVersion.txt"), "m_EditorVersion: 6000.4.9f1\n"); +writeFileSync(localModelSourcePath, localObjSource); writeFileSync(join(assetsDir, "UnityAiCheckpointFixture.txt"), "checkpoint-before\n"); writeFileSync( join(assetsDir, "Tests/EditMode/UnityAiE2E.EditMode.asmdef"), @@ -153,6 +170,8 @@ public static class UnityAiE2EConsoleDiagnosticsFixture ` ); +const assetFixtureServer = await startAssetFixtureServer(remoteObjSource); +const remoteModelUrl = `http://127.0.0.1:${assetFixtureServer.port}/remote-triangle.obj`; const unity = spawn(unityPath, [ "-batchmode", "-nographics", @@ -191,7 +210,7 @@ try { assertCapabilities(await callJsonTool(client, "unity.capabilities.list", {})); await assertApplyFixFlow(client); - const before = await callJsonTool(client, "unity.project.inspect", {}); + await callJsonTool(client, "unity.project.inspect", {}); await callJsonTool(client, "unity.console.read", {}); assertConsoleDiagnostics(await waitForConsoleDiagnostics(client, 60_000)); assertConsoleFixPlans(await callJsonTool(client, "unity.console.plan_fix", {})); @@ -208,8 +227,9 @@ try { assertPackageList(await callJsonTool(client, "unity.packages.list", {})); assertProjectSettings(await callJsonTool(client, "unity.project.settings.inspect", {})); await callJsonTool(client, "unity.meta_xr.validate_setup", {}); - await assertVisualVerificationFlow(client); + const visualEvidence = await assertVisualVerificationFlow(client); await assertExtendedControlPlaneFlow(client); + const before = await callJsonTool(client, "unity.project.inspect", {}); const dryRun = await callJsonTool(client, "unity.editor.create_empty_game_object", { name: "Unity AI E2E Dry Run", @@ -261,6 +281,7 @@ try { verificationStatus: "passed", requiresConfirmation: false }); + await assertAuditReportFlow(client, created, visualEvidence.baseline); const after = await callJsonTool(client, "unity.project.inspect", {}); if (after.rootGameObjectCount !== before.rootGameObjectCount + 1) { @@ -333,6 +354,7 @@ try { } finally { unity.kill("SIGTERM"); await waitForProcessExit(unity, 10_000); + await closeServer(assetFixtureServer.server); rmSync(tokenFile, { force: true }); } @@ -416,6 +438,21 @@ function assertCapabilities(capabilities) { fail("unity.project.snapshot capability must declare observation and console diagnostic verification."); } + const auditReportCapability = capabilities.find((capability) => capability.name === "unity.audit.report"); + if (!auditReportCapability) { + fail("unity.capabilities.list did not include unity.audit.report."); + } + + if (!auditReportCapability.permissions.includes("read_artifacts") || !auditReportCapability.permissions.includes("write_artifacts")) { + fail("unity.audit.report must declare read_artifacts and write_artifacts permissions."); + } + + for (const signal of ["audit_report_generated", "evidence_hash_verified", "operation_audited"]) { + if (!auditReportCapability.verification.includes(signal)) { + fail(`unity.audit.report must declare ${signal} verification.`); + } + } + if (!Array.isArray(applyFixCapability.permissions) || !applyFixCapability.permissions.includes("modify_assets")) { fail("unity.console.apply_fix capability must declare modify_assets permission."); } @@ -487,6 +524,57 @@ function assertCapabilities(capabilities) { fail("unity.vision.compare must declare diff and regression verification signals."); } + const assetImportCapability = capabilities.find((capability) => capability.name === "unity.assets.import"); + if (!assetImportCapability) { + fail("unity.capabilities.list did not include unity.assets.import."); + } + + for (const permission of ["read_external_files", "network_access", "modify_assets", "modify_scenes"]) { + if (!assetImportCapability.permissions.includes(permission)) { + fail(`unity.assets.import must declare ${permission}.`); + } + } + + for (const signal of ["asset_import_verified", "checkpoint_created", "operation_audited"]) { + if (!assetImportCapability.verification.includes(signal)) { + fail(`unity.assets.import must declare ${signal}.`); + } + } + + const scriptAuthoringCapability = capabilities.find((capability) => capability.name === "unity.scripts.author"); + if (!scriptAuthoringCapability) { + fail("unity.capabilities.list did not include unity.scripts.author."); + } + + for (const permission of ["read_console", "modify_assets", "modify_scenes", "execute_editor_script"]) { + if (!scriptAuthoringCapability.permissions.includes(permission)) { + fail(`unity.scripts.author must declare ${permission}.`); + } + } + + for (const signal of ["script_source_validated", "script_compilation_verified", "checkpoint_restored"]) { + if (!scriptAuthoringCapability.verification.includes(signal)) { + fail(`unity.scripts.author must declare ${signal}.`); + } + } + + const gameplayComposeCapability = capabilities.find((capability) => capability.name === "unity.gameplay.compose"); + if (!gameplayComposeCapability) { + fail("unity.capabilities.list did not include unity.gameplay.compose."); + } + + for (const permission of ["read_scenes", "modify_scenes"]) { + if (!gameplayComposeCapability.permissions.includes(permission)) { + fail(`unity.gameplay.compose must declare ${permission}.`); + } + } + + for (const signal of ["checkpoint_created", "gameplay_template_applied", "component_state_verified", "scene_mutation_verified"]) { + if (!gameplayComposeCapability.verification.includes(signal)) { + fail(`unity.gameplay.compose must declare ${signal}.`); + } + } + for (const name of [ "unity.tests.run", "unity.playmode.control", @@ -1330,6 +1418,141 @@ async function assertVisualVerificationFlow(client) { if (regression.changedPixelRatio < 0.99 || regression.meanAbsoluteError <= 0.1) { fail(`visual regression metrics were unexpectedly weak: ${JSON.stringify(regression)}.`); } + + return { baseline }; +} + +async function assertAuditReportFlow(client, mutation, baseline) { + const invalidEvidenceReport = await callJsonTool(client, "unity.audit.report", { + title: "Reject absolute audit evidence", + evidence: [ + { + phase: "before", + kind: "screenshot", + path: "/tmp/unity-ai-audit-evidence.png" + } + ], + verifications: [ + { + signal: "screenshot_ready", + status: "passed", + evidencePaths: ["/tmp/unity-ai-audit-evidence.png"] + } + ] + }); + if (invalidEvidenceReport.generated !== true + || invalidEvidenceReport.status !== "failed" + || invalidEvidenceReport.evidence?.[0]?.path !== "" + || invalidEvidenceReport.evidence?.[0]?.available !== false + || invalidEvidenceReport.verifications?.[0]?.evidenceReferencesValid !== false) { + fail(`audit report did not safely reject absolute evidence paths: ${JSON.stringify(invalidEvidenceReport)}.`); + } + assertNoAbsolutePathLeakInValue("audit report absolute path refusal", invalidEvidenceReport); + + const after = await callJsonTool(client, "unity.vision.capture", { + source: "game", + width: 160, + height: 90, + label: "after-audited-create", + cameraPath: "UnityAiE2EVisualCamera" + }); + assertReadyScreenshot("audit report after evidence", after, 160, 90); + + const comparison = await callJsonTool(client, "unity.vision.compare", { + beforePath: baseline.path, + afterPath: after.path, + pixelThreshold: 0.01, + maxChangedPixelRatio: 0, + maxMeanAbsoluteError: 0, + generateDiff: true, + label: "audited-create" + }); + assertVisualComparison("audit report visual comparison", comparison, false); + + const report = await callJsonTool(client, "unity.audit.report", { + title: "Unity AI E2E create verification", + summary: "Verify one controlled scene mutation with hashed before and after visual evidence.", + requestIds: [mutation.requestId], + requireBeforeAfter: true, + evidence: [ + { + phase: "before", + kind: "screenshot", + path: baseline.path, + description: "Game View before the controlled scene mutation." + }, + { + phase: "after", + kind: "screenshot", + path: after.path, + description: "Game View after the controlled scene mutation." + }, + { + phase: "supporting", + kind: "visual_diff", + path: comparison.diffPath, + description: "Generated visual diff for the before and after captures." + } + ], + verifications: [ + { + signal: "scene_mutation_verified", + status: "passed", + summary: "The controlled create operation reported a verified scene mutation." + }, + { + signal: "visual_regression_absent", + status: "passed", + summary: "The empty GameObject did not alter the rendered Game View.", + evidencePaths: [baseline.path, after.path, comparison.diffPath] + } + ] + }); + + if (report.generated !== true || report.status !== "passed" || report.beforeAfterComplete !== true || report.reportFilesVerified !== true) { + fail(`audit report did not produce a passed, verified before/after report: ${JSON.stringify(report)}.`); + } + + if (report.matchedAuditEvents !== 1 || report.auditEvents?.[0]?.requestId !== mutation.requestId) { + fail(`audit report did not select the requested mutation event: ${JSON.stringify(report.auditEvents)}.`); + } + + if (!Array.isArray(report.evidence) || report.evidence.length !== 3 || report.evidence.some((item) => item.available !== true || !/^[a-f0-9]{64}$/.test(item.sha256))) { + fail(`audit report evidence metadata was invalid: ${JSON.stringify(report.evidence)}.`); + } + + if (!Array.isArray(report.verifications) || report.verifications.some((item) => item.evidenceReferencesValid !== true)) { + fail(`audit report verification references were invalid: ${JSON.stringify(report.verifications)}.`); + } + + for (const field of ["reportJsonPath", "reportMarkdownPath"]) { + if (typeof report[field] !== "string" || !report[field].startsWith("UnityAIArtifacts/Audit/Reports/") || !existsSync(join(tempProject, report[field]))) { + fail(`audit report ${field} was invalid: ${report[field]}.`); + } + } + + if (sha256File(join(tempProject, report.reportJsonPath)) !== report.reportJsonSha256 + || sha256File(join(tempProject, report.reportMarkdownPath)) !== report.reportMarkdownSha256) { + fail("audit report artifact hashes did not match the written files."); + } + + const reportDocument = JSON.parse(readFileSync(join(tempProject, report.reportJsonPath), "utf8")); + if (reportDocument.reportId !== report.reportId || reportDocument.status !== "passed" || reportDocument.beforeAfterComplete !== true) { + fail(`audit report JSON document was invalid: ${JSON.stringify(reportDocument)}.`); + } + + if (report.auditPersisted !== true || report.auditEvent?.capability !== "unity.audit.report") { + fail(`audit report generation was not persisted to the audit log: ${JSON.stringify(report.auditEvent)}.`); + } + assertAuditLogContains("audit report generation", join(tempProject, report.auditLogPath), report.auditEvent); + + for (const signal of ["audit_report_generated", "evidence_hash_verified", "operation_audited", "structured_observation"]) { + if (!report.verificationSignals?.includes(signal)) { + fail(`audit report did not return verification signal ${signal}.`); + } + } + + assertNoAbsolutePathLeakInValue("audit report", report); } async function assertExtendedControlPlaneFlow(client) { @@ -1372,7 +1595,10 @@ async function assertExtendedControlPlaneFlow(client) { fail(`compilation wait job failed: ${JSON.stringify(compilationJob)}.`); } - await assertAssetAuthoringFlow(client); + const authoredAssets = await assertAssetAuthoringFlow(client); + await assertAssetImportFlow(client, authoredAssets); + await assertScriptAuthoringFlow(client); + await assertGameplayComposeFlow(client); await assertDurableCheckpointFlow(client); await assertPrefabManagementFlow(client); await assertTestRun(client, "edit", "UnityAiE2E.EditMode"); @@ -1427,6 +1653,27 @@ async function assertAssetAuthoringFlow(client) { ] }); assertAssetAuthored("animation", animation, "UnityEngine.AnimationClip"); + const animationPath = animation.path; + + const controller = await callJsonTool(client, "unity.assets.author", { + dryRun: false, + confirm: true, + kind: "animator_controller", + path: "Assets/UnityAiGenerated/E2E.controller", + defaultState: "Move", + animatorParameters: [ + { name: "Enabled", type: "bool", defaultBool: true } + ], + animatorStates: [ + { + name: "Move", + clipPath: animationPath, + speed: 1, + writeDefaultValues: true + } + ] + }); + assertAssetAuthored("animator controller", controller, "UnityEditor.Animations.AnimatorController"); const audio = await callJsonTool(client, "unity.assets.author", { dryRun: false, @@ -1447,6 +1694,11 @@ async function assertAssetAuthoringFlow(client) { } }); assertAssetAuthored("audio", audio, "UnityEngine.AudioClip"); + + return { + animationPath, + controllerPath: controller.path + }; } function assertAssetAuthored(label, result, expectedType) { @@ -1459,6 +1711,403 @@ function assertAssetAuthored(label, result, expectedType) { } } +async function assertAssetImportFlow(client, authoredAssets) { + const localDestination = "Assets/UnityAiGenerated/ImportedLocalTriangle.obj"; + const importedPrefabPath = "Assets/UnityAiGenerated/ImportedLocalTriangle.prefab"; + const local = await callJsonTool(client, "unity.assets.import", { + dryRun: false, + confirm: true, + sourceKind: "local", + sourcePath: localModelSourcePath, + destinationPath: localDestination, + expectedSha256: sha256File(localModelSourcePath), + model: { + globalScale: 1.5, + useFileScale: true, + addCollider: true, + importAnimation: false, + animationType: "none", + meshCompression: "low" + }, + instantiate: true, + objectName: "ImportedLocalTriangle", + transform: { + position: { x: 0, y: 0, z: 2 }, + rotationEuler: { x: 0, y: 30, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + }, + saveAsPrefabPath: importedPrefabPath + }); + assertImportedAsset("local model import", local, { + sourceKind: "local", + destinationPath: localDestination, + assetType: "UnityEngine.GameObject", + importerType: "UnityEditor.ModelImporter", + instantiated: true, + prefabCreated: true + }); + + const remoteDestination = "Assets/UnityAiGenerated/ImportedRemoteTriangle.obj"; + const remote = await callJsonTool(client, "unity.assets.import", { + dryRun: false, + confirm: true, + sourceKind: "url", + url: remoteModelUrl, + allowInsecureLocalhost: true, + destinationPath: remoteDestination, + expectedSha256: createHash("sha256").update(remoteObjSource).digest("hex"), + model: { + globalScale: 0.75, + importAnimation: false, + animationType: "none" + } + }); + assertImportedAsset("remote model import", remote, { + sourceKind: "url", + destinationPath: remoteDestination, + assetType: "UnityEngine.GameObject", + importerType: "UnityEditor.ModelImporter", + instantiated: false, + prefabCreated: false + }); + + const composed = await callJsonTool(client, "unity.scene.batch", { + dryRun: false, + confirm: true, + operations: [ + { + kind: "add_component", + targetPath: local.objectPath, + componentType: "UnityEngine.Animator" + }, + { + kind: "set_property", + targetPath: local.objectPath, + componentType: "UnityEngine.Animator", + propertyPath: "m_Controller", + value: { kind: "object_reference", assetPath: authoredAssets.controllerPath } + }, + { + kind: "add_component", + targetPath: local.objectPath, + componentType: "UnityAI.ControlPlane.Runtime.ContinuousRotation" + }, + { + kind: "set_property", + targetPath: local.objectPath, + componentType: "UnityAI.ControlPlane.Runtime.ContinuousRotation", + propertyPath: "axis", + value: { kind: "vector3", x: 0, y: 1, z: 0 } + }, + { + kind: "set_property", + targetPath: local.objectPath, + componentType: "UnityAI.ControlPlane.Runtime.ContinuousRotation", + propertyPath: "degreesPerSecond", + value: { kind: "number", numberValue: 90 } + } + ] + }); + if (composed.applied !== true || !composed.verificationSignals?.includes("component_state_verified")) { + fail(`imported model composition failed: ${JSON.stringify(composed)}.`); + } + + const inspected = await callJsonTool(client, "unity.scene.inspect_game_object", { + path: local.objectPath, + includeProperties: true, + maxProperties: 500, + maxPropertyDepth: 8 + }); + const animator = inspected.components?.find((component) => component.fullTypeName === "UnityEngine.Animator"); + const rotation = inspected.components?.find((component) => component.fullTypeName === "UnityAI.ControlPlane.Runtime.ContinuousRotation"); + if (!animator || !rotation) { + fail(`imported model did not contain Animator and ContinuousRotation: ${JSON.stringify(inspected.components)}.`); + } + + const controllerProperty = animator.properties?.find((property) => property.path === "m_Controller"); + const speedProperty = rotation.properties?.find((property) => property.path === "degreesPerSecond"); + if (controllerProperty?.objectReferencePath !== authoredAssets.controllerPath || Math.abs(Number(speedProperty?.value) - 90) > 0.001) { + fail(`imported model functionality was not serialized correctly: ${JSON.stringify({ controllerProperty, speedProperty })}.`); + } +} + +async function assertScriptAuthoringFlow(client) { + const unsafePreview = await callJsonTool(client, "unity.scripts.author", { + path: "Assets/UnityAiGenerated/UnsafeGenerated.cs", + source: `using System.IO; +using UnityEngine; + +public sealed class UnsafeGenerated : MonoBehaviour +{ + private void Update() + { + File.Delete("forbidden"); + } +} +`, + expectedClassName: "UnsafeGenerated" + }); + if (unsafePreview.refused !== true || !String(unsafePreview.message).includes("blocked API")) { + fail(`unsafe generated script was not refused: ${JSON.stringify(unsafePreview)}.`); + } + + const brokenSource = `using UnityEngine; + +public sealed class BrokenGenerated : MonoBehaviour +{ + private void Update() + { + transform.position = ; + } +} +`; + const brokenPath = "Assets/UnityAiGenerated/BrokenGenerated.cs"; + const brokenPreview = await callJsonTool(client, "unity.scripts.author", { + path: brokenPath, + source: brokenSource, + expectedClassName: "BrokenGenerated" + }); + if (brokenPreview.refused === true || !/^[a-f0-9]{64}$/.test(brokenPreview.sourceSha256)) { + fail(`compile-failure fixture did not pass source policy validation: ${JSON.stringify(brokenPreview)}.`); + } + + const brokenJob = await waitForJob(client, await callJsonTool(client, "unity.scripts.author", { + dryRun: false, + confirm: true, + path: brokenPath, + source: brokenSource, + expectedSourceSha256: brokenPreview.sourceSha256, + expectedClassName: "BrokenGenerated" + }), 180_000); + const brokenResult = parseJobResult(brokenJob); + if (brokenJob.status !== "failed" + || brokenResult.rolledBack !== true + || existsSync(join(tempProject, brokenPath)) + || !brokenJob.verificationSignals?.includes("checkpoint_restored")) { + fail(`generated script compile failure did not restore its checkpoint: ${JSON.stringify(brokenJob)}.`); + } + + const source = `using UnityEngine; + +namespace UnityAi.Generated +{ + public sealed class GeneratedRise : MonoBehaviour + { + public float unitsPerSecond = 1f; + + private void Update() + { + transform.Translate(Vector3.up * unitsPerSecond * Time.deltaTime, Space.World); + } + } +} +`; + const path = "Assets/UnityAiGenerated/GeneratedRise.cs"; + const preview = await callJsonTool(client, "unity.scripts.author", { + path, + source, + expectedClassName: "GeneratedRise", + expectedNamespace: "UnityAi.Generated", + attachToObjectPath: "ImportedLocalTriangle" + }); + if (preview.dryRun !== true + || preview.refused === true + || !/^[a-f0-9]{64}$/.test(preview.sourceSha256) + || !preview.verificationSignals?.includes("script_source_validated")) { + fail(`generated script dry run was invalid: ${JSON.stringify(preview)}.`); + } + + const started = await callJsonTool(client, "unity.scripts.author", { + dryRun: false, + confirm: true, + path, + source, + expectedSourceSha256: preview.sourceSha256, + expectedClassName: "GeneratedRise", + expectedNamespace: "UnityAi.Generated", + attachToObjectPath: "ImportedLocalTriangle" + }); + const job = await waitForJob(client, started, 180_000); + const result = parseJobResult(job); + if (job.status !== "succeeded" + || result.compiled !== true + || result.attached !== true + || result.fullTypeName !== "UnityAi.Generated.GeneratedRise" + || result.targetCompilerErrorCount !== 0 + || !existsSync(join(tempProject, path))) { + fail(`generated script compilation or attachment failed: ${JSON.stringify(job)}.`); + } + + for (const signal of ["script_source_validated", "script_compilation_verified", "component_state_verified", "scene_mutation_verified"]) { + if (!job.verificationSignals?.includes(signal)) { + fail(`generated script job did not include verification signal ${signal}.`); + } + } + + const inspected = await callJsonTool(client, "unity.scene.inspect_game_object", { + path: "ImportedLocalTriangle", + includeProperties: true, + maxProperties: 500, + maxPropertyDepth: 8 + }); + const generated = inspected.components?.find((component) => component.fullTypeName === "UnityAi.Generated.GeneratedRise"); + const speedProperty = generated?.properties?.find((property) => property.path === "unitsPerSecond"); + if (!generated || Math.abs(Number(speedProperty?.value) - 1) > 0.001) { + fail(`generated runtime component was not serialized correctly: ${JSON.stringify({ generated, speedProperty })}.`); + } +} + +async function assertGameplayComposeFlow(client) { + const objects = [ + { name: "GameplayInteractor", primitive: "sphere", position: { x: 20, y: 0, z: 0 }, active: true }, + { name: "GameplayDoor", primitive: "cube", position: { x: 20, y: 0, z: 1 }, active: true }, + { name: "GameplayPickup", primitive: "sphere", position: { x: 20, y: 0, z: 1.5 }, active: true }, + { name: "GameplayActivator", primitive: "empty", position: { x: 20, y: 0, z: 1 }, active: true }, + { name: "GameplaySignal", primitive: "cube", position: { x: 22, y: 0, z: 0 }, active: false } + ]; + + for (const object of objects) { + const created = await callJsonTool(client, "unity.scene.upsert_game_object", { + dryRun: false, + confirm: true, + mode: "create", + name: object.name, + primitive: object.primitive, + transform: { position: object.position }, + active: object.active + }); + if (created.created !== true || created.verificationStatus !== "passed") { + fail(`gameplay fixture object ${object.name} was not created: ${JSON.stringify(created)}.`); + } + } + + const templates = [ + { + kind: "door", + targetPath: "GameplayDoor", + interactorPath: "GameplayInteractor", + activationDistance: 3, + deactivationDistance: 4, + openOffset: { x: 0, y: 2, z: 0 }, + speed: 4, + startsOpen: false, + closeWhenOutOfRange: true + }, + { + kind: "pickup", + targetPath: "GameplayPickup", + interactorPath: "GameplayInteractor", + activationDistance: 3, + pickupId: "e2e-crystal", + value: 25, + collectAction: "deactivate", + spinAxis: { x: 0, y: 1, z: 0 }, + spinDegreesPerSecond: 120, + bobAmplitude: 0.2, + bobFrequency: 1 + }, + { + kind: "activator", + targetPath: "GameplayActivator", + interactorPath: "GameplayInteractor", + activationDistance: 3, + affectedPaths: ["GameplaySignal"], + action: "activate", + oneShot: true, + revertOnExit: false + } + ]; + + const preview = await callJsonTool(client, "unity.gameplay.compose", { templates }); + if (preview.dryRun !== true + || preview.applied !== false + || preview.templates?.length !== 3 + || preview.verificationStatus !== "passed") { + fail(`gameplay composition dry run was invalid: ${JSON.stringify(preview)}.`); + } + + const applied = await callJsonTool(client, "unity.gameplay.compose", { + dryRun: false, + confirm: true, + templates + }); + if (applied.applied !== true + || applied.appliedTemplateCount !== 3 + || !applied.checkpointId + || applied.templates?.some((template) => template.applied !== true || template.verified !== true) + || applied.auditPersisted !== true) { + fail(`gameplay composition failed: ${JSON.stringify(applied)}.`); + } + + for (const signal of ["checkpoint_created", "gameplay_template_applied", "component_state_verified", "scene_mutation_verified", "operation_audited"]) { + if (!applied.verificationSignals?.includes(signal)) { + fail(`gameplay composition did not include verification signal ${signal}.`); + } + } + + const expectedComponents = [ + ["GameplayDoor", "UnityAI.ControlPlane.Runtime.ProximityDoor"], + ["GameplayPickup", "UnityAI.ControlPlane.Runtime.ProximityPickup"], + ["GameplayActivator", "UnityAI.ControlPlane.Runtime.ProximityActivator"] + ]; + for (const [path, componentType] of expectedComponents) { + const inspected = await callJsonTool(client, "unity.scene.inspect_game_object", { + path, + includeProperties: true, + maxProperties: 500, + maxPropertyDepth: 8 + }); + if (!inspected.found || !inspected.components?.some((component) => component.fullTypeName === componentType)) { + fail(`gameplay component ${componentType} was not configured on ${path}: ${JSON.stringify(inspected)}.`); + } + + if (path === "GameplayActivator") { + const activator = inspected.components.find((component) => component.fullTypeName === componentType); + const targetReference = activator.properties?.find((property) => property.path === "targets.Array.data[0]"); + if (targetReference?.value !== "GameplaySignal") { + fail(`gameplay activator target reference was not serialized: ${JSON.stringify(activator)}.`); + } + } + } +} + +function assertImportedAsset(label, result, expected) { + if (result.imported !== true + || result.verificationStatus !== "passed" + || result.sourceKind !== expected.sourceKind + || result.destinationPath !== expected.destinationPath + || result.assetType !== expected.assetType + || result.importerType !== expected.importerType + || result.instantiated !== expected.instantiated + || result.prefabCreated !== expected.prefabCreated) { + fail(`${label} failed: ${JSON.stringify(result)}.`); + } + + if (!result.checkpointId || !/^[a-f0-9]{64}$/.test(result.sha256) || !existsSync(join(tempProject, result.destinationPath))) { + fail(`${label} did not return verified checkpoint/hash/file metadata.`); + } + + for (const signal of ["checkpoint_created", "asset_import_verified", "operation_audited"]) { + if (!result.verificationSignals?.includes(signal)) { + fail(`${label} did not include verification signal ${signal}.`); + } + } + + if (expected.instantiated && (!result.objectPath || !result.verificationSignals.includes("scene_mutation_verified"))) { + fail(`${label} did not verify scene instantiation.`); + } + + if (expected.prefabCreated && (!existsSync(join(tempProject, result.prefabPath)) || !result.verificationSignals.includes("prefab_mutation_verified"))) { + fail(`${label} did not verify prefab creation.`); + } + + if (result.auditPersisted !== true || result.auditEvent?.capability !== "unity.assets.import") { + fail(`${label} did not persist its audit event.`); + } + assertAuditLogContains(label, join(tempProject, result.auditLogPath), result.auditEvent); + assertNoAbsolutePathLeakInValue(label, result); +} + async function assertDurableCheckpointFlow(client) { const fixturePath = "Assets/UnityAiCheckpointFixture.txt"; const created = await callJsonTool(client, "unity.checkpoints.create", { @@ -1620,6 +2269,22 @@ async function assertPlayModeControlFlow(client) { fail("Play Mode should be stopped before control flow."); } + const behaviorBefore = await callJsonTool(client, "unity.scene.inspect_game_object", { + path: "ImportedLocalTriangle", + includeProperties: false + }); + const doorBefore = await callJsonTool(client, "unity.scene.inspect_game_object", { + path: "GameplayDoor", + includeProperties: false + }); + const pickupBefore = await callJsonTool(client, "unity.scene.inspect_game_object", { + path: "GameplayPickup", + includeProperties: false + }); + const signalBefore = await callJsonTool(client, "unity.scene.inspect_game_object", { + path: "GameplaySignal", + includeProperties: false + }); const entered = await waitForJob(client, await callJsonTool(client, "unity.playmode.control", { dryRun: false, confirm: true, @@ -1629,6 +2294,39 @@ async function assertPlayModeControlFlow(client) { fail(`enter Play Mode failed: ${JSON.stringify(entered)}.`); } + await delay(750); + const behaviorAfter = await callJsonTool(client, "unity.scene.inspect_game_object", { + path: "ImportedLocalTriangle", + includeProperties: false + }); + const doorAfter = await callJsonTool(client, "unity.scene.inspect_game_object", { + path: "GameplayDoor", + includeProperties: false + }); + const pickupAfter = await callJsonTool(client, "unity.scene.inspect_game_object", { + path: "GameplayPickup", + includeProperties: false + }); + const signalAfter = await callJsonTool(client, "unity.scene.inspect_game_object", { + path: "GameplaySignal", + includeProperties: false + }); + if (!behaviorBefore.found || !behaviorAfter.found || angleDelta(behaviorBefore.rotationEuler?.y, behaviorAfter.rotationEuler?.y) < 1) { + fail(`ContinuousRotation did not visibly update the imported object in Play Mode: ${JSON.stringify({ behaviorBefore, behaviorAfter })}.`); + } + if (Number(behaviorAfter.position?.y) - Number(behaviorBefore.position?.y) < 0.1) { + fail(`GeneratedRise did not visibly move the imported object in Play Mode: ${JSON.stringify({ behaviorBefore, behaviorAfter })}.`); + } + if (!doorBefore.found || !doorAfter.found || Number(doorAfter.position?.y) - Number(doorBefore.position?.y) < 0.5) { + fail(`ProximityDoor did not visibly open in Play Mode: ${JSON.stringify({ doorBefore, doorAfter })}.`); + } + if (!pickupBefore.found || pickupBefore.activeSelf !== true || !pickupAfter.found || pickupAfter.activeSelf !== false) { + fail(`ProximityPickup was not collected in Play Mode: ${JSON.stringify({ pickupBefore, pickupAfter })}.`); + } + if (!signalBefore.found || signalBefore.activeSelf !== false || !signalAfter.found || signalAfter.activeSelf !== true) { + fail(`ProximityActivator did not activate its target in Play Mode: ${JSON.stringify({ signalBefore, signalAfter })}.`); + } + for (const action of ["pause", "step", "resume"]) { const job = await waitForJob(client, await callJsonTool(client, "unity.playmode.control", { dryRun: false, @@ -2346,6 +3044,42 @@ function assertAuditLogContains(label, auditLogPath, expectedEvent) { } } +function sha256File(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function startAssetFixtureServer(body) { + return new Promise((resolvePromise, rejectPromise) => { + const server = createServer((request, response) => { + if (request.url !== "/remote-triangle.obj") { + response.writeHead(404); + response.end("not found"); + return; + } + + response.writeHead(200, { + "content-type": "text/plain", + "content-length": Buffer.byteLength(body) + }); + response.end(body); + }); + server.once("error", rejectPromise); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + rejectPromise(new Error("Asset fixture server did not expose a TCP port.")); + return; + } + + resolvePromise({ server, port: address.port }); + }); + }); +} + +function closeServer(server) { + return new Promise((resolvePromise) => server.close(() => resolvePromise())); +} + async function waitForFile(path, timeoutMs) { const deadline = Date.now() + timeoutMs; @@ -2452,6 +3186,81 @@ async function assertMutatingRouteRequiresToken(url) { } console.log("✓ unity.console.apply_fix rejects missing token"); + + const assetImportResponse = await fetch(`${url}/capabilities/${encodeURIComponent("unity.assets.import")}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + input: { + dryRun: false, + confirm: true, + sourceKind: "local", + sourcePath: localModelSourcePath, + destinationPath: "Assets/Unauthorized.obj" + } + }) + }); + + if (assetImportResponse.status !== 403) { + fail(`Expected unity.assets.import without token to return 403, got HTTP ${assetImportResponse.status}.`); + } + + console.log("✓ unity.assets.import rejects missing token"); + + const scriptAuthoringResponse = await fetch(`${url}/capabilities/${encodeURIComponent("unity.scripts.author")}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + input: { + dryRun: false, + confirm: true, + path: "Assets/Unauthorized.cs", + source: "public sealed class Unauthorized : UnityEngine.MonoBehaviour {}", + expectedSourceSha256: "0".repeat(64), + expectedClassName: "Unauthorized" + } + }) + }); + + if (scriptAuthoringResponse.status !== 403) { + fail(`Expected unity.scripts.author without token to return 403, got HTTP ${scriptAuthoringResponse.status}.`); + } + + console.log("✓ unity.scripts.author rejects missing token"); + + const gameplayComposeResponse = await fetch(`${url}/capabilities/${encodeURIComponent("unity.gameplay.compose")}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + input: { + dryRun: false, + confirm: true, + templates: [ + { + kind: "door", + targetPath: "UnauthorizedDoor", + interactorTag: "Player", + activationDistance: 2 + } + ] + } + }) + }); + + if (gameplayComposeResponse.status !== 403) { + fail(`Expected unity.gameplay.compose without token to return 403, got HTTP ${gameplayComposeResponse.status}.`); + } + + console.log("✓ unity.gameplay.compose rejects missing token"); +} + +function angleDelta(before, after) { + if (!Number.isFinite(before) || !Number.isFinite(after)) { + return 0; + } + + const raw = Math.abs(after - before) % 360; + return Math.min(raw, 360 - raw); } function delay(ms) { From 66b3433fed5bc2843d3d07b6abfd708d63d4499e Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:13:06 -0500 Subject: [PATCH 06/28] docs: describe content and gameplay authoring --- README.md | 7 +- apps/unity-plugin/README.md | 7 +- docs/capability-contract.md | 5 +- docs/content-authoring.md | 148 +++++++++++++++++++++++++++++ docs/control-plane-operations.md | 29 ++++++ docs/local-bridge.md | 4 + docs/roadmap.md | 17 +++- docs/safety-model.md | 8 +- docs/scaffold-status.md | 4 + docs/unity-package-verification.md | 2 + 10 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 docs/content-authoring.md diff --git a/README.md b/README.md index 545f56d..f0f9e90 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,15 @@ 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.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 +53,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 diff --git a/apps/unity-plugin/README.md b/apps/unity-plugin/README.md index f80b79f..b9b6373 100644 --- a/apps/unity-plugin/README.md +++ b/apps/unity-plugin/README.md @@ -21,8 +21,13 @@ The plugin starts with editor-only capabilities for: - compilation/import settling with console verification; - Android/Quest build validation and APK/AAB jobs; - shader, material, animation, WAV, and audio-import authoring; +- local/HTTPS model, texture, and audio import with size/hash verification and importer configuration; +- Animator Controller authoring plus reusable rotation, bobbing, and pulse runtime behaviours; +- hash-confirmed runtime `MonoBehaviour` generation with blocked high-risk APIs, persistent compilation checks, optional attachment, and checkpoint rollback; +- high-level proximity door, pickup, and multi-target activator composition with direct scene-reference wiring; - prefab assets, variants, edits, and override management; - Project Settings, Build Settings, package changes, and durable hashed checkpoints; +- correlated JSON/Markdown audit reports with hashed before/after evidence; - controlled command routing for approved Editor operations. -Broad authoring remains declarative: the plugin does not execute arbitrary generated C# or invoke arbitrary methods through reflection. +Broad authoring remains declarative. Custom runtime components pass a conservative source policy and exact-hash confirmation before compilation; arbitrary Editor code and reflective method invocation are not exposed. diff --git a/docs/capability-contract.md b/docs/capability-contract.md index 6445b6e..7efab40 100644 --- a/docs/capability-contract.md +++ b/docs/capability-contract.md @@ -52,13 +52,16 @@ verification: | Family | Purpose | |--------|---------| | `unity.project.*` | Inspect Unity project structure and settings. | +| `unity.audit.*` | Correlate persisted events with hashed before/after evidence and produce reports. | | `unity.assets.*` | Inspect project assets and asset metadata. | +| `unity.assets.import` | Copy/download external content, configure importers, instantiate models, and save prefabs. | | `unity.asset.*` | Inspect a specific asset, including dependencies. | | `unity.prefabs.*` | List prefab assets. | | `unity.prefab.*` | Inspect a specific prefab asset. | | `unity.scenes.*` | List Unity scenes. | | `unity.scene.*` | Inspect hierarchy/component state and apply controlled, atomic scene authoring batches. | -| `unity.scripts.*` | Inspect C# scripts visible to Unity. | +| `unity.gameplay.*` | Compose high-level doors, pickups, and activators from existing scene objects. | +| `unity.scripts.*` | Inspect C# scripts or author gated runtime `MonoBehaviour` components with compile verification. | | `unity.assemblies.*` | Inspect Unity script assemblies. | | `unity.console.*` | Read, summarize, and verify logs. | | `unity.vision.*` | Capture ready screenshots, compare before/after artifacts, generate diffs, and detect visual regressions. | diff --git a/docs/content-authoring.md b/docs/content-authoring.md new file mode 100644 index 0000000..3edfafc --- /dev/null +++ b/docs/content-authoring.md @@ -0,0 +1,148 @@ +# Content Authoring + +The control plane supports a closed content workflow: + +```text +acquire → import → configure → instantiate → animate → add behaviour → verify +``` + +## Import a model + +`unity.assets.import` accepts a local absolute file path or a remote URL and always writes under `Assets/`. + +```json +{ + "dryRun": false, + "confirm": true, + "sourceKind": "url", + "url": "https://example.com/robot.fbx", + "destinationPath": "Assets/Imported/Robot/robot.fbx", + "expectedSha256": "64-character-sha256", + "model": { + "globalScale": 1, + "animationType": "generic", + "importAnimation": true, + "addCollider": false + }, + "instantiate": true, + "objectName": "Robot", + "saveAsPrefabPath": "Assets/Imported/Robot/Robot.prefab" +} +``` + +Remote production imports require HTTPS. Every transfer is bounded by `maxBytes` and `timeoutSeconds`; private-network destinations are rejected. Supplying `expectedSha256` is recommended. + +FBX, OBJ, DAE, 3DS, and DXF use Unity's built-in model importer. GLB/glTF paths are accepted when the project has a compatible glTF importer package installed. + +## Create animation logic + +Use `unity.assets.author` with `kind: "animation_clip"` to write curves and `kind: "animator_controller"` to assemble states, parameters, transitions, and conditions. + +Assign the generated controller with `unity.scene.batch`: + +```json +{ + "dryRun": false, + "confirm": true, + "operations": [ + { + "kind": "add_component", + "targetPath": "Robot", + "componentType": "UnityEngine.Animator" + }, + { + "kind": "set_property", + "targetPath": "Robot", + "componentType": "UnityEngine.Animator", + "propertyPath": "m_Controller", + "value": { + "kind": "object_reference", + "assetPath": "Assets/Imported/Robot/Robot.controller" + } + } + ] +} +``` + +## Add functionality + +`unity.scene.batch` can add any unambiguous compiled `Component` already present in Unity, the project, or an installed package, then configure its serialized fields. + +The control-plane runtime package includes: + +- `UnityAI.ControlPlane.Runtime.ContinuousRotation` +- `UnityAI.ControlPlane.Runtime.BobbingMotion` +- `UnityAI.ControlPlane.Runtime.PulseScale` + +This keeps common authoring declarative. + +## Compose gameplay templates + +`unity.gameplay.compose` converts existing scene objects into complete, reusable gameplay mechanics without generating a unique script for every object. One confirmed request can atomically configure: + +- a proximity door with opening offset, speed, close distance, and explicit interactor; +- a pickup with value, identity, collection action, spin, and bob motion; +- a proximity activator that activates, deactivates, or toggles up to 32 target objects. + +```json +{ + "dryRun": false, + "confirm": true, + "templates": [ + { + "kind": "door", + "targetPath": "Environment/MainDoor", + "interactorPath": "Player", + "activationDistance": 2.5, + "deactivationDistance": 3.5, + "openOffset": { "x": 0, "y": 3, "z": 0 }, + "speed": 4 + }, + { + "kind": "pickup", + "targetPath": "Items/Crystal", + "interactorPath": "Player", + "pickupId": "crystal-blue", + "value": 25, + "collectAction": "deactivate" + }, + { + "kind": "activator", + "targetPath": "Triggers/SecretRoom", + "interactorPath": "Player", + "affectedPaths": ["Environment/HiddenBridge", "UI/SecretFound"], + "action": "activate", + "oneShot": true + } + ] +} +``` + +The operation requires a saved scene, creates a durable scene checkpoint, applies all templates in one Undo group, verifies every component and reference, and rolls the in-memory transaction back if any template fails. An explicit `interactorPath` is preferred; `interactorTag` is available as a reusable fallback. + +## Generate custom runtime behaviour + +`unity.scripts.author` creates a project `MonoBehaviour` through a gated workflow: + +1. Submit the exact source in dry-run mode. +2. Review the validation result and returned SHA-256. +3. Resubmit with `dryRun: false`, `confirm: true`, and that exact `expectedSourceSha256`. +4. Unity writes the source behind a durable checkpoint, recompiles, resolves the concrete class, and optionally attaches it to a GameObject. +5. Compilation or attachment failure restores the checkpoint by default. + +Generated code is limited to runtime components. The validator rejects Editor APIs, edit-time callbacks, file and network access, process execution, reflection, native interop, unsafe code, static constructors, and application termination. The filename, class, namespace, and `MonoBehaviour` inheritance are verified again after compilation. + +```json +{ + "dryRun": false, + "confirm": true, + "path": "Assets/Generated/DoorTrigger.cs", + "source": "using UnityEngine;\nnamespace Game.Generated { public sealed class DoorTrigger : MonoBehaviour { public float speed = 2f; private void Update() { transform.Translate(Vector3.up * speed * Time.deltaTime); } } }\n", + "expectedSourceSha256": "sha256-returned-by-the-dry-run", + "expectedClassName": "DoorTrigger", + "expectedNamespace": "Game.Generated", + "attachToObjectPath": "Environment/Door" +} +``` + +This source policy is a risk gate, not a formal security sandbox. Generated gameplay code should still be reviewed and tested like any other project code. diff --git a/docs/control-plane-operations.md b/docs/control-plane-operations.md index 568eb08..3312527 100644 --- a/docs/control-plane-operations.md +++ b/docs/control-plane-operations.md @@ -37,9 +37,26 @@ UnityAIArtifacts/Builds - `.shader` source; - `.mat` shader/properties/keywords/render queue; - `.anim` curve bindings and keyframes; +- `.controller` Animator parameters, states, clips, transitions, and conditions; - generated PCM16 `.wav` tones; - audio importer settings. +`unity.assets.import` supports: + +- local absolute source files with the explicit `read_external_files` permission; +- HTTPS downloads with redirect, private-address, timeout, byte-limit, and optional SHA-256 checks; +- explicit loopback HTTP only for local testing; +- FBX, OBJ, DAE, 3DS, and DXF model import, plus GLB/glTF when a compatible importer package is installed; +- common texture and audio formats; +- `ModelImporter`, `TextureImporter`, and `AudioImporter` settings; +- optional scene instantiation and prefab creation. + +Imported objects can be composed through `unity.scene.batch` with built-in, project, or package components. The package includes `ContinuousRotation`, `BobbingMotion`, and `PulseScale` runtime behaviours. + +`unity.scripts.author` extends composition to custom runtime behaviours. It requires a validated dry-run SHA-256, creates a durable checkpoint, blocks high-risk API families and edit-time execution, waits across Unity domain reloads, verifies the compiled `MonoBehaviour`, optionally attaches it, and restores the checkpoint on failure. + +`unity.gameplay.compose` operates one level above component editing. It configures reusable proximity doors, pickups, and activators, resolves direct scene references, checkpoints the saved scene, applies all requested templates atomically, and verifies the resulting component state. + `unity.prefab.manage` supports: - save a scene object as a prefab; @@ -58,3 +75,15 @@ UnityAIArtifacts/Checkpoints `unity.checkpoints.restore` optionally creates a safety checkpoint, restores files, removes paths that were originally absent, refreshes Unity, and verifies hashes. This provides rollback beyond the lifetime and scope of Unity Undo. All mutating operations require the local bridge token. High-impact operations also default to `dryRun: true` or require `confirm: true`. + +## Audit reports + +`unity.audit.report` filters persisted JSONL events by request ID, correlation ID, capability, or UTC interval. It accepts project-relative evidence files only under `UnityAIArtifacts`, verifies each file with SHA-256, validates verification-to-evidence references, and can require complete `before` and `after` phases. + +Each call writes a machine-readable JSON report and a Markdown report under: + +```text +UnityAIArtifacts/Audit/Reports +``` + +A report is `passed` only when supplied evidence exists, verification claims pass, requested audit filters match, and any required before/after pair is complete. diff --git a/docs/local-bridge.md b/docs/local-bridge.md index d13d545..4bb50e1 100644 --- a/docs/local-bridge.md +++ b/docs/local-bridge.md @@ -52,10 +52,12 @@ The bridge handles: - `POST /capabilities/unity.scene.inspect_game_object` - `POST /capabilities/unity.scene.upsert_game_object` - `POST /capabilities/unity.scene.batch` +- `POST /capabilities/unity.gameplay.compose` - `POST /capabilities/unity.prefabs.list` - `POST /capabilities/unity.prefab.inspect` - `POST /capabilities/unity.asset.dependencies` - `POST /capabilities/unity.scripts.list` +- `POST /capabilities/unity.scripts.author` - `POST /capabilities/unity.assemblies.list` - `POST /capabilities/unity.packages.list` - `POST /capabilities/unity.project.settings.inspect` @@ -92,10 +94,12 @@ The MCP server runs over stdio and exposes: - `unity.scene.inspect_game_object` - `unity.scene.upsert_game_object` - `unity.scene.batch` +- `unity.gameplay.compose` - `unity.prefabs.list` - `unity.prefab.inspect` - `unity.asset.dependencies` - `unity.scripts.list` +- `unity.scripts.author` - `unity.assemblies.list` - `unity.packages.list` - `unity.project.settings.inspect` diff --git a/docs/roadmap.md b/docs/roadmap.md index 76d167a..efac1e8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -51,7 +51,7 @@ Verified tools: - [x] Propose a safe fix plan. - [x] Apply one controlled Editor-side change. - [x] Recompile and verify console state. -- [ ] Produce an audit report with before/after evidence. +- [x] Produce hashed JSON and Markdown audit reports with correlated events and before/after evidence. ## Milestone 3.5 — Real project observability @@ -72,6 +72,21 @@ Verified tools: - [x] Add prefab asset authoring and override application/revert operations. - [x] Add material/shader/audio and animation mutation helpers. +## Milestone 3.75 — Content creation and editing + +- [x] Import local 3D models, textures, and audio with bounded size and SHA-256 verification. +- [x] Download remote assets over HTTPS with redirect and private-network protections. +- [x] Configure model, texture, and audio import settings. +- [x] Instantiate imported models and optionally save prefab assets. +- [x] Create Animator Controllers from authored or imported clips. +- [x] Add reusable runtime motion behaviours and compose them through scene batches. +- [x] Generate hash-confirmed runtime MonoBehaviours with compilation verification, optional attachment, and automatic checkpoint rollback. +- [x] Add atomic high-level templates for proximity doors, pickups, and multi-target activators. +- [ ] Inspect and automatically wire imported materials and texture maps. +- [ ] Add humanoid avatar configuration, animation retargeting, masks, and blend-tree authoring. +- [ ] Add license-aware asset catalog adapters and provenance metadata. +- [ ] Add higher-level UI, contextual audio, dialogue, quest, inventory, and XR interaction templates. + ## Milestone 4 — Meta XR readiness - [x] Validate Android build target, OpenXR/Meta OpenXR packages, loader/features, and Quest build settings. diff --git a/docs/safety-model.md b/docs/safety-model.md index 37335ad..031012a 100644 --- a/docs/safety-model.md +++ b/docs/safety-model.md @@ -15,4 +15,10 @@ The product should be powerful enough to operate real Unity projects, but contro ## Default stance -Read-only and report-only operations are safe by default. Mutations, generated Editor scripts, package changes, build changes, and destructive actions require stronger gates. +Read-only and report-only operations are safe by default. Mutations, generated scripts, package changes, build changes, and destructive actions require stronger gates. + +## Generated runtime code + +`unity.scripts.author` does not expose unrestricted Editor scripting. It only accepts project `MonoBehaviour` source after a dry-run validation and exact SHA-256 confirmation. The operation blocks Editor APIs, edit-time callbacks, process, file, network, reflection, native interop, unsafe code, and termination APIs; checkpoints the target; recompiles through a persistent job; verifies the resolved class; and rolls back by default when compilation or attachment fails. + +These controls are defense in depth, not a C# sandbox. A source-pattern policy cannot prove arbitrary gameplay code harmless, so generated components still require normal code review, tests, and project-level trust. diff --git a/docs/scaffold-status.md b/docs/scaffold-status.md index c76a72a..8f8211e 100644 --- a/docs/scaffold-status.md +++ b/docs/scaffold-status.md @@ -16,6 +16,7 @@ Current status: the repository has a working MCP-to-Unity control plane with obs - Unity bridge routes the first controlled act capability: `unity.editor.create_empty_game_object` with dry-run, structured audit events, and verification signals. - Unity bridge exposes detailed GameObject/component inspection and atomic scene batches with automatic Undo rollback on failure. - The first act capability persists audit events to `UnityAIArtifacts/Audit/events.jsonl`. +- `unity.audit.report` correlates persisted events with hashed before/after evidence and writes JSON/Markdown reports. - The first act capability requires explicit `confirm: true` for scene mutation. - Narrow Unity Undo verification is available through `unity.editor.undo_last_operation` for the first create→undo flow. - Act and rollback responses plus persisted audit events include request/correlation IDs. @@ -27,6 +28,9 @@ Current status: the repository has a working MCP-to-Unity control plane with obs - Persistent jobs cover Edit/Play tests, Play Mode transitions, compilation waits, Android builds, package changes, and Meta XR setup. - Durable checkpoints hash project files and can restore files that changed or remove assets that did not exist at checkpoint time. - Asset authoring covers shaders, materials, animation clips, WAV generation, and audio importer settings. +- External asset authoring covers local/HTTPS model, texture, and audio import, importer settings, model instantiation, prefab creation, Animator Controllers, and reusable runtime behaviours. +- Runtime script authoring covers source validation, exact-hash confirmation, durable checkpoints, domain-reload-safe compilation verification, component attachment, and automatic rollback. +- Gameplay composition covers atomic proximity doors, collectible pickups, and multi-target activators with direct scene-reference wiring and Play Mode verification. - Prefab management covers save, variants, prefab-content edits, and apply/revert overrides. - Android/Quest validation and Meta OpenXR configuration are routed through MCP. diff --git a/docs/unity-package-verification.md b/docs/unity-package-verification.md index aa48a71..966ca5b 100644 --- a/docs/unity-package-verification.md +++ b/docs/unity-package-verification.md @@ -80,6 +80,8 @@ Verified tools: - `unity.prefab.inspect` - `unity.asset.dependencies` - `unity.scripts.list` +- `unity.scripts.author` rejection, dry-run hashing, compile-failure rollback, attachment, and Play Mode execution +- `unity.gameplay.compose` dry-run planning, checkpointed component wiring, and simultaneous door/pickup/activator Play Mode effects - `unity.assemblies.list` - `unity.packages.list` - `unity.project.settings.inspect` From fde4e48e7a96d81a36fc2fef11a567035b1eeecd Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:28:31 -0500 Subject: [PATCH 07/28] chore(unity): add stable package asset metadata --- .../Packages/com.unity-ai.control-plane/Editor.meta | 8 ++++++++ .../Packages/com.unity-ai.control-plane/Editor/Act.meta | 8 ++++++++ .../Editor/Act/CreateEmptyGameObjectOperation.cs.meta | 2 ++ .../Editor/Act/GameplayComposeOperation.cs.meta | 2 ++ .../Editor/Act/ProjectSettingsUpdateOperation.cs.meta | 2 ++ .../Editor/Act/SceneBatchOperation.cs.meta | 2 ++ .../Editor/Act/SceneUpsertGameObjectOperation.cs.meta | 2 ++ .../Editor/Act/UndoLastOperation.cs.meta | 2 ++ .../com.unity-ai.control-plane/Editor/Assets.meta | 8 ++++++++ .../Editor/Assets/AssetAuthoringOperation.cs.meta | 2 ++ .../Editor/Assets/AssetImportOperation.cs.meta | 2 ++ .../Packages/com.unity-ai.control-plane/Editor/Audit.meta | 8 ++++++++ .../Editor/Audit/AuditLogStore.cs.meta | 2 ++ .../Editor/Audit/AuditReportGenerator.cs.meta | 2 ++ .../com.unity-ai.control-plane/Editor/Bridge.meta | 8 ++++++++ .../Editor/Bridge/UnityAiBridgeBatchRunner.cs.meta | 2 ++ .../Editor/Bridge/UnityAiBridgeServer.cs.meta | 2 ++ .../Packages/com.unity-ai.control-plane/Editor/Build.meta | 8 ++++++++ .../Editor/Build/BuildOperations.cs.meta | 2 ++ .../com.unity-ai.control-plane/Editor/Checkpoints.meta | 8 ++++++++ .../Editor/Checkpoints/DurableCheckpointStore.cs.meta | 2 ++ .../Packages/com.unity-ai.control-plane/Editor/Jobs.meta | 8 ++++++++ .../Editor/Jobs/CompilationController.cs.meta | 2 ++ .../Editor/Jobs/PackageOperations.cs.meta | 2 ++ .../Editor/Jobs/PlayModeController.cs.meta | 2 ++ .../Editor/Jobs/UnityAiJobStore.cs.meta | 2 ++ .../com.unity-ai.control-plane/Editor/MetaXR.meta | 8 ++++++++ .../Editor/MetaXR/MetaXrConfigurationController.cs.meta | 2 ++ .../Editor/MetaXR/MetaXrValidator.cs.meta | 2 ++ .../com.unity-ai.control-plane/Editor/Observe.meta | 8 ++++++++ .../Editor/Observe/AssetDependencyObserver.cs.meta | 2 ++ .../Editor/Observe/AssetListObserver.cs.meta | 2 ++ .../Editor/Observe/ConsoleLogBridge.cs.meta | 2 ++ .../Editor/Observe/GameObjectInspector.cs.meta | 2 ++ .../Editor/Observe/PackageListObserver.cs.meta | 2 ++ .../Editor/Observe/PrefabObserver.cs.meta | 2 ++ .../Editor/Observe/ProjectInspector.cs.meta | 2 ++ .../Editor/Observe/ProjectSettingsInspector.cs.meta | 2 ++ .../Editor/Observe/ProjectSnapshotObserver.cs.meta | 2 ++ .../Editor/Observe/SceneInspector.cs.meta | 2 ++ .../Editor/Observe/SceneListObserver.cs.meta | 2 ++ .../Editor/Observe/ScreenshotCapture.cs.meta | 2 ++ .../Editor/Observe/ScriptAndAssemblyObserver.cs.meta | 2 ++ .../Editor/Observe/VisualComparison.cs.meta | 2 ++ .../com.unity-ai.control-plane/Editor/Prefabs.meta | 8 ++++++++ .../Editor/Prefabs/PrefabAssetOperation.cs.meta | 2 ++ .../com.unity-ai.control-plane/Editor/Scripts.meta | 8 ++++++++ .../Editor/Scripts/ScriptAuthoringOperation.cs.meta | 2 ++ .../com.unity-ai.control-plane/Editor/TestRunner.meta | 8 ++++++++ .../Unity.AI.ControlPlane.Editor.TestRunner.asmdef.meta | 7 +++++++ .../Editor/TestRunner/UnityAiTestRunnerAdapter.cs.meta | 2 ++ .../Packages/com.unity-ai.control-plane/Editor/Tests.meta | 8 ++++++++ .../Editor/Tests/TestOperation.cs.meta | 2 ++ .../Editor/Unity.AI.ControlPlane.Editor.asmdef.meta | 7 +++++++ .../Editor/UnityAiControlPlaneWindow.cs.meta | 2 ++ .../Packages/com.unity-ai.control-plane/LICENSE.md.meta | 7 +++++++ .../Packages/com.unity-ai.control-plane/Runtime.meta | 8 ++++++++ .../Runtime/BobbingMotion.cs.meta | 2 ++ .../Runtime/ContinuousRotation.cs.meta | 2 ++ .../Runtime/ProximityActivator.cs.meta | 2 ++ .../Runtime/ProximityDoor.cs.meta | 2 ++ .../Runtime/ProximityInteractorResolver.cs.meta | 2 ++ .../Runtime/ProximityPickup.cs.meta | 2 ++ .../com.unity-ai.control-plane/Runtime/PulseScale.cs.meta | 2 ++ .../Runtime/Unity.AI.ControlPlane.Runtime.asmdef.meta | 7 +++++++ .../Packages/com.unity-ai.control-plane/package.json.meta | 7 +++++++ 66 files changed, 247 insertions(+) create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/CreateEmptyGameObjectOperation.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/GameplayComposeOperation.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/ProjectSettingsUpdateOperation.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/SceneBatchOperation.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/SceneUpsertGameObjectOperation.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Act/UndoLastOperation.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetAuthoringOperation.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Assets/AssetImportOperation.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit/AuditLogStore.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Audit/AuditReportGenerator.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge/UnityAiBridgeBatchRunner.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge/UnityAiBridgeServer.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Build.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Build/BuildOperations.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Checkpoints.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Checkpoints/DurableCheckpointStore.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/CompilationController.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/PackageOperations.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/PlayModeController.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Jobs/UnityAiJobStore.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/MetaXR.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/MetaXR/MetaXrConfigurationController.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/MetaXR/MetaXrValidator.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/AssetDependencyObserver.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/AssetListObserver.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ConsoleLogBridge.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/GameObjectInspector.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PackageListObserver.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PrefabObserver.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectInspector.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSettingsInspector.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSnapshotObserver.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/SceneInspector.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/SceneListObserver.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ScreenshotCapture.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ScriptAndAssemblyObserver.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/VisualComparison.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Prefabs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Prefabs/PrefabAssetOperation.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Scripts.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Scripts/ScriptAuthoringOperation.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/TestRunner.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/TestRunner/Unity.AI.ControlPlane.Editor.TestRunner.asmdef.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/TestRunner/UnityAiTestRunnerAdapter.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests/TestOperation.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Unity.AI.ControlPlane.Editor.asmdef.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/UnityAiControlPlaneWindow.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/LICENSE.md.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/BobbingMotion.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ContinuousRotation.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityActivator.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityDoor.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityInteractorResolver.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityPickup.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/PulseScale.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/Unity.AI.ControlPlane.Runtime.asmdef.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/package.json.meta 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.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.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.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.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.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.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.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.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/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.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.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.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/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.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.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.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/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.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.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.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/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/Unity.AI.ControlPlane.Editor.asmdef.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Unity.AI.ControlPlane.Editor.asmdef.meta new file mode 100644 index 0000000..6dac983 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Unity.AI.ControlPlane.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6a428d10211a549a29221b8208e93b95 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/UnityAiControlPlaneWindow.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/UnityAiControlPlaneWindow.cs.meta new file mode 100644 index 0000000..eff7e5a --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/UnityAiControlPlaneWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: de63fb08ff6a4425487195e5583fd931 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/LICENSE.md.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/LICENSE.md.meta new file mode 100644 index 0000000..ea17795 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/LICENSE.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ea65e4d24b35344d79a3d203ffb72b5c +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime.meta new file mode 100644 index 0000000..9e3ac07 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0a1a31effd9714b87817d385023389f8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/BobbingMotion.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/BobbingMotion.cs.meta new file mode 100644 index 0000000..2e7c48b --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/BobbingMotion.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6c15cc5f23e9e4f2486cecdfefdcf597 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ContinuousRotation.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ContinuousRotation.cs.meta new file mode 100644 index 0000000..6887dec --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ContinuousRotation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 062989dd0326b4c89811a0a5ebbfb35f \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityActivator.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityActivator.cs.meta new file mode 100644 index 0000000..c3ca396 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityActivator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7d5a88bb2ca9841ac9b0785e22679478 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityDoor.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityDoor.cs.meta new file mode 100644 index 0000000..94e8f16 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityDoor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7ed494ca2eee74524bdee2062685c2f5 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityInteractorResolver.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityInteractorResolver.cs.meta new file mode 100644 index 0000000..e0c6c0e --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityInteractorResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5a082d52697b74056bfa9470bf6c7391 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityPickup.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityPickup.cs.meta new file mode 100644 index 0000000..098e449 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/ProximityPickup.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 37dab4475772c4dd08f1805fb7226aa7 \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/PulseScale.cs.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/PulseScale.cs.meta new file mode 100644 index 0000000..2567e4f --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/PulseScale.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9f1240f38ecb24219a6707770569f4ef \ No newline at end of file diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/Unity.AI.ControlPlane.Runtime.asmdef.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/Unity.AI.ControlPlane.Runtime.asmdef.meta new file mode 100644 index 0000000..dec65b9 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/Unity.AI.ControlPlane.Runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a484f651da86d49e2b5527c8db766f24 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/apps/unity-plugin/Packages/com.unity-ai.control-plane/package.json.meta b/apps/unity-plugin/Packages/com.unity-ai.control-plane/package.json.meta new file mode 100644 index 0000000..abc09d2 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e733bc634bdd04c38b6f6b608f288cfa +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From eec15215513e60ff89b299d6288cb020760e60d6 Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:37:04 -0500 Subject: [PATCH 08/28] fix(bridge): survive Unity domain reloads --- apps/mcp-server/src/index.ts | 16 +- apps/mcp-server/src/unity-bridge-client.ts | 209 +++++++++++-- .../Editor/Bridge/UnityAiBridgeServer.cs | 277 +++++++++++++++++- package.json | 1 + scripts/verify-bridge-retry.mjs | 87 ++++++ 5 files changed, 558 insertions(+), 32 deletions(-) create mode 100644 scripts/verify-bridge-retry.mjs diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index e75cc26..55330de 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -8,7 +8,13 @@ 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 server = new McpServer({ @@ -996,8 +1002,12 @@ async function bridgeTool(capability: string, input: unknown = {}) { } 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/Bridge/UnityAiBridgeServer.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Bridge/UnityAiBridgeServer.cs index b570afa..b25e57a 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); @@ -136,27 +158,47 @@ private static void RestoreAfterDomainReload() { if (IsRunning || !SessionState.GetBool(SessionEnabledKey, false)) { + EditorApplication.update -= RetryRestoreWhenReady; return; } + if (_restoreDeadlineAt <= 0) + { + _restoreDeadlineAt = EditorApplication.timeSinceStartup + 90; + } + try { StartInternal(SessionState.GetString(SessionTokenKey, string.Empty), false); } - 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 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 +274,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) diff --git a/package.json b/package.json index 00f684d..670cc73 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "build": "tsc -b", "setup:user": "node scripts/setup-user.mjs", "typecheck": "tsc -b", + "verify:bridge-retry": "npm run build && node scripts/verify-bridge-retry.mjs", "verify:unity-package": "node scripts/verify-unity-package.mjs", "verify:mcp-unity-e2e": "npm run build && node scripts/verify-mcp-unity-e2e.mjs" }, diff --git a/scripts/verify-bridge-retry.mjs b/scripts/verify-bridge-retry.mjs new file mode 100644 index 0000000..923eeeb --- /dev/null +++ b/scripts/verify-bridge-retry.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { createServer } from "node:http"; + +import { UnityBridgeClient } from "../apps/mcp-server/dist/unity-bridge-client.js"; + +const received = []; +let postAttempts = 0; +const server = createServer(async (request, response) => { + if (request.method === "GET" && request.url === "/health") { + response.writeHead(200, { "content-type": "application/json" }); + response.end('{"status":"ok"}'); + return; + } + + const body = await readBody(request); + const envelope = JSON.parse(body); + received.push(envelope); + postAttempts += 1; + + if (postAttempts === 1) { + request.socket.destroy(); + return; + } + + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + ok: true, + capability: "unity.project.inspect", + requestId: envelope.requestId, + correlationId: envelope.correlationId, + resultJson: '{"status":"recovered"}' + })); +}); + +await listen(server); +const address = server.address(); +assert(address && typeof address === "object"); + +try { + const client = new UnityBridgeClient({ + baseUrl: `http://127.0.0.1:${address.port}`, + timeoutMs: 1_000, + retry: { + maxAttempts: 4, + maxElapsedMs: 5_000, + baseDelayMs: 10, + maxDelayMs: 50 + } + }); + const result = await client.call("unity.project.inspect"); + + assert.equal(result.ok, true); + assert.equal(result.attempts, 2); + assert.equal(result.recoveredAfterRetry, true); + assert.equal(received.length, 2); + assert.equal(received[0].requestId, received[1].requestId); + assert.equal(received[0].correlationId, received[1].correlationId); + console.log("Bridge retry verification passed: the request recovered with stable idempotency IDs."); +} finally { + await close(server); +} + +function readBody(request) { + return new Promise((resolve, reject) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { + body += chunk; + }); + request.on("end", () => resolve(body)); + request.on("error", reject); + }); +} + +function listen(httpServer) { + return new Promise((resolve, reject) => { + httpServer.once("error", reject); + httpServer.listen(0, "127.0.0.1", resolve); + }); +} + +function close(httpServer) { + return new Promise((resolve, reject) => { + httpServer.close((error) => error ? reject(error) : resolve()); + }); +} From 07b4a8d1be6b1bc64fe3afe1ed053eb331c5d99a Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:39:27 -0500 Subject: [PATCH 09/28] feat(unity): expose render and input environment --- .../Editor/Observe/ProjectInspector.cs | 220 ++++++++++++++++++ .../Editor/Observe/ProjectSnapshotObserver.cs | 19 +- scripts/verify-mcp-unity-e2e.mjs | 26 ++- 3 files changed, 262 insertions(+), 3 deletions(-) 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/ProjectSnapshotObserver.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSnapshotObserver.cs index c50835e..9b8b4d2 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(); @@ -149,6 +150,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()); @@ -426,6 +428,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; } @@ -449,6 +461,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,7 +476,7 @@ 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) { diff --git a/scripts/verify-mcp-unity-e2e.mjs b/scripts/verify-mcp-unity-e2e.mjs index 660b868..52a31eb 100644 --- a/scripts/verify-mcp-unity-e2e.mjs +++ b/scripts/verify-mcp-unity-e2e.mjs @@ -210,7 +210,7 @@ try { assertCapabilities(await callJsonTool(client, "unity.capabilities.list", {})); await assertApplyFixFlow(client); - await callJsonTool(client, "unity.project.inspect", {}); + assertProjectInspect(await callJsonTool(client, "unity.project.inspect", {})); await callJsonTool(client, "unity.console.read", {}); assertConsoleDiagnostics(await waitForConsoleDiagnostics(client, 60_000)); assertConsoleFixPlans(await callJsonTool(client, "unity.console.plan_fix", {})); @@ -2690,6 +2690,24 @@ function assertProjectSettings(report) { } } +function assertProjectInspect(report) { + if (!report?.renderPipeline || !["built_in", "urp", "hdrp", "custom"].includes(report.renderPipeline.kind)) { + fail(`unity.project.inspect returned an invalid render pipeline environment: ${JSON.stringify(report?.renderPipeline)}.`); + } + + if (typeof report.renderPipeline.recommendedShader !== "string" || report.renderPipeline.recommendedShader.length === 0) { + fail("unity.project.inspect must expose a recommended shader for the effective render pipeline."); + } + + if (!report?.inputSystem || !["legacy", "input_system", "both", "unknown"].includes(report.inputSystem.mode)) { + fail(`unity.project.inspect returned an invalid input system environment: ${JSON.stringify(report?.inputSystem)}.`); + } + + if (typeof report.inputSystem.recommendedApi !== "string" || report.inputSystem.recommendedApi.length === 0 || !Array.isArray(report.compatibilityWarnings)) { + fail("unity.project.inspect must expose input API guidance and compatibility warnings."); + } +} + function assertProjectSnapshot(snapshot) { if (!snapshot || typeof snapshot !== "object") { fail("unity.project.snapshot returned an invalid snapshot shape."); @@ -2736,6 +2754,10 @@ function assertProjectSnapshot(snapshot) { fail("unity.project.snapshot returned an invalid bounded packages summary."); } + if (!snapshot.environment?.renderPipeline || !snapshot.environment?.inputSystem || !Array.isArray(snapshot.environment.compatibilityWarnings)) { + fail(`unity.project.snapshot returned an invalid mandatory environment summary: ${JSON.stringify(snapshot.environment)}.`); + } + if (!snapshot.metaXr || typeof snapshot.metaXr.likelyMetaXrInstalled !== "boolean" || !Array.isArray(snapshot.metaXr.findings) || snapshot.metaXr.findings.length > 10) { fail("unity.project.snapshot returned an invalid Meta XR summary."); } @@ -2756,7 +2778,7 @@ function assertProjectSnapshot(snapshot) { fail(`unity.project.snapshot did not include fact-based recommended actions: ${JSON.stringify(snapshot.recommendedNextActions)}.`); } - if (!Array.isArray(snapshot.verificationSignals) || !snapshot.verificationSignals.includes("structured_observation") || !snapshot.verificationSignals.includes("console_diagnostics")) { + if (!Array.isArray(snapshot.verificationSignals) || !snapshot.verificationSignals.includes("structured_observation") || !snapshot.verificationSignals.includes("console_diagnostics") || !snapshot.verificationSignals.includes("environment_introspected")) { fail(`unity.project.snapshot did not include expected verification signals: ${JSON.stringify(snapshot.verificationSignals)}.`); } From 27c7edf3cd972df6513cd0de9d7000e5d2e8a881 Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:40:22 -0500 Subject: [PATCH 10/28] fix(mcp): publish complete local tool schemas --- apps/mcp-server/generated/tool-schemas.json | 3801 +++++++++++++++++++ package.json | 3 + scripts/sync-mcp-tool-schemas.mjs | 118 + 3 files changed, 3922 insertions(+) create mode 100644 apps/mcp-server/generated/tool-schemas.json create mode 100644 scripts/sync-mcp-tool-schemas.mjs diff --git a/apps/mcp-server/generated/tool-schemas.json b/apps/mcp-server/generated/tool-schemas.json new file mode 100644 index 0000000..4e8c7e7 --- /dev/null +++ b/apps/mcp-server/generated/tool-schemas.json @@ -0,0 +1,3801 @@ +{ + "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.import", + "description": "Copy or download a model, texture, or audio file, apply importer settings, and optionally instantiate it 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" + } + }, + "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.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": "Diagnose Unity Console compiler/runtime issues as structured, read-only guidance.", + "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 summary and recent log entries through the local Unity Editor bridge.", + "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.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 and player settings.", + "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, 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" + }, + "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.scene.batch", + "description": "Apply an atomic, undo-backed batch of hierarchy, prefab, component, and serialized-property scene 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": [ + { + "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", + "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 the active Unity scene hierarchy at a high level.", + "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 + } + }, + "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 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 + } + }, + "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/package.json b/package.json index 670cc73..6e3ca40 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,9 @@ ], "scripts": { "build": "tsc -b", + "schemas:antigravity": "npm run build && node scripts/sync-mcp-tool-schemas.mjs --antigravity", + "schemas:check": "npm run build && node scripts/sync-mcp-tool-schemas.mjs --check", + "schemas:generate": "npm run build && node scripts/sync-mcp-tool-schemas.mjs --write-repo", "setup:user": "node scripts/setup-user.mjs", "typecheck": "tsc -b", "verify:bridge-retry": "npm run build && node scripts/verify-bridge-retry.mjs", diff --git a/scripts/sync-mcp-tool-schemas.mjs b/scripts/sync-mcp-tool-schemas.mjs new file mode 100644 index 0000000..175ff47 --- /dev/null +++ b/scripts/sync-mcp-tool-schemas.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +const repoRoot = resolve(new URL("..", import.meta.url).pathname); +const serverEntry = join(repoRoot, "apps/mcp-server/dist/index.js"); +const generatedManifestPath = join(repoRoot, "apps/mcp-server/generated/tool-schemas.json"); +const args = new Set(process.argv.slice(2)); + +if (!existsSync(serverEntry)) { + fail(`MCP server build not found: ${serverEntry}. Run npm run build first.`); +} + +if (!args.has("--write-repo") && !args.has("--check") && !args.has("--antigravity")) { + fail("Pass --write-repo, --check, or --antigravity."); +} + +const tools = await readTools(); +const records = tools + .map((tool) => ({ + name: tool.name, + description: tool.description ?? "", + parameters: requireSchema(tool.name, tool.inputSchema) + })) + .sort((left, right) => left.name.localeCompare(right.name)); +const manifest = `${JSON.stringify({ + schemaVersion: 1, + server: "unity-ai-control-plane", + tools: records +}, null, 2)}\n`; + +if (args.has("--check")) { + if (!existsSync(generatedManifestPath) || readFileSync(generatedManifestPath, "utf8") !== manifest) { + fail(`Generated tool schemas are stale. Run npm run schemas:generate.`); + } + + console.log(`Verified ${records.length} generated MCP tool schemas.`); +} + +if (args.has("--write-repo")) { + writeAtomic(generatedManifestPath, manifest); + console.log(`Generated ${records.length} MCP tool schemas at ${generatedManifestPath}.`); +} + +if (args.has("--antigravity")) { + for (const directory of antigravitySchemaDirectories()) { + syncSchemaDirectory(directory, records); + console.log(`Synchronized ${records.length} Antigravity tool schemas in ${directory}.`); + } +} + +async function readTools() { + const client = new Client({ name: "unity-ai-schema-sync", version: "0.1.0" }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [serverEntry], + env: process.env, + stderr: "pipe" + }); + + try { + await client.connect(transport); + const result = await client.listTools(); + return result.tools; + } finally { + await client.close(); + } +} + +function requireSchema(toolName, schema) { + if (!schema || typeof schema !== "object" || Array.isArray(schema) || schema.type !== "object") { + fail(`Tool ${toolName} did not publish a valid object inputSchema.`); + } + + return schema; +} + +function antigravitySchemaDirectories() { + const configured = process.env.UNITY_AI_ANTIGRAVITY_SCHEMA_DIRS; + if (configured) { + return configured.split(":").map((value) => resolve(value)).filter(Boolean); + } + + return [ + join(homedir(), ".gemini/antigravity/mcp/unity-ai"), + join(homedir(), ".gemini/antigravity-cli/mcp/unity-ai") + ]; +} + +function syncSchemaDirectory(directory, schemaRecords) { + mkdirSync(directory, { recursive: true }); + const expectedFiles = new Set(schemaRecords.map((record) => `${record.name}.json`)); + + for (const fileName of readdirSync(directory)) { + if (fileName.endsWith(".json") && !expectedFiles.has(fileName)) { + rmSync(join(directory, fileName)); + } + } + + for (const record of schemaRecords) { + writeAtomic(join(directory, `${record.name}.json`), `${JSON.stringify(record)}\n`); + } +} + +function writeAtomic(path, content) { + mkdirSync(dirname(path), { recursive: true }); + const temporaryPath = `${path}.tmp-${process.pid}`; + writeFileSync(temporaryPath, content); + renameSync(temporaryPath, path); +} + +function fail(message) { + console.error(message); + process.exit(1); +} From 497a01701f400739fbe6a40ec1af3185ac0fea27 Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:49:55 -0500 Subject: [PATCH 11/28] feat(assets): import verified catalog content --- .../catalog-assets/starter-crate.obj | 16 + .../catalog-assets/starter-ramp.obj | 13 + apps/mcp-server/generated/tool-schemas.json | 317 ++++++++++++++ apps/mcp-server/src/asset-catalog.ts | 412 ++++++++++++++++++ apps/mcp-server/src/capabilities.ts | 14 + apps/mcp-server/src/index.ts | 146 +++++++ .../Editor/Assets/AssetImportOperation.cs | 96 +++- .../Editor/Bridge/UnityAiBridgeServer.cs | 3 + .../Editor/Observe/ProjectSnapshotObserver.cs | 2 + packages/core-protocol/src/index.ts | 3 + 10 files changed, 1016 insertions(+), 6 deletions(-) create mode 100644 apps/mcp-server/catalog-assets/starter-crate.obj create mode 100644 apps/mcp-server/catalog-assets/starter-ramp.obj create mode 100644 apps/mcp-server/src/asset-catalog.ts 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-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/generated/tool-schemas.json b/apps/mcp-server/generated/tool-schemas.json index 4e8c7e7..27a9bf7 100644 --- a/apps/mcp-server/generated/tool-schemas.json +++ b/apps/mcp-server/generated/tool-schemas.json @@ -510,6 +510,52 @@ "$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 it or save a prefab.", @@ -793,6 +839,277 @@ "$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, 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" + } + }, + "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.", diff --git a/apps/mcp-server/src/asset-catalog.ts b/apps/mcp-server/src/asset-catalog.ts new file mode 100644 index 0000000..3b05cb6 --- /dev/null +++ b/apps/mcp-server/src/asset-catalog.ts @@ -0,0 +1,412 @@ +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 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 query = normalize(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 (query && !searchable.includes(query)) { + 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"] + }) + ]; +} + +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(); +} + +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 3a9b905..aedbdd0 100644 --- a/apps/mcp-server/src/capabilities.ts +++ b/apps/mcp-server/src/capabilities.ts @@ -253,6 +253,20 @@ export const initialCapabilities: CapabilityManifest[] = [ effects: ["write_checkpoint", "write_audit_log", "asset_change", "scene_change"], verification: ["checkpoint_created", "operation_audited", "asset_import_verified", "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, and SHA-256 verification.", + 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", "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 55330de..b716700 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -2,6 +2,7 @@ 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"; @@ -16,6 +17,11 @@ const bridge = new UnityBridgeClient({ 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({ name: "unity-ai-control-plane", @@ -778,6 +784,27 @@ const textureImportSettingsSchema = z.object({ 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", { @@ -811,6 +838,91 @@ server.registerTool( 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, 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(""), @@ -1001,6 +1113,40 @@ 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 { return parsePositiveInteger(value, 10_000); } 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 index 8d3cfc1..0c45cc3 100644 --- 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 @@ -33,6 +33,7 @@ public sealed class AssetImportInput 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(); @@ -43,6 +44,20 @@ public sealed class AssetImportInput 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 { @@ -102,6 +117,7 @@ public sealed class AssetImportResult public string checkpointId; public string objectPath; public string prefabPath; + public AssetCatalogProvenanceInput catalogProvenance = new(); public string message; public string verificationStatus; public string[] verificationSignals = Array.Empty(); @@ -113,7 +129,8 @@ public sealed class AssetImportResult public static class AssetImportOperation { - private const string Capability = "unity.assets.import"; + 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) { @@ -122,7 +139,7 @@ public static class AssetImportOperation ".wav", ".mp3", ".ogg", ".aif", ".aiff" }; - public static AssetImportResult Execute(string requestBody) + public static AssetImportResult Execute(string requestBody, string capability = DefaultCapability) { var request = ParseRequest(requestBody); var input = request.input ?? new AssetImportInput(); @@ -131,7 +148,7 @@ public static AssetImportResult Execute(string requestBody) var destinationPath = NormalizeAssetPath(input.destinationPath); var prefabPath = NormalizeAssetPath(input.saveAsPrefabPath); - if (!Validate(input, sourceKind, destinationPath, prefabPath, out var validationError)) + if (!Validate(input, sourceKind, destinationPath, prefabPath, capability, out var validationError)) { return Refused(request, sourceKind, destinationPath, validationError, timestamp); } @@ -146,6 +163,7 @@ public static AssetImportResult Execute(string requestBody) 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" }, @@ -162,6 +180,7 @@ public static AssetImportResult Execute(string requestBody) sourceKind = sourceKind, destinationPath = destinationPath, prefabPath = prefabPath, + catalogProvenance = input.catalogProvenance ?? new AssetCatalogProvenanceInput(), requiresConfirmation = true, message = "Asset import requires confirm=true.", verificationStatus = "needs_confirmation", @@ -269,10 +288,10 @@ public static AssetImportResult Execute(string requestBody) var auditEvent = new UnityAiAuditEvent { timestamp = DateTime.UtcNow.ToString("O"), - capability = Capability, + capability = capability, requestId = request.requestId, correlationId = request.correlationId, - message = $"Imported '{destinationPath}' from {sourceKind}; instantiated={instantiated}; prefabCreated={prefabCreated}.", + message = BuildAuditMessage(input, destinationPath, sourceKind, instantiated, prefabCreated), effects = BuildEffects(instantiated, prefabCreated, true) }; var auditPersisted = PersistAudit(auditEvent); @@ -297,6 +316,12 @@ public static AssetImportResult Execute(string requestBody) 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); @@ -321,6 +346,7 @@ public static AssetImportResult Execute(string requestBody) checkpointId = checkpoint.checkpointId, objectPath = objectPath, prefabPath = prefabPath, + catalogProvenance = input.catalogProvenance ?? new AssetCatalogProvenanceInput(), message = verified ? $"Imported and verified '{destinationPath}'." : $"Imported '{destinationPath}', but verification failed.", verificationStatus = verified ? "passed" : "failed", verificationSignals = signals.ToArray(), @@ -581,7 +607,13 @@ private static bool IsPrivateAddress(IPAddress address) || (bytes.Length == 16 && (bytes[0] & 0xfe) == 0xfc); } - private static bool Validate(AssetImportInput input, string sourceKind, string destinationPath, string prefabPath, out string error) + private static bool Validate( + AssetImportInput input, + string sourceKind, + string destinationPath, + string prefabPath, + string capability, + out string error) { if (sourceKind != "local" && sourceKind != "url") { @@ -615,6 +647,17 @@ private static bool Validate(AssetImportInput input, string sourceKind, string d 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."; @@ -643,6 +686,32 @@ private static bool Validate(AssetImportInput input, string sourceKind, string d return true; } + 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" }; @@ -905,6 +974,20 @@ private static string DescribeSource(AssetImportInput input, string sourceKind) : $"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, @@ -920,6 +1003,7 @@ private static AssetImportResult Refused( correlationId = request.correlationId, sourceKind = sourceKind, destinationPath = destinationPath, + catalogProvenance = request.input?.catalogProvenance ?? new AssetCatalogProvenanceInput(), message = message, verificationStatus = "failed", timestampUtc = timestamp 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 b25e57a..1f652c0 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 @@ -598,6 +598,8 @@ private static BridgeResponse ExecuteCapability(string capability, string reques 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": @@ -688,6 +690,7 @@ private static bool IsMutatingCapability(string capability) 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": 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 9b8b4d2..6d74b17 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 @@ -379,6 +379,8 @@ private static ProjectSnapshotCapability[] BuildCapabilities() Capability("unity.console.plan_fix", "read"), Capability("unity.console.apply_fix", "mutating_token_required"), Capability("unity.assets.list", "read"), + Capability("unity.assets.catalog.search", "read"), + Capability("unity.assets.import_from_catalog", "mutating_token_required"), Capability("unity.scenes.list", "read"), Capability("unity.scene.inspect", "read"), Capability("unity.scene.inspect_game_object", "read"), diff --git a/packages/core-protocol/src/index.ts b/packages/core-protocol/src/index.ts index f05acf2..7e01edb 100644 --- a/packages/core-protocol/src/index.ts +++ b/packages/core-protocol/src/index.ts @@ -71,6 +71,9 @@ export type VerificationSignal = | "audit_report_generated" | "evidence_hash_verified" | "asset_import_verified" + | "asset_license_verified" + | "asset_hash_available" + | "asset_hash_verified" | "script_source_validated" | "script_compilation_verified" | "gameplay_template_applied"; From 1aab89852d4275a6e90722c692676c6f78e473ba Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:49:59 -0500 Subject: [PATCH 12/28] test(assets): verify catalog import end to end --- package.json | 1 + scripts/verify-asset-catalog.mjs | 106 ++++++++++++++++++++++ scripts/verify-mcp-unity-e2e.mjs | 146 ++++++++++++++++++++++++++++++- 3 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 scripts/verify-asset-catalog.mjs diff --git a/package.json b/package.json index 6e3ca40..44e8bb9 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "schemas:generate": "npm run build && node scripts/sync-mcp-tool-schemas.mjs --write-repo", "setup:user": "node scripts/setup-user.mjs", "typecheck": "tsc -b", + "verify:asset-catalog": "npm run build && node scripts/verify-asset-catalog.mjs", "verify:bridge-retry": "npm run build && node scripts/verify-bridge-retry.mjs", "verify:unity-package": "node scripts/verify-unity-package.mjs", "verify:mcp-unity-e2e": "npm run build && node scripts/verify-mcp-unity-e2e.mjs" diff --git a/scripts/verify-asset-catalog.mjs b/scripts/verify-asset-catalog.mjs new file mode 100644 index 0000000..9bd3456 --- /dev/null +++ b/scripts/verify-asset-catalog.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createServer } from "node:http"; + +import { AssetCatalogService } from "../apps/mcp-server/dist/asset-catalog.js"; + +const model = "o CatalogTriangle\nv 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n"; +const sha256 = createHash("sha256").update(model).digest("hex"); +const server = createServer((request, response) => { + if (request.url === "/catalog.json") { + const address = server.address(); + assert(address && typeof address === "object"); + const baseUrl = `http://127.0.0.1:${address.port}`; + const manifest = JSON.stringify({ + schemaVersion: 1, + catalog: { + id: "verification", + name: "Verification Catalog", + homepage: "https://example.com/catalog" + }, + assets: [ + { + id: "triangle", + name: "Catalog Triangle", + description: "Deterministic catalog fixture", + kind: "model", + format: "obj", + tags: ["fixture", "triangle"], + downloadUrl: `${baseUrl}/triangle.obj`, + sourceUrl: "https://example.com/catalog/triangle", + sha256, + sizeBytes: Buffer.byteLength(model), + license: { + spdxId: "CC0-1.0", + name: "CC0 1.0 Universal", + url: "https://creativecommons.org/publicdomain/zero/1.0/" + } + }, + { + id: "blocked-license", + name: "Blocked License", + description: "", + kind: "model", + format: "obj", + tags: [], + downloadUrl: `${baseUrl}/triangle.obj`, + sourceUrl: "https://example.com/catalog/blocked", + sha256, + sizeBytes: Buffer.byteLength(model), + license: { + spdxId: "LicenseRef-Proprietary", + name: "Proprietary", + url: "https://example.com/license" + } + } + ] + }); + response.writeHead(200, { + "content-type": "application/json", + "content-length": Buffer.byteLength(manifest) + }); + response.end(manifest); + return; + } + + response.writeHead(404); + response.end(); +}); + +await listen(server); +const address = server.address(); +assert(address && typeof address === "object"); + +try { + const catalog = new AssetCatalogService({ + manifestUrls: [`http://127.0.0.1:${address.port}/catalog.json`], + allowedLicenses: ["CC0-1.0"], + allowInsecureLocalhost: true + }); + const search = await catalog.search({ query: "triangle", kind: "model" }); + assert(search.assets.some((asset) => asset.assetId === "verification:triangle")); + assert(!search.assets.some((asset) => asset.assetId === "verification:blocked-license")); + assert(search.warnings.some((warning) => warning.includes("LicenseRef-Proprietary"))); + + const resolved = await catalog.resolve("verification:triangle"); + assert.equal(resolved.sha256, sha256); + assert.equal(resolved.source.kind, "url"); + assert.equal(resolved.license.spdxId, "CC0-1.0"); + console.log("Asset catalog verification passed: manifest, license allowlist, provenance, and SHA-256 are enforced."); +} finally { + await close(server); +} + +function listen(httpServer) { + return new Promise((resolvePromise, rejectPromise) => { + httpServer.once("error", rejectPromise); + httpServer.listen(0, "127.0.0.1", resolvePromise); + }); +} + +function close(httpServer) { + return new Promise((resolvePromise, rejectPromise) => { + httpServer.close((error) => error ? rejectPromise(error) : resolvePromise()); + }); +} diff --git a/scripts/verify-mcp-unity-e2e.mjs b/scripts/verify-mcp-unity-e2e.mjs index 52a31eb..f059369 100644 --- a/scripts/verify-mcp-unity-e2e.mjs +++ b/scripts/verify-mcp-unity-e2e.mjs @@ -172,6 +172,7 @@ public static class UnityAiE2EConsoleDiagnosticsFixture const assetFixtureServer = await startAssetFixtureServer(remoteObjSource); const remoteModelUrl = `http://127.0.0.1:${assetFixtureServer.port}/remote-triangle.obj`; +const catalogManifestUrl = `http://127.0.0.1:${assetFixtureServer.port}/catalog.json`; const unity = spawn(unityPath, [ "-batchmode", "-nographics", @@ -202,7 +203,9 @@ try { env: { ...process.env, UNITY_AI_BRIDGE_URL: bridgeUrl, - UNITY_AI_BRIDGE_TOKEN: bridgeToken + UNITY_AI_BRIDGE_TOKEN: bridgeToken, + UNITY_AI_ASSET_CATALOG_URLS: catalogManifestUrl, + UNITY_AI_CATALOG_ALLOW_INSECURE_LOCALHOST: "1" } }); @@ -541,6 +544,13 @@ function assertCapabilities(capabilities) { } } + const assetCatalogSearchCapability = capabilities.find((capability) => capability.name === "unity.assets.catalog.search"); + const assetCatalogImportCapability = capabilities.find((capability) => capability.name === "unity.assets.import_from_catalog"); + if (!assetCatalogSearchCapability?.verification.includes("asset_license_verified") + || !assetCatalogImportCapability?.verification.includes("asset_hash_verified")) { + fail("catalog capabilities must declare license and hash verification."); + } + const scriptAuthoringCapability = capabilities.find((capability) => capability.name === "unity.scripts.author"); if (!scriptAuthoringCapability) { fail("unity.capabilities.list did not include unity.scripts.author."); @@ -1597,6 +1607,7 @@ async function assertExtendedControlPlaneFlow(client) { const authoredAssets = await assertAssetAuthoringFlow(client); await assertAssetImportFlow(client, authoredAssets); + await assertAssetCatalogFlow(client); await assertScriptAuthoringFlow(client); await assertGameplayComposeFlow(client); await assertDurableCheckpointFlow(client); @@ -1831,6 +1842,74 @@ async function assertAssetImportFlow(client, authoredAssets) { } } +async function assertAssetCatalogFlow(client) { + const search = await callJsonTool(client, "unity.assets.catalog.search", { + query: "remote triangle", + kind: "model", + maxResults: 20, + refresh: true + }); + const catalogAsset = search.assets?.find((asset) => asset.assetId === "unity-ai-e2e:remote-triangle"); + const expectedHash = createHash("sha256").update(remoteObjSource).digest("hex"); + if (!catalogAsset + || catalogAsset.license?.spdxId !== "CC0-1.0" + || catalogAsset.sha256 !== expectedHash + || catalogAsset.format !== "obj") { + fail(`catalog search did not return verified fixture metadata: ${JSON.stringify(search)}.`); + } + + const destinationPath = "Assets/UnityAiGenerated/ImportedCatalogTriangle.obj"; + const preview = await callJsonTool(client, "unity.assets.import_from_catalog", { + catalogAssetId: catalogAsset.assetId, + expectedCatalogSha256: expectedHash, + destinationPath, + model: { + globalScale: 1, + importAnimation: false, + animationType: "none" + } + }); + if (preview.dryRun !== true + || preview.catalogProvenance?.catalogAssetId !== catalogAsset.assetId + || preview.catalogAsset?.sha256 !== expectedHash) { + fail(`catalog import preview was invalid: ${JSON.stringify(preview)}.`); + } + + const imported = await callJsonTool(client, "unity.assets.import_from_catalog", { + dryRun: false, + confirm: true, + catalogAssetId: catalogAsset.assetId, + expectedCatalogSha256: expectedHash, + destinationPath, + model: { + globalScale: 1, + importAnimation: false, + animationType: "none" + } + }); + assertImportedAsset("catalog model import", imported, { + sourceKind: "url", + destinationPath, + assetType: "UnityEngine.GameObject", + importerType: "UnityEditor.ModelImporter", + instantiated: false, + prefabCreated: false, + auditCapability: "unity.assets.import_from_catalog" + }); + + for (const signal of ["asset_license_verified", "asset_hash_verified"]) { + if (!imported.verificationSignals?.includes(signal)) { + fail(`catalog model import did not include verification signal ${signal}.`); + } + } + + if (imported.catalogProvenance?.licenseSpdxId !== "CC0-1.0" + || imported.catalogProvenance?.catalogAssetId !== catalogAsset.assetId + || imported.catalogAsset?.sourceUrl !== "https://example.com/unity-ai-e2e/remote-triangle") { + fail(`catalog model import did not retain provenance: ${JSON.stringify(imported)}.`); + } +} + async function assertScriptAuthoringFlow(client) { const unsafePreview = await callJsonTool(client, "unity.scripts.author", { path: "Assets/UnityAiGenerated/UnsafeGenerated.cs", @@ -2101,7 +2180,7 @@ function assertImportedAsset(label, result, expected) { fail(`${label} did not verify prefab creation.`); } - if (result.auditPersisted !== true || result.auditEvent?.capability !== "unity.assets.import") { + if (result.auditPersisted !== true || result.auditEvent?.capability !== (expected.auditCapability ?? "unity.assets.import")) { fail(`${label} did not persist its audit event.`); } assertAuditLogContains(label, join(tempProject, result.auditLogPath), result.auditEvent); @@ -3073,6 +3152,49 @@ function sha256File(path) { function startAssetFixtureServer(body) { return new Promise((resolvePromise, rejectPromise) => { const server = createServer((request, response) => { + if (request.url === "/catalog.json") { + const address = server.address(); + if (!address || typeof address === "string") { + response.writeHead(500); + response.end("missing address"); + return; + } + + const manifest = JSON.stringify({ + schemaVersion: 1, + catalog: { + id: "unity-ai-e2e", + name: "Unity AI E2E Catalog", + homepage: "https://example.com/unity-ai-e2e" + }, + assets: [ + { + id: "remote-triangle", + name: "Remote Triangle", + description: "Deterministic remote OBJ fixture", + kind: "model", + format: "obj", + tags: ["fixture", "triangle"], + downloadUrl: `http://127.0.0.1:${address.port}/remote-triangle.obj`, + sourceUrl: "https://example.com/unity-ai-e2e/remote-triangle", + sha256: createHash("sha256").update(body).digest("hex"), + sizeBytes: Buffer.byteLength(body), + license: { + spdxId: "CC0-1.0", + name: "CC0 1.0 Universal", + url: "https://creativecommons.org/publicdomain/zero/1.0/" + } + } + ] + }); + response.writeHead(200, { + "content-type": "application/json", + "content-length": Buffer.byteLength(manifest) + }); + response.end(manifest); + return; + } + if (request.url !== "/remote-triangle.obj") { response.writeHead(404); response.end("not found"); @@ -3229,6 +3351,26 @@ async function assertMutatingRouteRequiresToken(url) { console.log("✓ unity.assets.import rejects missing token"); + const catalogImportResponse = await fetch(`${url}/capabilities/${encodeURIComponent("unity.assets.import_from_catalog")}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + input: { + dryRun: false, + confirm: true, + sourceKind: "url", + url: remoteModelUrl, + destinationPath: "Assets/UnauthorizedCatalog.obj" + } + }) + }); + + if (catalogImportResponse.status !== 403) { + fail(`Expected unity.assets.import_from_catalog without token to return 403, got HTTP ${catalogImportResponse.status}.`); + } + + console.log("✓ unity.assets.import_from_catalog rejects missing token"); + const scriptAuthoringResponse = await fetch(`${url}/capabilities/${encodeURIComponent("unity.scripts.author")}`, { method: "POST", headers: { "content-type": "application/json" }, From 549b0219d710f7b917a9fdbf854ba194184a918d Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:50:05 -0500 Subject: [PATCH 13/28] docs: describe resilient bridge and asset catalogs --- README.md | 6 ++++ docs/capability-contract.md | 1 + docs/content-authoring.md | 52 ++++++++++++++++++++++++++++++++ docs/control-plane-operations.md | 2 ++ docs/local-bridge.md | 9 +++++- docs/roadmap.md | 2 +- 6 files changed, 70 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f0f9e90..ae6215d 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Initial tool families: - `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. @@ -188,7 +189,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's local `parameters` JSON instead of leaving stale `parameters: null` entries. diff --git a/docs/capability-contract.md b/docs/capability-contract.md index 7efab40..c97ae07 100644 --- a/docs/capability-contract.md +++ b/docs/capability-contract.md @@ -55,6 +55,7 @@ verification: | `unity.audit.*` | Correlate persisted events with hashed before/after evidence and produce reports. | | `unity.assets.*` | Inspect project assets and asset metadata. | | `unity.assets.import` | Copy/download external content, configure importers, instantiate models, and save prefabs. | +| `unity.assets.catalog.*` / `unity.assets.import_from_catalog` | Search and import license/hash/provenance-bound catalog content. | | `unity.asset.*` | Inspect a specific asset, including dependencies. | | `unity.prefabs.*` | List prefab assets. | | `unity.prefab.*` | Inspect a specific prefab asset. | diff --git a/docs/content-authoring.md b/docs/content-authoring.md index 3edfafc..0569899 100644 --- a/docs/content-authoring.md +++ b/docs/content-authoring.md @@ -34,6 +34,58 @@ Remote production imports require HTTPS. Every transfer is bounded by `maxBytes` FBX, OBJ, DAE, 3DS, and DXF use Unity's built-in model importer. GLB/glTF paths are accepted when the project has a compatible glTF importer package installed. +## Search and import a catalog asset + +`unity.assets.catalog.search` always includes the bundled starter catalog. Additional HTTPS/CDN manifests can be configured with the comma-separated `UNITY_AI_ASSET_CATALOG_URLS` environment variable. Only `CC0-1.0` entries are accepted by default; override the allowlist explicitly with `UNITY_AI_ASSET_CATALOG_ALLOWED_LICENSES`. + +Each remote manifest entry must include a direct supported file URL, source URL, SPDX license, byte size, and SHA-256. Import does not accept an arbitrary URL from the tool caller: + +```json +{ + "schemaVersion": 1, + "catalog": { + "id": "studio-assets", + "name": "Studio CC0 Assets", + "homepage": "https://assets.example.com" + }, + "assets": [ + { + "id": "sports-car", + "name": "Sports Car", + "description": "Game-ready vehicle model", + "kind": "model", + "format": "fbx", + "tags": ["vehicle", "car"], + "downloadUrl": "https://cdn.example.com/sports-car.fbx", + "sourceUrl": "https://assets.example.com/sports-car", + "sha256": "64-character-sha256", + "sizeBytes": 12345678, + "license": { + "spdxId": "CC0-1.0", + "name": "CC0 1.0 Universal", + "url": "https://creativecommons.org/publicdomain/zero/1.0/" + } + } + ] +} +``` + +Search first, retain the returned hash, then confirm the import: + +```json +{ + "dryRun": false, + "confirm": true, + "catalogAssetId": "studio-assets:sports-car", + "expectedCatalogSha256": "hash-returned-by-search", + "destinationPath": "Assets/Imported/Vehicles/SportsCar.fbx", + "instantiate": true, + "saveAsPrefabPath": "Assets/Imported/Vehicles/SportsCar.prefab" +} +``` + +The Unity audit result retains catalog ID, asset ID, source, SPDX license, and license URL. Downloads still use the bridge's HTTPS, redirect, private-network, timeout, byte-limit, checkpoint, and hash checks. + ## Create animation logic Use `unity.assets.author` with `kind: "animation_clip"` to write curves and `kind: "animator_controller"` to assemble states, parameters, transitions, and conditions. diff --git a/docs/control-plane-operations.md b/docs/control-plane-operations.md index 3312527..6124138 100644 --- a/docs/control-plane-operations.md +++ b/docs/control-plane-operations.md @@ -51,6 +51,8 @@ UnityAIArtifacts/Builds - `ModelImporter`, `TextureImporter`, and `AudioImporter` settings; - optional scene instantiation and prefab creation. +`unity.assets.catalog.search` and `unity.assets.import_from_catalog` add a manifest-backed acquisition layer. Catalog entries are license-allowlisted, hash-bound, size-bound, and audited with their provenance; direct arbitrary URLs are not accepted by the catalog import tool. + Imported objects can be composed through `unity.scene.batch` with built-in, project, or package components. The package includes `ContinuousRotation`, `BobbingMotion`, and `PulseScale` runtime behaviours. `unity.scripts.author` extends composition to custom runtime behaviours. It requires a validated dry-run SHA-256, creates a durable checkpoint, blocks high-risk API families and edit-time execution, waits across Unity domain reloads, verifies the compiled `MonoBehaviour`, optionally attaches it, and restores the checkpoint on failure. diff --git a/docs/local-bridge.md b/docs/local-bridge.md index 4bb50e1..d168083 100644 --- a/docs/local-bridge.md +++ b/docs/local-bridge.md @@ -19,7 +19,10 @@ Unity project | Unity bridge URL | `http://127.0.0.1:39071` | | MCP env override | `UNITY_AI_BRIDGE_URL` | | Mutating route token | `UNITY_AI_BRIDGE_TOKEN` | -| Timeout override | `UNITY_AI_BRIDGE_TIMEOUT_MS` | +| Per-attempt timeout | `UNITY_AI_BRIDGE_TIMEOUT_MS` | +| Retry window | `UNITY_AI_BRIDGE_RETRY_TIMEOUT_MS` (default `90000`) | +| Maximum attempts | `UNITY_AI_BRIDGE_RETRY_MAX_ATTEMPTS` (default `12`) | +| Retry delays | `UNITY_AI_BRIDGE_RETRY_BASE_DELAY_MS`, `UNITY_AI_BRIDGE_RETRY_MAX_DELAY_MS` | ## Unity side @@ -37,6 +40,8 @@ Start Local Bridge The Editor window generates a bridge token. MCP servers must send that token through the `x-unity-ai-bridge-token` header for every mutating route. The bridge token and enabled state survive Unity domain reloads for the current Editor session. +During compilation/domain reload, MCP calls wait behind a shared health/reconnection gate and retry with the same `requestId` and `correlationId`. Mutating responses are persisted under `Library/UnityAIControlPlane/BridgeResponses`, so a lost HTTP response can be replayed after reload without applying the operation twice. + The MCP server only attaches the token when `UNITY_AI_BRIDGE_URL` points to `http://127.0.0.1`, `http://localhost`, or `http://[::1]`. Trailing slashes are normalized by the MCP bridge client. @@ -70,6 +75,7 @@ The bridge handles: - `POST /capabilities/unity.build.validate_android_quest` - `POST /capabilities/unity.build.android` - `POST /capabilities/unity.assets.author` +- `POST /capabilities/unity.assets.import_from_catalog` - `POST /capabilities/unity.prefab.manage` - `POST /capabilities/unity.checkpoints.create|list|restore|delete` - `POST /capabilities/unity.vision.capture` @@ -111,6 +117,7 @@ The MCP server runs over stdio and exposes: - `unity.compilation.status`, `unity.compilation.wait` - `unity.build.validate_android_quest`, `unity.build.android` - `unity.assets.author` +- `unity.assets.catalog.search`, `unity.assets.import_from_catalog` - `unity.prefab.manage` - `unity.checkpoints.create`, `unity.checkpoints.list`, `unity.checkpoints.restore`, `unity.checkpoints.delete` - `unity.vision.capture` diff --git a/docs/roadmap.md b/docs/roadmap.md index efac1e8..19c2455 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -84,7 +84,7 @@ Verified tools: - [x] Add atomic high-level templates for proximity doors, pickups, and multi-target activators. - [ ] Inspect and automatically wire imported materials and texture maps. - [ ] Add humanoid avatar configuration, animation retargeting, masks, and blend-tree authoring. -- [ ] Add license-aware asset catalog adapters and provenance metadata. +- [x] Add license-aware asset catalog manifests, CDN adapters, and provenance metadata. - [ ] Add higher-level UI, contextual audio, dialogue, quest, inventory, and XR interaction templates. ## Milestone 4 — Meta XR readiness From 5d713c0ca7beb32ba369af0049d11246a39b2f98 Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:56:14 -0500 Subject: [PATCH 14/28] fix(bridge): recover missing desktop session state --- .../Editor/Bridge/UnityAiBridgeServer.cs | 37 ++++++++++++++++++- docs/local-bridge.md | 2 +- 2 files changed, 36 insertions(+), 3 deletions(-) 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 1f652c0..c28f5d4 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 @@ -156,7 +156,17 @@ 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; @@ -169,7 +179,7 @@ private static void RestoreAfterDomainReload() try { - StartInternal(SessionState.GetString(SessionTokenKey, string.Empty), false); + StartInternal(token, !sessionEnabled); } catch (Exception exception) { @@ -188,6 +198,29 @@ private static void RestoreAfterDomainReload() } } + 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) diff --git a/docs/local-bridge.md b/docs/local-bridge.md index d168083..eb7a1f0 100644 --- a/docs/local-bridge.md +++ b/docs/local-bridge.md @@ -38,7 +38,7 @@ Then click: Start Local Bridge ``` -The Editor window generates a bridge token. MCP servers must send that token through the `x-unity-ai-bridge-token` header for every mutating route. The bridge token and enabled state survive Unity domain reloads for the current Editor session. +The Editor window generates a bridge token. MCP servers must send that token through the `x-unity-ai-bridge-token` header for every mutating route. The bridge token and enabled state survive Unity domain reloads for the current Editor session. Desktop Editors can also recover from missing session state by reading `~/.config/unity-ai/bridge-token`; this fallback is disabled in batch mode. During compilation/domain reload, MCP calls wait behind a shared health/reconnection gate and retry with the same `requestId` and `correlationId`. Mutating responses are persisted under `Library/UnityAIControlPlane/BridgeResponses`, so a lost HTTP response can be replayed after reload without applying the operation twice. From 91a817ca9996763fa143359bf696ce160f53f02a Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:00:42 -0500 Subject: [PATCH 15/28] feat(scene): add bounded hierarchy filters --- apps/mcp-server/generated/tool-schemas.json | 91 ++++++- apps/mcp-server/src/capabilities.ts | 2 +- apps/mcp-server/src/index.ts | 28 +- .../Editor/Observe/SceneInspector.cs | 254 ++++++++++++++++-- 4 files changed, 344 insertions(+), 31 deletions(-) diff --git a/apps/mcp-server/generated/tool-schemas.json b/apps/mcp-server/generated/tool-schemas.json index 27a9bf7..2662d8b 100644 --- a/apps/mcp-server/generated/tool-schemas.json +++ b/apps/mcp-server/generated/tool-schemas.json @@ -3676,7 +3676,7 @@ }, { "name": "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.", "parameters": { "type": "object", "properties": { @@ -3695,6 +3695,95 @@ "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, diff --git a/apps/mcp-server/src/capabilities.ts b/apps/mcp-server/src/capabilities.ts index aedbdd0..598dcd1 100644 --- a/apps/mcp-server/src/capabilities.ts +++ b/apps/mcp-server/src/capabilities.ts @@ -66,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"] diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index b716700..4e4edba 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -168,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( 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..0abe155 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,126 @@ 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 || filter.withinRadius != null + ? filter + : null; + } + + private static Vector3? ResolveRadiusCenter(SceneInspectFilterInput filter) + { + var radiusFilter = filter?.withinRadius; + if (radiusFilter == null) + { + 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; + } + + if (radiusFilter.center == null) + { + throw new InvalidOperationException("Radius filter requires centerPath or center."); + } + + return new Vector3(radiusFilter.center.x, radiusFilter.center.y, radiusFilter.center.z); + } + + 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 filter.withinRadius == null || 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 +329,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)) From 741c06749c8e9df14923c9482ec4a5e7a0407998 Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:02:06 -0500 Subject: [PATCH 16/28] feat(scene): bind serialized object references --- apps/mcp-server/generated/tool-schemas.json | 913 +++++++++--------- apps/mcp-server/src/capabilities.ts | 2 +- apps/mcp-server/src/index.ts | 20 +- .../Editor/Act/SceneBatchOperation.cs | 40 + .../Editor/Observe/GameObjectInspector.cs | 34 +- 5 files changed, 566 insertions(+), 443 deletions(-) diff --git a/apps/mcp-server/generated/tool-schemas.json b/apps/mcp-server/generated/tool-schemas.json index 2662d8b..7910824 100644 --- a/apps/mcp-server/generated/tool-schemas.json +++ b/apps/mcp-server/generated/tool-schemas.json @@ -2870,7 +2870,7 @@ }, { "name": "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.", "parameters": { "type": "object", "properties": { @@ -3162,420 +3162,513 @@ "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" + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "bool" + }, + "boolValue": { + "type": "boolean" + } + }, + "required": [ + "kind", + "boolValue" + ], + "additionalProperties": false }, - "x": { - "type": "integer" + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "integer" + }, + "integerValue": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "kind", + "integerValue" + ], + "additionalProperties": false }, - "y": { - "type": "integer" + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "number" + }, + "numberValue": { + "type": "number" + } + }, + "required": [ + "kind", + "numberValue" + ], + "additionalProperties": false }, - "z": { - "type": "integer" + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "string" + }, + "stringValue": { + "type": "string" + } + }, + "required": [ + "kind", + "stringValue" + ], + "additionalProperties": false }, - "w": { - "type": "integer" - } - }, - "required": [ - "kind", - "x", - "y", - "z", - "w" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "kind": { - "type": "string", - "const": "bounds" + { + "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 }, - "x": { - "type": "number" + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "vector2" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y" + ], + "additionalProperties": false }, - "y": { - "type": "number" + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "vector3" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y", + "z" + ], + "additionalProperties": false }, - "z": { - "type": "number" + { + "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 }, - "x2": { - "type": "number" + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "vector2_int" + }, + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + } + }, + "required": [ + "kind", + "x", + "y" + ], + "additionalProperties": false }, - "y2": { - "type": "number" + { + "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 }, - "z2": { - "type": "number" - } - }, - "required": [ - "kind", - "x", - "y", - "z", - "x2", - "y2", - "z2" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "kind": { - "type": "string", - "const": "bounds_int" + { + "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 }, - "x": { - "type": "integer" + { + "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 }, - "y": { - "type": "integer" + { + "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 }, - "z": { - "type": "integer" + { + "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 }, - "x2": { - "type": "integer" + { + "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 }, - "y2": { - "type": "integer" + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "enum" + }, + "enumName": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": [ + "kind", + "enumName" + ], + "additionalProperties": false }, - "z2": { - "type": "integer" - } - }, - "required": [ - "kind", - "x", - "y", - "z", - "x2", - "y2", - "z2" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "kind": { - "type": "string", - "const": "quaternion" + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "enum" + }, + "enumIndex": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "kind", + "enumIndex" + ], + "additionalProperties": false }, - "x": { - "type": "number" + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "object_reference" + }, + "assetPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": [ + "kind", + "assetPath" + ], + "additionalProperties": false }, - "y": { - "type": "number" + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "null" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false }, - "z": { - "type": "number" + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "array_size" + }, + "integerValue": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + } + }, + "required": [ + "kind", + "integerValue" + ], + "additionalProperties": false }, - "w": { - "type": "number" + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "character" + }, + "integerValue": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + }, + "required": [ + "kind", + "integerValue" + ], + "additionalProperties": false } - }, - "required": [ - "kind", - "x", - "y", - "z", - "w" - ], - "additionalProperties": false + ] }, { "type": "object", "properties": { "kind": { "type": "string", - "const": "enum" + "const": "game_object_reference" }, - "enumName": { + "path": { "type": "string", "minLength": 1, - "maxLength": 200 - } - }, - "required": [ - "kind", - "enumName" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "kind": { - "type": "string", - "const": "enum" - }, - "enumIndex": { - "type": "integer", - "minimum": 0 + "maxLength": 512 } }, "required": [ "kind", - "enumIndex" + "path" ], "additionalProperties": false }, @@ -3584,68 +3677,28 @@ "properties": { "kind": { "type": "string", - "const": "object_reference" + "const": "component_reference" }, - "assetPath": { + "path": { "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": { + "componentType": { "type": "string", - "const": "character" + "minLength": 1, + "maxLength": 256 }, - "integerValue": { + "componentIndex": { "type": "integer", "minimum": 0, - "maximum": 65535 + "default": 0 } }, "required": [ "kind", - "integerValue" + "path", + "componentType" ], "additionalProperties": false } diff --git a/apps/mcp-server/src/capabilities.ts b/apps/mcp-server/src/capabilities.ts index 598dcd1..8fe6594 100644 --- a/apps/mcp-server/src/capabilities.ts +++ b/apps/mcp-server/src/capabilities.ts @@ -87,7 +87,7 @@ 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"] diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index 4e4edba..ff0f204 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -242,7 +242,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(), @@ -282,6 +282,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"), @@ -343,7 +357,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), @@ -951,7 +965,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( 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/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) From 7f124c050d516bf0259d78bd126731df374c8d05 Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:05:54 -0500 Subject: [PATCH 17/28] feat(physics): add bounded scene diagnostics --- apps/mcp-server/generated/tool-schemas.json | 104 +++ apps/mcp-server/src/capabilities.ts | 7 + apps/mcp-server/src/index.ts | 30 + .../Editor/Bridge/UnityAiBridgeServer.cs | 2 + .../Editor/Observe/PhysicsInspector.cs | 813 ++++++++++++++++++ .../Editor/Observe/PhysicsInspector.cs.meta | 2 + .../Editor/Observe/ProjectSnapshotObserver.cs | 1 + 7 files changed, 959 insertions(+) create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PhysicsInspector.cs create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PhysicsInspector.cs.meta diff --git a/apps/mcp-server/generated/tool-schemas.json b/apps/mcp-server/generated/tool-schemas.json index 7910824..8212eb6 100644 --- a/apps/mcp-server/generated/tool-schemas.json +++ b/apps/mcp-server/generated/tool-schemas.json @@ -2041,6 +2041,110 @@ "$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.", diff --git a/apps/mcp-server/src/capabilities.ts b/apps/mcp-server/src/capabilities.ts index 8fe6594..3ba56b7 100644 --- a/apps/mcp-server/src/capabilities.ts +++ b/apps/mcp-server/src/capabilities.ts @@ -78,6 +78,13 @@ 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.scene.upsert_game_object", description: "Create or update a GameObject in the active scene from a safe, schema-bound spec.", diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index ff0f204..91640b9 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -212,6 +212,36 @@ 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) +); + const sceneVectorSchema = z.object({ x: z.number().finite(), y: z.number().finite(), 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 c28f5d4..fbae542 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 @@ -581,6 +581,8 @@ 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.scene.upsert_game_object": return JsonResult(capability, envelope, SceneUpsertGameObjectOperation.Execute(requestBody)); case "unity.scene.batch": 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..8166e38 --- /dev/null +++ b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/PhysicsInspector.cs @@ -0,0 +1,813 @@ +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 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), + 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.velocity; + 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.velocity.x, body.velocity.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.velocity : Vector3.zero; + } + + private static Vector3 GetVelocity(Rigidbody2D body) + { + return body != null ? new Vector3(body.velocity.x, body.velocity.y, 0f) : Vector3.zero; + } + + private static Vector3? ResolveRadiusCenter(SceneRadiusFilterInput filter) + { + if (filter == 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; + } + + if (filter.center == null) + { + throw new InvalidOperationException("Physics radius filter requires centerPath or center."); + } + + 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/ProjectSnapshotObserver.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSnapshotObserver.cs index 6d74b17..4db6a21 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 @@ -384,6 +384,7 @@ private static ProjectSnapshotCapability[] BuildCapabilities() Capability("unity.scenes.list", "read"), Capability("unity.scene.inspect", "read"), Capability("unity.scene.inspect_game_object", "read"), + Capability("unity.physics.inspect", "read"), Capability("unity.scene.upsert_game_object", "mutating_token_required"), Capability("unity.scene.batch", "mutating_token_required"), Capability("unity.prefabs.list", "read"), From a3b0ddc4e7b120bafb0019ca6e206ae7a5301e6d Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:09:13 -0500 Subject: [PATCH 18/28] fix(console): isolate blocking compilation errors --- apps/mcp-server/generated/tool-schemas.json | 4 +- apps/mcp-server/src/capabilities.ts | 4 +- apps/mcp-server/src/index.ts | 4 +- .../Editor/Jobs/CompilationController.cs | 8 +- .../Editor/Observe/ConsoleLogBridge.cs | 293 ++++++++++++++++-- .../Editor/Observe/ProjectSnapshotObserver.cs | 13 +- 6 files changed, 284 insertions(+), 42 deletions(-) diff --git a/apps/mcp-server/generated/tool-schemas.json b/apps/mcp-server/generated/tool-schemas.json index 8212eb6..c3b551e 100644 --- a/apps/mcp-server/generated/tool-schemas.json +++ b/apps/mcp-server/generated/tool-schemas.json @@ -1561,7 +1561,7 @@ }, { "name": "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.", "parameters": { "type": "object", "properties": {}, @@ -1581,7 +1581,7 @@ }, { "name": "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.", "parameters": { "type": "object", "properties": {}, diff --git a/apps/mcp-server/src/capabilities.ts b/apps/mcp-server/src/capabilities.ts index 3ba56b7..4c0c9fc 100644 --- a/apps/mcp-server/src/capabilities.ts +++ b/apps/mcp-server/src/capabilities.ts @@ -24,14 +24,14 @@ export const initialCapabilities: CapabilityManifest[] = [ }, { 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"] diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index 91640b9..8b7ee12 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -101,7 +101,7 @@ server.registerTool( 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") @@ -110,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") 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/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/ProjectSnapshotObserver.cs b/apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Observe/ProjectSnapshotObserver.cs index 4db6a21..3c5a4c9 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 @@ -46,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(); } @@ -184,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() }; } @@ -195,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"); } @@ -448,7 +455,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"); @@ -481,7 +488,7 @@ private static List BuildVerificationSignals(ProjectContextSnapshot snap { 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"); } From 0a9dba35cbfcb2b24ad26e4896b71e476ba64ce6 Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:15:29 -0500 Subject: [PATCH 19/28] fix(physics): preserve Unity 2022 velocity API --- .../Editor/Observe/PhysicsInspector.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index 8166e38..67b362e 100644 --- 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 @@ -295,7 +295,7 @@ private static PhysicsBodyInfo BuildBodyInfo(Rigidbody body) return null; } - var velocity = body.velocity; + var velocity = body.linearVelocity; var estimate = SampleForce(BodySamples3D, body.GetInstanceID(), velocity, body.mass, !body.isKinematic); return new PhysicsBodyInfo { @@ -326,7 +326,7 @@ private static PhysicsBodyInfo BuildBodyInfo(Rigidbody2D body) return null; } - var velocity = new Vector3(body.velocity.x, body.velocity.y, 0f); + 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 { @@ -677,12 +677,12 @@ private static bool IsUsable(Collider2D collider) private static Vector3 GetVelocity(Rigidbody body) { - return body != null ? body.velocity : Vector3.zero; + return body != null ? body.linearVelocity : Vector3.zero; } private static Vector3 GetVelocity(Rigidbody2D body) { - return body != null ? new Vector3(body.velocity.x, body.velocity.y, 0f) : Vector3.zero; + return body != null ? new Vector3(body.linearVelocity.x, body.linearVelocity.y, 0f) : Vector3.zero; } private static Vector3? ResolveRadiusCenter(SceneRadiusFilterInput filter) From bb1caf5c7227166c7b1ab0eadcc20e14ea22745f Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:18:28 -0500 Subject: [PATCH 20/28] feat(tests): simulate Input System events --- apps/mcp-server/generated/tool-schemas.json | 174 ++++++- apps/mcp-server/src/capabilities.ts | 2 +- apps/mcp-server/src/index.ts | 35 +- .../Editor/InputSystem.meta | 8 + .../InputSystemSimulationBackend.cs | 130 +++++ .../InputSystemSimulationBackend.cs.meta | 2 + ....AI.ControlPlane.Editor.InputSystem.asmdef | 27 + ...ontrolPlane.Editor.InputSystem.asmdef.meta | 7 + .../TestRunner/UnityAiTestRunnerAdapter.cs | 24 +- .../Editor/Tests/InputSimulationScheduler.cs | 462 ++++++++++++++++++ .../Tests/InputSimulationScheduler.cs.meta | 2 + .../Editor/Tests/TestOperation.cs | 57 +++ scripts/verify-unity-package.mjs | 56 ++- 13 files changed, 979 insertions(+), 7 deletions(-) create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem/InputSystemSimulationBackend.cs create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem/InputSystemSimulationBackend.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem/Unity.AI.ControlPlane.Editor.InputSystem.asmdef create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/InputSystem/Unity.AI.ControlPlane.Editor.InputSystem.asmdef.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests/InputSimulationScheduler.cs create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/Tests/InputSimulationScheduler.cs.meta diff --git a/apps/mcp-server/generated/tool-schemas.json b/apps/mcp-server/generated/tool-schemas.json index c3b551e..77d7072 100644 --- a/apps/mcp-server/generated/tool-schemas.json +++ b/apps/mcp-server/generated/tool-schemas.json @@ -4190,7 +4190,7 @@ }, { "name": "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.", "parameters": { "type": "object", "properties": { @@ -4257,6 +4257,178 @@ "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, diff --git a/apps/mcp-server/src/capabilities.ts b/apps/mcp-server/src/capabilities.ts index 4c0c9fc..2af3e53 100644 --- a/apps/mcp-server/src/capabilities.ts +++ b/apps/mcp-server/src/capabilities.ts @@ -199,7 +199,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"] diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index 8b7ee12..29fcfd8 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -630,7 +630,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), @@ -640,7 +640,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) 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/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/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/scripts/verify-unity-package.mjs b/scripts/verify-unity-package.mjs index 67e83ef..276d84d 100644 --- a/scripts/verify-unity-package.mjs +++ b/scripts/verify-unity-package.mjs @@ -37,18 +37,61 @@ const packagesDir = join(tempProject, "Packages"); const assetsDir = join(tempProject, "Assets"); const logsDir = join(repoRoot, "artifacts/unity-verification"); const logPath = join(logsDir, "editor-compile.log"); +const includeInputSystem = process.env.UNITY_AI_VERIFY_INPUT_SYSTEM === "1"; mkdirSync(packagesDir, { recursive: true }); mkdirSync(assetsDir, { recursive: true }); mkdirSync(logsDir, { recursive: true }); cpSync(packageSource, join(packagesDir, "com.unity-ai.control-plane"), { recursive: true }); +if (includeInputSystem) { + const editorDir = join(assetsDir, "Editor"); + mkdirSync(editorDir, { recursive: true }); + writeFileSync( + join(editorDir, "UnityAiInputSystemVerification.cs"), + `using System; +using UnityEditor; +using UnityEngine; +using UnityEngine.InputSystem; +using UnityAI.ControlPlane.Editor.InputSystemSupport; + +public static class UnityAiInputSystemVerification +{ + public static void Run() + { + var queueError = InputSystemSimulationBackend.Queue("keyboard", "space", "button", 1f, 0f, 0f); + if (!string.IsNullOrWhiteSpace(queueError)) + { + throw new InvalidOperationException(queueError); + } + + InputSystem.Update(); + if (Keyboard.current == null || !Keyboard.current.spaceKey.isPressed) + { + throw new InvalidOperationException("Virtual keyboard space press was not observed."); + } + + var resetError = InputSystemSimulationBackend.Reset(); + if (!string.IsNullOrWhiteSpace(resetError)) + { + throw new InvalidOperationException(resetError); + } + + Debug.Log("UNITY_AI_INPUT_SIMULATION_VERIFIED"); + EditorApplication.Exit(0); + } +} +` + ); +} + writeFileSync( join(packagesDir, "manifest.json"), JSON.stringify( { dependencies: { - "com.unity-ai.control-plane": "file:com.unity-ai.control-plane" + "com.unity-ai.control-plane": "file:com.unity-ai.control-plane", + ...(includeInputSystem ? { "com.unity.inputsystem": process.env.UNITY_AI_INPUT_SYSTEM_VERSION ?? "1.14.0" } : {}) } }, null, @@ -65,10 +108,14 @@ const args = [ "-logFile", logPath ]; +if (includeInputSystem) { + args.push("-executeMethod", "UnityAiInputSystemVerification.Run"); +} console.log(`Verifying Unity package with: ${unityPath}`); console.log(`Temporary project: ${tempProject}`); console.log(`Log file: ${logPath}`); +console.log(`Input System verification: ${includeInputSystem ? "enabled" : "disabled"}`); const result = spawnSync(unityPath, args, { stdio: "inherit" }); @@ -82,6 +129,13 @@ if (result.status !== 0 && !isSuccessfulUnityCompileWithShutdownCrash(logPath)) fail(`Unity package verification failed. Check log: ${logPath}`); } +if (includeInputSystem) { + const log = existsSync(logPath) ? readFileSync(logPath, "utf8") : ""; + if (!log.includes("UNITY_AI_INPUT_SIMULATION_VERIFIED")) { + fail(`Input System simulation verification did not complete. Check log: ${logPath}`); + } +} + console.log(`Unity package verification passed. Log: ${logPath}`); function fail(message) { From bcb82b17d8ae1e4a7304cc5ca72e8618c046778b Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:19:33 -0500 Subject: [PATCH 21/28] docs: describe scene physics and input workflows --- docs/capability-contract.md | 11 +++++--- docs/control-plane-operations.md | 37 +++++++++++++++++++++++++- docs/local-bridge.md | 2 ++ docs/scene-authoring.md | 42 ++++++++++++++++++++++++++++-- docs/unity-package-verification.md | 11 ++++++++ 5 files changed, 96 insertions(+), 7 deletions(-) diff --git a/docs/capability-contract.md b/docs/capability-contract.md index c97ae07..d7dec71 100644 --- a/docs/capability-contract.md +++ b/docs/capability-contract.md @@ -60,14 +60,15 @@ verification: | `unity.prefabs.*` | List prefab assets. | | `unity.prefab.*` | Inspect a specific prefab asset. | | `unity.scenes.*` | List Unity scenes. | -| `unity.scene.*` | Inspect hierarchy/component state and apply controlled, atomic scene authoring batches. | +| `unity.scene.*` | Inspect filtered hierarchy/component state and apply controlled, atomic scene authoring batches. | +| `unity.physics.*` | Inspect bounded 3D/2D bodies, colliders, overlaps, impact speed, and sampled force estimates. | | `unity.gameplay.*` | Compose high-level doors, pickups, and activators from existing scene objects. | | `unity.scripts.*` | Inspect C# scripts or author gated runtime `MonoBehaviour` components with compile verification. | | `unity.assemblies.*` | Inspect Unity script assemblies. | | `unity.console.*` | Read, summarize, and verify logs. | | `unity.vision.*` | Capture ready screenshots, compare before/after artifacts, generate diffs, and detect visual regressions. | | `unity.jobs.*` | Inspect and cancel persistent long-running operations. | -| `unity.tests.*` | Run Edit Mode and Play Mode tests with XML evidence. | +| `unity.tests.*` | Run Edit Mode and Play Mode tests with XML evidence and optional new Input System event sequences. | | `unity.playmode.*` | Inspect and control Play Mode. | | `unity.compilation.*` | Wait for compilation/import and verify console state. | | `unity.build.*` | Validate and produce Android/Quest builds. | @@ -77,8 +78,10 @@ verification: ## Broad scene authoring -`unity.scene.inspect_game_object` returns bounded component and visible serialized-property metadata for one hierarchy path. +`unity.scene.inspect` supports combined name, path, component, active-state, and radius filters so agents do not need to ingest the complete hierarchy. -`unity.scene.batch` accepts up to 50 declarative operations and commits them as one isolated Unity Undo group. Supported operations include hierarchy creation/deletion, duplication, rename, reparenting, active state, prefab instantiation, component add/remove, and serialized-property writes. +`unity.scene.inspect_game_object` returns bounded component and visible serialized-property metadata for one hierarchy path, including reusable asset, GameObject, and component reference metadata. + +`unity.scene.batch` accepts up to 50 declarative operations and commits them as one isolated Unity Undo group. Supported operations include hierarchy creation/deletion, duplication, rename, reparenting, active state, prefab instantiation, component add/remove, serialized-property writes, and serialized GameObject/component reference binding. The batch capability does not invoke arbitrary methods or execute generated C#. diff --git a/docs/control-plane-operations.md b/docs/control-plane-operations.md index 6124138..aed7e21 100644 --- a/docs/control-plane-operations.md +++ b/docs/control-plane-operations.md @@ -14,10 +14,45 @@ Terminal states are `succeeded`, `failed`, and `cancelled`. Successful jobs incl ## Tests and compilation -- `unity.tests.run`: Edit Mode or Play Mode filters, confirmation, persistent state, XML results under `UnityAIArtifacts/TestResults`. +- `unity.tests.run`: Edit Mode or Play Mode filters, confirmation, persistent state, XML results under `UnityAIArtifacts/TestResults`, and optional frame-timed keyboard/mouse/gamepad events for the new Input System. +- `unity.physics.inspect`: bounded 3D/2D body and collider inspection, overlap diagnostics, relative impact speed, and sampled net-force estimates. - `unity.compilation.wait`: optional asset refresh, timeout, stable-frame settling, and maximum accepted console error count. - `unity.playmode.control`: `enter`, `exit`, `pause`, `resume`, or `step`. +Console entries expose `classification` and `blocking`. Only `CompilationError` is blocking for compilation jobs; runtime, bridge, import, and warning diagnostics remain visible without causing unrelated compile waits to fail. + +Play Mode input sequences are relative to each leaf test's `TestStarted` callback and survive domain reload: + +```json +{ + "mode": "play", + "dryRun": false, + "confirm": true, + "inputStartDelayFrames": 1, + "inputEvents": [ + { + "valueType": "button", + "frameOffset": 0, + "device": "keyboard", + "control": "w", + "action": "press", + "durationFrames": 30 + }, + { + "valueType": "vector2", + "frameOffset": 5, + "device": "mouse", + "control": "position", + "action": "set", + "x": 640, + "y": 360 + } + ] +} +``` + +Set `targetTest` on an event to restrict it to one exact fully qualified test name. Unscoped events replay for every selected leaf test. + ## Android and Quest `unity.build.validate_android_quest` checks Android Build Support, active target, enabled scenes, application identifier, IL2CPP, ARM64, SDK settings, XR Management, OpenXR, and Meta OpenXR. diff --git a/docs/local-bridge.md b/docs/local-bridge.md index eb7a1f0..a617eaa 100644 --- a/docs/local-bridge.md +++ b/docs/local-bridge.md @@ -55,6 +55,7 @@ The bridge handles: - `POST /capabilities/unity.scenes.list` - `POST /capabilities/unity.scene.inspect` - `POST /capabilities/unity.scene.inspect_game_object` +- `POST /capabilities/unity.physics.inspect` - `POST /capabilities/unity.scene.upsert_game_object` - `POST /capabilities/unity.scene.batch` - `POST /capabilities/unity.gameplay.compose` @@ -98,6 +99,7 @@ The MCP server runs over stdio and exposes: - `unity.scenes.list` - `unity.scene.inspect` - `unity.scene.inspect_game_object` +- `unity.physics.inspect` - `unity.scene.upsert_game_object` - `unity.scene.batch` - `unity.gameplay.compose` diff --git a/docs/scene-authoring.md b/docs/scene-authoring.md index b947f69..778df81 100644 --- a/docs/scene-authoring.md +++ b/docs/scene-authoring.md @@ -2,13 +2,34 @@ The control plane exposes broad Unity scene control through two complementary MCP tools. +## Inspect a filtered hierarchy + +`unity.scene.inspect` can combine filters while keeping the response bounded: + +```json +{ + "includeComponents": true, + "maxDepth": 10, + "maxGameObjects": 50, + "filter": { + "componentType": "UnityEngine.Light", + "withinRadius": { + "centerPath": "Player", + "radius": 5 + } + } +} +``` + +The report distinguishes visited, matched, and returned objects and includes world position and distance from the radius center. + ## Inspect one GameObject `unity.scene.inspect_game_object` reads: - hierarchy identity, active state, tag, layer, and local transform; - component type, assembly, script asset path, and stable type index; -- bounded visible `SerializedProperty` paths, types, editability, values, and asset references. +- bounded visible `SerializedProperty` paths, types, editability, values, and asset/scene references. Use the returned property path and component type as inputs to a later batch. @@ -61,6 +82,23 @@ Every real batch requires the bridge token, `dryRun: false`, and `confirm: true` Supported serialized value kinds are `bool`, `integer`, `number`, `string`, `color`, vectors, integer vectors, rects, bounds, quaternion, enum, asset object reference, null, array size, and character. -Object references are restricted to existing `Assets/` or `Packages/` paths. Component types must resolve to concrete Unity `Component` types. `Transform` cannot be added or removed, and `m_Script` cannot be replaced. +Scene references use `game_object_reference` with a hierarchy `path`, or `component_reference` with `path`, `componentType`, and `componentIndex`: + +```json +{ + "kind": "set_property", + "targetPath": "Door", + "componentType": "Game.DoorController", + "propertyPath": "player", + "value": { + "kind": "component_reference", + "path": "Player", + "componentType": "Game.PlayerController", + "componentIndex": 0 + } +} +``` + +Asset references remain restricted to existing `Assets/` or `Packages/` paths. Component types must resolve to concrete Unity `Component` types. `Transform` cannot be added or removed, and `m_Script` cannot be replaced. The batch API does not execute arbitrary C# or invoke arbitrary methods. Package-specific behavior should be added as reviewed capabilities or adapters on top of this authoring layer. diff --git a/docs/unity-package-verification.md b/docs/unity-package-verification.md index 966ca5b..56cb058 100644 --- a/docs/unity-package-verification.md +++ b/docs/unity-package-verification.md @@ -16,6 +16,16 @@ Verified locally with: UNITY_PATH="/Applications/Unity/Hub/Editor/6000.4.9f1/Unity.app/Contents/MacOS/Unity" npm run verify:unity-package ``` +Compile and functionally verify the optional new Input System backend with: + +```bash +UNITY_AI_VERIFY_INPUT_SYSTEM=1 \ +UNITY_PATH="/Applications/Unity/Hub/Editor/6000.4.9f1/Unity.app/Contents/MacOS/Unity" \ +npm run verify:unity-package +``` + +This variant installs `com.unity.inputsystem`, creates a virtual keyboard, injects a Space press, verifies the state change, and removes the device. + The script creates a temporary Unity project, imports the local package, launches Unity in batchmode, and writes the Editor log to: ```text @@ -27,6 +37,7 @@ artifacts/unity-verification/editor-compile.log - Unity package manifest can be imported. - Editor assembly definitions are valid. - C# Editor scripts compile against Unity APIs. +- When enabled, the optional Input System assembly can inject and reset virtual-device state. ## What this does not verify yet From 53ee5bfe274e689eaf01b2595c85a84855601d31 Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:28:06 -0500 Subject: [PATCH 22/28] fix(observe): tolerate omitted radius filters --- .../Editor/Observe/PhysicsInspector.cs | 13 +++++++------ .../Editor/Observe/SceneInspector.cs | 18 ++++++++++-------- 2 files changed, 17 insertions(+), 14 deletions(-) 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 index 67b362e..f25412c 100644 --- 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 @@ -75,6 +75,8 @@ public sealed class PhysicsObjectInfo public int layer; public string layerName; public SceneVector3 worldPosition; + public bool hasBody3D; + public bool hasBody2D; public PhysicsBodyInfo body3D; public PhysicsBodyInfo body2D; public PhysicsColliderInfo[] colliders3D; @@ -260,6 +262,8 @@ private static void InspectHierarchy( 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, @@ -687,7 +691,9 @@ private static Vector3 GetVelocity(Rigidbody2D body) private static Vector3? ResolveRadiusCenter(SceneRadiusFilterInput filter) { - if (filter == null) + if (filter == null + || string.IsNullOrWhiteSpace(filter.centerPath) + && filter.center == null) { return null; } @@ -703,11 +709,6 @@ private static Vector3 GetVelocity(Rigidbody2D body) return target.transform.position; } - if (filter.center == null) - { - throw new InvalidOperationException("Physics radius filter requires centerPath or center."); - } - return new Vector3(filter.center.x, filter.center.y, filter.center.z); } 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 0abe155..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 @@ -216,7 +216,7 @@ private static SceneInspectFilterInput NormalizeFilter(SceneInspectFilterInput f var hasActiveState = activeState == "active" || activeState == "inactive"; filter.activeState = hasActiveState ? activeState : "any"; - return hasName || hasPath || hasComponent || hasActiveState || filter.withinRadius != null + return hasName || hasPath || hasComponent || hasActiveState || HasRadiusFilter(filter.withinRadius) ? filter : null; } @@ -224,7 +224,7 @@ private static SceneInspectFilterInput NormalizeFilter(SceneInspectFilterInput f private static Vector3? ResolveRadiusCenter(SceneInspectFilterInput filter) { var radiusFilter = filter?.withinRadius; - if (radiusFilter == null) + if (!HasRadiusFilter(radiusFilter)) { return null; } @@ -240,14 +240,15 @@ private static SceneInspectFilterInput NormalizeFilter(SceneInspectFilterInput f return centerObject.transform.position; } - if (radiusFilter.center == null) - { - throw new InvalidOperationException("Radius filter requires centerPath or center."); - } - 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) @@ -282,7 +283,8 @@ private static bool MatchesFilter(GameObject gameObject, string path, SceneInspe return false; } - return filter.withinRadius == null || distanceFromFilterCenter <= Math.Max(0f, filter.withinRadius.radius); + return !HasRadiusFilter(filter.withinRadius) + || distanceFromFilterCenter <= Math.Max(0f, filter.withinRadius.radius); } private static bool HasComponentType(GameObject gameObject, string requestedType) From 2ddb949e08cd0496e25c108b31738c6b737bc6a9 Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:28:06 -0500 Subject: [PATCH 23/28] test(e2e): cover scene physics and console upgrades --- scripts/verify-mcp-unity-e2e.mjs | 154 +++++++++++++++++++++++++++++-- 1 file changed, 147 insertions(+), 7 deletions(-) diff --git a/scripts/verify-mcp-unity-e2e.mjs b/scripts/verify-mcp-unity-e2e.mjs index f059369..9b2ddbf 100644 --- a/scripts/verify-mcp-unity-e2e.mjs +++ b/scripts/verify-mcp-unity-e2e.mjs @@ -81,7 +81,8 @@ writeFileSync( { dependencies: { "com.unity-ai.control-plane": "file:com.unity-ai.control-plane", - "com.unity.test-framework": "1.6.0" + "com.unity.test-framework": "1.6.0", + "com.unity.modules.physics": "1.0.0" } }, null, @@ -138,6 +139,17 @@ public sealed class UnityAiPlayModeTests } ` ); +writeFileSync( + join(assetsDir, "UnityAiReferenceFixture.cs"), + `using UnityEngine; + +public sealed class UnityAiReferenceFixture : MonoBehaviour +{ + public GameObject targetObject; + public Rigidbody targetBody; +} +` +); writeFileSync( join(tempProject, applyFixFile), `public static class UnityAiE2EApplyFixFixture @@ -490,6 +502,11 @@ function assertCapabilities(capabilities) { fail("unity.scene.inspect_game_object must be a read-only scene inspection capability."); } + const physicsInspectCapability = capabilities.find((capability) => capability.name === "unity.physics.inspect"); + if (!physicsInspectCapability || !physicsInspectCapability.permissions.includes("read_scenes") || !physicsInspectCapability.effects.includes("report_only")) { + fail("unity.physics.inspect must be a read-only scene inspection capability."); + } + const sceneBatchCapability = capabilities.find((capability) => capability.name === "unity.scene.batch"); if (!sceneBatchCapability) { fail("unity.capabilities.list did not include unity.scene.batch."); @@ -1209,9 +1226,31 @@ async function assertSceneBatchFlow(client) { name: "UnityAiBatchRenamed" }, { - kind: "set_active", - targetPath: "UnityAiBatchRoot/UnityAiBatchRenamed", - active: false + kind: "add_component", + targetPath: "UnityAiBatchRoot/UnityAiE2ECube", + componentType: "UnityAiReferenceFixture" + }, + { + kind: "set_property", + targetPath: "UnityAiBatchRoot/UnityAiE2ECube", + componentType: "UnityAiReferenceFixture", + propertyPath: "targetObject", + value: { + kind: "game_object_reference", + path: "UnityAiBatchRoot/UnityAiBatchRenamed" + } + }, + { + kind: "set_property", + targetPath: "UnityAiBatchRoot/UnityAiE2ECube", + componentType: "UnityAiReferenceFixture", + propertyPath: "targetBody", + value: { + kind: "component_reference", + path: "UnityAiBatchRoot/UnityAiBatchRenamed", + componentType: "UnityEngine.Rigidbody", + componentIndex: 0 + } }, { kind: "instantiate_prefab", @@ -1232,7 +1271,7 @@ async function assertSceneBatchFlow(client) { forbiddenSignals: [] }); - if (applied.appliedOperationCount !== 9 || !Array.isArray(applied.operations) || applied.operations.some((operation) => operation.applied !== true || operation.verified !== true)) { + if (applied.appliedOperationCount !== 11 || !Array.isArray(applied.operations) || applied.operations.some((operation) => operation.applied !== true || operation.verified !== true)) { fail(`real scene batch did not verify all operations: ${JSON.stringify(applied.operations)}.`); } @@ -1243,6 +1282,66 @@ async function assertSceneBatchFlow(client) { maxPropertyDepth: 8 }); assertGameObjectInspection(inspected); + assertReferenceBinding(inspected); + + const filtered = await callJsonTool(client, "unity.scene.inspect", { + includeComponents: true, + maxDepth: 10, + maxGameObjects: 10, + filter: { + pathPrefix: "UnityAiBatchRoot", + componentType: "UnityEngine.Rigidbody", + withinRadius: { + centerPath: "UnityAiBatchRoot/UnityAiE2ECube", + radius: 0.1 + } + } + }); + if (filtered.filtered !== true + || filtered.returnedGameObjectCount !== 2 + || filtered.matchedGameObjectCount !== 2 + || filtered.gameObjects.some((item) => !item.components.includes("Rigidbody") || item.distanceFromFilterCenter > 0.1)) { + fail(`filtered scene inspection did not return the two colocated rigidbodies: ${JSON.stringify(filtered)}.`); + } + + const physics = await callJsonTool(client, "unity.physics.inspect", { + pathPrefix: "UnityAiBatchRoot", + includeInactive: true, + dimension: "3d", + includeOverlapDiagnostics: true, + maxObjects: 20, + maxOverlaps: 20 + }); + if (physics.returnedPhysicsObjectCount < 2 + || !Array.isArray(physics.objects) + || physics.objects.filter((item) => item.hasBody3D === true && item.body3D?.dimension === "3d").length < 2 + || physics.detectedOverlapCount < 1 + || !Array.isArray(physics.overlaps) + || !physics.overlaps.some((overlap) => overlap.dimension === "3d" && overlap.relativeNormalSpeed >= 0)) { + fail(`physics inspection did not expose bodies and overlap diagnostics: ${JSON.stringify(physics)}.`); + } + + const deactivated = await callJsonTool(client, "unity.scene.batch", { + dryRun: false, + confirm: true, + operations: [ + { + kind: "set_active", + targetPath: "UnityAiBatchRoot/UnityAiBatchRenamed", + active: false + } + ] + }); + assertSceneBatchResult("scene batch deactivation", deactivated, { + dryRun: false, + applied: true, + rolledBack: false, + verificationStatus: "passed", + requiresConfirmation: false, + effects: ["scene_change", "write_audit_log"], + requiredSignals: ["batch_applied", "scene_mutation_verified"], + forbiddenSignals: [] + }); scene = await callJsonTool(client, "unity.scene.inspect", { includeComponents: true, maxDepth: 6, maxGameObjects: 200 }); const renamedCopy = findSceneObject(scene, "UnityAiBatchRoot/UnityAiBatchRenamed"); @@ -1298,6 +1397,11 @@ async function assertSceneBatchFlow(client) { targetPath: "UnityAiBatchRoot/UnityAiE2ECube", componentType: "UnityEngine.Rigidbody" }, + { + kind: "remove_component", + targetPath: "UnityAiBatchRoot/UnityAiE2ECube", + componentType: "UnityAiReferenceFixture" + }, { kind: "delete", targetPath: "UnityAiBatchRoot/UnityAiBatchRenamed" }, { kind: "delete", targetPath: "UnityAiBatchRoot/UnityAiBatchPrefab" } ] @@ -1384,6 +1488,24 @@ function assertGameObjectInspection(report) { assertNoAbsolutePathLeakInValue("game object inspection", report); } +function assertReferenceBinding(report) { + const fixture = report.components.find((component) => component.fullTypeName === "UnityAiReferenceFixture"); + if (!fixture || !Array.isArray(fixture.properties)) { + fail("scene reference fixture component was not inspectable."); + } + + const targetObject = fixture.properties.find((property) => property.path === "targetObject"); + const targetBody = fixture.properties.find((property) => property.path === "targetBody"); + if (targetObject?.objectReferenceKind !== "game_object" + || targetObject.objectReferencePath !== "UnityAiBatchRoot/UnityAiBatchRenamed" + || targetBody?.objectReferenceKind !== "component" + || targetBody.objectReferencePath !== "UnityAiBatchRoot/UnityAiBatchRenamed" + || targetBody.objectReferenceComponentType !== "UnityEngine.Rigidbody" + || targetBody.objectReferenceComponentIndex !== 0) { + fail(`serialized scene references were not bound or reported correctly: ${JSON.stringify(fixture.properties)}.`); + } +} + async function assertVisualVerificationFlow(client) { const refusedAbsolutePath = await callJsonTool(client, "unity.vision.compare", { beforePath: "/tmp/unity-ai-before.png", @@ -2906,6 +3028,15 @@ function assertConsoleDiagnostics(report) { fail(`unity.console.diagnose diagnosticCount ${report.diagnosticCount} did not match diagnostics length ${report.diagnostics.length}.`); } + if (typeof report.blockingErrorCount !== "number" + || typeof report.nonBlockingIssueCount !== "number" + || typeof report.hasBlockingErrors !== "boolean" + || report.compilationErrorCount < 1 + || report.runtimeErrorCount < 1 + || report.hasBlockingErrors !== true) { + fail(`unity.console.diagnose did not expose strict blocking classifications: ${JSON.stringify(report)}.`); + } + if (!findDiagnostic(report, diagnosticWarningMarker, "warning", "warning")) { fail("unity.console.diagnose did not map the deterministic warning fixture to severity=warning category=warning."); } @@ -2918,12 +3049,21 @@ function assertConsoleDiagnostics(report) { fail("unity.console.diagnose did not map the deterministic exception fixture to severity=error category=runtime_exception."); } + const compilerDiagnostic = findDiagnostic(report, diagnosticErrorMarker, "error", "compiler_error"); + const runtimeDiagnostic = findDiagnostic(report, diagnosticExceptionMarker, "error", "runtime_exception"); + if (compilerDiagnostic.classification !== "CompilationError" + || compilerDiagnostic.blocking !== true + || runtimeDiagnostic.classification !== "RuntimeError" + || runtimeDiagnostic.blocking !== false) { + fail("unity.console.diagnose did not distinguish blocking compiler errors from non-blocking runtime errors."); + } + for (const diagnostic of report.diagnostics) { - if (typeof diagnostic.category !== "string" || typeof diagnostic.severity !== "string" || typeof diagnostic.message !== "string" || typeof diagnostic.stackHint !== "string" || typeof diagnostic.functionHint !== "string" || typeof diagnostic.likelyRootCause !== "string" || typeof diagnostic.suggestedNextSafeAction !== "string") { + if (typeof diagnostic.category !== "string" || typeof diagnostic.classification !== "string" || typeof diagnostic.blocking !== "boolean" || typeof diagnostic.severity !== "string" || typeof diagnostic.message !== "string" || typeof diagnostic.stackHint !== "string" || typeof diagnostic.functionHint !== "string" || typeof diagnostic.likelyRootCause !== "string" || typeof diagnostic.suggestedNextSafeAction !== "string") { fail("unity.console.diagnose returned a diagnostic with missing string fields."); } - const diagnosticStringFields = ["category", "severity", "message", "file", "stackHint", "functionHint", "likelyRootCause", "suggestedNextSafeAction"]; + const diagnosticStringFields = ["category", "classification", "severity", "message", "file", "stackHint", "functionHint", "likelyRootCause", "suggestedNextSafeAction"]; for (const field of diagnosticStringFields) { assertNoAbsolutePathLeak(`diagnostic.${field}`, diagnostic[field]); } From 06b9379bbf43fcbde798bfd2c72b0fd735b7e4eb Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:47:57 -0500 Subject: [PATCH 24/28] fix(setup): install unity mcp for antigravity --- README.md | 7 ++- docs/setup.md | 12 ++-- scripts/setup-user.mjs | 95 +++++++++++++++++++++++++++++-- scripts/sync-mcp-tool-schemas.mjs | 3 +- 4 files changed, 104 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ae6215d..b1b6aff 100644 --- a/README.md +++ b/README.md @@ -159,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: @@ -168,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. @@ -197,4 +198,4 @@ 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's local `parameters` JSON instead of leaving stale `parameters: null` entries. +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/docs/setup.md b/docs/setup.md index 3c92051..d8210e3 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -13,16 +13,16 @@ npm ci 2. Preview the setup: ```bash -npm run setup:user -- --unity-project /path/to/UnityProject --opencode --claude-code --codex +npm run setup:user -- --unity-project /path/to/UnityProject --opencode --claude-code --codex --antigravity ``` 3. Apply the setup and build the MCP server: ```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 ``` -4. Restart opencode or Codex so it reloads config. Claude Code is configured through its CLI. +4. Restart opencode, Codex, or Antigravity so it reloads config. Claude Code is configured through its CLI. 5. In Unity, open `Tools -> Unity AI -> Control Plane`, start the local bridge, and use the token printed by the setup script. @@ -34,6 +34,7 @@ npm run setup:user -- --unity-project /path/to/UnityProject --opencode --claude- | opencode | Adds or updates `mcp.unity-ai` in `~/.config/opencode/opencode.json` with a local MCP command and `environment` variables. | | Claude Code | Runs `claude mcp add-json unity-ai ...` when applying with `--write`. Dry-runs print the command with supplied tokens masked or generated-token placeholders. | | Codex | Adds or updates a generated block in `~/.codex/config.toml` with `[mcp_servers.unity-ai]`, `command`, `args`, and `[mcp_servers.unity-ai.env]`. | +| Antigravity | Adds or updates `mcpServers.unity-ai` in `~/.gemini/antigravity/mcp_config.json`, `~/.gemini/antigravity-cli/mcp_config.json`, and `~/.gemini/antigravity-ide/mcp_config.json`. | | Backups | Writes `.bak-YYYYMMDDHHmmss` next to each modified file before changing it. | The script preserves unrelated JSON fields and validates JSON after writing. For Codex TOML, it preserves existing content and only replaces the generated `unity-ai` block; if an unmarked `[mcp_servers.unity-ai]` section already exists, it stops instead of creating duplicate TOML tables. @@ -47,13 +48,14 @@ The script preserves unrelated JSON fields and validates JSON after writing. For | `--claude-code` | Configure Claude Code by invoking `claude mcp add-json`. Requires `claude` on `PATH` when using `--write`. | | `--claude-scope ` | Optional Claude Code scope. By default no scope flag is passed. | | `--codex` | Configure Codex by writing a generated MCP block to `~/.codex/config.toml`. | +| `--antigravity` | Configure Antigravity, Antigravity CLI, and Antigravity IDE MCP server entries. | | `--write` | Apply changes. Without it, the script only prints the planned changes. | | `--yes` | Alias for `--write`. | | `--build` | Run `npm run build` before configuring MCP hosts. If `node_modules` is missing, run `npm ci` first. | | `--bridge-url ` | Set `UNITY_AI_BRIDGE_URL`. Defaults to `http://127.0.0.1:39071`. | | `--bridge-token ` | Set `UNITY_AI_BRIDGE_TOKEN`. If omitted when applying with `--write` and any MCP host, the script generates and prints a local token. | -For Codex write tests or scripted setup that must not touch the real user config, set `UNITY_AI_CODEX_CONFIG_PATH` to a temporary TOML path before running the setup script. +For Codex write tests or scripted setup that must not touch the real user config, set `UNITY_AI_CODEX_CONFIG_PATH` to a temporary TOML path before running the setup script. For Antigravity, set `UNITY_AI_ANTIGRAVITY_CONFIG_PATHS` to a colon-separated list of temporary `mcp_config.json` paths. ## Safety Notes @@ -64,4 +66,4 @@ For Codex write tests or scripted setup that must not touch the real user config - Generated tokens are printed only when applying with `--write`. - Codex config is backed up before writes and only the generated block is replaced. - Claude Code config is not edited directly; setup uses the Claude Code CLI and fails with an actionable message if `claude` is missing. -- opencode and Codex config changes require restarting the host or reloading MCP configuration. +- opencode, Codex, and Antigravity config changes require restarting the host or reloading MCP configuration. diff --git a/scripts/setup-user.mjs b/scripts/setup-user.mjs index d70b63f..dd6032a 100644 --- a/scripts/setup-user.mjs +++ b/scripts/setup-user.mjs @@ -8,11 +8,17 @@ import { dirname, join, resolve } from "node:path"; const repoRoot = resolve(new URL("..", import.meta.url).pathname); const unityPackagePath = join(repoRoot, "apps/unity-plugin/Packages/com.unity-ai.control-plane"); const mcpServerPath = join(repoRoot, "apps/mcp-server/dist/index.js"); +const mcpNodeCommand = resolveNodeCommand(); const defaultBridgeUrl = "http://127.0.0.1:39071"; const opencodeConfigPath = join(homedir(), ".config/opencode/opencode.json"); const codexConfigPath = process.env.UNITY_AI_CODEX_CONFIG_PATH ?? join(homedir(), ".codex/config.toml"); const codexBlockStart = "# BEGIN unity-ai MCP server generated by scripts/setup-user.mjs"; const codexBlockEnd = "# END unity-ai MCP server generated by scripts/setup-user.mjs"; +const defaultAntigravityConfigPaths = [ + join(homedir(), ".gemini/antigravity/mcp_config.json"), + join(homedir(), ".gemini/antigravity-cli/mcp_config.json"), + join(homedir(), ".gemini/antigravity-ide/mcp_config.json") +]; const args = parseArgs(process.argv.slice(2)); @@ -25,8 +31,8 @@ if (args.yes) { args.write = true; } -if (!args.unityProject && !args.opencode && !args.claudeCode && !args.codex && !args.build) { - fail("Nothing to do. Pass --unity-project , --opencode, --claude-code, --codex, or --build. Use --help for examples."); +if (!args.unityProject && !args.opencode && !args.claudeCode && !args.codex && !args.antigravity && !args.build) { + fail("Nothing to do. Pass --unity-project , --opencode, --claude-code, --codex, --antigravity, or --build. Use --help for examples."); } if (!existsSync(unityPackagePath)) { @@ -34,7 +40,7 @@ if (!existsSync(unityPackagePath)) { } const bridgeUrl = args.bridgeUrl ?? defaultBridgeUrl; -const selectedMcpHost = args.opencode || args.claudeCode || args.codex; +const selectedMcpHost = args.opencode || args.claudeCode || args.codex || args.antigravity; const generatedToken = selectedMcpHost && args.write && !args.bridgeToken ? generateToken() : undefined; const bridgeToken = args.bridgeToken ?? generatedToken; const plannedChanges = []; @@ -63,6 +69,10 @@ if (args.codex) { setupCodex(); } +if (args.antigravity) { + setupAntigravity(); +} + if (plannedChanges.length > 0) { console.log("\nPlanned changes:"); for (const change of plannedChanges) { @@ -245,6 +255,37 @@ function setupCodex() { } } +function setupAntigravity() { + warnIfMcpServerMissing("Antigravity"); + + for (const configPath of antigravityConfigPaths()) { + const config = existsSync(configPath) ? readJson(configPath) : {}; + if (!config || typeof config !== "object" || Array.isArray(config)) { + fail(`Antigravity MCP config must be a JSON object: ${configPath}`); + } + + if (!config.mcpServers || typeof config.mcpServers !== "object" || Array.isArray(config.mcpServers)) { + config.mcpServers = {}; + } + + config.mcpServers["unity-ai"] = createAntigravityMcpConfig(); + + plannedChanges.push(`Add/update Antigravity MCP server "unity-ai" in ${configPath}`); + plannedChanges.push(`Set command to "${mcpNodeCommand}" with args = ["${mcpServerPath}"]`); + plannedChanges.push(`Set UNITY_AI_BRIDGE_URL = "${bridgeUrl}"`); + if (bridgeToken) { + plannedChanges.push(`Set UNITY_AI_BRIDGE_TOKEN = "${maskSecret(bridgeToken)}"`); + } else { + plannedChanges.push("Generate and set a local UNITY_AI_BRIDGE_TOKEN when run with --write"); + } + + if (args.write) { + mkdirSync(dirname(configPath), { recursive: true }); + writeJsonWithBackup(configPath, config); + } + } +} + function warnIfMcpServerMissing(hostName) { if (!args.build && !existsSync(mcpServerPath)) { plannedChanges.push(`Warning: MCP server build output is missing for ${hostName}: ${mcpServerPath}. Run npm run build or pass --build before using this host.`); @@ -269,6 +310,24 @@ function createClaudeMcpConfig(options = {}) { }; } +function createAntigravityMcpConfig(options = {}) { + const env = { + UNITY_AI_BRIDGE_URL: bridgeUrl + }; + + if (bridgeToken) { + env.UNITY_AI_BRIDGE_TOKEN = bridgeToken; + } else if (options.display) { + env.UNITY_AI_BRIDGE_TOKEN = ""; + } + + return { + command: mcpNodeCommand, + args: [mcpServerPath], + env + }; +} + function createCodexBlock() { const lines = [ codexBlockStart, @@ -482,6 +541,7 @@ function writeTextWithBackup(path, value) { function parseArgs(argv) { const parsed = { + antigravity: false, build: false, claudeCode: false, codex: false, @@ -505,6 +565,9 @@ function parseArgs(argv) { parsed.bridgeUrl = readValue(argv, index, arg); index += 1; break; + case "--antigravity": + parsed.antigravity = true; + break; case "--claude-code": parsed.claudeCode = true; break; @@ -545,6 +608,15 @@ function parseArgs(argv) { return parsed; } +function antigravityConfigPaths() { + const configured = process.env.UNITY_AI_ANTIGRAVITY_CONFIG_PATHS; + if (configured) { + return configured.split(":").map((value) => resolve(value)).filter(Boolean); + } + + return defaultAntigravityConfigPaths; +} + function readValue(argv, index, flag) { const value = argv[index + 1]; if (!value || value.startsWith("--")) { @@ -557,6 +629,20 @@ function generateToken() { return randomBytes(24).toString("base64url"); } +function resolveNodeCommand() { + if (process.env.UNITY_AI_NODE_PATH) { + return resolve(process.env.UNITY_AI_NODE_PATH); + } + + for (const candidate of ["/opt/homebrew/bin/node", "/usr/local/bin/node", process.execPath]) { + if (candidate && existsSync(candidate)) { + return candidate; + } + } + + return process.execPath; +} + function maskSecret(secret) { if (secret.length <= 8) { return "****"; @@ -600,7 +686,7 @@ Usage: npm run setup:user -- --opencode npm run setup:user -- --claude-code --codex npm run setup:user -- --unity-project /path/to/UnityProject - npm run setup:user -- --opencode --claude-code --codex --unity-project /path/to/UnityProject --build --write + npm run setup:user -- --opencode --claude-code --codex --antigravity --unity-project /path/to/UnityProject --build --write Options: --unity-project Add/update the Unity package file dependency in Packages/manifest.json. @@ -608,6 +694,7 @@ Options: --claude-code Configure Claude Code using 'claude mcp add-json'. --claude-scope Optional Claude Code scope: local, project, or user. --codex Add/update a generated Codex MCP server block in ~/.codex/config.toml. + --antigravity Add/update Antigravity, Antigravity CLI, and Antigravity IDE MCP server entries. --write Apply changes. Without this flag, setup runs as a dry run. --yes Alias for --write. --build Run npm run build before configuring MCP hosts. diff --git a/scripts/sync-mcp-tool-schemas.mjs b/scripts/sync-mcp-tool-schemas.mjs index 175ff47..55bb6b4 100644 --- a/scripts/sync-mcp-tool-schemas.mjs +++ b/scripts/sync-mcp-tool-schemas.mjs @@ -86,7 +86,8 @@ function antigravitySchemaDirectories() { return [ join(homedir(), ".gemini/antigravity/mcp/unity-ai"), - join(homedir(), ".gemini/antigravity-cli/mcp/unity-ai") + join(homedir(), ".gemini/antigravity-cli/mcp/unity-ai"), + join(homedir(), ".gemini/antigravity-ide/mcp/unity-ai") ]; } From 1e6b21cfab4ec14ef0c4dfe35c631cacafe0a3e1 Mon Sep 17 00:00:00 2001 From: Marco Soto Maceda <158858927+marcosotomac@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:28:28 -0500 Subject: [PATCH 25/28] feat(ui): compose and audit quality screens --- apps/mcp-server/generated/tool-schemas.json | 334 +++++ apps/mcp-server/src/capabilities.ts | 14 + apps/mcp-server/src/index.ts | 81 + .../Editor/Bridge/UnityAiBridgeServer.cs | 5 + .../com.unity-ai.control-plane/Editor/UI.meta | 8 + .../Editor/UI/UiComposeOperation.cs | 1316 +++++++++++++++++ .../Editor/UI/UiComposeOperation.cs.meta | 2 + .../Unity.AI.ControlPlane.Editor.asmdef | 3 +- .../Runtime/UnityAiUiActionMarker.cs | 11 + .../Runtime/UnityAiUiActionMarker.cs.meta | 2 + .../Runtime/UnityAiUiScreenMarker.cs | 12 + .../Runtime/UnityAiUiScreenMarker.cs.meta | 2 + .../com.unity-ai.control-plane/package.json | 3 + docs/control-plane-operations.md | 4 + docs/local-bridge.md | 3 + docs/roadmap.md | 3 +- packages/core-protocol/src/index.ts | 5 +- scripts/verify-mcp-unity-e2e.mjs | 28 + 18 files changed, 1833 insertions(+), 3 deletions(-) create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/UI.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/UI/UiComposeOperation.cs create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Editor/UI/UiComposeOperation.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/UnityAiUiActionMarker.cs create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/UnityAiUiActionMarker.cs.meta create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/UnityAiUiScreenMarker.cs create mode 100644 apps/unity-plugin/Packages/com.unity-ai.control-plane/Runtime/UnityAiUiScreenMarker.cs.meta diff --git a/apps/mcp-server/generated/tool-schemas.json b/apps/mcp-server/generated/tool-schemas.json index 77d7072..73d1949 100644 --- a/apps/mcp-server/generated/tool-schemas.json +++ b/apps/mcp-server/generated/tool-schemas.json @@ -4435,6 +4435,340 @@ "$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.", diff --git a/apps/mcp-server/src/capabilities.ts b/apps/mcp-server/src/capabilities.ts index 2af3e53..7097f5b 100644 --- a/apps/mcp-server/src/capabilities.ts +++ b/apps/mcp-server/src/capabilities.ts @@ -85,6 +85,13 @@ export const initialCapabilities: CapabilityManifest[] = [ effects: ["report_only"], verification: ["structured_observation"] }, + { + 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.", @@ -99,6 +106,13 @@ export const initialCapabilities: CapabilityManifest[] = [ 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.", diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index 29fcfd8..addd5fe 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -242,12 +242,93 @@ server.registerTool( async (input) => bridgeTool("unity.physics.inspect", 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", { 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 fbae542..4303b15 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 @@ -583,10 +583,14 @@ private static BridgeResponse ExecuteCapability(string capability, string reques return JsonResult(capability, envelope, GameObjectInspector.Inspect(requestBody)); case "unity.physics.inspect": return JsonResult(capability, envelope, PhysicsInspector.Inspect(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": @@ -715,6 +719,7 @@ 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": 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