Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Scripts
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring command that adds a MonoBehaviour component to a GameObject, addressing the script
|
||||
/// either by its (compiled) type name OR by its source asset path (CLI-195, CLI-224).
|
||||
///
|
||||
/// COMPILE-AWARE: the type must already be compiled. A script created via create_script does not
|
||||
/// have a compiled <see cref="Type"/> until a domain reload runs, so attaching it before that
|
||||
/// fails with a CLEAR, RECOVERABLE error (no crash, no silent no-op) telling the agent to
|
||||
/// recompile and retry. The full flow is:
|
||||
/// create_script -> recompile -> poll recompile_status (completed/up_to_date) -> attach_script.
|
||||
///
|
||||
/// Addressing by asset path resolves the backing class through
|
||||
/// <see cref="MonoScript.GetClass"/>, which correctly handles a class whose name differs from
|
||||
/// the filename. A path whose MonoScript has no class yet (not compiled, or no single matching
|
||||
/// top-level type) surfaces the same recoverable "not yet compiled / ambiguous" error.
|
||||
///
|
||||
/// The component add is wrapped in an <see cref="AuthoringUndoScope"/> and registered with the
|
||||
/// Undo system so it reverts as one step and the owning object/prefab is marked dirty.
|
||||
/// </summary>
|
||||
static class AttachScriptCommand
|
||||
{
|
||||
[CliCommand("attach_script",
|
||||
"Add a MonoBehaviour to a GameObject by its (compiled) type name OR by its script asset path. " +
|
||||
"Provide exactly one of 'type' or 'script'. " +
|
||||
"If the type isn't compiled yet, returns a recoverable error: recompile, poll recompile_status, then retry.",
|
||||
Tags = new[] { "scripts" })]
|
||||
public static AuthoringResult AttachScript(
|
||||
[CliArg("target", "Reference to the GameObject to add the component to (globalId/path/guid/instanceId/hierarchyPath).", Required = true)] ObjectRef target,
|
||||
[CliArg("type", "Component type name to add, e.g. PlayerController or Game.Player.PlayerController. Must already be compiled. Mutually exclusive with 'script'.")] string type = null,
|
||||
[CliArg("script", "Script asset path, e.g. 'Assets/Pool/Scripts/CueShooter.cs'. The backing class is resolved via MonoScript.GetClass(), so the class name may differ from the filename. Mutually exclusive with 'type'.")] string script = null)
|
||||
{
|
||||
if (target == null || target.IsEmpty)
|
||||
throw new ArgumentException("attach_script 'target' is required.");
|
||||
|
||||
// Exactly one addressing form must be supplied. Both is ambiguous; neither is incomplete.
|
||||
var hasType = !string.IsNullOrWhiteSpace(type);
|
||||
var hasScript = !string.IsNullOrWhiteSpace(script);
|
||||
if (hasType && hasScript)
|
||||
throw new ArgumentException(
|
||||
"Provide either 'type' (class name) or 'script' (asset path), not both.");
|
||||
if (!hasType && !hasScript)
|
||||
throw new ArgumentException(
|
||||
"Provide either 'type' (class name) or 'script' (asset path).");
|
||||
|
||||
if (!ObjectResolver.TryResolve(target, out var obj, out var resolveError))
|
||||
throw new ArgumentException($"Could not resolve target: {resolveError}");
|
||||
|
||||
var go = obj as GameObject ?? (obj as Component)?.gameObject;
|
||||
if (go == null)
|
||||
throw new ArgumentException(
|
||||
$"Target '{target}' resolved to a {obj.GetType().Name}, which is not a GameObject. " +
|
||||
"attach_script needs a GameObject.");
|
||||
|
||||
// Resolve the component Type from whichever addressing form was given. A type that exists but
|
||||
// isn't compiled yet (or is the wrong kind) surfaces as InvalidOperationException so the
|
||||
// server returns a 400 "Command Execution Failed" with the recovery instructions intact.
|
||||
// ArgumentException is reserved for caller input mistakes — both/neither args, or a script
|
||||
// path that points at no asset — which the server reports as "Parameter Validation Failed".
|
||||
var componentType = hasScript
|
||||
? ResolveTypeFromScriptPath(script)
|
||||
: ResolveTypeFromName(type);
|
||||
|
||||
using (new AuthoringUndoScope($"Attach {componentType.Name}"))
|
||||
{
|
||||
// Undo.AddComponent registers the add for undo and returns the new component.
|
||||
var component = Undo.AddComponent(go, componentType);
|
||||
if (component == null)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to add component '{componentType.FullName}' to '{go.name}'. " +
|
||||
"It may conflict with [DisallowMultipleComponent] or a required-component rule.");
|
||||
|
||||
EditorUtility.SetDirty(go);
|
||||
|
||||
var result = ObjectResolver.Describe(component) ?? new AuthoringResult { Type = componentType.Name };
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a component type by its compiled type name. A missing type is the expected,
|
||||
/// recoverable "created but not yet compiled" case.
|
||||
/// </summary>
|
||||
private static Type ResolveTypeFromName(string type)
|
||||
{
|
||||
if (!ScriptTypeResolver.TryResolveComponentType(type, out var componentType, out var typeError))
|
||||
throw new InvalidOperationException(typeError);
|
||||
|
||||
return componentType;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a component type from a script asset path. Loads the <see cref="MonoScript"/> at the
|
||||
/// path and resolves its backing class via <see cref="MonoScript.GetClass"/> — which correctly
|
||||
/// handles a class whose name differs from the filename. Validates the class is a concrete
|
||||
/// MonoBehaviour. A null class is the recoverable "not yet compiled / ambiguous" case and
|
||||
/// mirrors the type-not-found message style. A path that resolves to no MonoScript asset is a
|
||||
/// caller input mistake (ArgumentException → "Parameter Validation Failed"), not a recompile-
|
||||
/// recoverable failure.
|
||||
/// </summary>
|
||||
private static Type ResolveTypeFromScriptPath(string path)
|
||||
{
|
||||
var trimmed = path.Trim();
|
||||
|
||||
var mono = AssetDatabase.LoadAssetAtPath<MonoScript>(trimmed);
|
||||
if (mono == null)
|
||||
throw new ArgumentException(
|
||||
$"No MonoScript at '{trimmed}'. Check the asset path (it should be a project-relative " +
|
||||
"path to a .cs file, e.g. 'Assets/Scripts/PlayerController.cs').");
|
||||
|
||||
var cls = mono.GetClass();
|
||||
if (cls == null)
|
||||
throw new InvalidOperationException(
|
||||
$"Script at '{trimmed}' has no resolvable class. " +
|
||||
"If you just created or edited this script it is not compiled yet: run 'recompile', poll " +
|
||||
"'recompile_status' until it reports completed/up_to_date, then retry attach_script. " +
|
||||
"If it should already exist, ensure the file has a single top-level type matching the " +
|
||||
"script and that it compiled without errors.");
|
||||
|
||||
if (!typeof(MonoBehaviour).IsAssignableFrom(cls))
|
||||
throw new InvalidOperationException(
|
||||
$"Type '{cls.FullName}' (from '{trimmed}') does not derive from MonoBehaviour and cannot be added as a component.");
|
||||
|
||||
if (cls.IsAbstract)
|
||||
throw new InvalidOperationException(
|
||||
$"Type '{cls.FullName}' (from '{trimmed}') is abstract and cannot be instantiated as a component.");
|
||||
|
||||
return cls;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 488a53ecbf5c4984bdc009bf1f2f9a45
|
||||
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Scripts
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring command that writes a new C# MonoBehaviour (or other base-class) script from a
|
||||
/// template into the project under the authoring root (CLI-195).
|
||||
///
|
||||
/// IMPORTANT — the compile/domain-reload boundary:
|
||||
/// Writing a .cs file does NOT make the type available. Unity must import and compile the new
|
||||
/// file, which triggers a domain reload, before the type exists and can be attached. The agent
|
||||
/// workflow is therefore:
|
||||
/// 1. create_script (this command — writes the file)
|
||||
/// 2. recompile (triggers compilation; see <see cref="RecompileCommand"/>)
|
||||
/// 3. poll recompile_status (wait until "completed" / "up_to_date")
|
||||
/// 4. attach_script (now the type exists; see <see cref="AttachScriptCommand"/>)
|
||||
/// This command intentionally does its part only: it creates the file and returns the asset
|
||||
/// identity. It does not trigger a recompile itself — the agent owns that step so it can batch
|
||||
/// multiple authoring writes before paying the domain-reload cost once.
|
||||
/// </summary>
|
||||
static class CreateScriptCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Default class body (the Start/Update stubs) written inside the generated class, matching
|
||||
/// Unity's own new-MonoBehaviour template so the output reads like a hand-authored script.
|
||||
/// <see cref="BuildSource"/> wraps it with the using/class declaration and an optional namespace.
|
||||
/// </summary>
|
||||
private const string DefaultBody =
|
||||
" // Use this for initialization\n" +
|
||||
" void Start()\n" +
|
||||
" {\n\n" +
|
||||
" }\n\n" +
|
||||
" // Update is called once per frame\n" +
|
||||
" void Update()\n" +
|
||||
" {\n\n" +
|
||||
" }\n";
|
||||
|
||||
[CliCommand("create_script",
|
||||
"Create a new C# script (default base class MonoBehaviour) from a template under the authoring root. " +
|
||||
"NOTE: the type does not exist until a recompile completes — to attach it, call recompile, poll recompile_status, then attach_script.",
|
||||
Tags = new[] { "scripts" })]
|
||||
public static AuthoringResult CreateScript(
|
||||
[CliArg("name", "Class/file name without extension, e.g. PlayerController. Must be a valid C# identifier.", Required = true)] string name,
|
||||
[CliArg("path", "Folder (relative to the authoring root; the Assets/ prefix is optional) to write the .cs into. Defaults to the authoring root.")] string path = null,
|
||||
[CliArg("namespace", "Optional namespace to wrap the class in. Omit for the global namespace.")] string @namespace = null,
|
||||
[CliArg("base_class", "Base class to derive from. Defaults to MonoBehaviour.")] string baseClass = "MonoBehaviour",
|
||||
[CliArg("overwrite", "Overwrite the file if it already exists. Defaults to false (an existing file is an error).")] bool overwrite = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("Script 'name' is required.");
|
||||
|
||||
var className = name.Trim();
|
||||
if (className.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
|
||||
className = className.Substring(0, className.Length - 3);
|
||||
|
||||
if (!IsValidIdentifier(className))
|
||||
throw new ArgumentException($"Script name '{className}' is not a valid C# class identifier.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(baseClass))
|
||||
baseClass = "MonoBehaviour";
|
||||
|
||||
// Resolve & sandbox the destination folder through the project-path policy. A null/empty
|
||||
// path means "the authoring root itself" — Resolve treats an empty path as an error, so
|
||||
// fall back to the configured root in that case (it is already a confined, valid path).
|
||||
string folder;
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
folder = ProjectPaths.AuthoringRoot;
|
||||
}
|
||||
else
|
||||
{
|
||||
folder = ProjectPaths.Resolve(path, out var error);
|
||||
if (folder == null)
|
||||
throw new ArgumentException(error);
|
||||
}
|
||||
|
||||
if (!AssetDatabase.IsValidFolder(folder))
|
||||
throw new ArgumentException(
|
||||
$"Destination folder '{folder}' does not exist. Create it first with create_folder.");
|
||||
|
||||
var assetPath = $"{folder}/{className}.cs";
|
||||
if (File.Exists(ToAbsolute(assetPath)) && !overwrite)
|
||||
throw new ArgumentException(
|
||||
$"A script already exists at '{assetPath}'. Pass overwrite=true to replace it.");
|
||||
|
||||
var source = BuildSource(className, @namespace, baseClass);
|
||||
|
||||
// Write through the filesystem then import: AssetDatabase has no "create text asset" API,
|
||||
// and CreateAsset is for UnityEngine.Object instances, not source files.
|
||||
File.WriteAllText(ToAbsolute(assetPath), source, new UTF8Encoding(false));
|
||||
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
|
||||
|
||||
// The MonoScript may already be loadable (its asset entry exists), but its compiled Type
|
||||
// will not exist until the next domain reload — see the class doc.
|
||||
var script = AssetDatabase.LoadAssetAtPath<MonoScript>(assetPath);
|
||||
var result = ObjectResolver.Describe(script) ?? new AuthoringResult { Type = "MonoScript" };
|
||||
result.AssetPath = assetPath;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the script source from the template, substituting the class name, optional namespace
|
||||
/// and base class. When a namespace is supplied the class body is indented one extra level.
|
||||
/// </summary>
|
||||
private static string BuildSource(string className, string @namespace, string baseClass)
|
||||
{
|
||||
var usings = "using UnityEngine;\n\n";
|
||||
var classDecl = $"public class {className} : {baseClass}\n";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(usings);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(@namespace))
|
||||
{
|
||||
sb.Append($"namespace {@namespace}\n{{\n");
|
||||
sb.Append(Indent(classDecl, " "));
|
||||
sb.Append(" {\n");
|
||||
sb.Append(Indent(DefaultBody, " "));
|
||||
sb.Append(" }\n");
|
||||
sb.Append("}\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(classDecl);
|
||||
sb.Append("{\n");
|
||||
sb.Append(DefaultBody);
|
||||
sb.Append("}\n");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string Indent(string text, string prefix)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var line in text.Split('\n'))
|
||||
sb.Append(line.Length == 0 ? "\n" : prefix + line + "\n");
|
||||
// Split adds a trailing empty element for a trailing newline; trim the extra newline we
|
||||
// appended for it.
|
||||
if (text.EndsWith("\n") && sb.Length > 0)
|
||||
sb.Length -= 1;
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static bool IsValidIdentifier(string value)
|
||||
{
|
||||
// A C# identifier: starts with a letter or underscore, then letters/digits/underscores.
|
||||
return Regex.IsMatch(value, @"^[A-Za-z_][A-Za-z0-9_]*$");
|
||||
}
|
||||
|
||||
private static string ToAbsolute(string assetPath)
|
||||
{
|
||||
// assetPath is project-relative ("Assets/..."); ProjectRoot is the folder containing Assets/.
|
||||
return Path.Combine(ProjectPaths.ProjectRoot, assetPath).Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 34d285de0757475796fcecc889996421
|
||||
@@ -0,0 +1,664 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using Unity.Pipeline.Runtime.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Debug = UnityEngine.Debug;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Scripts
|
||||
{
|
||||
/// <summary>
|
||||
/// The <c>run_script</c> command (AUTHAPI-26): compile a single project <c>.cs</c> file in memory
|
||||
/// via the shared <see cref="RoslynCompilationService"/> and execute a NAMED static entry point —
|
||||
/// with NO domain reload and NO code carried through the protocol. This formalizes the
|
||||
/// "builder pattern": bulk construction lives in a versioned project script that is written to
|
||||
/// disk (ideally OUTSIDE <c>Assets/</c>, so writing it triggers no asset import / domain reload)
|
||||
/// and invoked here by name, instead of shipping escaped C# strings through <c>eval</c>.
|
||||
///
|
||||
/// It composes the two halves the pipeline already had — <c>eval_file</c> (code from disk) and
|
||||
/// <c>reload_file</c> (single-file in-memory Roslyn compile) — into one first-class command:
|
||||
/// - <c>ephemeral</c> (default): compile to an in-memory assembly, run the entry point, discard.
|
||||
/// - <c>hotpatch</c>: delegate to <c>reload_file</c>'s in-place <c>[CodeReload]</c> semantics.
|
||||
///
|
||||
/// This is still arbitrary code execution — only the carrier changes (a reviewable file on disk
|
||||
/// instead of a protocol string). It is grouped under the same <c>scripts/eval</c> tag as
|
||||
/// <c>eval</c> so a host that disables the eval family disables this too.
|
||||
/// </summary>
|
||||
static class RunScriptCommand
|
||||
{
|
||||
/// <summary>Upper bound on a single run's timeout — 24 hours, matching the <c>eval</c> cap (CLI-335).</summary>
|
||||
private const int MaxTimeoutMs = 86_400_000;
|
||||
|
||||
[CliCommand("run_script",
|
||||
"Compile a project C# file in memory (no domain reload) and execute a named static entry point. " +
|
||||
"The builder-pattern path for bulk construction. Still arbitrary code execution — shares eval's capability gate.",
|
||||
MainThreadRequired = true, Tags = new[] { "scripts/eval" })]
|
||||
public static async Task<RunScriptResponse> RunScript(
|
||||
[CliArg("file", "Path to a .cs file to compile. Relative paths resolve against the PROJECT ROOT (the parent of Assets/), not the process working directory. May live OUTSIDE Assets/ (e.g. AgentScripts/) so writing it never triggers an asset import or domain reload.", Required = true)] string file,
|
||||
[CliArg("entry", "Entry point as Namespace.Type.Method (or Type.Method, or a bare Method name). Default: the single public static method if unambiguous, else a method named Main. Static methods only in v1. Ephemeral mode only (rejected with mode=hotpatch, which never invokes an entry point).")] string entry = null,
|
||||
[CliArg("args", "JSON array of arguments passed to the entry point, coerced to its parameter types: primitives, enum names, string[], and a string/object handle (ObjectRef) for UnityEngine.Object parameters. Ephemeral mode only (rejected with mode=hotpatch).")] JArray args = null,
|
||||
[CliArg("mode", "ephemeral (default): compile to an in-memory assembly, run the entry, discard. hotpatch: apply [CodeReload] method replacements to already-loaded types (reload_file semantics).")] string mode = "ephemeral",
|
||||
[CliArg("references", "Extra assembly name prefixes to reference beyond the auto-resolved set. In the Editor all loaded assemblies are already referenced, so this is effectively a no-op there. Ephemeral mode only; not applied by hotpatch (which delegates to reload_file's own compile).")] string[] references = null,
|
||||
[CliArg("defines", "Extra scripting define symbols APPENDED to the project's active editor defines (UNITY_EDITOR etc. are already set) for the compilation's #if directives. Ephemeral mode only; not applied by hotpatch.")] string[] defines = null,
|
||||
[CliArg("pdb", "Emit a portable PDB mapped to the source file so breakpoints bind and exception stack traces map to file:line. Compiles unoptimized.")] bool pdb = false,
|
||||
[CliArg("timeout_ms", "Timeout in milliseconds. Bounds the main-thread dispatcher wait AND, for an async entry point, how long its Task is awaited (on expiry the task keeps running detached).")] int timeoutMs = 30000,
|
||||
[CliArg("dry_run", "Compile only: return diagnostics; the emitted assembly is NOT loaded into the domain and nothing executes. Ephemeral mode only (rejected with mode=hotpatch, which would apply live method replacements).")] bool dryRun = false)
|
||||
{
|
||||
var total = Stopwatch.StartNew();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(file))
|
||||
return RunScriptResponse.Fail("Bad Request", "The 'file' parameter is required and cannot be empty.", total.ElapsedMilliseconds);
|
||||
|
||||
if (!file.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
|
||||
return RunScriptResponse.Fail("Bad Request", "The 'file' must have a .cs extension.", total.ElapsedMilliseconds);
|
||||
|
||||
if (timeoutMs <= 0 || timeoutMs > MaxTimeoutMs)
|
||||
return RunScriptResponse.Fail("Bad Request", $"'timeout_ms' must be between 1 and {MaxTimeoutMs}.", total.ElapsedMilliseconds);
|
||||
|
||||
var normalizedMode = (mode ?? "ephemeral").Trim().ToLowerInvariant();
|
||||
|
||||
try
|
||||
{
|
||||
switch (normalizedMode)
|
||||
{
|
||||
case "ephemeral":
|
||||
return await RunEphemeral(file, entry, args, references, defines, pdb, dryRun, timeoutMs, total);
|
||||
case "hotpatch":
|
||||
{
|
||||
// hotpatch delegates wholesale to reload_file's [CodeReload] semantics:
|
||||
// nothing is invoked, so entry/args are meaningless, and there is no
|
||||
// compile-only path — a "dry run" would still apply live method
|
||||
// replacements. Reject rather than silently ignore or, worse, apply.
|
||||
if (dryRun)
|
||||
{
|
||||
return RunScriptResponse.Fail("Bad Request",
|
||||
"'dry_run' is not supported with mode=hotpatch; it would apply live method replacements. " +
|
||||
"Use mode=ephemeral (the default) for a compile-only check.",
|
||||
total.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
var unsupported = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(entry)) unsupported.Add("'entry'");
|
||||
if (args != null && args.Count > 0) unsupported.Add("'args'");
|
||||
if (unsupported.Count > 0)
|
||||
{
|
||||
return RunScriptResponse.Fail("Bad Request",
|
||||
$"{string.Join(" and ", unsupported)} {(unsupported.Count == 1 ? "is" : "are")} not supported with mode=hotpatch: " +
|
||||
"it applies [CodeReload] method replacements in place and never invokes an entry point. " +
|
||||
"Use mode=ephemeral to run an entry point.",
|
||||
total.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return RunHotpatch(file, pdb, timeoutMs, total);
|
||||
}
|
||||
default:
|
||||
return RunScriptResponse.Fail("Bad Request", $"Unknown mode '{mode}'. Expected 'ephemeral' or 'hotpatch'.", total.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Pipeline: run_script failed: {ex}");
|
||||
return RunScriptResponse.Fail("Execution Failed", ex.ToString(), total.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ephemeral mode: single-file in-memory compile → resolve the entry point → coerce args →
|
||||
/// invoke → serialize. No <c>AssetDatabase.Refresh</c> and no domain reload occur. Async so
|
||||
/// a Task-returning entry point can be awaited without blocking the main thread (see the
|
||||
/// comment at the await); <paramref name="timeoutMs"/> bounds that wait.
|
||||
/// </summary>
|
||||
private static async Task<RunScriptResponse> RunEphemeral(
|
||||
string file, string entry, JArray args, string[] references, string[] defines, bool pdb, bool dryRun, int timeoutMs, Stopwatch total)
|
||||
{
|
||||
// Resolve the path: absolute as-is, else relative to the PROJECT ROOT (the parent of
|
||||
// Assets/) — NOT the process working directory, which is usually but not guaranteed to
|
||||
// be the project root. This deliberately accepts files outside Assets/. (hotpatch mode
|
||||
// intentionally keeps reload_file's own resolution, which probes under Assets/.)
|
||||
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Directory.GetCurrentDirectory();
|
||||
var fullPath = Path.GetFullPath(Path.IsPathRooted(file) ? file : Path.Combine(projectRoot, file));
|
||||
if (!File.Exists(fullPath))
|
||||
return RunScriptResponse.Fail("File Not Found", $"Source file not found: {fullPath}", total.ElapsedMilliseconds);
|
||||
|
||||
string source;
|
||||
try
|
||||
{
|
||||
source = File.ReadAllText(fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return RunScriptResponse.Fail("Bad Request", $"Failed to read file: {ex.Message}", total.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
return RunScriptResponse.Fail("Bad Request", $"File is empty: {fullPath}", total.ElapsedMilliseconds);
|
||||
|
||||
var assemblyName = $"PipelineRunScript_{Path.GetFileNameWithoutExtension(fullPath)}_{Guid.NewGuid():N}";
|
||||
|
||||
// Emit symbols mapped to the source file whenever we are going to execute, so that
|
||||
// exception stack traces (and, with an attached debugger, breakpoints) map back to
|
||||
// file:line in the original .cs. dry_run only needs symbols when explicitly requested.
|
||||
var emitDebug = pdb || !dryRun;
|
||||
|
||||
// Seed the compile with the project's ACTIVE editor defines (UNITY_EDITOR, the
|
||||
// UNITY_<version> symbols, platform defines, project scripting symbols, …) so the
|
||||
// source's #if directives behave like project code. The user's `defines` extend that
|
||||
// set; they do not replace it.
|
||||
var activeDefines = EditorUserBuildSettings.activeScriptCompilationDefines;
|
||||
var effectiveDefines = defines == null || defines.Length == 0
|
||||
? activeDefines
|
||||
: activeDefines.Concat(defines).Distinct().ToArray();
|
||||
|
||||
var compileSw = Stopwatch.StartNew();
|
||||
var compilation = RoslynCompilationService.Compile(new CompilationRequest
|
||||
{
|
||||
SourceCode = source,
|
||||
AssemblyName = assemblyName,
|
||||
EmitDebugInformation = emitDebug,
|
||||
DocumentPath = fullPath,
|
||||
AdditionalAssemblyPrefixes = references,
|
||||
PreprocessorSymbols = effectiveDefines,
|
||||
// dry_run never executes, so don't load the emitted assembly into the domain —
|
||||
// in-memory assemblies are not unloadable under Mono and would leak per call.
|
||||
SkipLoad = dryRun
|
||||
});
|
||||
compileSw.Stop();
|
||||
|
||||
if (!compilation.Success)
|
||||
{
|
||||
return RunScriptResponse.Fail(
|
||||
"Compilation Failed",
|
||||
"The script failed to compile. See diagnostics for line/column details.",
|
||||
total.ElapsedMilliseconds,
|
||||
compilation.Diagnostics,
|
||||
compileSw.ElapsedMilliseconds,
|
||||
0,
|
||||
assemblyName);
|
||||
}
|
||||
|
||||
if (dryRun)
|
||||
{
|
||||
// Emit-only: no assembly was loaded, so there is no assemblyName to report.
|
||||
var dr = RunScriptResponse.Ok(null, null, compileSw.ElapsedMilliseconds, 0, total.ElapsedMilliseconds, compilation.Diagnostics);
|
||||
dr.Message = "Compiled successfully (dry run; nothing was loaded or executed).";
|
||||
return dr;
|
||||
}
|
||||
|
||||
// Resolve the entry point.
|
||||
var method = ResolveEntryPoint(compilation.Assembly, entry, Path.GetFileName(fullPath), out var entryError);
|
||||
if (method == null)
|
||||
{
|
||||
return RunScriptResponse.Fail("Entry Point Not Found", entryError, total.ElapsedMilliseconds,
|
||||
compilation.Diagnostics, compileSw.ElapsedMilliseconds, 0, assemblyName);
|
||||
}
|
||||
|
||||
// Coerce JSON args to the entry point's parameter types.
|
||||
if (!TryCoerceArguments(method, args, out var callArgs, out var argError))
|
||||
{
|
||||
return RunScriptResponse.Fail("Bad Request", argError, total.ElapsedMilliseconds,
|
||||
compilation.Diagnostics, compileSw.ElapsedMilliseconds, 0, assemblyName);
|
||||
}
|
||||
|
||||
// Execute.
|
||||
var execSw = Stopwatch.StartNew();
|
||||
object returnValue = null;
|
||||
try
|
||||
{
|
||||
returnValue = method.Invoke(null, callArgs);
|
||||
|
||||
// A Task/Task<T>-returning entry point must be awaited — otherwise the call
|
||||
// reports success the moment the first await yields, and any exception in the
|
||||
// async work is never observed. Awaited ASYNCHRONOUSLY (never a blocking
|
||||
// GetResult()): this command runs on the main thread, and an entry point's awaits
|
||||
// resume on the Unity SynchronizationContext — i.e. this same thread — so a
|
||||
// blocking wait here deadlocks the whole Editor permanently. Awaiting instead
|
||||
// returns control to the editor loop at this point (the server unwraps the
|
||||
// command's own Task off the main thread), letting those continuations pump.
|
||||
// The wait is bounded by what remains of timeout_ms; on expiry the task keeps
|
||||
// running detached, like an abandoned eval. Task<T> exposes Result via reflection
|
||||
// (an `async Task` materializes as Task<VoidTaskResult>, whose Result carries no
|
||||
// value — treat it as null).
|
||||
if (returnValue is Task task)
|
||||
{
|
||||
var remainingMs = (int)Math.Max(0, timeoutMs - total.ElapsedMilliseconds);
|
||||
var completed = await Task.WhenAny(task, Task.Delay(remainingMs));
|
||||
if (completed != task)
|
||||
{
|
||||
execSw.Stop();
|
||||
return RunScriptResponse.Fail(
|
||||
"Timeout",
|
||||
$"The entry point's Task did not complete within the {timeoutMs}ms budget " +
|
||||
$"({remainingMs}ms remained after the synchronous part). The task keeps running detached and " +
|
||||
"its effects may still land; raise timeout_ms if it legitimately needs longer.",
|
||||
total.ElapsedMilliseconds,
|
||||
compilation.Diagnostics,
|
||||
compileSw.ElapsedMilliseconds,
|
||||
execSw.ElapsedMilliseconds,
|
||||
assemblyName);
|
||||
}
|
||||
|
||||
await task; // a faulted task throws the entry point's own exception here
|
||||
var resultProperty = task.GetType().GetProperty("Result");
|
||||
returnValue = resultProperty != null && resultProperty.PropertyType.FullName != "System.Threading.Tasks.VoidTaskResult"
|
||||
? resultProperty.GetValue(task)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TargetInvocationException || returnValue is Task)
|
||||
{
|
||||
execSw.Stop();
|
||||
// Reflection wraps a synchronous throw in a TargetInvocationException; a faulted
|
||||
// task rethrows the entry point's own exception from the await. Normalize both
|
||||
// to the entry point's exception. With the PDB emitted above and DocumentPath set
|
||||
// to the source file, the stack trace carries "... in <file>:line <n>", so the
|
||||
// failure maps back to the original source.
|
||||
var inner = (ex as TargetInvocationException)?.InnerException ?? ex;
|
||||
return RunScriptResponse.Fail(
|
||||
"Runtime Error",
|
||||
$"{inner.GetType().Name}: {inner.Message}\n{inner.StackTrace}",
|
||||
total.ElapsedMilliseconds,
|
||||
compilation.Diagnostics,
|
||||
compileSw.ElapsedMilliseconds,
|
||||
execSw.ElapsedMilliseconds,
|
||||
assemblyName);
|
||||
}
|
||||
execSw.Stop();
|
||||
|
||||
return RunScriptResponse.Ok(
|
||||
SerializeResult(returnValue),
|
||||
assemblyName,
|
||||
compileSw.ElapsedMilliseconds,
|
||||
execSw.ElapsedMilliseconds,
|
||||
total.ElapsedMilliseconds,
|
||||
compilation.Diagnostics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// hotpatch mode: apply in-place <c>[CodeReload]</c> method replacements to already-loaded
|
||||
/// types, delegating to the existing <c>reload_file</c> command so its behavior/tests are
|
||||
/// preserved. The result is re-shaped into a <see cref="RunScriptResponse"/>.
|
||||
/// </summary>
|
||||
private static RunScriptResponse RunHotpatch(string file, bool pdb, int timeoutMs, Stopwatch total)
|
||||
{
|
||||
var hr = CodeReloadCommands.ReloadFile(file, timeoutMs, null, pdb);
|
||||
|
||||
// reload_file reports a single duration for the whole reload (compile + apply); surface
|
||||
// it as executeMs rather than mislabeling it compile time. compileMs stays 0 here.
|
||||
var reloadMs = hr.ExecutionTimeMs ?? 0L;
|
||||
|
||||
if (hr.Success)
|
||||
{
|
||||
// reload_file can succeed with advisory diagnostics (e.g. partially-skipped
|
||||
// overrides) — keep them, as "warning" entries in the same shape the failure
|
||||
// path uses, instead of dropping them on the floor.
|
||||
var warnings = (hr.Diagnostics ?? new List<string>())
|
||||
.Select(d => new DiagnosticInfo { Severity = "warning", Message = d })
|
||||
.ToList();
|
||||
var ok = RunScriptResponse.Ok(hr.Items, hr.AssemblyName, 0, reloadMs, total.ElapsedMilliseconds, warnings);
|
||||
ok.Message = hr.Message;
|
||||
return ok;
|
||||
}
|
||||
|
||||
var diagnostics = (hr.Diagnostics ?? new List<string>())
|
||||
.Select(d => new DiagnosticInfo { Severity = "error", Message = d })
|
||||
.ToList();
|
||||
return RunScriptResponse.Fail(hr.Error ?? "Code Reload Failed", hr.ErrorDetails, total.ElapsedMilliseconds,
|
||||
diagnostics, 0, reloadMs, hr.AssemblyName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the static entry method from a compiled assembly.
|
||||
/// - "Namespace.Type.Method" / "Type.Method": split on the last dot; find the type, then the
|
||||
/// static method by name (public or non-public). Nested types may be written with dots
|
||||
/// ("Outer.Inner.Method") — the '+' reflection form is retried automatically.
|
||||
/// - bare "Method": the single static method with that name across all types.
|
||||
/// - null/empty: the single public static method if unambiguous, else a method named "Main".
|
||||
/// Generic methods are never candidates (no type arguments to close them with).
|
||||
/// Returns null and sets <paramref name="error"/> when it cannot resolve unambiguously.
|
||||
/// </summary>
|
||||
private static MethodInfo ResolveEntryPoint(Assembly assembly, string entry, string fileName, out string error)
|
||||
{
|
||||
error = null;
|
||||
|
||||
// Skip compiler-generated types (e.g. <PrivateImplementationDetails>).
|
||||
var types = assembly.GetTypes().Where(t => !t.Name.Contains("<") && !t.Name.Contains(">")).ToArray();
|
||||
|
||||
const BindingFlags AnyStatic = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly;
|
||||
const BindingFlags PublicStatic = BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
entry = entry.Trim();
|
||||
var lastDot = entry.LastIndexOf('.');
|
||||
|
||||
if (lastDot > 0)
|
||||
{
|
||||
var typeName = entry.Substring(0, lastDot);
|
||||
var methodName = entry.Substring(lastDot + 1);
|
||||
|
||||
var type = assembly.GetType(typeName)
|
||||
?? types.FirstOrDefault(t => t.FullName == typeName || t.Name == typeName);
|
||||
if (type == null && typeName.IndexOf('.') >= 0)
|
||||
{
|
||||
// Nested types use '+' in reflection names ("Outer+Inner"), while callers
|
||||
// naturally write dots ("Outer.Inner.Method"). Retry with the last dot of
|
||||
// the type part replaced.
|
||||
var nestedDot = typeName.LastIndexOf('.');
|
||||
var nestedName = typeName.Substring(0, nestedDot) + "+" + typeName.Substring(nestedDot + 1);
|
||||
type = assembly.GetType(nestedName)
|
||||
?? types.FirstOrDefault(t => t.FullName == nestedName);
|
||||
}
|
||||
if (type == null)
|
||||
{
|
||||
error = $"No type '{typeName}' in compiled file '{fileName}'. Types: [{string.Join(", ", types.Select(t => t.FullName))}].";
|
||||
return null;
|
||||
}
|
||||
|
||||
var named = type
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
|
||||
.Where(m => m.Name == methodName && !m.IsSpecialName)
|
||||
.ToList();
|
||||
var candidates = named.Where(m => !m.ContainsGenericParameters).ToList();
|
||||
if (candidates.Count == 1)
|
||||
return candidates[0];
|
||||
if (candidates.Count > 1)
|
||||
{
|
||||
error = $"Entry '{entry}' is overloaded ({candidates.Count} static overloads); overloaded entry points are not supported in v1.";
|
||||
return null;
|
||||
}
|
||||
if (named.Count > 0)
|
||||
{
|
||||
error = $"Entry '{entry}' only resolves to generic method(s); generic entry points are not supported.";
|
||||
return null;
|
||||
}
|
||||
|
||||
var instance = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.FirstOrDefault(m => m.Name == methodName && !m.IsSpecialName);
|
||||
error = instance != null
|
||||
? $"Entry '{entry}' is an instance method; only static methods are supported in v1."
|
||||
: $"No static method '{methodName}' on type '{type.FullName}'.";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bare method name — search every type for a static method with that name.
|
||||
var byNameAll = types.SelectMany(t => t.GetMethods(AnyStatic))
|
||||
.Where(m => m.Name == entry && !m.IsSpecialName)
|
||||
.ToList();
|
||||
var byName = byNameAll.Where(m => !m.ContainsGenericParameters).ToList();
|
||||
if (byName.Count == 1)
|
||||
return byName[0];
|
||||
if (byName.Count == 0)
|
||||
{
|
||||
error = byNameAll.Count > 0
|
||||
? $"Static method '{entry}' in '{fileName}' is generic; generic entry points are not supported."
|
||||
: $"No static method named '{entry}' found in '{fileName}'.";
|
||||
return null;
|
||||
}
|
||||
|
||||
error = $"Multiple static methods named '{entry}' found in '{fileName}'; qualify the entry as Namespace.Type.Method.";
|
||||
return null;
|
||||
}
|
||||
|
||||
// No entry specified — auto-detect. Generic methods can't be invoked without type
|
||||
// arguments, so they are never entry-point candidates.
|
||||
var publicStatic = types.SelectMany(t => t.GetMethods(PublicStatic))
|
||||
.Where(m => !m.IsSpecialName && !m.ContainsGenericParameters)
|
||||
.ToList();
|
||||
|
||||
if (publicStatic.Count == 1)
|
||||
return publicStatic[0];
|
||||
|
||||
var mains = types.SelectMany(t => t.GetMethods(AnyStatic))
|
||||
.Where(m => m.Name == "Main" && !m.IsSpecialName && !m.ContainsGenericParameters)
|
||||
.ToList();
|
||||
if (mains.Count == 1)
|
||||
return mains[0];
|
||||
|
||||
if (publicStatic.Count == 0)
|
||||
{
|
||||
error = $"No public static entry point found in '{fileName}'. Add a public static method or specify 'entry'.";
|
||||
return null;
|
||||
}
|
||||
|
||||
error = $"Ambiguous entry point in '{fileName}': {publicStatic.Count} public static methods and no unique 'Main'. " +
|
||||
$"Specify 'entry' as Namespace.Type.Method. Candidates: [{string.Join(", ", publicStatic.Select(m => (m.DeclaringType?.FullName ?? "?") + "." + m.Name))}].";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coerce the JSON <paramref name="args"/> array to the entry point's parameter types using
|
||||
/// the shared converters: primitives / enum names / string[] via Newtonsoft, and a
|
||||
/// string-or-object handle (<see cref="ObjectRef"/> + <see cref="ObjectResolver"/>) for
|
||||
/// UnityEngine.Object parameters. Omitted trailing args fall back to C# default values.
|
||||
/// An explicit JSON null is only valid for reference-type and Nullable<T> parameters —
|
||||
/// for a non-nullable value type it is a coercion error, never a silent default(T).
|
||||
/// </summary>
|
||||
private static bool TryCoerceArguments(MethodInfo method, JArray args, out object[] callArgs, out string error)
|
||||
{
|
||||
error = null;
|
||||
var parameters = method.GetParameters();
|
||||
callArgs = new object[parameters.Length];
|
||||
var provided = args?.Count ?? 0;
|
||||
|
||||
if (provided > parameters.Length)
|
||||
{
|
||||
error = $"Entry point '{method.DeclaringType?.FullName}.{method.Name}' expects {parameters.Length} argument(s) but {provided} were provided.";
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < parameters.Length; i++)
|
||||
{
|
||||
var p = parameters[i];
|
||||
var pt = p.ParameterType;
|
||||
|
||||
if (i >= provided)
|
||||
{
|
||||
if (p.HasDefaultValue)
|
||||
{
|
||||
callArgs[i] = p.DefaultValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
error = $"Missing required argument #{i} for parameter '{p.Name}' ({pt.Name}).";
|
||||
return false;
|
||||
}
|
||||
|
||||
var token = args[i];
|
||||
|
||||
if (typeof(Object).IsAssignableFrom(pt))
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
{
|
||||
callArgs[i] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
ObjectRef handle;
|
||||
try
|
||||
{
|
||||
handle = token.ToObject<ObjectRef>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Argument #{i} ('{p.Name}'): not a valid object reference: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (handle == null || handle.IsEmpty)
|
||||
{
|
||||
error = $"Argument #{i} ('{p.Name}'): empty object reference.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ObjectResolver.TryResolve(handle, out var resolved, out var resolveError))
|
||||
{
|
||||
error = $"Argument #{i} ('{p.Name}'): {resolveError}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!pt.IsInstanceOfType(resolved))
|
||||
{
|
||||
// Convenience: a GameObject handle can satisfy a Component parameter (and vice versa).
|
||||
Object coerced = null;
|
||||
if (resolved is GameObject go && typeof(Component).IsAssignableFrom(pt))
|
||||
coerced = go.GetComponent(pt);
|
||||
else if (resolved is Component c && pt == typeof(GameObject))
|
||||
coerced = c.gameObject;
|
||||
|
||||
if (coerced != null && pt.IsInstanceOfType(coerced))
|
||||
{
|
||||
resolved = coerced;
|
||||
}
|
||||
else
|
||||
{
|
||||
error = $"Argument #{i} ('{p.Name}') resolved to a {resolved.GetType().Name}, which is not assignable to {pt.Name}.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
callArgs[i] = resolved;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
{
|
||||
// null has no representation in a non-nullable value type — silently passing
|
||||
// default(T) would hide the caller's mistake. Nullable<T> (and reference
|
||||
// types) accept null as-is.
|
||||
if (pt.IsValueType && Nullable.GetUnderlyingType(pt) == null)
|
||||
{
|
||||
error = $"Argument #{i} ('{p.Name}'): null cannot be converted to value type {pt.Name}; pass a value, or make the parameter nullable ({pt.Name}?).";
|
||||
return false;
|
||||
}
|
||||
|
||||
callArgs[i] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
callArgs[i] = token.ToObject(pt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Argument #{i} ('{p.Name}'): cannot convert to {pt.Name}: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize the entry point's return value for the JSON response. Mirrors
|
||||
/// <c>EvalCodeCompiler.SerializeResult</c> so run_script results look like eval results:
|
||||
/// primitives pass through, Unity objects go via JsonUtility, everything else via Newtonsoft,
|
||||
/// with a ToString() fallback.
|
||||
/// </summary>
|
||||
private static object SerializeResult(object value)
|
||||
{
|
||||
if (value == null) return null;
|
||||
|
||||
if (value is string || value is bool || value is int || value is long || value is float || value is double)
|
||||
return value;
|
||||
|
||||
if (value is Object unityObj)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonConvert.DeserializeObject(JsonUtility.ToJson(unityObj));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return value.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for <c>run_script</c>. Result shape: <c>{ result, diagnostics, compileMs, executeMs,
|
||||
/// assemblyName }</c> on top of the standard command envelope. Deliberately carries no echo of the
|
||||
/// source (path + diagnostics only), so MCP results stay small and reviewable.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
class RunScriptResponse : CommandExecutionResponse
|
||||
{
|
||||
/// <summary>Compilation diagnostics (errors, warnings) from Roslyn, with line/column.</summary>
|
||||
[JsonProperty("diagnostics")]
|
||||
public List<DiagnosticInfo> Diagnostics { get; set; } = new List<DiagnosticInfo>();
|
||||
|
||||
/// <summary>
|
||||
/// Milliseconds spent compiling the file to an in-memory assembly. 0 for hotpatch, whose
|
||||
/// compile is inside <c>reload_file</c> and not reported separately (see <see cref="ExecuteMs"/>).
|
||||
/// </summary>
|
||||
[JsonProperty("compileMs")]
|
||||
public long CompileMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Milliseconds spent executing the entry point (0 for dry_run). For hotpatch this is the
|
||||
/// whole reload duration — compile + apply — since <c>reload_file</c> does not split the two.
|
||||
/// </summary>
|
||||
[JsonProperty("executeMs")]
|
||||
public long ExecuteMs { get; set; }
|
||||
|
||||
/// <summary>Name of the generated in-memory assembly.</summary>
|
||||
[JsonProperty("assemblyName")]
|
||||
public string AssemblyName { get; set; }
|
||||
|
||||
public static RunScriptResponse Ok(object result, string assemblyName, long compileMs, long executeMs, long executionTimeMs, List<DiagnosticInfo> diagnostics)
|
||||
{
|
||||
return new RunScriptResponse
|
||||
{
|
||||
Success = true,
|
||||
Command = "run_script",
|
||||
Result = result,
|
||||
AssemblyName = assemblyName,
|
||||
CompileMs = compileMs,
|
||||
ExecuteMs = executeMs,
|
||||
ExecutionTimeMs = executionTimeMs,
|
||||
Diagnostics = diagnostics ?? new List<DiagnosticInfo>(),
|
||||
ExecutedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
public static RunScriptResponse Fail(string error, string errorDetails, long executionTimeMs,
|
||||
List<DiagnosticInfo> diagnostics = null, long compileMs = 0, long executeMs = 0, string assemblyName = null)
|
||||
{
|
||||
return new RunScriptResponse
|
||||
{
|
||||
Success = false,
|
||||
Command = "run_script",
|
||||
Error = error,
|
||||
ErrorDetails = errorDetails,
|
||||
AssemblyName = assemblyName,
|
||||
CompileMs = compileMs,
|
||||
ExecuteMs = executeMs,
|
||||
ExecutionTimeMs = executionTimeMs,
|
||||
Diagnostics = diagnostics ?? new List<DiagnosticInfo>(),
|
||||
ExecutedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2b6f31ba780d4489823da503b4329157
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,161 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Scripts
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves an agent-supplied type name (or a <see cref="MonoScript"/> reference) to a compiled
|
||||
/// <see cref="Type"/>, searching the currently-loaded assemblies.
|
||||
///
|
||||
/// This is the heart of the compile-aware behaviour for CLI-195: a script that was just created
|
||||
/// has no compiled type until a domain reload runs, so resolution here will fail until the agent
|
||||
/// recompiles. Callers translate that failure into a clear, recoverable error rather than a crash.
|
||||
/// </summary>
|
||||
static class ScriptTypeResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Try to resolve a MonoBehaviour-derived type by its name. Accepts either a bare class name
|
||||
/// ("PlayerController") or a fully-qualified name ("Game.Player.PlayerController"). When a
|
||||
/// bare name is ambiguous across namespaces the first match wins; callers should prefer the
|
||||
/// fully-qualified name. Returns false with a human-readable <paramref name="error"/> when no
|
||||
/// loaded type matches (typically: not yet compiled).
|
||||
/// </summary>
|
||||
public static bool TryResolveComponentType(string typeName, out Type type, out string error)
|
||||
{
|
||||
type = null;
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(typeName))
|
||||
{
|
||||
error = "Type name is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var name = typeName.Trim();
|
||||
var match = FindType(name);
|
||||
|
||||
if (match == null)
|
||||
{
|
||||
error =
|
||||
$"Type '{name}' was not found in any loaded assembly. " +
|
||||
"If you just created this script it is not compiled yet: run 'recompile', poll " +
|
||||
"'recompile_status' until it reports completed/up_to_date, then retry attach_script. " +
|
||||
"If it should already exist, check the class name (and namespace) and that the file compiled without errors.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!typeof(MonoBehaviour).IsAssignableFrom(match))
|
||||
{
|
||||
error = $"Type '{match.FullName}' does not derive from MonoBehaviour and cannot be added as a component.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (match.IsAbstract)
|
||||
{
|
||||
error = $"Type '{match.FullName}' is abstract and cannot be instantiated as a component.";
|
||||
return false;
|
||||
}
|
||||
|
||||
type = match;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the compiled <see cref="Type"/> backing a <see cref="MonoScript"/> asset reference
|
||||
/// (e.g. one returned by create_script). Returns false when the script has no class yet
|
||||
/// (uncompiled) or the asset is not a MonoScript.
|
||||
/// </summary>
|
||||
public static bool TryResolveFromMonoScript(MonoScript script, out Type type, out string error)
|
||||
{
|
||||
type = null;
|
||||
error = null;
|
||||
|
||||
if (script == null)
|
||||
{
|
||||
error = "MonoScript reference is null.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var scriptClass = script.GetClass();
|
||||
if (scriptClass == null)
|
||||
{
|
||||
error =
|
||||
$"Script '{script.name}' has no compiled class. It is likely not compiled yet: run 'recompile', " +
|
||||
"poll 'recompile_status', then retry.";
|
||||
return false;
|
||||
}
|
||||
|
||||
type = scriptClass;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search all loaded assemblies for a type by full name first, then by bare class name. Done
|
||||
/// over <see cref="AppDomain.CurrentDomain"/> rather than TypeCache so it also finds types in
|
||||
/// non-Unity-managed assemblies and matches what Activator/AddComponent can actually load.
|
||||
/// </summary>
|
||||
private static Type FindType(string name)
|
||||
{
|
||||
var assemblies = PipelineUtils.GetLoadedAssemblies();
|
||||
|
||||
// 1. Fully-qualified name (exact).
|
||||
foreach (var asm in assemblies)
|
||||
{
|
||||
var t = asm.GetType(name, throwOnError: false, ignoreCase: false);
|
||||
if (t != null)
|
||||
return t;
|
||||
}
|
||||
|
||||
// 2. Bare class name — match on Name across loaded types. GetTypes() is expensive, so skip
|
||||
// dynamic and system/Unity assemblies (which never hold user MonoBehaviours). This mirrors
|
||||
// CommandRegistry.IsSystemAssembly's predicate (replicated locally rather than shared so we
|
||||
// don't widen that internal helper's surface).
|
||||
foreach (var asm in assemblies)
|
||||
{
|
||||
if (IsSkippableAssembly(asm))
|
||||
continue;
|
||||
|
||||
Type[] types;
|
||||
try
|
||||
{
|
||||
types = asm.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
types = ex.Types.Where(t => t != null).ToArray();
|
||||
}
|
||||
|
||||
var hit = types.FirstOrDefault(t => t != null && t.Name == name);
|
||||
if (hit != null)
|
||||
return hit;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assemblies we never need to scan for a user MonoBehaviour: dynamic (in-memory, can't be
|
||||
/// reflected for types reliably) and core system/Unity assemblies. Mirrors the predicate in
|
||||
/// <c>CommandRegistry.IsSystemAssembly</c>.
|
||||
/// </summary>
|
||||
private static bool IsSkippableAssembly(Assembly assembly)
|
||||
{
|
||||
if (assembly.IsDynamic)
|
||||
return true;
|
||||
|
||||
var name = assembly.GetName().Name;
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return false;
|
||||
|
||||
return name.StartsWith("System.", StringComparison.Ordinal) ||
|
||||
name.StartsWith("Microsoft.", StringComparison.Ordinal) ||
|
||||
name.StartsWith("mscorlib", StringComparison.Ordinal) ||
|
||||
name.StartsWith("netstandard", StringComparison.Ordinal) ||
|
||||
name.Equals("UnityEngine", StringComparison.Ordinal) ||
|
||||
name.Equals("UnityEditor", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6b1131a6a27648b1829e78c8c0bd69a7
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Editor.Commands.GameObjects;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Scripts
|
||||
{
|
||||
/// <summary>
|
||||
/// Read and write serialized ([SerializeField] / public) fields on a component or asset through
|
||||
/// Unity's <see cref="SerializedObject"/> model (CLI-195).
|
||||
///
|
||||
/// Why SerializedObject and not plain reflection: it honours Unity's serialization rules (private
|
||||
/// [SerializeField] fields, property drawers, prefab overrides), marks the object dirty correctly,
|
||||
/// and integrates with Undo. Writes are wrapped in an <see cref="AuthoringUndoScope"/> so an
|
||||
/// agent's change reverts as one step.
|
||||
///
|
||||
/// Field addressing uses Unity's native SerializedProperty path syntax, so array elements are
|
||||
/// reachable directly: "myArray.Array.data[2]" sets the third element; "myArray.Array.size" sets
|
||||
/// the array length. Nested fields use dotted paths ("settings.speed").
|
||||
/// </summary>
|
||||
static class SerializedFieldCommands
|
||||
{
|
||||
[CliCommand("set_serialized_field",
|
||||
"Set a serialized field on a component/asset. Supports primitives, enums, Vector/Color/Rect/Bounds, " +
|
||||
"object references (value = an ObjectRef: asset by guid/fileId/path or scene object by instanceId/hierarchyPath), " +
|
||||
"and array elements via 'name.Array.data[i]' (or 'name.Array.size' to resize).",
|
||||
Tags = new[] { "scripts", "gameobjects/components" })]
|
||||
public static AuthoringResult SetSerializedField(
|
||||
[CliArg("target", "Reference to the component or asset to modify (globalId/path/guid/instanceId/hierarchyPath). May be a GameObject when 'component' is given.", Required = true)] ObjectRef target,
|
||||
[CliArg("field", "SerializedProperty path, e.g. 'speed', 'settings.speed', or 'waypoints.Array.data[0]'.", Required = true)] string field,
|
||||
[CliArg("value", "JSON value to assign. For object references pass an ObjectRef object (or null to clear). For enums pass the value name.", Required = true)] JToken value,
|
||||
[CliArg("component", "Component type name on the target GameObject (e.g. 'Rigidbody'). Use when 'target' is a GameObject; omit when 'target' is already a component handle.")] string component = null)
|
||||
{
|
||||
if (target == null || target.IsEmpty)
|
||||
throw new ArgumentException("set_serialized_field 'target' is required.");
|
||||
if (string.IsNullOrWhiteSpace(field))
|
||||
throw new ArgumentException("set_serialized_field 'field' is required.");
|
||||
|
||||
var obj = ResolveSerializable(target, component);
|
||||
|
||||
using (new AuthoringUndoScope($"Set {field}"))
|
||||
{
|
||||
// RegisterCompleteObjectUndo captures the pre-change state for revert; the
|
||||
// SerializedObject below then records the change for prefab/asset dirtying.
|
||||
Undo.RegisterCompleteObjectUndo(obj, $"Set {field}");
|
||||
|
||||
var so = new SerializedObject(obj);
|
||||
var prop = so.FindProperty(field);
|
||||
if (prop == null)
|
||||
throw new ArgumentException(
|
||||
$"Field '{field}' was not found on '{obj.GetType().Name}'. " +
|
||||
"Use a SerializedProperty path (e.g. 'speed', 'settings.speed', 'items.Array.data[0]').");
|
||||
|
||||
SerializedPropertyConverter.SetValue(prop, value);
|
||||
so.ApplyModifiedProperties();
|
||||
EditorUtility.SetDirty(obj);
|
||||
}
|
||||
|
||||
var result = ObjectResolver.Describe(obj) ?? new AuthoringResult { Type = obj.GetType().Name };
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("get_serialized_fields",
|
||||
"Read serialized fields of a component/asset. Returns each top-level field's name, type and value " +
|
||||
"(object references are returned as re-usable handles). Pass 'field' to read a single SerializedProperty path. " +
|
||||
"Pass format='value' to return just the value(s) — a token-efficient read for scalars.",
|
||||
Tags = new[] { "scripts", "gameobjects/components" })]
|
||||
public static object GetSerializedFields(
|
||||
[CliArg("target", "Reference to the component or asset to read (globalId/path/guid/instanceId/hierarchyPath). May be a GameObject when 'component' is given.", Required = true)] ObjectRef target,
|
||||
[CliArg("field", "Optional single SerializedProperty path to read (e.g. 'speed' or 'items.Array.data[0]'). Omit to read all top-level fields.")] string field = null,
|
||||
[CliArg("component", "Component type name on the target GameObject (e.g. 'Rigidbody'). Use when 'target' is a GameObject; omit when 'target' is already a component handle.")] string component = null,
|
||||
[CliArg("format", "Output shape. Omit (default) for a full descriptor per field {name,path,propertyType,isArray,arrayLength,value}; 'value' returns just the value(s): the raw value for a single 'field', or a {name: value} map for all fields.")] string format = null)
|
||||
{
|
||||
if (target == null || target.IsEmpty)
|
||||
throw new ArgumentException("get_serialized_fields 'target' is required.");
|
||||
|
||||
// Value projection (AUTHAPI-21): drop the per-field descriptor and return only the value(s)
|
||||
// so a scalar read costs bytes, not an envelope. Anything other than 'value' is the default
|
||||
// descriptor form, so existing callers are unaffected.
|
||||
var valueOnly = string.Equals(format, "value", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var obj = ResolveSerializable(target, component);
|
||||
var so = new SerializedObject(obj);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(field))
|
||||
{
|
||||
var prop = so.FindProperty(field);
|
||||
if (prop == null)
|
||||
throw new ArgumentException($"Field '{field}' was not found on '{obj.GetType().Name}'.");
|
||||
|
||||
if (valueOnly)
|
||||
return SerializedPropertyConverter.GetValue(prop);
|
||||
|
||||
return new
|
||||
{
|
||||
type = obj.GetType().Name,
|
||||
fields = new[] { DescribeProperty(prop) }
|
||||
};
|
||||
}
|
||||
|
||||
// Each mode gets only its own container (the other stays null).
|
||||
var fields = valueOnly ? null : new List<object>();
|
||||
var values = valueOnly ? new Dictionary<string, object>() : null;
|
||||
var iterator = so.GetIterator();
|
||||
// enterChildren=true on the first MoveNext to step into the top level; then false to stay
|
||||
// at the top level and skip nested children (callers drill in via an explicit 'field').
|
||||
var enterChildren = true;
|
||||
while (iterator.NextVisible(enterChildren))
|
||||
{
|
||||
enterChildren = false;
|
||||
|
||||
// m_Script is Unity's hidden back-reference to the MonoScript; not a user field.
|
||||
if (iterator.propertyPath == "m_Script")
|
||||
continue;
|
||||
|
||||
if (valueOnly)
|
||||
{
|
||||
// Degrade per-field rather than failing the whole map read (mirrors
|
||||
// get_component_properties' per-property guard in ComponentCommands).
|
||||
try { values[iterator.name] = SerializedPropertyConverter.GetValue(iterator); }
|
||||
catch (Exception ex) { values[iterator.name] = $"<error:{ex.Message}>"; }
|
||||
}
|
||||
else
|
||||
{
|
||||
fields.Add(DescribeProperty(iterator));
|
||||
}
|
||||
}
|
||||
|
||||
return valueOnly ? (object)values : new { type = obj.GetType().Name, fields };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a handle to an object that a SerializedObject can wrap: a Component or an asset.
|
||||
///
|
||||
/// Two addressing forms (CLI-225):
|
||||
/// * The handle already points at a Component (or asset) → use it directly. An optional
|
||||
/// <paramref name="component"/> type name is validated against it as a guard.
|
||||
/// * The handle points at a GameObject AND <paramref name="component"/> is given → look up
|
||||
/// the matching component(s) on that GameObject. Exactly one match is used; MULTIPLE
|
||||
/// same-type components are an error that lists each instanceId so the agent can re-address
|
||||
/// by instanceId; zero matches is a clear error.
|
||||
///
|
||||
/// A GameObject with no <paramref name="component"/> is rejected (a GameObject's "fields" are
|
||||
/// its components), with a hint to pass --component or a specific component handle.
|
||||
///
|
||||
/// This mirrors <see cref="ComponentCommands.ResolveComponent"/>'s GO-path+type pattern, but
|
||||
/// deliberately errors on MULTIPLE matches (that helper returns the first) so a set/get never
|
||||
/// silently targets the wrong instance.
|
||||
/// </summary>
|
||||
private static Object ResolveSerializable(ObjectRef target, string component = null)
|
||||
{
|
||||
if (!ObjectResolver.TryResolve(target, out var obj, out var error))
|
||||
throw new ArgumentException($"Could not resolve target: {error}");
|
||||
|
||||
// The handle already points at a specific Component: honour it directly. A 'component' here
|
||||
// is only a constraint to validate against, never a reason to re-fetch (which could pick a
|
||||
// different same-type instance and ignore the handle).
|
||||
if (obj is Component directComponent)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(component))
|
||||
{
|
||||
var requestedType = TypeResolver.ResolveComponentType(component);
|
||||
if (requestedType == null)
|
||||
throw new ArgumentException($"Could not resolve component type '{component}'.");
|
||||
if (!requestedType.IsInstanceOfType(directComponent))
|
||||
throw new ArgumentException(
|
||||
$"Target resolves to a {directComponent.GetType().Name}, which is not a '{requestedType.Name}'.");
|
||||
}
|
||||
|
||||
return directComponent;
|
||||
}
|
||||
|
||||
if (obj is GameObject go)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(component))
|
||||
throw new ArgumentException(
|
||||
$"Target '{target}' is a GameObject. Pass --component <TypeName> to pick a component on it, " +
|
||||
"or target a specific component directly (use its instanceId/globalId), " +
|
||||
"to read or set serialized fields.");
|
||||
|
||||
var componentType = TypeResolver.ResolveComponentType(component);
|
||||
if (componentType == null)
|
||||
throw new ArgumentException($"Could not resolve component type '{component}'.");
|
||||
|
||||
var matches = go.GetComponents(componentType).Where(c => c != null).ToArray();
|
||||
if (matches.Length == 0)
|
||||
throw new ArgumentException(
|
||||
$"GameObject '{go.name}' has no component of type '{componentType.Name}'.");
|
||||
|
||||
if (matches.Length > 1)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"GameObject '{go.name}' has {matches.Length} components of type '{componentType.Name}'. ")
|
||||
.Append("Re-target a specific one by its instanceId: ");
|
||||
for (int i = 0; i < matches.Length; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(", ");
|
||||
sb.Append($"instanceId {PipelineUtils.GetObjectId(matches[i])}");
|
||||
}
|
||||
sb.Append('.');
|
||||
throw new ArgumentException(sb.ToString());
|
||||
}
|
||||
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
// Not a Component, not a GameObject — an asset (e.g. ScriptableObject) handled directly.
|
||||
// A 'component' filter is meaningless for an asset; reject it rather than silently ignore it
|
||||
// (a supplied component here signals a misrouted or misspelled request).
|
||||
if (!string.IsNullOrWhiteSpace(component))
|
||||
throw new ArgumentException(
|
||||
$"Target '{target}' resolved to a {obj.GetType().Name} (an asset), which has no components. " +
|
||||
"Omit --component when targeting an asset directly.");
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
private static object DescribeProperty(SerializedProperty prop)
|
||||
{
|
||||
return new
|
||||
{
|
||||
name = prop.name,
|
||||
path = prop.propertyPath,
|
||||
propertyType = prop.propertyType.ToString(),
|
||||
isArray = prop.isArray && prop.propertyType != SerializedPropertyType.String,
|
||||
arrayLength = (prop.isArray && prop.propertyType != SerializedPropertyType.String) ? prop.arraySize : (int?)null,
|
||||
value = SerializedPropertyConverter.GetValue(prop)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 974b8c0dcec74d388bbf2061fc3aa3dc
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Scripts
|
||||
{
|
||||
/// <summary>
|
||||
/// Bridges agent-supplied JSON values and Unity's <see cref="SerializedProperty"/> model for the
|
||||
/// set_serialized_field / get_serialized_fields commands (CLI-195).
|
||||
///
|
||||
/// Supported property kinds: integers, floats, bools, strings, enums, Vector2/3/4, Color,
|
||||
/// Quaternion, Rect, Bounds, and object references. Object references accept an <see cref="ObjectRef"/>
|
||||
/// handle (resolved via <see cref="ObjectResolver"/>) — assets by GUID/fileId or path, and scene
|
||||
/// objects by instanceId/hierarchyPath. Arrays are addressed element-by-element via a path like
|
||||
/// "myArray.Array.data[2]" (Unity's native SerializedProperty path syntax) — see the commands.
|
||||
/// </summary>
|
||||
static class SerializedPropertyConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Assign a JSON value to a serialized property, converting by the property's type. Throws
|
||||
/// <see cref="ArgumentException"/> with a clear message when the value can't be coerced or the
|
||||
/// property type isn't supported. The caller is responsible for ApplyModifiedProperties.
|
||||
/// </summary>
|
||||
public static void SetValue(SerializedProperty prop, JToken value)
|
||||
{
|
||||
// Whole-array assignment: an array/list field (Generic + isArray) set from a JSON array —
|
||||
// resize, then recurse per element (handles primitive and object-reference arrays alike).
|
||||
// Element-by-element addressing via "field.Array.data[i]" / "field.Array.size" still works.
|
||||
if (prop.isArray && prop.propertyType != SerializedPropertyType.String)
|
||||
{
|
||||
if (!(value is JArray array))
|
||||
throw new ArgumentException(
|
||||
$"Field '{prop.propertyPath}' is an array; provide a JSON array of elements.");
|
||||
|
||||
prop.arraySize = array.Count;
|
||||
for (int i = 0; i < array.Count; i++)
|
||||
SetValue(prop.GetArrayElementAtIndex(i), array[i]);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (prop.propertyType)
|
||||
{
|
||||
case SerializedPropertyType.Integer:
|
||||
prop.longValue = value.ToObject<long>();
|
||||
break;
|
||||
case SerializedPropertyType.Boolean:
|
||||
prop.boolValue = value.ToObject<bool>();
|
||||
break;
|
||||
case SerializedPropertyType.Float:
|
||||
prop.doubleValue = value.ToObject<double>();
|
||||
break;
|
||||
case SerializedPropertyType.String:
|
||||
prop.stringValue = value.ToObject<string>();
|
||||
break;
|
||||
case SerializedPropertyType.Enum:
|
||||
SetEnum(prop, value);
|
||||
break;
|
||||
case SerializedPropertyType.Vector2:
|
||||
prop.vector2Value = ToVector2(value);
|
||||
break;
|
||||
case SerializedPropertyType.Vector3:
|
||||
prop.vector3Value = ToVector3(value);
|
||||
break;
|
||||
case SerializedPropertyType.Vector4:
|
||||
prop.vector4Value = ToVector4(value);
|
||||
break;
|
||||
case SerializedPropertyType.Quaternion:
|
||||
var v4 = ToVector4(value);
|
||||
prop.quaternionValue = new Quaternion(v4.x, v4.y, v4.z, v4.w);
|
||||
break;
|
||||
case SerializedPropertyType.Color:
|
||||
prop.colorValue = ToColor(value);
|
||||
break;
|
||||
case SerializedPropertyType.Rect:
|
||||
prop.rectValue = ToRect(value);
|
||||
break;
|
||||
case SerializedPropertyType.Bounds:
|
||||
prop.boundsValue = ToBounds(value);
|
||||
break;
|
||||
case SerializedPropertyType.ObjectReference:
|
||||
prop.objectReferenceValue = ResolveObjectReference(value);
|
||||
break;
|
||||
case SerializedPropertyType.ArraySize:
|
||||
// Resizing an array via "<field>.Array.size" — the property is an int under the hood.
|
||||
prop.intValue = value.ToObject<int>();
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException(
|
||||
$"Field '{prop.propertyPath}' has unsupported type '{prop.propertyType}'. " +
|
||||
"Supported: int, float, bool, string, enum, Vector2/3/4, Quaternion, Color, Rect, Bounds, object reference, array size.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a serialized property into a plain object suitable for JSON serialization. Object
|
||||
/// references are described via <see cref="ObjectResolver.Describe"/> so the agent gets a
|
||||
/// re-usable handle; arrays are returned as a length plus per-element values.
|
||||
/// </summary>
|
||||
public static object GetValue(SerializedProperty prop)
|
||||
{
|
||||
// Array/list fields are returned as a JSON array of their elements so they round-trip with
|
||||
// SetValue. (String also reports isArray, hence the exclusion.)
|
||||
if (prop.isArray && prop.propertyType != SerializedPropertyType.String)
|
||||
{
|
||||
var elements = new JArray();
|
||||
for (int i = 0; i < prop.arraySize; i++)
|
||||
{
|
||||
var v = GetValue(prop.GetArrayElementAtIndex(i));
|
||||
elements.Add(v == null ? JValue.CreateNull() : JToken.FromObject(v));
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
switch (prop.propertyType)
|
||||
{
|
||||
case SerializedPropertyType.Integer:
|
||||
return prop.longValue;
|
||||
case SerializedPropertyType.Boolean:
|
||||
return prop.boolValue;
|
||||
case SerializedPropertyType.Float:
|
||||
return prop.doubleValue;
|
||||
case SerializedPropertyType.String:
|
||||
return prop.stringValue;
|
||||
case SerializedPropertyType.Enum:
|
||||
return prop.enumValueIndex >= 0 && prop.enumValueIndex < prop.enumNames.Length
|
||||
? prop.enumNames[prop.enumValueIndex]
|
||||
: (object)prop.intValue;
|
||||
case SerializedPropertyType.Vector2:
|
||||
return new { x = prop.vector2Value.x, y = prop.vector2Value.y };
|
||||
case SerializedPropertyType.Vector3:
|
||||
return new { x = prop.vector3Value.x, y = prop.vector3Value.y, z = prop.vector3Value.z };
|
||||
case SerializedPropertyType.Vector4:
|
||||
return new { x = prop.vector4Value.x, y = prop.vector4Value.y, z = prop.vector4Value.z, w = prop.vector4Value.w };
|
||||
case SerializedPropertyType.Quaternion:
|
||||
return new { x = prop.quaternionValue.x, y = prop.quaternionValue.y, z = prop.quaternionValue.z, w = prop.quaternionValue.w };
|
||||
case SerializedPropertyType.Color:
|
||||
return new { r = prop.colorValue.r, g = prop.colorValue.g, b = prop.colorValue.b, a = prop.colorValue.a };
|
||||
case SerializedPropertyType.Rect:
|
||||
return new { x = prop.rectValue.x, y = prop.rectValue.y, width = prop.rectValue.width, height = prop.rectValue.height };
|
||||
case SerializedPropertyType.Bounds:
|
||||
var b = prop.boundsValue;
|
||||
return new { center = new { x = b.center.x, y = b.center.y, z = b.center.z }, size = new { x = b.size.x, y = b.size.y, z = b.size.z } };
|
||||
case SerializedPropertyType.ObjectReference:
|
||||
return ObjectResolver.Describe(prop.objectReferenceValue);
|
||||
case SerializedPropertyType.ArraySize:
|
||||
return prop.intValue;
|
||||
default:
|
||||
// Unknown/unsupported types are reported by kind rather than failing a whole read.
|
||||
return new { unsupported = prop.propertyType.ToString() };
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetEnum(SerializedProperty prop, JToken value)
|
||||
{
|
||||
// Accept either the enum name (preferred, stable across reordering) or the numeric index.
|
||||
if (value.Type == JTokenType.String)
|
||||
{
|
||||
var name = value.ToObject<string>();
|
||||
var idx = Array.IndexOf(prop.enumNames, name);
|
||||
if (idx < 0)
|
||||
idx = Array.IndexOf(prop.enumDisplayNames, name);
|
||||
if (idx < 0)
|
||||
throw new ArgumentException(
|
||||
$"Enum '{name}' is not a valid value for '{prop.propertyPath}'. Valid: {string.Join(", ", prop.enumNames)}.");
|
||||
prop.enumValueIndex = idx;
|
||||
}
|
||||
else
|
||||
{
|
||||
var idx = value.ToObject<int>();
|
||||
if (idx < 0 || idx >= prop.enumNames.Length)
|
||||
throw new ArgumentException(
|
||||
$"Enum index {idx} is out of range for '{prop.propertyPath}' (valid 0..{prop.enumNames.Length - 1}). " +
|
||||
$"Valid values: {string.Join(", ", prop.enumNames)}.");
|
||||
prop.enumValueIndex = idx;
|
||||
}
|
||||
}
|
||||
|
||||
private static Object ResolveObjectReference(JToken value)
|
||||
{
|
||||
// An explicit null clears the reference; anything else MUST resolve — never silently no-op
|
||||
// (an unresolved handle used to be dropped, so the command reported success while assigning
|
||||
// nothing).
|
||||
if (value == null || value.Type == JTokenType.Null)
|
||||
return null;
|
||||
|
||||
var handle = value.ToObject<ObjectRef>();
|
||||
if (handle == null || handle.IsEmpty)
|
||||
throw new ArgumentException(
|
||||
$"'{value}' is not a recognized object reference handle. " +
|
||||
"Provide a path, guid, globalId, or instanceId (or null to clear).");
|
||||
|
||||
if (!ObjectResolver.TryResolve(handle, out var obj, out var error))
|
||||
throw new ArgumentException($"Could not resolve object reference: {error}");
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
private static float F(JToken t, string key, float fallback = 0f)
|
||||
{
|
||||
// Indexing a non-object token (e.g. the agent passed a number or array for a Vector3/Color)
|
||||
// with a string key throws an opaque InvalidOperationException — validate first so the
|
||||
// caller gets a clear, actionable ArgumentException instead.
|
||||
if (!(t is JObject obj))
|
||||
throw new ArgumentException(
|
||||
$"Expected a JSON object with named components (e.g. {{ \"{key}\": 0 }}) " +
|
||||
$"but received a '{t?.Type.ToString() ?? "null"}'.");
|
||||
|
||||
var token = obj[key];
|
||||
return token != null ? token.ToObject<float>() : fallback;
|
||||
}
|
||||
|
||||
private static Vector2 ToVector2(JToken t) => new Vector2(F(t, "x"), F(t, "y"));
|
||||
private static Vector3 ToVector3(JToken t) => new Vector3(F(t, "x"), F(t, "y"), F(t, "z"));
|
||||
private static Vector4 ToVector4(JToken t) => new Vector4(F(t, "x"), F(t, "y"), F(t, "z"), F(t, "w"));
|
||||
|
||||
private static Color ToColor(JToken t) => new Color(F(t, "r"), F(t, "g"), F(t, "b"), F(t, "a", 1f));
|
||||
|
||||
private static Rect ToRect(JToken t) => new Rect(F(t, "x"), F(t, "y"), F(t, "width"), F(t, "height"));
|
||||
|
||||
private static Bounds ToBounds(JToken t)
|
||||
{
|
||||
if (!(t is JObject obj))
|
||||
throw new ArgumentException(
|
||||
$"Expected a JSON object with 'center' and 'size' components but received a " +
|
||||
$"'{t?.Type.ToString() ?? "null"}'.");
|
||||
|
||||
var center = obj["center"] != null ? ToVector3(obj["center"]) : Vector3.zero;
|
||||
var size = obj["size"] != null ? ToVector3(obj["size"]) : Vector3.zero;
|
||||
return new Bounds(center, size);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ce4000d573a64d76a63e42b2b40c60a5
|
||||
Reference in New Issue
Block a user