Add Unity Pipeline package support
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
// Lets the runtime test assembly's PipelineClient read a server's internal Token to authenticate.
|
||||
[assembly: InternalsVisibleTo("Unity.Pipeline.Tests.Runtime")]
|
||||
// Lets the EditMode test assembly unit-test internal runtime utilities (e.g. MaxLengthStream).
|
||||
[assembly: InternalsVisibleTo("Unity.Pipeline.Tests.Editor")]
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 87863085291527c4581eb05fcc20b47c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 67704748e63416c4c959bd6de99b4c4f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
#if UNITY_6000_7_OR_NEWER
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Captures a UI Toolkit <see cref="VisualElement"/> from a live runtime panel to a PNG and
|
||||
/// returns it base64-encoded (and writes it to disk). Runtime-only so it is available in a
|
||||
/// development Player as well as the editor's Play mode.
|
||||
///
|
||||
/// A runtime panel is hosted by either a <see cref="UIDocument"/> or a <see cref="PanelRenderer"/>.
|
||||
/// The panel is identified by name (the <see cref="PanelSettings"/> asset name or the host
|
||||
/// GameObject name) rather than an asset path, because a Player has no AssetDatabase.
|
||||
/// </summary>
|
||||
public static class CaptureRuntimeElementCommand
|
||||
{
|
||||
[CliCommand("capture_runtime_element",
|
||||
"Capture a UI Toolkit VisualElement (by selector) from a live runtime panel (UIDocument or PanelRenderer) to a PNG; returns path + base64.",
|
||||
MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static CaptureElementResponse CaptureRuntimeElement(
|
||||
[CliArg("panel", "Name of the target panel: matches the PanelSettings asset name or the host GameObject name (UIDocument or PanelRenderer). Optional when exactly one panel exists.")] string panel = "",
|
||||
[CliArg("selector", "Element selector: '#name', '.class', a type name (e.g. Button), descendant (space) / child ('>') chains, optional pseudo-states (:checked,:hover,:focus,:active,:enabled,:disabled,:not(...)).", Required = true)] string selector = "",
|
||||
[CliArg("output", "Output PNG path (absolute, or relative to Application.persistentDataPath). Defaults to a timestamped file under Application.persistentDataPath.")] string output = "")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selector))
|
||||
return CaptureElementResponse.Fail("A 'selector' is required.");
|
||||
|
||||
var hosts = GatherHosts();
|
||||
if (hosts.Count == 0)
|
||||
return CaptureElementResponse.Fail("No live runtime UI panels found (UIDocument or PanelRenderer). Show the UI first.");
|
||||
|
||||
List<Host> matched;
|
||||
string resolvedName;
|
||||
if (string.IsNullOrWhiteSpace(panel))
|
||||
{
|
||||
if (hosts.Count > 1)
|
||||
{
|
||||
var names = string.Join(", ", hosts.Select(h => h.DisplayName).Distinct());
|
||||
return CaptureElementResponse.Fail(
|
||||
$"Multiple runtime panels exist; specify --panel as one of: {names}.");
|
||||
}
|
||||
|
||||
matched = hosts;
|
||||
resolvedName = hosts[0].DisplayName;
|
||||
}
|
||||
else
|
||||
{
|
||||
matched = hosts.Where(h => h.PanelSettingsName == panel || h.GameObjectName == panel).ToList();
|
||||
if (matched.Count == 0)
|
||||
{
|
||||
var names = string.Join(", ", hosts.Select(h => h.DisplayName).Distinct());
|
||||
return CaptureElementResponse.Fail(
|
||||
$"No live runtime panel named '{panel}'. Available: {names}.");
|
||||
}
|
||||
|
||||
resolvedName = panel;
|
||||
}
|
||||
|
||||
// Lower sorting order is drawn first (further back); query in that order so the topmost
|
||||
// match is found last only if earlier panels miss. Roots that can't be resolved are skipped.
|
||||
var roots = matched
|
||||
.OrderBy(h => h.SortingOrder)
|
||||
.Select(h => h.GetRoot())
|
||||
.Where(r => r != null)
|
||||
.ToList();
|
||||
|
||||
if (roots.Count == 0)
|
||||
return CaptureElementResponse.Fail(
|
||||
$"Panel '{resolvedName}' has no initialized root VisualElement yet. Show the UI first.");
|
||||
|
||||
var element = VisualElementCaptureSupport.ResolveElement(roots, selector, out _);
|
||||
if (element == null)
|
||||
return CaptureElementResponse.Fail(
|
||||
$"No element matched selector '{selector}' in panel '{resolvedName}'.");
|
||||
|
||||
var dir = Application.persistentDataPath;
|
||||
return VisualElementCaptureSupport.CaptureAndRespond(
|
||||
element, selector, $"panel:{resolvedName}", output,
|
||||
relativeBaseDir: dir, defaultDir: dir, prefix: "element");
|
||||
}
|
||||
|
||||
/// <summary>A live runtime UI host (UIDocument or PanelRenderer) and how to reach its root.</summary>
|
||||
class Host
|
||||
{
|
||||
public string PanelSettingsName;
|
||||
public string GameObjectName;
|
||||
public float SortingOrder;
|
||||
public System.Func<VisualElement> GetRoot;
|
||||
|
||||
public string DisplayName => PanelSettingsName ?? GameObjectName;
|
||||
}
|
||||
|
||||
static List<Host> GatherHosts()
|
||||
{
|
||||
var hosts = new List<Host>();
|
||||
|
||||
foreach (var doc in PipelineUtils.FindObjectsByType<UIDocument>())
|
||||
{
|
||||
var d = doc;
|
||||
hosts.Add(new Host
|
||||
{
|
||||
PanelSettingsName = d.panelSettings != null ? d.panelSettings.name : null,
|
||||
GameObjectName = d.gameObject.name,
|
||||
SortingOrder = d.sortingOrder,
|
||||
GetRoot = () => d.rootVisualElement
|
||||
});
|
||||
}
|
||||
|
||||
// PanelRenderer is a 6000.5+ UI Toolkit API; on older editors only UIDocument panels exist.
|
||||
foreach (var renderer in PipelineUtils.FindObjectsByType<PanelRenderer>())
|
||||
{
|
||||
var r = renderer;
|
||||
hosts.Add(new Host
|
||||
{
|
||||
PanelSettingsName = r.panelSettings != null ? r.panelSettings.name : null,
|
||||
GameObjectName = r.gameObject.name,
|
||||
SortingOrder = r.sortingOrder,
|
||||
GetRoot = () => GetPanelRendererRoot(r)
|
||||
});
|
||||
}
|
||||
|
||||
return hosts;
|
||||
}
|
||||
|
||||
// PanelRenderer.rootVisualElement is internal; the public route to its root is a UI-reload
|
||||
// callback, which fires synchronously when the panel is already initialized. Register, capture
|
||||
// the root, and immediately unregister. PanelRenderer is a 6000.5+ API.
|
||||
static VisualElement GetPanelRendererRoot(PanelRenderer renderer)
|
||||
{
|
||||
VisualElement captured = null;
|
||||
PanelRenderer.VersionedUIReloadCallback callback = (pr, root, version) => captured = root;
|
||||
renderer.RegisterUIReloadCallback(callback);
|
||||
renderer.UnregisterUIReloadCallback(callback);
|
||||
return captured;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 54dc75c59cc13424c9e6e0e7b237e412
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using Unity.Pipeline.Models;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using UnityEngine;
|
||||
using Unity.Pipeline.Commands;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Command for evaluating C# code dynamically using Roslyn compilation.
|
||||
/// Requires security token for authorization.
|
||||
/// </summary>
|
||||
public static class CodeEvalCommand
|
||||
{
|
||||
[CliCommand("eval", "Evaluate C# code dynamically using Roslyn compiler", MainThreadRequired = true)]
|
||||
public static EvalResponse EvaluateCode(
|
||||
[CliArg("code", "C# code to evaluate", Required = true)] string code,
|
||||
[CliArg("timeout", "Timeout in milliseconds")] int timeout = 5000)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
return EvalResponse.EvalFailure(
|
||||
"Bad Request",
|
||||
"Code parameter is required and cannot be empty");
|
||||
}
|
||||
|
||||
return EvaluateSource(code, timeout);
|
||||
}
|
||||
|
||||
[CliCommand("eval_file", "Evaluate C# code read from a .cs file on disk", MainThreadRequired = true)]
|
||||
public static EvalResponse EvaluateFile(
|
||||
[CliArg("file", "Path to a .cs file to evaluate", Required = true)] string file,
|
||||
[CliArg("timeout", "Timeout in milliseconds")] int timeout = 5000)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(file))
|
||||
{
|
||||
return EvalResponse.EvalFailure(
|
||||
"Bad Request",
|
||||
"File parameter is required and cannot be empty");
|
||||
}
|
||||
|
||||
if (!file.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EvalResponse.EvalFailure(
|
||||
"Bad Request",
|
||||
"File must have a .cs extension");
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(file))
|
||||
{
|
||||
return EvalResponse.EvalFailure(
|
||||
"Bad Request",
|
||||
$"File not found: {file}");
|
||||
}
|
||||
|
||||
string code;
|
||||
try
|
||||
{
|
||||
code = System.IO.File.ReadAllText(file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return EvalResponse.EvalFailure(
|
||||
"Bad Request",
|
||||
$"Failed to read file: {ex.Message}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
return EvalResponse.EvalFailure(
|
||||
"Bad Request",
|
||||
$"File is empty: {file}");
|
||||
}
|
||||
|
||||
return EvaluateSource(code, timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared evaluation path: compiles and executes the given C# source and returns the
|
||||
/// response. Both the <c>eval</c> and <c>eval_file</c> commands funnel their resolved
|
||||
/// source string into here.
|
||||
/// </summary>
|
||||
private static EvalResponse EvaluateSource(string code, int timeout)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Validate timeout
|
||||
if (timeout <= 0 || timeout > 30000)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return EvalResponse.EvalFailure(
|
||||
"Bad Request",
|
||||
"Timeout must be between 1ms and 30000ms",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
// Execute compilation and evaluation. We're already on the main thread
|
||||
// (MainThreadRequired = true), so call the synchronous path directly.
|
||||
var result = EvalCodeCompiler.CompileAndExecuteOnMainThread(code, timeout, null);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
// Update execution time
|
||||
if (result != null)
|
||||
{
|
||||
result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds;
|
||||
}
|
||||
|
||||
return result ?? EvalResponse.EvalFailure(
|
||||
"Unknown Error",
|
||||
"Compilation returned null result",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"Pipeline: Eval command failed: {ex.Message}");
|
||||
Debug.LogError($"Pipeline: Stack trace: {ex.StackTrace}");
|
||||
|
||||
return EvalResponse.EvalFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bc1d3406462d2c246a5f84dd30dd02c7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,45 @@
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Console;
|
||||
using Unity.Pipeline.Models;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns captured Unity console output. Backs the CLI's
|
||||
/// <c>console [--tail N] [--level log|warn|error] [--follow]</c>.
|
||||
///
|
||||
/// Lives in the runtime assembly, so it is available from both the Editor and player builds —
|
||||
/// console capture is driven by <see cref="ConsoleLogCapture"/>, which runs in both contexts.
|
||||
///
|
||||
/// The package has no streaming transport (the HTTP server handles one request at a time), so
|
||||
/// <c>--follow</c> is realized client-side: the CLI polls this command, prints new entries, and
|
||||
/// passes the returned <see cref="ConsoleLogResponse.Cursor"/> back as <c>since</c> on the next
|
||||
/// call. The flag mapping is:
|
||||
/// --tail N -> tail
|
||||
/// --level X -> level (minimum severity threshold)
|
||||
/// --follow -> repeated calls with since = previous cursor
|
||||
///
|
||||
/// Reads only the in-memory buffer (no Unity main-thread APIs), so it is marked
|
||||
/// <c>MainThreadRequired = false</c> for fast polling.
|
||||
/// </summary>
|
||||
public static class ConsoleCommand
|
||||
{
|
||||
/// <summary>Default number of entries returned when <c>tail</c> is not supplied.</summary>
|
||||
public const int DefaultTail = 100;
|
||||
|
||||
[CliCommand("console", "Get captured Unity console output (Editor or Player; supports tail, level filtering, and follow via a cursor)", MainThreadRequired = false)]
|
||||
public static ConsoleLogResponse GetConsole(
|
||||
[CliArg("tail", "Maximum number of most-recent entries to return")] int tail = DefaultTail,
|
||||
[CliArg("level", "Minimum severity to include: log | warn | error")] string level = ConsoleLogBuffer.LevelLog,
|
||||
[CliArg("since", "Cursor: only return entries newer than this seq. Use the 'cursor' from a previous response to follow.")] long since = -1)
|
||||
{
|
||||
// Guard against nonsensical tail values; <= 0 would otherwise mean "unlimited" in the
|
||||
// buffer, which is not what a caller passing e.g. --tail 0 expects.
|
||||
if (tail <= 0)
|
||||
tail = DefaultTail;
|
||||
|
||||
var minSeverity = ConsoleLogBuffer.SeverityFromLevelName(level);
|
||||
return ConsoleLogCapture.Buffer.Query(since, tail, minSeverity);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 92be7cfe398a263a7b06a4a8a00a802c
|
||||
+563
@@ -0,0 +1,563 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using Unity.Pipeline.HotReload;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// CLI commands for hot reload operations.
|
||||
/// Provides runtime compilation and management of hot reload files.
|
||||
/// Follows existing [CliCommand] patterns and integrates with Pipeline Server.
|
||||
/// </summary>
|
||||
public static class HotReloadCommands
|
||||
{
|
||||
[CliCommand("reload_file_override", "Compile and apply hot reload file changes immediately", MainThreadRequired = true)]
|
||||
internal static HotReloadResponse ReloadFileOverride(
|
||||
[CliArg("filename", "Hot reload source file to compile (e.g. PlayerTweaks.cs)", Required = true)] string filename,
|
||||
[CliArg("timeout", "Compilation timeout in milliseconds")] int timeout = 30000,
|
||||
[CliArg("assemblyDir", "Directory to save compiled assemblies to disk (optional, default is in-memory only)")] string assemblyDir = null)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Validate filename input
|
||||
if (string.IsNullOrWhiteSpace(filename))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Bad Request",
|
||||
"Filename parameter is required and cannot be empty",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
// Ensure filename ends with .cs
|
||||
if (!filename.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
filename += ".cs";
|
||||
}
|
||||
|
||||
// Validate timeout
|
||||
if (timeout <= 0 || timeout > 300000) // Max 5 minutes
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Bad Request",
|
||||
"Timeout must be between 1ms and 300000ms (5 minutes)",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
Debug.Log($"HotReload: Executing reload_file_override command (helper workflow): {filename}, timeout: {timeout}ms, assemblyDir: {assemblyDir ?? "in-memory"}");
|
||||
|
||||
// Resolve the override file path (absolute, or relative to the project root).
|
||||
var fullPath = Path.IsPathRooted(filename) ? filename : Path.GetFullPath(filename);
|
||||
|
||||
// In a Player build, the file must be inside the project's build-baked roots. The
|
||||
// project layout cannot be resolved from a running build, so this is skipped in the
|
||||
// editor (which resolves files against the live project).
|
||||
if (!Application.isEditor)
|
||||
{
|
||||
var scopeCheck = ValidateReloadPath(fullPath, HotReloadRegistry.AllowedReloadRoots);
|
||||
if (!scopeCheck.IsValid)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return HotReloadResponse.CmdFailure(
|
||||
scopeCheck.Error,
|
||||
scopeCheck.ErrorDetails,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"File Not Found",
|
||||
$"Override file not found: {fullPath}",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
// Up-front validation so a misconfigured override file produces a clear, actionable
|
||||
// message instead of a confusing compiler error from generated/duplicate code.
|
||||
var validation = OverrideFileValidator.Validate(File.ReadAllText(fullPath), Path.GetFileName(fullPath));
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Invalid Override File",
|
||||
validation.GetFormattedErrorMessage(),
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
validation.Errors);
|
||||
}
|
||||
|
||||
// Compile and apply (helper / separate override file workflow).
|
||||
var task = ExecuteHotReloadAsync(fullPath, timeout, assemblyDir);
|
||||
task.Wait();
|
||||
var result = task.Result;
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds;
|
||||
}
|
||||
|
||||
Debug.Log($"HotReload: reload_file_override completed in {stopwatch.ElapsedMilliseconds}ms, success: {result?.Success}");
|
||||
|
||||
return result ?? HotReloadResponse.CmdFailure(
|
||||
"Unknown Error",
|
||||
"Hot reload compilation returned null result",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"HotReload: reload_file_override command failed: {ex.Message}");
|
||||
Debug.LogError($"HotReload: Stack trace: {ex.StackTrace}");
|
||||
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
[CliCommand("reload_file", "Compile and apply in-place [HotReload] edits from a source file", MainThreadRequired = true)]
|
||||
public static HotReloadResponse ReloadFile(
|
||||
[CliArg("filename", "Source file containing [HotReload] methods (e.g. Assets/Scripts/Player.cs)", Required = true)] string filename,
|
||||
[CliArg("timeout", "Compilation timeout in milliseconds")] int timeout = 30000,
|
||||
[CliArg("assemblyDir", "Directory to save compiled assemblies to disk (optional, default is in-memory only)")] string assemblyDir = null,
|
||||
[CliArg("pdb", "Emit debug symbols (portable PDB) mapped to the original source so breakpoints bind in your editor. Compiles unoptimized.")] bool pdb = false)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filename))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Bad Request",
|
||||
"Filename parameter is required and cannot be empty",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
if (!filename.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
filename += ".cs";
|
||||
}
|
||||
|
||||
var fullPath = ResolveSourceFilePath(filename);
|
||||
if (string.IsNullOrEmpty(fullPath))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"File Not Found",
|
||||
$"Could not locate source file: {filename}",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
// In a Player build, the file must be inside the project's build-baked roots. The
|
||||
// project layout cannot be resolved from a running build, so this is skipped in the
|
||||
// editor (which resolves files against the live project).
|
||||
if (!Application.isEditor)
|
||||
{
|
||||
var scopeCheck = ValidateReloadPath(fullPath, HotReloadRegistry.AllowedReloadRoots);
|
||||
if (!scopeCheck.IsValid)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return HotReloadResponse.CmdFailure(
|
||||
scopeCheck.Error,
|
||||
scopeCheck.ErrorDetails,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"HotReload: Executing reload_file command: {fullPath}, timeout: {timeout}ms, assemblyDir: {assemblyDir ?? "in-memory"}, pdb: {pdb}");
|
||||
|
||||
var task = InPlaceReloadProcessor.ProcessSourceFileAsync(fullPath, assemblyDir, pdb);
|
||||
task.Wait();
|
||||
var result = task.Result;
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
return HotReloadResponse.CmdSuccess(
|
||||
result.AssemblyName,
|
||||
$"In-place hot reload successful: {result.AssemblyName} with {result.RegisteredMethods.Count} methods",
|
||||
result.RegisteredMethods,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"In-Place Reload Failed",
|
||||
result.ErrorMessage ?? "In-place hot reload processing failed",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
result.CompilationDiagnostics);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"HotReload: reload_file command failed: {ex.Message}");
|
||||
Debug.LogError($"HotReload: Stack trace: {ex.StackTrace}");
|
||||
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
[CliCommand("cleanup_hotreload", "Remove old hot reload DLL versions and clear registry", MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static HotReloadResponse CleanupHotReload(
|
||||
[CliArg("assemblyDir", "Directory containing assemblies to cleanup", Required = true)] string assemblyDir,
|
||||
[CliArg("force_domain_reload", "Force Unity domain reload after cleanup")] bool forceDomainReload = true)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
Debug.Log($"HotReload: Executing cleanup_hotreload command for directory: {assemblyDir}");
|
||||
|
||||
// Execute cleanup
|
||||
var cleanupResult = HotReloadCompiler.CleanupHotReloadDlls(assemblyDir);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (cleanupResult.Success)
|
||||
{
|
||||
var message = $"Cleanup successful: {cleanupResult.Message}";
|
||||
if (cleanupResult.DeletedFiles.Count > 0)
|
||||
{
|
||||
message += $" Files removed: {string.Join(", ", cleanupResult.DeletedFiles)}";
|
||||
}
|
||||
|
||||
// Force domain reload if requested (interrupts gameplay but ensures clean state)
|
||||
if (forceDomainReload && Application.isEditor)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
Debug.Log("HotReload: Requesting Unity domain reload to clean memory state");
|
||||
UnityEditor.EditorUtility.RequestScriptReload();
|
||||
message += " Unity domain reload requested.";
|
||||
#endif
|
||||
}
|
||||
|
||||
Debug.Log($"HotReload: cleanup_hotreload completed successfully in {stopwatch.ElapsedMilliseconds}ms");
|
||||
|
||||
return HotReloadResponse.CmdSuccess(
|
||||
"cleanup_completed",
|
||||
message,
|
||||
cleanupResult.DeletedFiles,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Cleanup Failed",
|
||||
cleanupResult.Message,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"HotReload: cleanup_hotreload command failed: {ex.Message}");
|
||||
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
[CliCommand("hotreload_status", "Show current hot reload registry status and statistics", MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static HotReloadResponse HotReloadStatus()
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Get current registry stats
|
||||
var stats = HotReloadRegistry.GetStats();
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var statusMessage = $"Hot Reload Status - " +
|
||||
$"Reloadable Methods: {stats.ReloadableMethodCount}, " +
|
||||
$"Active Overrides: {stats.ActiveOverrideCount}, " +
|
||||
$"Loaded Types: {stats.LoadedTypeCount}";
|
||||
|
||||
Debug.Log($"HotReload: {statusMessage}");
|
||||
|
||||
return HotReloadResponse.CmdSuccess(
|
||||
"status_retrieved",
|
||||
statusMessage,
|
||||
stats.ActiveOverrideIds,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"HotReload: hotreload_status command failed: {ex.Message}");
|
||||
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate that a resolved file path lives inside one of the allowed project roots and
|
||||
/// exists on disk. "In scope" means under the Assets folder or a loaded package's location.
|
||||
/// The path is normalized (collapsing any <c>..</c> segments) before the scope check, so a
|
||||
/// path that escapes the allowed roots via traversal is rejected. A null or empty root set
|
||||
/// matches nothing, so the path is rejected as out of scope. This is a security boundary —
|
||||
/// it prevents files outside the project from being compiled and injected into the assembly.
|
||||
/// </summary>
|
||||
public static PathValidationResult ValidateReloadPath(string resolvedPath, System.Collections.Generic.IReadOnlyCollection<string> allowedRoots)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(resolvedPath))
|
||||
{
|
||||
return PathValidationResult.Invalid("Bad Request", "Resolved file path is empty.");
|
||||
}
|
||||
|
||||
string fullPath;
|
||||
try
|
||||
{
|
||||
fullPath = Path.GetFullPath(resolvedPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return PathValidationResult.Invalid("Bad Request", $"Invalid path: {ex.Message}");
|
||||
}
|
||||
|
||||
var underAnyRoot = false;
|
||||
if (allowedRoots != null)
|
||||
{
|
||||
foreach (var root in allowedRoots)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string fullRoot;
|
||||
try
|
||||
{
|
||||
fullRoot = Path.GetFullPath(root);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsUnderDirectory(fullPath, fullRoot))
|
||||
{
|
||||
underAnyRoot = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!underAnyRoot)
|
||||
{
|
||||
return PathValidationResult.Invalid(
|
||||
"Out Of Project Scope",
|
||||
$"Hot reload only accepts files inside the project's baked roots (Assets/ or a loaded " +
|
||||
$"package). '{fullPath}' is outside the project scope and will not be compiled.");
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
return PathValidationResult.Invalid("File Not Found", $"Source file not found: {fullPath}");
|
||||
}
|
||||
|
||||
return PathValidationResult.Valid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="path"/> is contained within <paramref name="directory"/>.
|
||||
/// Both are expected to be normalized absolute paths. A trailing separator is appended to
|
||||
/// the directory so that a sibling such as "AssetsExtra" is not treated as being under "Assets".
|
||||
/// </summary>
|
||||
private static bool IsUnderDirectory(string path, string directory)
|
||||
{
|
||||
var prefix = directory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
+ Path.DirectorySeparatorChar;
|
||||
|
||||
// Windows file systems are case-insensitive; Unix file systems are case-sensitive.
|
||||
var comparison = Path.DirectorySeparatorChar == '\\'
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
return path.StartsWith(prefix, comparison);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve source file path from various possible locations (used by the in-place command).
|
||||
/// </summary>
|
||||
private static string ResolveSourceFilePath(string filename)
|
||||
{
|
||||
if (Path.IsPathRooted(filename) && File.Exists(filename))
|
||||
{
|
||||
return filename;
|
||||
}
|
||||
|
||||
var potentialPaths = new[]
|
||||
{
|
||||
Path.Combine("Assets", filename),
|
||||
Path.Combine("Assets", "Scripts", filename),
|
||||
filename // Project root
|
||||
};
|
||||
|
||||
foreach (var path in potentialPaths)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute hot reload compilation and application asynchronously (for separate override files).
|
||||
/// </summary>
|
||||
private static async Task<HotReloadResponse> ExecuteHotReloadAsync(string filename, int timeoutMs, string assemblyDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use the HotReloadCompiler to compile and apply the changes
|
||||
var compileResult = await HotReloadCompiler.CompileAndApplyAsync(filename, assemblyDir);
|
||||
|
||||
if (!compileResult.IsSuccess)
|
||||
{
|
||||
return HotReloadResponse.CmdFailure(
|
||||
compileResult.Error ?? "Compilation Failed",
|
||||
compileResult.ErrorDetails ?? "Hot reload compilation failed",
|
||||
compileResult.ExecutionTimeMs,
|
||||
compileResult.Diagnostics);
|
||||
}
|
||||
|
||||
// Compiled, but no override actually bound (e.g. the target is not [HotReloadWithOverrides],
|
||||
// or the component is not in the scene / not in play mode). Report it rather than
|
||||
// claiming success when nothing changed.
|
||||
if (compileResult.RegisteredMethods.Count == 0)
|
||||
{
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"No Overrides Applied",
|
||||
compileResult.Diagnostics.Count > 0
|
||||
? "No hot reload overrides were applied:\n- " + string.Join("\n- ", compileResult.Diagnostics)
|
||||
: "No [HotReloadOverrideMethod] overrides were applied. Ensure the target component is in the scene and in play mode.",
|
||||
compileResult.ExecutionTimeMs,
|
||||
compileResult.Diagnostics);
|
||||
}
|
||||
|
||||
var message = $"Hot reload successful: {compileResult.AssemblyName} with {compileResult.RegisteredMethods.Count} methods";
|
||||
var response = HotReloadResponse.CmdSuccess(
|
||||
compileResult.AssemblyName,
|
||||
message,
|
||||
compileResult.RegisteredMethods,
|
||||
compileResult.ExecutionTimeMs);
|
||||
response.Diagnostics = compileResult.Diagnostics; // surface any partially-skipped overrides
|
||||
return response;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Timeout",
|
||||
$"Hot reload compilation exceeded {timeoutMs}ms timeout",
|
||||
timeoutMs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response model for hot reload CLI commands.
|
||||
/// Provides consistent response format for all hot reload operations.
|
||||
/// </summary>
|
||||
public class HotReloadResponse : CommandExecutionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Assembly name or operation identifier for successful operations.
|
||||
/// </summary>
|
||||
public string AssemblyName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of registered method IDs or files processed.
|
||||
/// </summary>
|
||||
public System.Collections.Generic.List<string> Items { get; set; } = new System.Collections.Generic.List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Compilation or processing diagnostics.
|
||||
/// </summary>
|
||||
public System.Collections.Generic.List<string> Diagnostics { get; set; } = new System.Collections.Generic.List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Create a successful hot reload response.
|
||||
/// </summary>
|
||||
public static HotReloadResponse CmdSuccess(string assemblyName, string message, System.Collections.Generic.List<string> items, long executionTimeMs)
|
||||
{
|
||||
return new HotReloadResponse
|
||||
{
|
||||
Success = true,
|
||||
AssemblyName = assemblyName,
|
||||
Message = message,
|
||||
Items = items ?? new System.Collections.Generic.List<string>(),
|
||||
ExecutionTimeMs = executionTimeMs
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a failed hot reload response.
|
||||
/// </summary>
|
||||
public static HotReloadResponse CmdFailure(string error, string errorDetails, long executionTimeMs, System.Collections.Generic.List<string> diagnostics = null)
|
||||
{
|
||||
return new HotReloadResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = error,
|
||||
ErrorDetails = errorDetails,
|
||||
ExecutionTimeMs = executionTimeMs,
|
||||
Diagnostics = diagnostics ?? new System.Collections.Generic.List<string>()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of validating a hot reload source path. <see cref="Error"/> is a short category and
|
||||
/// <see cref="ErrorDetails"/> the human-readable explanation, matching the shape consumed by
|
||||
/// <see cref="HotReloadResponse.CmdFailure"/>.
|
||||
/// </summary>
|
||||
public class PathValidationResult
|
||||
{
|
||||
public bool IsValid { get; private set; }
|
||||
public string Error { get; private set; }
|
||||
public string ErrorDetails { get; private set; }
|
||||
|
||||
public static PathValidationResult Valid()
|
||||
{
|
||||
return new PathValidationResult { IsValid = true };
|
||||
}
|
||||
|
||||
public static PathValidationResult Invalid(string error, string errorDetails)
|
||||
{
|
||||
return new PathValidationResult { IsValid = false, Error = error, ErrorDetails = errorDetails };
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 33ec6e594cb07434cb51fbc751ca37f2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Runtime application control commands for Unity Player builds.
|
||||
/// Provides basic application lifecycle and performance control.
|
||||
/// </summary>
|
||||
public static class RuntimeApplicationCommand
|
||||
{
|
||||
[CliCommand("quit", "Gracefully quit the Unity application", MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static string QuitApplication(
|
||||
[CliArg("exitCode", "Exit code for the application")] int exitCode = 0)
|
||||
{
|
||||
// Schedule quit on next frame to allow HTTP response to be sent
|
||||
var quitObject = new GameObject("PipelineQuitScheduler");
|
||||
var quitScheduler = quitObject.AddComponent<QuitScheduler>();
|
||||
quitScheduler.Initialize(exitCode);
|
||||
|
||||
return $"Application quit scheduled with exit code {exitCode}";
|
||||
}
|
||||
|
||||
[CliCommand("set_target_framerate", "Set the target frame rate for the application", MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static string SetTargetFrameRate(
|
||||
[CliArg("frameRate", "Target frame rate (-1 for platform default, 0 for unlimited)", Required = true)] int frameRate)
|
||||
{
|
||||
var previousFrameRate = Application.targetFrameRate;
|
||||
Application.targetFrameRate = frameRate;
|
||||
|
||||
var frameRateDescription = frameRate switch
|
||||
{
|
||||
-1 => "platform default",
|
||||
0 => "unlimited (VSync)",
|
||||
_ => $"{frameRate} FPS"
|
||||
};
|
||||
|
||||
return $"Target frame rate set to {frameRateDescription} (was {previousFrameRate})";
|
||||
}
|
||||
|
||||
[CliCommand("set_timescale", "Set the time scale for the application", MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static string SetTimeScale(
|
||||
[CliArg("scale", "Time scale multiplier (0.0 to pause, 1.0 for normal speed)", Required = true)] float scale)
|
||||
{
|
||||
if (scale < 0f)
|
||||
{
|
||||
return "Error: Time scale cannot be negative";
|
||||
}
|
||||
|
||||
var previousScale = Time.timeScale;
|
||||
Time.timeScale = scale;
|
||||
|
||||
var scaleDescription = scale switch
|
||||
{
|
||||
0f => "paused",
|
||||
1f => "normal speed",
|
||||
_ => $"{scale}x speed"
|
||||
};
|
||||
|
||||
return $"Time scale set to {scaleDescription} (was {previousScale}x)";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MonoBehaviour to handle delayed application quit.
|
||||
/// Allows HTTP response to be sent before application terminates.
|
||||
/// </summary>
|
||||
private class QuitScheduler : MonoBehaviour
|
||||
{
|
||||
private int m_ExitCode;
|
||||
private float m_QuitTime;
|
||||
|
||||
public void Initialize(int exitCode)
|
||||
{
|
||||
m_ExitCode = exitCode;
|
||||
m_QuitTime = Time.realtimeSinceStartup + 0.1f; // 100ms delay
|
||||
DontDestroyOnLoad(gameObject);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (Time.realtimeSinceStartup >= m_QuitTime)
|
||||
{
|
||||
Debug.Log($"Pipeline: Quitting application with exit code {m_ExitCode}");
|
||||
Application.Quit(m_ExitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e508f97d464c66347816d5281c68e276
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEngine;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.Controls;
|
||||
using UnityEngine.InputSystem.LowLevel;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// <para>
|
||||
/// Input-simulation commands that drive a running Player by injecting synthetic input — the enabler
|
||||
/// for automated play-testing (CLI-208). Keyboard and pointer events are injected through the Input
|
||||
/// System (<c>InputSystem.QueueEvent</c> with a state event captured from the live device), which also
|
||||
/// drives uGUI / UI Toolkit through the Input System UI module — so a simulated pointer click lands on
|
||||
/// on-screen UI the same way a real one does.
|
||||
/// </para>
|
||||
///
|
||||
/// <para><b>Input stack:</b> device injection requires the Input System package to be present and active
|
||||
/// (<c>ENABLE_INPUT_SYSTEM</c>). Legacy <c>UnityEngine.Input</c> is poll-only and exposes no injection
|
||||
/// API, so it is intentionally not supported; when the Input System is absent these commands report an
|
||||
/// "unavailable" result rather than silently no-op'ing.</para>
|
||||
///
|
||||
/// <para><b>Safety:</b> these are write/destructive commands. The package has no safety-policy gate yet
|
||||
/// (CAT-2509 is not implemented), so they follow the existing unguarded write-command model (cf.
|
||||
/// <c>set_target_framerate</c>). TODO(CAT-2509): route input commands through the safety policy when it
|
||||
/// lands.</para>
|
||||
/// </summary>
|
||||
public static class RuntimeInputCommand
|
||||
{
|
||||
[CliCommand("simulate_key", "Simulate a keyboard key event (Input System). Drives the running app.",
|
||||
MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static InputSimulationResponse SimulateKey(
|
||||
[CliArg("key", "Input System Key name, e.g. Space, W, Enter, LeftArrow", Required = true)] string key,
|
||||
[CliArg("action", "down | up | press (down+up). Default: press")] string action = "press")
|
||||
{
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
var keyboard = Keyboard.current;
|
||||
if (keyboard == null)
|
||||
return InputSimulationResponse.Fail("simulate_key", "No keyboard device is present.");
|
||||
|
||||
if (!Enum.TryParse<Key>(key, ignoreCase: true, out var parsedKey) || parsedKey == Key.None)
|
||||
return InputSimulationResponse.Fail("simulate_key", $"Unknown key '{key}'. Use an Input System Key name (e.g. Space, W, Enter).");
|
||||
|
||||
var control = keyboard[parsedKey];
|
||||
var act = Normalize(action);
|
||||
switch (act)
|
||||
{
|
||||
case "down":
|
||||
QueueButton(control, true);
|
||||
break;
|
||||
case "up":
|
||||
QueueButton(control, false);
|
||||
break;
|
||||
case "press":
|
||||
QueueButton(control, true);
|
||||
QueueButton(control, false);
|
||||
break;
|
||||
default:
|
||||
return InputSimulationResponse.Fail("simulate_key", $"Unknown action '{action}'. Use down | up | press.");
|
||||
}
|
||||
|
||||
// Flush queued events so the effect is observable synchronously to the caller.
|
||||
InputSystem.Update();
|
||||
return InputSimulationResponse.Ok("simulate_key", $"key={parsedKey} action={act}");
|
||||
#else
|
||||
return InputSimulationResponse.Unavailable("simulate_key");
|
||||
#endif
|
||||
}
|
||||
|
||||
[CliCommand("simulate_pointer", "Simulate a mouse/pointer event at screen coordinates (Input System).",
|
||||
MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static InputSimulationResponse SimulatePointer(
|
||||
[CliArg("x", "Screen X in pixels (origin bottom-left)", Required = true)] float x,
|
||||
[CliArg("y", "Screen Y in pixels (origin bottom-left)", Required = true)] float y,
|
||||
[CliArg("action", "move | down | up | click (down+up). Default: click")] string action = "click",
|
||||
[CliArg("button", "left | right | middle. Default: left")] string button = "left")
|
||||
{
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
var mouse = Mouse.current;
|
||||
if (mouse == null)
|
||||
return InputSimulationResponse.Fail("simulate_pointer", "No mouse/pointer device is present.");
|
||||
|
||||
var position = new Vector2(x, y);
|
||||
var act = Normalize(action);
|
||||
|
||||
ButtonControl btn;
|
||||
switch (Normalize(button))
|
||||
{
|
||||
case "left": btn = mouse.leftButton; break;
|
||||
case "right": btn = mouse.rightButton; break;
|
||||
case "middle": btn = mouse.middleButton; break;
|
||||
default:
|
||||
return InputSimulationResponse.Fail("simulate_pointer", $"Unknown button '{button}'. Use left | right | middle.");
|
||||
}
|
||||
|
||||
switch (act)
|
||||
{
|
||||
case "move":
|
||||
QueuePointer(mouse, position, null, false);
|
||||
break;
|
||||
case "down":
|
||||
QueuePointer(mouse, position, btn, true);
|
||||
break;
|
||||
case "up":
|
||||
QueuePointer(mouse, position, btn, false);
|
||||
break;
|
||||
case "click":
|
||||
QueuePointer(mouse, position, btn, true);
|
||||
QueuePointer(mouse, position, btn, false);
|
||||
break;
|
||||
default:
|
||||
return InputSimulationResponse.Fail("simulate_pointer", $"Unknown action '{action}'. Use move | down | up | click.");
|
||||
}
|
||||
|
||||
InputSystem.Update();
|
||||
return InputSimulationResponse.Ok("simulate_pointer", $"pos=({x},{y}) action={act} button={Normalize(button)}");
|
||||
#else
|
||||
return InputSimulationResponse.Unavailable("simulate_pointer");
|
||||
#endif
|
||||
}
|
||||
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
// Capture the device's current state into a state event, override a single control, then queue it.
|
||||
// Capturing current state (rather than sending a zeroed full state) preserves any other controls
|
||||
// that are already held — so chained down/up calls compose correctly.
|
||||
private static void QueueButton(InputControl<float> control, bool pressed)
|
||||
{
|
||||
using (StateEvent.From(control.device, out var eventPtr))
|
||||
{
|
||||
control.WriteValueIntoEvent(pressed ? 1f : 0f, eventPtr);
|
||||
InputSystem.QueueEvent(eventPtr);
|
||||
}
|
||||
}
|
||||
|
||||
private static void QueuePointer(Mouse mouse, Vector2 position, ButtonControl button, bool pressed)
|
||||
{
|
||||
using (StateEvent.From(mouse, out var eventPtr))
|
||||
{
|
||||
mouse.position.WriteValueIntoEvent(position, eventPtr);
|
||||
if (button != null)
|
||||
button.WriteValueIntoEvent(pressed ? 1f : 0f, eventPtr);
|
||||
InputSystem.QueueEvent(eventPtr);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private static string Normalize(string s) => (s ?? string.Empty).Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structured result for an input-simulation command: whether the event was injected, and a short
|
||||
/// human-readable detail (or an error / unavailable reason).
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class InputSimulationResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Command { get; set; }
|
||||
public string Detail { get; set; }
|
||||
public string Error { get; set; }
|
||||
|
||||
public static InputSimulationResponse Ok(string command, string detail) =>
|
||||
new InputSimulationResponse { Success = true, Command = command, Detail = detail };
|
||||
|
||||
public static InputSimulationResponse Fail(string command, string error) =>
|
||||
new InputSimulationResponse { Success = false, Command = command, Error = error };
|
||||
|
||||
public static InputSimulationResponse Unavailable(string command) =>
|
||||
new InputSimulationResponse
|
||||
{
|
||||
Success = false,
|
||||
Command = command,
|
||||
Error = "Input simulation requires the Input System package to be present and active " +
|
||||
"(ENABLE_INPUT_SYSTEM). Legacy input injection is not supported."
|
||||
};
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fa9e0d957acd4b1293e4d40e83bca73f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Runtime logging commands for Unity Player builds.
|
||||
/// Provides console logging capabilities from CLI.
|
||||
/// </summary>
|
||||
public static class RuntimeLogCommand
|
||||
{
|
||||
[CliCommand("log", "Write a message to Unity console", MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static string LogMessage(
|
||||
[CliArg("message", "Message to log to console", Required = true)] string message,
|
||||
[CliArg("level", "Log level: info, warning, error")] string level = "info")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
return "Error: Message cannot be empty";
|
||||
}
|
||||
|
||||
Debug.developerConsoleVisible = false;
|
||||
var logLevel = level?.ToLowerInvariant() ?? "info";
|
||||
|
||||
switch (logLevel)
|
||||
{
|
||||
case "info":
|
||||
case "log":
|
||||
Debug.Log($"[Pipeline] {message}");
|
||||
return $"Logged info message: {message}";
|
||||
|
||||
case "warning":
|
||||
case "warn":
|
||||
Debug.LogWarning($"[Pipeline] {message}");
|
||||
return $"Logged warning message: {message}";
|
||||
|
||||
case "error":
|
||||
case "err":
|
||||
Debug.LogError($"[Pipeline] {message}");
|
||||
return $"Logged error message: {message}";
|
||||
|
||||
default:
|
||||
Debug.LogWarning($"[Pipeline] Unknown log level '{level}', defaulting to info: {message}");
|
||||
Debug.Log($"[Pipeline] {message}");
|
||||
return $"Logged message with default level: {message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8e82ea92b18fc264faf68d664212e013
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Runtime.Telemetry;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Profiling;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Runtime status information for Unity Player builds.
|
||||
/// Provides Player-appropriate status without Editor-specific APIs.
|
||||
/// </summary>
|
||||
public static class RuntimeStatusCommand
|
||||
{
|
||||
[CliCommand("runtime_status", "Get comprehensive runtime application status", MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static RuntimeStatusResponse GetRuntimeStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
return new RuntimeStatusResponse
|
||||
{
|
||||
// Application info
|
||||
UnityVersion = Application.unityVersion,
|
||||
Platform = Application.platform.ToString(),
|
||||
BuildGuid = Application.buildGUID,
|
||||
Version = Application.version,
|
||||
|
||||
// Runtime state
|
||||
IsPlaying = true, // Always true in Player builds
|
||||
TargetFrameRate = Application.targetFrameRate,
|
||||
TimeScale = Time.timeScale,
|
||||
RealTimeSinceStartup = Time.realtimeSinceStartup,
|
||||
|
||||
// Performance info
|
||||
FrameCount = Time.frameCount,
|
||||
LoadedLevelName = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name,
|
||||
LoadedLevelBuildIndex = UnityEngine.SceneManagement.SceneManager.GetActiveScene().buildIndex,
|
||||
|
||||
// Live performance telemetry (fps, frame time, memory, profiler counters)
|
||||
Performance = BuildPerformanceTelemetry(),
|
||||
|
||||
// Timestamp
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Pipeline: Runtime status command failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assemble the live performance section from two independent sources:
|
||||
/// the per-frame <see cref="FrameStatsSampler"/> (fps / frame time / profiler counters), which only
|
||||
/// exists while a <see cref="RuntimePipelineManager"/> is feeding it, and the Profiler memory APIs,
|
||||
/// which can be read on demand at any time. Frame-stat fields stay at their defaults (and
|
||||
/// <see cref="RuntimePerformanceTelemetry.FrameStatsAvailable"/> stays false) when no sampler is
|
||||
/// running — e.g. in EditMode tests — so a missing sampler degrades gracefully rather than throwing.
|
||||
/// </summary>
|
||||
private static RuntimePerformanceTelemetry BuildPerformanceTelemetry()
|
||||
{
|
||||
var perf = new RuntimePerformanceTelemetry();
|
||||
|
||||
var sampler = FrameStatsSampler.Shared;
|
||||
if (sampler != null)
|
||||
{
|
||||
var snap = sampler.GetSnapshot();
|
||||
perf.FrameStatsAvailable = snap.Available;
|
||||
perf.SampleWindow = snap.SampleWindow;
|
||||
perf.Fps = snap.Fps;
|
||||
perf.AverageFrameTimeMs = snap.AverageFrameTimeMs;
|
||||
perf.MinFrameTimeMs = snap.MinFrameTimeMs;
|
||||
perf.MaxFrameTimeMs = snap.MaxFrameTimeMs;
|
||||
perf.LastFrameTimeMs = snap.LastFrameTimeMs;
|
||||
perf.GpuTimingAvailable = snap.GpuTimingAvailable;
|
||||
perf.CpuFrameTimeMs = snap.CpuFrameTimeMs;
|
||||
perf.GpuFrameTimeMs = snap.GpuFrameTimeMs;
|
||||
perf.Counters = snap.Counters;
|
||||
}
|
||||
else
|
||||
{
|
||||
perf.Counters = new Dictionary<string, double>();
|
||||
}
|
||||
|
||||
// Memory is sampled directly (no per-frame accumulation needed). These return 0 in a
|
||||
// non-development build where the profiler is stripped, which is reported faithfully.
|
||||
perf.TotalAllocatedMemory = Profiler.GetTotalAllocatedMemoryLong();
|
||||
perf.TotalReservedMemory = Profiler.GetTotalReservedMemoryLong();
|
||||
perf.MonoUsedSize = Profiler.GetMonoUsedSizeLong();
|
||||
perf.MonoHeapSize = Profiler.GetMonoHeapSizeLong();
|
||||
perf.GcMemory = GC.GetTotalMemory(false);
|
||||
|
||||
return perf;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response model for runtime status information.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class RuntimeStatusResponse
|
||||
{
|
||||
// Application Information
|
||||
public string UnityVersion { get; set; }
|
||||
public string Platform { get; set; }
|
||||
public string BuildGuid { get; set; }
|
||||
public string Version { get; set; }
|
||||
|
||||
// Runtime State
|
||||
public bool IsPlaying { get; set; } // Always true for Player builds
|
||||
public int TargetFrameRate { get; set; }
|
||||
public float TimeScale { get; set; }
|
||||
public float RealTimeSinceStartup { get; set; }
|
||||
|
||||
// Performance Information
|
||||
public int FrameCount { get; set; }
|
||||
public string LoadedLevelName { get; set; }
|
||||
public int LoadedLevelBuildIndex { get; set; }
|
||||
|
||||
// Live performance telemetry
|
||||
public RuntimePerformanceTelemetry Performance { get; set; }
|
||||
|
||||
// Metadata
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live performance telemetry for a running Player: rolling fps / frame-time statistics, optional
|
||||
/// CPU/GPU frame timing, memory usage, and a map of profiler counters. Frame-stat and counter fields
|
||||
/// are populated from the per-frame <see cref="FrameStatsSampler"/>; memory fields are read on demand.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class RuntimePerformanceTelemetry
|
||||
{
|
||||
// Frame timing — sampled over a rolling window by the RuntimePipelineManager.
|
||||
/// <summary>False when no sampler is running; the fps / frame-time fields below are then meaningless.</summary>
|
||||
public bool FrameStatsAvailable { get; set; }
|
||||
public int SampleWindow { get; set; }
|
||||
public float Fps { get; set; }
|
||||
public float AverageFrameTimeMs { get; set; }
|
||||
public float MinFrameTimeMs { get; set; }
|
||||
public float MaxFrameTimeMs { get; set; }
|
||||
public float LastFrameTimeMs { get; set; }
|
||||
|
||||
// CPU/GPU frame timing via FrameTimingManager (not available on every platform).
|
||||
public bool GpuTimingAvailable { get; set; }
|
||||
public double CpuFrameTimeMs { get; set; }
|
||||
public double GpuFrameTimeMs { get; set; }
|
||||
|
||||
// Memory (bytes) via the Profiler API; 0 when the profiler is stripped (non-development build).
|
||||
public long TotalAllocatedMemory { get; set; }
|
||||
public long TotalReservedMemory { get; set; }
|
||||
public long MonoUsedSize { get; set; }
|
||||
public long MonoHeapSize { get; set; }
|
||||
public long GcMemory { get; set; }
|
||||
|
||||
// Profiler counters by reported name (e.g. DrawCalls, Triangles, GcAllocInFrame).
|
||||
public Dictionary<string, double> Counters { get; set; }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 846a260c41f6d8f4583f1473632c1d62
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
#if UNITY_6000_7_OR_NEWER
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
using UnityEngine.UIElements;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared, runtime-safe helpers behind the visual-element capture commands
|
||||
/// (<c>capture_editor_element</c> and <c>capture_runtime_element</c>). Lives in the runtime
|
||||
/// assembly with no <c>UnityEditor</c> dependency so both the Editor command and the
|
||||
/// player-side runtime command can use it.
|
||||
///
|
||||
/// Capture uses <see cref="VisualElementCaptureExtensions.CaptureToRenderTexture"/> (runtime
|
||||
/// module) and encodes the PNG here, rather than the editor-only
|
||||
/// <c>VisualElementCaptureEditorExtensions.CaptureToPNG</c>, so the same path works in a Player.
|
||||
///
|
||||
/// Element selection maps a small USS-like selector string onto UQuery's public
|
||||
/// <see cref="UQueryBuilder{T}"/> (the engine's USS string parser is internal). Supported:
|
||||
/// descendant (space) and child (<c>></c>) combinators; each simple part is
|
||||
/// <c>Type#name.class1.class2</c> (any subset); optional pseudo-state suffixes
|
||||
/// <c>:checked :hover :focus :active :enabled :disabled</c> and <c>:not(<state>)</c>.
|
||||
/// </summary>
|
||||
public static class VisualElementCaptureSupport
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a failure response when no GPU is available (batchmode/headless), where a capture
|
||||
/// would read back a blank image; otherwise returns null (capture may proceed).
|
||||
/// </summary>
|
||||
public static CaptureElementResponse GpuUnavailableResponse()
|
||||
{
|
||||
if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null)
|
||||
return CaptureElementResponse.Fail("No GPU available (batchmode/headless); cannot capture.");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the first <see cref="VisualElement"/> under <paramref name="root"/> matching
|
||||
/// <paramref name="selector"/>, or null if the selector is empty/invalid or nothing matches.
|
||||
/// </summary>
|
||||
public static VisualElement ResolveElement(VisualElement root, string selector)
|
||||
{
|
||||
if (root == null || string.IsNullOrWhiteSpace(selector))
|
||||
return null;
|
||||
|
||||
if (!TryBuildQuery(root, selector, out var query))
|
||||
return null;
|
||||
|
||||
return query.First();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the selector against each root in order, returning the first match (and the root
|
||||
/// it was found under via <paramref name="matchedRoot"/>), or null if none match.
|
||||
/// </summary>
|
||||
public static VisualElement ResolveElement(IEnumerable<VisualElement> roots, string selector,
|
||||
out VisualElement matchedRoot)
|
||||
{
|
||||
matchedRoot = null;
|
||||
if (roots == null)
|
||||
return null;
|
||||
|
||||
foreach (var root in roots)
|
||||
{
|
||||
var element = ResolveElement(root, selector);
|
||||
if (element != null)
|
||||
{
|
||||
matchedRoot = root;
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture <paramref name="element"/> to PNG bytes at its own pixel size. Restores the active
|
||||
/// RenderTexture and releases all temporaries so the capture leaves no global render state.
|
||||
/// Throws <see cref="InvalidOperationException"/> for camera-drawn (world-space) or detached
|
||||
/// panels (surfaced from <see cref="VisualElementCaptureExtensions.CaptureToRenderTexture"/>).
|
||||
/// </summary>
|
||||
public static byte[] CaptureElementToPng(VisualElement element, out int width, out int height)
|
||||
{
|
||||
if (element == null)
|
||||
throw new ArgumentNullException(nameof(element));
|
||||
|
||||
var rt = element.CaptureToRenderTexture();
|
||||
var prevActive = RenderTexture.active;
|
||||
Texture2D tex = null;
|
||||
try
|
||||
{
|
||||
width = rt.width;
|
||||
height = rt.height;
|
||||
|
||||
RenderTexture.active = rt;
|
||||
tex = new Texture2D(rt.width, rt.height, TextureFormat.RGBA32, false);
|
||||
tex.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0, false);
|
||||
tex.Apply(false, false);
|
||||
return tex.EncodeToPNG();
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderTexture.active = prevActive;
|
||||
rt.Release();
|
||||
DestroyObject(rt);
|
||||
if (tex != null)
|
||||
DestroyObject(tex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the output path. An explicit rooted path is used as-is; an explicit relative path
|
||||
/// is resolved against <paramref name="relativeBaseDir"/>; an empty path produces a
|
||||
/// timestamped <c><prefix>_yyyyMMdd_HHmmss_fff.png</c> file under
|
||||
/// <paramref name="defaultDir"/>.
|
||||
/// </summary>
|
||||
public static string ResolveOutputPath(string output, string relativeBaseDir, string defaultDir, string prefix)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return Path.IsPathRooted(output)
|
||||
? output
|
||||
: Path.GetFullPath(Path.Combine(relativeBaseDir, output));
|
||||
}
|
||||
|
||||
var stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff", CultureInfo.InvariantCulture);
|
||||
return Path.Combine(defaultDir, $"{prefix}_{stamp}.png");
|
||||
}
|
||||
|
||||
/// <summary>Write PNG bytes to <paramref name="path"/>, creating the directory if needed.</summary>
|
||||
public static void WritePng(string path, byte[] png)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllBytes(path, png);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture the resolved <paramref name="element"/>, write the PNG to disk, and build a
|
||||
/// populated success response (or a failure response if the panel cannot be captured). This
|
||||
/// is the shared tail both commands run once they have an element + a panel root.
|
||||
/// </summary>
|
||||
public static CaptureElementResponse CaptureAndRespond(VisualElement element, string selector,
|
||||
string source, string output, string relativeBaseDir, string defaultDir, string prefix)
|
||||
{
|
||||
var gpuGuard = GpuUnavailableResponse();
|
||||
if (gpuGuard != null)
|
||||
return gpuGuard;
|
||||
|
||||
try
|
||||
{
|
||||
var png = CaptureElementToPng(element, out var width, out var height);
|
||||
var path = ResolveOutputPath(output, relativeBaseDir, defaultDir, prefix);
|
||||
WritePng(path, png);
|
||||
|
||||
return new CaptureElementResponse
|
||||
{
|
||||
Success = true,
|
||||
Selector = selector,
|
||||
Source = source,
|
||||
Path = path,
|
||||
Width = width,
|
||||
Height = height,
|
||||
Encoding = "png",
|
||||
Base64 = Convert.ToBase64String(png),
|
||||
Bytes = png.Length,
|
||||
Message = $"Captured {source} to {path}"
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// The capture API throws this for camera-drawn (world-space) or detached panels.
|
||||
return CaptureElementResponse.Fail(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CaptureElementResponse.Fail($"Failed to capture '{selector}' from {source}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Selector parsing -------------------------------------------------------------------
|
||||
|
||||
// Build a UQuery from the selector, mapping tokens onto the public UQueryBuilder fluent API.
|
||||
static bool TryBuildQuery(VisualElement root, string selector, out UQueryBuilder<VisualElement> query)
|
||||
{
|
||||
query = root.Query();
|
||||
|
||||
var parts = Tokenize(selector);
|
||||
if (parts.Count == 0)
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var (isChild, partStr) = parts[i];
|
||||
if (!TryParsePart(partStr, out var part))
|
||||
return false;
|
||||
|
||||
if (i == 0)
|
||||
query = query.Name(part.Name);
|
||||
else
|
||||
query = isChild
|
||||
? query.Children<VisualElement>(part.Name)
|
||||
: query.Descendents<VisualElement>(part.Name);
|
||||
|
||||
foreach (var cls in part.Classes)
|
||||
query = query.Class(cls);
|
||||
|
||||
if (part.Type != null)
|
||||
{
|
||||
var typeName = part.Type;
|
||||
query = query.Where(e => TypeNameMatches(e, typeName));
|
||||
}
|
||||
|
||||
foreach (var pseudo in part.Pseudos)
|
||||
{
|
||||
if (!TryApplyPseudo(ref query, pseudo.Name, pseudo.Negate))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Split a selector into simple parts, flagging each with whether it follows a '>' (child)
|
||||
// combinator. Whitespace separates descendant parts; '>' is treated as its own token.
|
||||
static List<(bool isChild, string part)> Tokenize(string selector)
|
||||
{
|
||||
var result = new List<(bool, string)>();
|
||||
var raw = selector.Replace(">", " > ")
|
||||
.Split((char[])null, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
bool nextIsChild = false;
|
||||
foreach (var token in raw)
|
||||
{
|
||||
if (token == ">")
|
||||
{
|
||||
nextIsChild = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add((nextIsChild, token));
|
||||
nextIsChild = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
class SelectorPart
|
||||
{
|
||||
public string Type;
|
||||
public string Name;
|
||||
public readonly List<string> Classes = new List<string>();
|
||||
public readonly List<(bool Negate, string Name)> Pseudos = new List<(bool, string)>();
|
||||
}
|
||||
|
||||
// Parse "Type#name.class1.class2" with optional ":pseudo" / ":not(pseudo)" suffixes.
|
||||
static bool TryParsePart(string raw, out SelectorPart part)
|
||||
{
|
||||
part = new SelectorPart();
|
||||
|
||||
var simple = ExtractPseudos(raw, part.Pseudos);
|
||||
if (simple == null)
|
||||
return false;
|
||||
|
||||
int i = 0;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Leading run (before any '#' or '.') is the type token.
|
||||
while (i < simple.Length && simple[i] != '#' && simple[i] != '.')
|
||||
sb.Append(simple[i++]);
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
var type = sb.ToString();
|
||||
if (type != "*")
|
||||
part.Type = type;
|
||||
}
|
||||
|
||||
while (i < simple.Length)
|
||||
{
|
||||
char kind = simple[i++];
|
||||
sb.Clear();
|
||||
while (i < simple.Length && simple[i] != '#' && simple[i] != '.')
|
||||
sb.Append(simple[i++]);
|
||||
|
||||
var value = sb.ToString();
|
||||
if (value.Length == 0)
|
||||
return false; // dangling '#' or '.'
|
||||
|
||||
if (kind == '#')
|
||||
{
|
||||
if (part.Name != null)
|
||||
return false; // more than one id
|
||||
part.Name = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
part.Classes.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Strip ":pseudo" and ":not(pseudo)" suffixes into 'pseudos', returning the simple-selector
|
||||
// remainder, or null if a pseudo is malformed.
|
||||
static string ExtractPseudos(string raw, List<(bool Negate, string Name)> pseudos)
|
||||
{
|
||||
int idx;
|
||||
while ((idx = raw.IndexOf(':')) >= 0)
|
||||
{
|
||||
var head = raw.Substring(0, idx);
|
||||
var rest = raw.Substring(idx + 1);
|
||||
|
||||
if (rest.StartsWith("not(", StringComparison.Ordinal))
|
||||
{
|
||||
int close = rest.IndexOf(')');
|
||||
if (close < 0)
|
||||
return null;
|
||||
var inner = rest.Substring(4, close - 4).Trim().TrimStart(':');
|
||||
if (inner.Length == 0)
|
||||
return null;
|
||||
pseudos.Add((true, inner.ToLowerInvariant()));
|
||||
raw = head + rest.Substring(close + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
int next = rest.IndexOf(':');
|
||||
var name = next < 0 ? rest : rest.Substring(0, next);
|
||||
if (name.Length == 0)
|
||||
return null;
|
||||
pseudos.Add((false, name.ToLowerInvariant()));
|
||||
raw = head + (next < 0 ? string.Empty : rest.Substring(next));
|
||||
}
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
// Apply one pseudo-state to the query, honoring negation. Returns false for an unknown state.
|
||||
static bool TryApplyPseudo(ref UQueryBuilder<VisualElement> query, string name, bool negate)
|
||||
{
|
||||
bool on = !negate;
|
||||
switch (name)
|
||||
{
|
||||
case "checked": query = on ? query.Checked() : query.NotChecked(); return true;
|
||||
case "hover": query = on ? query.Hovered() : query.NotHovered(); return true;
|
||||
case "focus": query = on ? query.Focused() : query.NotFocused(); return true;
|
||||
case "active": query = on ? query.Active() : query.NotActive(); return true;
|
||||
case "enabled": query = on ? query.Enabled() : query.NotEnabled(); return true;
|
||||
case "disabled": query = on ? query.NotEnabled() : query.Enabled(); return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Match a type token against the element's runtime type name or any of its base type names,
|
||||
// so "Button" matches a Button and "VisualElement" matches everything. Avoids reflecting the
|
||||
// generic OfType<T> over a runtime-resolved Type.
|
||||
static bool TypeNameMatches(VisualElement element, string typeName)
|
||||
{
|
||||
for (var t = element.GetType(); t != null && t != typeof(object); t = t.BaseType)
|
||||
{
|
||||
if (string.Equals(t.Name, typeName, StringComparison.Ordinal))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void DestroyObject(Object obj)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
Object.DestroyImmediate(obj);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
Object.Destroy(obj);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a visual-element capture command: the PNG payload (base64), its dimensions, the
|
||||
/// panel/source it was rendered from, and the path it was written to. JSON property names mirror
|
||||
/// the GameView capture command's result shape (PR #13) for easy convergence later.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class CaptureElementResponse
|
||||
{
|
||||
/// <summary>Whether the capture succeeded.</summary>
|
||||
[JsonProperty("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Human-readable message (the error text on failure).</summary>
|
||||
[JsonProperty("message")]
|
||||
public string Message { get; set; }
|
||||
|
||||
/// <summary>The selector used to locate the element.</summary>
|
||||
[JsonProperty("selector")]
|
||||
public string Selector { get; set; }
|
||||
|
||||
/// <summary>What was captured, e.g. "window:Inspector" or "panel:MainUI".</summary>
|
||||
[JsonProperty("source")]
|
||||
public string Source { get; set; }
|
||||
|
||||
/// <summary>Absolute filesystem path the PNG was written to.</summary>
|
||||
[JsonProperty("path")]
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>Captured width in pixels.</summary>
|
||||
[JsonProperty("width")]
|
||||
public int Width { get; set; }
|
||||
|
||||
/// <summary>Captured height in pixels.</summary>
|
||||
[JsonProperty("height")]
|
||||
public int Height { get; set; }
|
||||
|
||||
/// <summary>Image encoding; always "png".</summary>
|
||||
[JsonProperty("encoding")]
|
||||
public string Encoding { get; set; }
|
||||
|
||||
/// <summary>Base64-encoded PNG bytes.</summary>
|
||||
[JsonProperty("base64")]
|
||||
public string Base64 { get; set; }
|
||||
|
||||
/// <summary>Length of the raw PNG byte array.</summary>
|
||||
[JsonProperty("bytes")]
|
||||
public int Bytes { get; set; }
|
||||
|
||||
public static CaptureElementResponse Fail(string message) => new CaptureElementResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3e2225ea88f5b5a4c9be3afc9c5d9f4e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c9f4a11d82a4dd54d93200f6c3aa4678
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+1177
File diff suppressed because it is too large
Load Diff
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b6905de2a6949fc40bea9d708751b0fe
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
|
||||
namespace Unity.Pipeline.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Attribute to mark parameters of CLI command methods.
|
||||
/// Provides parameter metadata for CLI validation and help generation.
|
||||
/// Adapted from unity-tools [Parameter] attribute for Pipeline organization requirements.
|
||||
///
|
||||
/// Also valid on the fields/properties of an <see cref="IStructuredCommandInput"/> DTO, where it
|
||||
/// describes a member of a nested object schema (same Name/Description/Required semantics).
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)]
|
||||
public class CliArgAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the parameter as it appears in CLI arguments.
|
||||
/// Used in "unity request command_name --param_name value" syntax.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of the parameter.
|
||||
/// Used in CLI help text and parameter documentation.
|
||||
/// </summary>
|
||||
public string Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this parameter is required for command execution.
|
||||
/// Default: false (parameter is optional).
|
||||
/// </summary>
|
||||
public bool Required { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Default value for the parameter if not provided.
|
||||
/// Must be compatible with the parameter type.
|
||||
/// </summary>
|
||||
public object DefaultValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a new CLI parameter attribute.
|
||||
/// </summary>
|
||||
/// <param name="name">Parameter name for CLI arguments</param>
|
||||
/// <param name="description">Human-readable description</param>
|
||||
public CliArgAttribute(string name, string description)
|
||||
{
|
||||
Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
Description = description ?? throw new ArgumentNullException(nameof(description));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ac8886d9f7e92d844b4f1728ce2e2cab
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
|
||||
namespace Unity.Pipeline.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Attribute to mark static methods as CLI-accessible commands.
|
||||
/// Commands with this attribute can be executed remotely via Pipeline Server.
|
||||
/// Adapted from unity-tools [Tool] attribute for Pipeline organization requirements.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
|
||||
public class CliCommandAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique name of the command for CLI execution.
|
||||
/// Used in "unity request command_name" syntax.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of what the command does.
|
||||
/// Used in CLI help text and command discovery.
|
||||
/// </summary>
|
||||
public string Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this command requires Unity main thread execution.
|
||||
/// Default: true (most Unity APIs require main thread).
|
||||
/// </summary>
|
||||
public bool MainThreadRequired { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this command belongs to the runtime (Player) command surface only.
|
||||
/// Runtime-only commands are hidden from an Editor server's command listing
|
||||
/// (they remain executable, but are not advertised when connected to an Editor).
|
||||
/// Default: false (listed on the Editor server).
|
||||
/// </summary>
|
||||
public bool RuntimeOnly { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new CLI command attribute.
|
||||
/// </summary>
|
||||
/// <param name="name">Unique command name for CLI execution</param>
|
||||
/// <param name="description">Human-readable description</param>
|
||||
public CliCommandAttribute(string name, string description)
|
||||
{
|
||||
Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
Description = description ?? throw new ArgumentNullException(nameof(description));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee4da62af1984f442af8ada30f3eee83
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,64 @@
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
|
||||
namespace Unity.Pipeline.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Information about a discovered CLI command.
|
||||
/// Contains metadata needed for command execution and CLI help generation.
|
||||
/// </summary>
|
||||
public class CommandInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique name of the command for CLI execution.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of the command.
|
||||
/// </summary>
|
||||
public string Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this command requires Unity main thread execution.
|
||||
/// </summary>
|
||||
public bool MainThreadRequired { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this command is part of the runtime (Player) command surface only.
|
||||
/// Editor servers hide runtime-only commands from their command listing.
|
||||
/// </summary>
|
||||
public bool RuntimeOnly { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Method that implements this command.
|
||||
/// </summary>
|
||||
public MethodInfo Method { get; } // TODO Maybe look at generating a dynamic Delegate to increase performance of the command call.
|
||||
|
||||
/// <summary>
|
||||
/// Parameters that this command accepts.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CommandParameterInfo> Parameters { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Create command information from discovery.
|
||||
/// </summary>
|
||||
public CommandInfo(string name, string description, bool mainThreadRequired,
|
||||
MethodInfo method, IReadOnlyList<CommandParameterInfo> parameters, bool runtimeOnly = false)
|
||||
{
|
||||
Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
Description = description ?? throw new ArgumentNullException(nameof(description));
|
||||
Method = method ?? throw new ArgumentNullException(nameof(method));
|
||||
Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters));
|
||||
MainThreadRequired = mainThreadRequired;
|
||||
RuntimeOnly = runtimeOnly;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{Name} MainThreadRequired:{MainThreadRequired} Parameters:{Parameters.Count}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 40290d720d0d35b44a9fa5843a5ee294
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,258 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Unity.Pipeline.HotReload;
|
||||
using UnityEngine;
|
||||
#if UNITY_6000_3_OR_NEWER
|
||||
using UnityEngine.Assemblies;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry for discovering and managing CLI commands via pluggable discovery mechanism.
|
||||
/// Scans for methods marked with [CliCommand] attribute across all assemblies.
|
||||
/// Based on unity-tools ToolRegistry patterns adapted for Pipeline requirements.
|
||||
/// </summary>
|
||||
public static class CommandRegistry
|
||||
{
|
||||
private static IReadOnlyList<CommandInfo> m_CachedCommands;
|
||||
private static ICommandDiscovery m_Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Set the command discovery mechanism.
|
||||
/// Editor assembly provides TypeCache-based discovery, Runtime uses reflection fallback.
|
||||
/// </summary>
|
||||
public static void SetDiscovery(ICommandDiscovery discovery)
|
||||
{
|
||||
m_Discovery = discovery;
|
||||
ClearCache(); // Re-discover with new mechanism
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discover all CLI commands marked with [CliCommand] attribute.
|
||||
/// Uses injected discovery mechanism (TypeCache in Editor, reflection in Runtime).
|
||||
/// Results are cached until domain reload.
|
||||
/// </summary>
|
||||
public static IEnumerable<CommandInfo> DiscoverCommands()
|
||||
{
|
||||
if (m_CachedCommands == null)
|
||||
{
|
||||
m_CachedCommands = DiscoverCommandsInternal().ToList();
|
||||
|
||||
// Also discover hot reload methods when commands are discovered
|
||||
DiscoverHotReloadMethods();
|
||||
}
|
||||
|
||||
return m_CachedCommands;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear the command cache. Called automatically on domain reload.
|
||||
/// Can be called manually for testing or dynamic command registration.
|
||||
/// </summary>
|
||||
public static void ClearCache()
|
||||
{
|
||||
m_CachedCommands = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal implementation of command discovery using injected discovery mechanism.
|
||||
/// </summary>
|
||||
private static IEnumerable<CommandInfo> DiscoverCommandsInternal()
|
||||
{
|
||||
var commands = new List<CommandInfo>();
|
||||
|
||||
try
|
||||
{
|
||||
// Use injected discovery mechanism if available, otherwise fallback to reflection
|
||||
IEnumerable<MethodInfo> methods;
|
||||
|
||||
if (m_Discovery != null)
|
||||
{
|
||||
methods = m_Discovery.GetMethodsWithAttribute<CliCommandAttribute>();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: Use reflection to scan loaded assemblies
|
||||
methods = GetMethodsWithAttributeViaReflection<CliCommandAttribute>();
|
||||
}
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
try
|
||||
{
|
||||
var commandInfo = CreateCommandInfo(method);
|
||||
if (commandInfo != null)
|
||||
{
|
||||
commands.Add(commandInfo);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"Failed to register command from method {method.DeclaringType?.Name}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Failed to discover commands: {ex.Message}");
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback command discovery using reflection when TypeCache is not available.
|
||||
/// </summary>
|
||||
private static IEnumerable<MethodInfo> GetMethodsWithAttributeViaReflection<T>() where T : Attribute
|
||||
{
|
||||
var methods = new List<MethodInfo>();
|
||||
|
||||
try
|
||||
{
|
||||
var assemblies = PipelineUtils.GetLoadedAssemblies();
|
||||
|
||||
foreach (var assembly in assemblies)
|
||||
{
|
||||
// Skip system assemblies for performance
|
||||
if (IsSystemAssembly(assembly))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
foreach (var method in type.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
|
||||
{
|
||||
if (method.GetCustomAttribute<T>() != null)
|
||||
{
|
||||
methods.Add(method);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (ReflectionTypeLoadException)
|
||||
{
|
||||
// Skip assemblies that can't be loaded
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"Reflection-based command discovery failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return methods;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if assembly is a system assembly that should be skipped during discovery.
|
||||
/// </summary>
|
||||
private static bool IsSystemAssembly(Assembly assembly)
|
||||
{
|
||||
var name = assembly.GetName().Name;
|
||||
return name.StartsWith("System.") ||
|
||||
name.StartsWith("Microsoft.") ||
|
||||
name.StartsWith("mscorlib") ||
|
||||
name.StartsWith("netstandard") ||
|
||||
name.Equals("UnityEngine") ||
|
||||
name.Equals("UnityEditor");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create CommandInfo from a method with CliCommand attribute.
|
||||
/// </summary>
|
||||
private static CommandInfo CreateCommandInfo(MethodInfo method)
|
||||
{
|
||||
var commandAttr = method.GetCustomAttribute<CliCommandAttribute>();
|
||||
if (commandAttr == null)
|
||||
return null;
|
||||
|
||||
// Validate method is static (required for CLI commands).
|
||||
// Any static method may be registered regardless of accessibility
|
||||
// (public, internal, or private) — invocation goes through MethodInfo.Invoke.
|
||||
if (!method.IsStatic)
|
||||
{
|
||||
Debug.LogWarning($"Command method {method.DeclaringType?.Name}.{method.Name} must be static");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Discover parameters
|
||||
var parameters = DiscoverParameters(method).ToList();
|
||||
|
||||
return new CommandInfo(
|
||||
commandAttr.Name,
|
||||
commandAttr.Description,
|
||||
commandAttr.MainThreadRequired,
|
||||
method,
|
||||
parameters,
|
||||
commandAttr.RuntimeOnly
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discover parameter information from method parameters.
|
||||
/// </summary>
|
||||
private static IEnumerable<CommandParameterInfo> DiscoverParameters(MethodInfo method)
|
||||
{
|
||||
foreach (var param in method.GetParameters())
|
||||
{
|
||||
var argAttr = param.GetCustomAttribute<CliArgAttribute>();
|
||||
|
||||
// Parameters without CliArg attribute get default metadata
|
||||
var name = argAttr?.Name ?? param.Name;
|
||||
var description = argAttr?.Description ?? $"Parameter: {param.Name}";
|
||||
var required = argAttr?.Required ?? !param.HasDefaultValue;
|
||||
var defaultValue = param.HasDefaultValue ? param.DefaultValue : argAttr?.DefaultValue;
|
||||
|
||||
yield return new CommandParameterInfo(name, description, required, param.ParameterType, defaultValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discover and register methods marked with [HotReloadWithOverrides] attribute.
|
||||
/// Called during command discovery to populate the hot reload registry.
|
||||
/// </summary>
|
||||
private static void DiscoverHotReloadMethods()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use injected discovery mechanism if available, otherwise fallback to reflection
|
||||
IEnumerable<MethodInfo> methods;
|
||||
|
||||
if (m_Discovery != null)
|
||||
{
|
||||
methods = m_Discovery.GetMethodsWithAttribute<HotReloadWithOverridesAttribute>();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: Use reflection to scan loaded assemblies
|
||||
methods = GetMethodsWithAttributeViaReflection<HotReloadWithOverridesAttribute>();
|
||||
}
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hotReloadAttr = method.GetCustomAttribute<HotReloadWithOverridesAttribute>();
|
||||
if (hotReloadAttr != null)
|
||||
{
|
||||
HotReloadRegistry.RegisterReloadableMethod(method, hotReloadAttr);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"Failed to register hot reload method {method.DeclaringType?.Name}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Failed to discover hot reload methods: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c53b0ba553311e14c833aadafdb44999
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,200 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Threading
|
||||
{
|
||||
/// <summary>
|
||||
/// Dispatches work to Unity's main thread from background threads (required for accessing Unity
|
||||
/// APIs from HTTP request handlers).
|
||||
///
|
||||
/// Each pipeline server owns its own instance (no global singleton): it is initialized on Start
|
||||
/// and pumped from the main thread — auto-pumped via EditorApplication.update in the editor, and
|
||||
/// by RuntimePipelineManager.Update in a player.
|
||||
/// </summary>
|
||||
public class Dispatcher
|
||||
{
|
||||
private readonly ConcurrentQueue<WorkItem> m_WorkQueue = new ConcurrentQueue<WorkItem>();
|
||||
private volatile bool m_IsInitialized;
|
||||
private int m_MainThreadId = -1;
|
||||
|
||||
public bool IsInitialized => m_IsInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the dispatcher. Must be called from Unity's main thread.
|
||||
/// </summary>
|
||||
public void Initialize()
|
||||
{
|
||||
if (m_IsInitialized)
|
||||
return;
|
||||
|
||||
m_MainThreadId = Thread.CurrentThread.ManagedThreadId;
|
||||
m_IsInitialized = true;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
UnityEditor.EditorApplication.update += ProcessWorkQueue;
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shutdown the dispatcher and cancel any pending work.
|
||||
/// </summary>
|
||||
public void Shutdown()
|
||||
{
|
||||
if (!m_IsInitialized)
|
||||
return;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
UnityEditor.EditorApplication.update -= ProcessWorkQueue;
|
||||
#endif
|
||||
|
||||
while (m_WorkQueue.TryDequeue(out var item))
|
||||
{
|
||||
try
|
||||
{
|
||||
item.SetException(new OperationCanceledException("Dispatcher is shutting down"));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
m_IsInitialized = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute a function on the main thread and return the result (synchronous wait).
|
||||
/// </summary>
|
||||
public T Invoke<T>(Func<T> function, int timeoutMs = 60000)
|
||||
{
|
||||
if (!m_IsInitialized)
|
||||
throw new InvalidOperationException("Dispatcher must be initialized first");
|
||||
|
||||
if (IsMainThread())
|
||||
return function();
|
||||
|
||||
var workItem = new WorkItem<T>(function);
|
||||
m_WorkQueue.Enqueue(workItem);
|
||||
|
||||
var startTime = DateTime.UtcNow;
|
||||
var task = workItem.TaskCompletionSource.Task;
|
||||
|
||||
while (!task.IsCompleted)
|
||||
{
|
||||
if ((DateTime.UtcNow - startTime).TotalMilliseconds > timeoutMs)
|
||||
throw new TimeoutException($"Main thread operation timed out after {timeoutMs}ms");
|
||||
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
|
||||
if (task.IsFaulted)
|
||||
throw task.Exception?.GetBaseException() ?? new Exception("Unknown error");
|
||||
|
||||
if (task.IsCanceled)
|
||||
throw new OperationCanceledException("Main thread operation was cancelled");
|
||||
|
||||
return task.Result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute an action on the main thread.
|
||||
/// </summary>
|
||||
public void Invoke(Action action, int timeoutMs = 60000)
|
||||
{
|
||||
Invoke<object>(() =>
|
||||
{
|
||||
action();
|
||||
return null;
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute a function on the main thread and return the result (async version).
|
||||
/// </summary>
|
||||
public async Task<T> InvokeAsync<T>(Func<T> function, int timeoutMs = 60000)
|
||||
{
|
||||
return await Task.Run(() => Invoke(function, timeoutMs));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute an action on the main thread (async version).
|
||||
/// </summary>
|
||||
public async Task InvokeAsync(Action action, int timeoutMs = 60000)
|
||||
{
|
||||
await Task.Run(() => Invoke(action, timeoutMs));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if we're currently on Unity's main thread.
|
||||
/// </summary>
|
||||
public bool IsMainThread()
|
||||
{
|
||||
return m_MainThreadId != -1 && Thread.CurrentThread.ManagedThreadId == m_MainThreadId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process queued work items. Called from EditorApplication.update or MonoBehaviour.Update.
|
||||
/// </summary>
|
||||
/// <param name="maxItemsPerFrame">Max items to process per call, to limit frame-rate impact.</param>
|
||||
public void ProcessWorkQueue(int maxItemsPerFrame)
|
||||
{
|
||||
int processedCount = 0;
|
||||
|
||||
while (processedCount < maxItemsPerFrame && m_WorkQueue.TryDequeue(out var workItem))
|
||||
{
|
||||
try
|
||||
{
|
||||
workItem.Execute();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Dispatcher work item failed: {ex.Message}");
|
||||
workItem.SetException(ex);
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
public void ProcessWorkQueue()
|
||||
{
|
||||
ProcessWorkQueue(10);
|
||||
}
|
||||
|
||||
private abstract class WorkItem
|
||||
{
|
||||
public abstract void Execute();
|
||||
public abstract void SetException(Exception exception);
|
||||
}
|
||||
|
||||
private class WorkItem<T> : WorkItem
|
||||
{
|
||||
private readonly Func<T> m_Function;
|
||||
public TaskCompletionSource<T> TaskCompletionSource { get; }
|
||||
|
||||
public WorkItem(Func<T> function)
|
||||
{
|
||||
m_Function = function ?? throw new ArgumentNullException(nameof(function));
|
||||
TaskCompletionSource = new TaskCompletionSource<T>();
|
||||
}
|
||||
|
||||
public override void Execute()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = m_Function();
|
||||
TaskCompletionSource.SetResult(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TaskCompletionSource.SetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public override void SetException(Exception exception)
|
||||
{
|
||||
TaskCompletionSource.SetException(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df7819f4aeb94b2408fd309361f67243
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using UnityEngine;
|
||||
using Debug = UnityEngine.Debug;
|
||||
|
||||
namespace Unity.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Restricts a file so that only the user who started the process can read or write it.
|
||||
/// Used to protect the instance descriptor (port file), which carries the auth token.
|
||||
///
|
||||
/// The managed ACL APIs (System.Security.AccessControl) are not part of Unity's runtime
|
||||
/// profile, so Windows is handled via icacls and Unix via libc chmod.
|
||||
/// </summary>
|
||||
public static class FilePermissions
|
||||
{
|
||||
/// <summary>
|
||||
/// Restrict the file at <paramref name="path"/> to the current user only.
|
||||
/// Failures are logged but never thrown, so they cannot block server startup.
|
||||
/// </summary>
|
||||
public static void RestrictToCurrentUser(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (Application.platform)
|
||||
{
|
||||
case RuntimePlatform.WindowsEditor:
|
||||
case RuntimePlatform.WindowsPlayer:
|
||||
RestrictWindows(path);
|
||||
break;
|
||||
default:
|
||||
RestrictUnix(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"Pipeline: Could not restrict permissions on '{path}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RestrictWindows(string path)
|
||||
{
|
||||
// Grant by SID rather than account name: AzureAD/Entra-joined accounts (e.g.
|
||||
// "AzureAD\\user") have no name-to-SID mapping for icacls, but the SID always resolves.
|
||||
var sid = GetCurrentUserSid();
|
||||
if (string.IsNullOrEmpty(sid))
|
||||
{
|
||||
Debug.LogWarning($"Pipeline: Could not resolve current user SID; leaving default permissions on '{path}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
// /inheritance:r drops inherited ACEs; /grant:r replaces the DACL with a single
|
||||
// full-control grant to the current user.
|
||||
Run("icacls", $"\"{path}\" /inheritance:r /grant:r \"*{sid}:(F)\"", out _);
|
||||
}
|
||||
|
||||
private static string GetCurrentUserSid()
|
||||
{
|
||||
if (!Run("whoami", "/user /fo csv /nh", out var output) || string.IsNullOrEmpty(output))
|
||||
return null;
|
||||
|
||||
// Output looks like: "domain\user","S-1-5-21-..."
|
||||
var idx = output.IndexOf("S-1-", StringComparison.Ordinal);
|
||||
if (idx < 0)
|
||||
return null;
|
||||
|
||||
var end = idx;
|
||||
while (end < output.Length && (char.IsLetterOrDigit(output[end]) || output[end] == '-'))
|
||||
end++;
|
||||
|
||||
return output.Substring(idx, end - idx);
|
||||
}
|
||||
|
||||
private static bool Run(string fileName, string arguments, out string stdout)
|
||||
{
|
||||
stdout = null;
|
||||
var psi = new ProcessStartInfo(fileName, arguments)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
|
||||
using (var process = Process.Start(psi))
|
||||
{
|
||||
if (process == null)
|
||||
return false;
|
||||
|
||||
// Read before WaitForExit to avoid a full-pipe deadlock.
|
||||
stdout = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("libc", SetLastError = true)]
|
||||
private static extern int chmod(string pathname, uint mode);
|
||||
|
||||
private static void RestrictUnix(string path)
|
||||
{
|
||||
// 0600 (octal) == owner read/write only.
|
||||
const uint ownerReadWrite = 0x180;
|
||||
chmod(path, ownerReadWrite);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e310dee2532338d4e914d3c909ea4db0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
|
||||
namespace Unity.Pipeline.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for command discovery mechanism.
|
||||
/// Allows Runtime assembly to discover commands without directly using Editor APIs.
|
||||
/// </summary>
|
||||
public interface ICommandDiscovery
|
||||
{
|
||||
/// <summary>
|
||||
/// Find all methods marked with CliCommand attribute.
|
||||
/// Implementation uses TypeCache in Editor or reflection fallback in Runtime.
|
||||
/// </summary>
|
||||
IEnumerable<MethodInfo> GetMethodsWithAttribute<T>() where T : System.Attribute;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c6e1392c576ec4d4d9b389d5fc899303
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
namespace Unity.Pipeline.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Marker for a command parameter type that should be advertised to clients as a nested JSON
|
||||
/// <em>object</em> schema in <c>GET /api/commands</c>, instead of collapsing to <c>string</c>.
|
||||
///
|
||||
/// Convention for command authors: when a command needs a structured (multi-field) argument,
|
||||
/// declare a small DTO that implements this interface and take it as a single parameter. The
|
||||
/// <see cref="JsonSchemaGenerator"/> reflects over the type's public, writable fields and
|
||||
/// properties to emit <c>{ "type": "object", "properties": { ... } }</c> (recursing into nested
|
||||
/// <see cref="IStructuredCommandInput"/> members and arrays/lists of them). At runtime the value
|
||||
/// deserializes automatically via Newtonsoft in
|
||||
/// <c>BasePipelineServer.ExtractCommandParameters</c>, so no extra wiring is needed.
|
||||
///
|
||||
/// Member metadata:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Annotate members with <c>[CliArg(name, description, Required = ...)]</c> to control the
|
||||
/// property name, description, and whether it appears in the schema's <c>required</c> array
|
||||
/// (mirrors how command parameters are described).</description></item>
|
||||
/// <item><description>Without a <c>[CliArg]</c>, the member name (or its Newtonsoft <c>[JsonProperty]</c> name)
|
||||
/// is used and the member is optional.</description></item>
|
||||
/// <item><description><c>[JsonIgnore]</c> members are omitted from the schema.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public interface IStructuredCommandInput
|
||||
{
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 366b2704c3b5c044aa544fe545d462f0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Unity.Pipeline.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates JSON Schema for CLI commands to enable parameter validation and help generation.
|
||||
/// Produces standard JSON Schema format that CLI tools can use for validation and auto-completion.
|
||||
///
|
||||
/// Primitive, enum, and array parameters map directly. Parameters whose type implements
|
||||
/// <see cref="IStructuredCommandInput"/> are emitted as nested object schemas (recursing into
|
||||
/// structured members and arrays/lists of them) rather than collapsing to <c>string</c>, so
|
||||
/// agents calling the package — directly or as MCP tools whose schemas come from
|
||||
/// <c>GET /api/commands</c> — can construct structured arguments reliably.
|
||||
/// </summary>
|
||||
public static class JsonSchemaGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate JSON Schema for a command.
|
||||
/// Returns standard JSON Schema format for CLI validation.
|
||||
/// </summary>
|
||||
public static string GenerateCommandSchema(CommandInfo command)
|
||||
{
|
||||
if (command == null)
|
||||
throw new ArgumentNullException(nameof(command));
|
||||
|
||||
var schema = new JObject
|
||||
{
|
||||
["$schema"] = "http://json-schema.org/draft-07/schema#",
|
||||
["type"] = "object",
|
||||
["title"] = command.Name,
|
||||
["description"] = command.Description,
|
||||
["properties"] = GenerateParameterProperties(command.Parameters),
|
||||
["required"] = new JArray(command.Parameters.Where(p => p.Required).Select(p => p.Name)),
|
||||
["additionalProperties"] = false
|
||||
};
|
||||
|
||||
// Add command metadata for CLI tools
|
||||
schema["x-command-metadata"] = new JObject
|
||||
{
|
||||
["mainThreadRequired"] = command.MainThreadRequired,
|
||||
["methodName"] = $"{command.Method.DeclaringType?.FullName}.{command.Method.Name}"
|
||||
};
|
||||
|
||||
return schema.ToString(Formatting.Indented);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate properties section of JSON Schema from command parameters.
|
||||
/// </summary>
|
||||
private static JObject GenerateParameterProperties(IEnumerable<CommandParameterInfo> parameters)
|
||||
{
|
||||
var properties = new JObject();
|
||||
|
||||
foreach (var parameter in parameters)
|
||||
{
|
||||
properties[parameter.Name] = GenerateParameterSchema(parameter);
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate JSON Schema for a single top-level command parameter.
|
||||
/// </summary>
|
||||
private static JObject GenerateParameterSchema(CommandParameterInfo parameter)
|
||||
{
|
||||
var schema = GenerateTypeSchema(parameter.ParameterType, new HashSet<Type>());
|
||||
schema["description"] = parameter.Description;
|
||||
|
||||
// Add default value if present (skip for objects/arrays whose default is typically null)
|
||||
if (parameter.DefaultValue != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
schema["default"] = JToken.FromObject(parameter.DefaultValue);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Non-serializable default; omit rather than fail schema generation.
|
||||
}
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the schema fragment for a CLR type: scalar/enum, array/list, or — for
|
||||
/// <see cref="IStructuredCommandInput"/> types — a nested object schema. <paramref name="visited"/>
|
||||
/// tracks structured types currently being expanded so self-referential graphs don't recurse
|
||||
/// forever.
|
||||
/// </summary>
|
||||
private static JObject GenerateTypeSchema(Type type, HashSet<Type> visited)
|
||||
{
|
||||
// Unwrap Nullable<T> so e.g. int? schemas as "integer".
|
||||
var underlying = Nullable.GetUnderlyingType(type);
|
||||
if (underlying != null)
|
||||
type = underlying;
|
||||
|
||||
if (typeof(IStructuredCommandInput).IsAssignableFrom(type) && !type.IsAbstract && !type.IsInterface)
|
||||
return GenerateObjectSchema(type, visited);
|
||||
|
||||
if (TryGetEnumerableElementType(type, out var elementType))
|
||||
{
|
||||
return new JObject
|
||||
{
|
||||
["type"] = "array",
|
||||
["items"] = GenerateTypeSchema(elementType, visited)
|
||||
};
|
||||
}
|
||||
|
||||
var schema = new JObject();
|
||||
var (jsonType, format) = MapTypeToJsonSchema(type);
|
||||
schema["type"] = jsonType;
|
||||
|
||||
if (!string.IsNullOrEmpty(format))
|
||||
schema["format"] = format;
|
||||
|
||||
if (type.IsEnum)
|
||||
schema["enum"] = new JArray(Enum.GetNames(type));
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit a nested object schema for an <see cref="IStructuredCommandInput"/> type by reflecting
|
||||
/// its public, writable fields and properties.
|
||||
/// </summary>
|
||||
private static JObject GenerateObjectSchema(Type type, HashSet<Type> visited)
|
||||
{
|
||||
var schema = new JObject { ["type"] = "object" };
|
||||
|
||||
// Cycle guard: if we're already expanding this type higher in the stack, stop recursing
|
||||
// and emit an open object rather than looping.
|
||||
if (!visited.Add(type))
|
||||
{
|
||||
schema["additionalProperties"] = true;
|
||||
return schema;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var properties = new JObject();
|
||||
var required = new JArray();
|
||||
|
||||
foreach (var member in GetSchemaMembers(type))
|
||||
{
|
||||
var memberSchema = GenerateTypeSchema(member.Type, visited);
|
||||
if (!string.IsNullOrEmpty(member.Description))
|
||||
memberSchema["description"] = member.Description;
|
||||
|
||||
properties[member.Name] = memberSchema;
|
||||
if (member.Required)
|
||||
required.Add(member.Name);
|
||||
}
|
||||
|
||||
schema["properties"] = properties;
|
||||
if (required.Count > 0)
|
||||
schema["required"] = required;
|
||||
schema["additionalProperties"] = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
visited.Remove(type);
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerate the schema-bearing members of a structured-input type: public instance fields and
|
||||
/// read/write properties. Honors <c>[JsonIgnore]</c>, and reads name/description/required from
|
||||
/// <c>[CliArg]</c> (falling back to a Newtonsoft <c>[JsonProperty]</c> name, then the member name).
|
||||
/// </summary>
|
||||
private static IEnumerable<SchemaMember> GetSchemaMembers(Type type)
|
||||
{
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance;
|
||||
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (field.IsInitOnly || field.IsLiteral)
|
||||
continue;
|
||||
if (field.GetCustomAttribute<JsonIgnoreAttribute>() != null)
|
||||
continue;
|
||||
|
||||
yield return ToSchemaMember(field, field.FieldType);
|
||||
}
|
||||
|
||||
foreach (var property in type.GetProperties(flags))
|
||||
{
|
||||
if (!property.CanRead || !property.CanWrite)
|
||||
continue;
|
||||
if (property.GetIndexParameters().Length > 0)
|
||||
continue;
|
||||
if (property.GetCustomAttribute<JsonIgnoreAttribute>() != null)
|
||||
continue;
|
||||
|
||||
yield return ToSchemaMember(property, property.PropertyType);
|
||||
}
|
||||
}
|
||||
|
||||
private static SchemaMember ToSchemaMember(MemberInfo member, Type memberType)
|
||||
{
|
||||
var cliArg = member.GetCustomAttribute<CliArgAttribute>();
|
||||
var jsonProperty = member.GetCustomAttribute<JsonPropertyAttribute>();
|
||||
|
||||
return new SchemaMember
|
||||
{
|
||||
Name = cliArg?.Name ?? jsonProperty?.PropertyName ?? member.Name,
|
||||
Type = memberType,
|
||||
Description = cliArg?.Description,
|
||||
Required = cliArg?.Required ?? false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect a JSON-array-shaped type (arrays and common generic collections) and yield its
|
||||
/// element type. <c>string</c> is intentionally excluded (it is <see cref="IEnumerable{T}"/>
|
||||
/// of char but maps to a JSON string).
|
||||
/// </summary>
|
||||
private static bool TryGetEnumerableElementType(Type type, out Type elementType)
|
||||
{
|
||||
elementType = null;
|
||||
|
||||
if (type == typeof(string))
|
||||
return false;
|
||||
|
||||
if (type.IsArray)
|
||||
{
|
||||
elementType = type.GetElementType();
|
||||
return elementType != null;
|
||||
}
|
||||
|
||||
if (type.IsGenericType)
|
||||
{
|
||||
var definition = type.GetGenericTypeDefinition();
|
||||
if (definition == typeof(List<>) || definition == typeof(IList<>) ||
|
||||
definition == typeof(IEnumerable<>) || definition == typeof(ICollection<>) ||
|
||||
definition == typeof(IReadOnlyList<>) || definition == typeof(IReadOnlyCollection<>))
|
||||
{
|
||||
elementType = type.GetGenericArguments()[0];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a scalar/enum C# type to JSON Schema type and format. Object and array shapes are
|
||||
/// handled by <see cref="GenerateTypeSchema"/> before this is reached; anything else falls
|
||||
/// back to <c>string</c>.
|
||||
/// </summary>
|
||||
private static (string type, string format) MapTypeToJsonSchema(Type csharpType)
|
||||
{
|
||||
// Handle nullable types
|
||||
if (Nullable.GetUnderlyingType(csharpType) != null)
|
||||
{
|
||||
csharpType = Nullable.GetUnderlyingType(csharpType);
|
||||
}
|
||||
|
||||
// String types
|
||||
if (csharpType == typeof(string) || csharpType == typeof(char))
|
||||
{
|
||||
return ("string", null);
|
||||
}
|
||||
|
||||
// Integer types
|
||||
if (csharpType == typeof(int) || csharpType == typeof(long) ||
|
||||
csharpType == typeof(short) || csharpType == typeof(byte) ||
|
||||
csharpType == typeof(uint) || csharpType == typeof(ulong) ||
|
||||
csharpType == typeof(ushort) || csharpType == typeof(sbyte))
|
||||
{
|
||||
return ("integer", null);
|
||||
}
|
||||
|
||||
// Number types
|
||||
if (csharpType == typeof(float) || csharpType == typeof(double) || csharpType == typeof(decimal))
|
||||
{
|
||||
return ("number", null);
|
||||
}
|
||||
|
||||
// Boolean type
|
||||
if (csharpType == typeof(bool))
|
||||
{
|
||||
return ("boolean", null);
|
||||
}
|
||||
|
||||
// Enum types - represent as string with enum constraint (added by the caller)
|
||||
if (csharpType.IsEnum)
|
||||
{
|
||||
return ("string", null);
|
||||
}
|
||||
|
||||
// DateTime types
|
||||
if (csharpType == typeof(DateTime) || csharpType == typeof(DateTimeOffset))
|
||||
{
|
||||
return ("string", "date-time");
|
||||
}
|
||||
|
||||
// Guid type
|
||||
if (csharpType == typeof(Guid))
|
||||
{
|
||||
return ("string", "uuid");
|
||||
}
|
||||
|
||||
// Default to string for unknown types
|
||||
return ("string", null);
|
||||
}
|
||||
|
||||
/// <summary>A reflected member of a structured-input type, flattened for schema emission.</summary>
|
||||
private struct SchemaMember
|
||||
{
|
||||
public string Name;
|
||||
public Type Type;
|
||||
public string Description;
|
||||
public bool Required;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 779098faca184884f9f588e41c4c0898
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Unity.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-only stream wrapper that enforces a maximum number of bytes read from an inner stream.
|
||||
/// Once cumulative reads exceed <c>maxBytes</c>, the next read throws
|
||||
/// <see cref="RequestTooLargeException"/>. Used to cap HTTP request bodies even when the
|
||||
/// Content-Length header is absent or untruthful (e.g. chunked transfer-encoding), so an
|
||||
/// oversized body cannot be buffered into memory.
|
||||
/// </summary>
|
||||
internal sealed class MaxLengthStream : Stream
|
||||
{
|
||||
private readonly Stream m_Inner;
|
||||
private readonly long m_MaxBytes;
|
||||
private readonly bool m_LeaveOpen;
|
||||
private long m_TotalRead;
|
||||
|
||||
/// <param name="leaveOpen">
|
||||
/// When true, disposing this wrapper does not dispose <paramref name="inner"/>. The server
|
||||
/// uses this so it can keep reading (draining) the raw request stream after the wrapper is
|
||||
/// disposed on an over-limit read.
|
||||
/// </param>
|
||||
public MaxLengthStream(Stream inner, long maxBytes, bool leaveOpen = false)
|
||||
{
|
||||
m_Inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
m_MaxBytes = maxBytes;
|
||||
m_LeaveOpen = leaveOpen;
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
var read = m_Inner.Read(buffer, offset, count);
|
||||
Account(read);
|
||||
return read;
|
||||
}
|
||||
|
||||
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
var read = await m_Inner.ReadAsync(buffer, offset, count, cancellationToken);
|
||||
Account(read);
|
||||
return read;
|
||||
}
|
||||
|
||||
// Throw as soon as the running total exceeds the cap. Exactly maxBytes is allowed.
|
||||
private void Account(int read)
|
||||
{
|
||||
if (read <= 0)
|
||||
return;
|
||||
|
||||
m_TotalRead += read;
|
||||
if (m_TotalRead > m_MaxBytes)
|
||||
throw new RequestTooLargeException(m_MaxBytes);
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position
|
||||
{
|
||||
get => m_TotalRead;
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override void Flush() { }
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
// Own the inner stream's lifetime (matching the previous `using (StreamReader(InputStream))`)
|
||||
// unless the caller asked to leave it open so it can keep reading from it afterwards.
|
||||
if (disposing && !m_LeaveOpen)
|
||||
m_Inner.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="MaxLengthStream"/> when a read would exceed the configured byte cap.
|
||||
/// </summary>
|
||||
internal sealed class RequestTooLargeException : Exception
|
||||
{
|
||||
public long MaxBytes { get; }
|
||||
|
||||
public RequestTooLargeException(long maxBytes)
|
||||
: base($"Request body exceeds the maximum allowed size of {maxBytes} bytes.")
|
||||
{
|
||||
MaxBytes = maxBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d75fa6089aa06114c9450387a6d816ad
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
|
||||
namespace Unity.Pipeline.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Information about a CLI command parameter.
|
||||
/// Contains metadata needed for parameter validation and CLI help generation.
|
||||
/// </summary>
|
||||
public class CommandParameterInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the parameter for CLI arguments.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of the parameter.
|
||||
/// </summary>
|
||||
public string Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this parameter is required for command execution.
|
||||
/// </summary>
|
||||
public bool Required { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of the parameter for validation and conversion.
|
||||
/// </summary>
|
||||
public Type ParameterType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Default value for optional parameters.
|
||||
/// </summary>
|
||||
public object DefaultValue { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Create parameter information from discovery.
|
||||
/// </summary>
|
||||
public CommandParameterInfo(string name, string description, bool required,
|
||||
Type parameterType, object defaultValue = null)
|
||||
{
|
||||
Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
Description = description ?? throw new ArgumentNullException(nameof(description));
|
||||
ParameterType = parameterType ?? throw new ArgumentNullException(nameof(parameterType));
|
||||
Required = required;
|
||||
DefaultValue = defaultValue;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{Name} Required:{Required} Type:{ParameterType}";
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7127497e92677024f949772c30aaf79b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
#if UNITY_6000_3_OR_NEWER
|
||||
using UnityEngine.Assemblies;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Version-stable handle to a Unity object id. In 6000.4 Unity replaced the int instance id with
|
||||
/// <see cref="EntityId"/> (a ulong-backed handle), made <c>Object.GetInstanceID()</c> /
|
||||
/// <c>EditorUtility.InstanceIDToObject(int)</c> obsolete-as-error, and is removing the
|
||||
/// <c>EntityId</c><-><c>int</c> conversions. This struct stores the concrete id type for the
|
||||
/// running editor — <see cref="EntityId"/> on 6000.4+, <c>int</c> below — and converts implicitly to
|
||||
/// it so it can be passed straight to Unity APIs. All version branching for object ids is confined
|
||||
/// here. It (de)serializes as a single JSON integer (the raw ulong on 6000.4+, the int below).
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(ObjectIdConverter))]
|
||||
public readonly struct ObjectId : System.IEquatable<ObjectId>
|
||||
{
|
||||
#if UNITY_6000_4_OR_NEWER
|
||||
readonly EntityId m_Value;
|
||||
public ObjectId(EntityId value) { m_Value = value; }
|
||||
public static implicit operator EntityId(ObjectId id) => id.m_Value;
|
||||
public static implicit operator ObjectId(EntityId value) => new ObjectId(value);
|
||||
/// <summary>Raw 64-bit id — the canonical wire / serialization form.</summary>
|
||||
public ulong RawValue => EntityId.ToULong(m_Value);
|
||||
public static ObjectId FromRaw(ulong raw) => new ObjectId(EntityId.FromULong(raw));
|
||||
public bool Equals(ObjectId other) => m_Value.Equals(other.m_Value);
|
||||
public override int GetHashCode() => m_Value.GetHashCode();
|
||||
#else
|
||||
readonly int m_Value;
|
||||
public ObjectId(int value) { m_Value = value; }
|
||||
public static implicit operator int(ObjectId id) => id.m_Value;
|
||||
public static implicit operator ObjectId(int value) => new ObjectId(value);
|
||||
/// <summary>Raw id — the canonical wire / serialization form.</summary>
|
||||
public long RawValue => m_Value;
|
||||
public static ObjectId FromRaw(long raw) => new ObjectId((int)raw);
|
||||
public bool Equals(ObjectId other) => m_Value == other.m_Value;
|
||||
public override int GetHashCode() => m_Value;
|
||||
#endif
|
||||
public override bool Equals(object obj) => obj is ObjectId other && Equals(other);
|
||||
public override string ToString() => RawValue.ToString();
|
||||
|
||||
/// <summary>Parse the canonical numeric form produced by <see cref="ToString"/>.</summary>
|
||||
public static ObjectId Parse(string s)
|
||||
{
|
||||
#if UNITY_6000_4_OR_NEWER
|
||||
return FromRaw(ulong.Parse(s));
|
||||
#else
|
||||
return FromRaw(long.Parse(s));
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>Try to parse the canonical numeric form produced by <see cref="ToString"/>.</summary>
|
||||
public static bool TryParse(string s, out ObjectId id)
|
||||
{
|
||||
#if UNITY_6000_4_OR_NEWER
|
||||
if (ulong.TryParse(s, out var raw)) { id = FromRaw(raw); return true; }
|
||||
#else
|
||||
if (long.TryParse(s, out var raw)) { id = FromRaw(raw); return true; }
|
||||
#endif
|
||||
id = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>JSON converter: an <see cref="ObjectId"/> is a single integer on the wire.</summary>
|
||||
public sealed class ObjectIdConverter : JsonConverter<ObjectId>
|
||||
{
|
||||
public override void WriteJson(JsonWriter writer, ObjectId value, JsonSerializer serializer)
|
||||
{
|
||||
writer.WriteValue(value.RawValue);
|
||||
}
|
||||
|
||||
public override ObjectId ReadJson(JsonReader reader, System.Type objectType, ObjectId existingValue,
|
||||
bool hasExistingValue, JsonSerializer serializer)
|
||||
{
|
||||
var token = JToken.Load(reader);
|
||||
switch (token.Type)
|
||||
{
|
||||
case JTokenType.Integer:
|
||||
#if UNITY_6000_4_OR_NEWER
|
||||
return ObjectId.FromRaw(token.Value<ulong>());
|
||||
#else
|
||||
return ObjectId.FromRaw(token.Value<long>());
|
||||
#endif
|
||||
case JTokenType.String:
|
||||
return ObjectId.Parse((string)token);
|
||||
default:
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class PipelineUtils
|
||||
{
|
||||
public static string GetLoadedAssemblyPath(System.Reflection.Assembly a)
|
||||
{
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
return a.GetLoadedAssemblyPath();
|
||||
#else
|
||||
return a.Location;
|
||||
#endif
|
||||
}
|
||||
|
||||
public static System.Reflection.Assembly LoadFromBytes(byte[] bytes, byte[] pdb = null)
|
||||
{
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
return UnityEngine.Assemblies.CurrentAssemblies.LoadFromBytes(bytes, pdb);
|
||||
#else
|
||||
return System.Reflection.Assembly.Load(bytes, pdb);
|
||||
#endif
|
||||
}
|
||||
|
||||
public static T[] FindObjectsByType<T>() where T : Object
|
||||
{
|
||||
#if UNITY_6000_4_OR_NEWER
|
||||
return GameObject.FindObjectsByType<T>();
|
||||
#else
|
||||
return GameObject.FindObjectsByType<T>(FindObjectsSortMode.None) ;
|
||||
#endif
|
||||
}
|
||||
|
||||
public static IReadOnlyList<System.Reflection.Assembly> GetLoadedAssemblies()
|
||||
{
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
return CurrentAssemblies.GetLoadedAssemblies();
|
||||
#else
|
||||
return System.AppDomain.CurrentDomain.GetAssemblies();
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>The object's id as a version-stable <see cref="ObjectId"/> (EntityId on 6000.4+, int below).</summary>
|
||||
public static ObjectId GetObjectId(Object obj)
|
||||
{
|
||||
#if UNITY_6000_4_OR_NEWER
|
||||
return new ObjectId(obj.GetEntityId());
|
||||
#else
|
||||
return new ObjectId(obj.GetInstanceID());
|
||||
#endif
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
/// <summary>Resolve a loaded/scene object from its <see cref="ObjectId"/>, or null if not found.</summary>
|
||||
public static Object IdToObject(ObjectId id)
|
||||
{
|
||||
#if UNITY_6000_4_OR_NEWER
|
||||
return UnityEditor.EditorUtility.EntityIdToObject(id);
|
||||
#elif UNITY_6000_3_OR_NEWER
|
||||
// EntityIdToObject is the non-obsolete resolver from 6000.3, but the ulong-backed ObjectId
|
||||
// (GetRawData/FromULong) doesn't exist until 6000.4 — so ObjectId is still int-backed here and
|
||||
// we route the int through the (non-obsolete on 6.3) int->EntityId conversion.
|
||||
return UnityEditor.EditorUtility.EntityIdToObject((int)id);
|
||||
#else
|
||||
return UnityEditor.EditorUtility.InstanceIDToObject((int)id);
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e6c6a6cac05ac41438850c53ea3178b1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 257ad47737473da48b7569484405904e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Compilation
|
||||
{
|
||||
/// <summary>
|
||||
/// C# code evaluation service using shared Roslyn compilation infrastructure.
|
||||
/// Wraps user code in Execute() method, compiles, and executes with result serialization.
|
||||
/// Supports both Editor and Runtime compilation for desktop development builds.
|
||||
/// </summary>
|
||||
public static class EvalCodeCompiler
|
||||
{
|
||||
#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG)
|
||||
|
||||
// Fixed lines in the generated source before user code begins
|
||||
private const int BaseCodeLineOffset = 11;
|
||||
|
||||
/// <summary>
|
||||
/// Compile and execute code on Unity's main thread using Roslyn.
|
||||
/// Eval is a MainThreadRequired command, so the server marshals the caller to the main
|
||||
/// thread before invoking this; compilation + execution run synchronously here.
|
||||
/// </summary>
|
||||
public static EvalResponse CompileAndExecuteOnMainThread(string code, int timeoutMs, System.Diagnostics.Stopwatch stopwatch)
|
||||
{
|
||||
if (stopwatch == null)
|
||||
{
|
||||
stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Compile on current thread
|
||||
var compilationResult = CompileCodeToAssembly(code, timeoutMs, stopwatch);
|
||||
|
||||
if (!compilationResult.Success)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return EvalResponse.EvalFailure(
|
||||
"Compilation Failed",
|
||||
"Code compilation failed",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
compilationResult.Diagnostics);
|
||||
}
|
||||
|
||||
// Execute on current thread
|
||||
var executionResult = ExecuteCompiledAssembly(compilationResult.Assembly);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (executionResult.Success)
|
||||
{
|
||||
return EvalResponse.EvalSuccess(
|
||||
executionResult.ReturnValue,
|
||||
executionResult.Output,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
return EvalResponse.EvalFailure(
|
||||
executionResult.Error,
|
||||
executionResult.ErrorDetails,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return EvalResponse.EvalFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compile C# code to assembly using shared RoslynCompilationService.
|
||||
/// Thread-safe and can run on background thread.
|
||||
/// </summary>
|
||||
private static CompilationResult CompileCodeToAssembly(string userCode, int timeoutMs, System.Diagnostics.Stopwatch stopwatch)
|
||||
{
|
||||
var id = Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
|
||||
// Generate wrapper code
|
||||
var sourceCode = BuildSourceCode(id, userCode, Application.isEditor);
|
||||
|
||||
// Use shared compilation service
|
||||
var request = new CompilationRequest
|
||||
{
|
||||
SourceCode = sourceCode,
|
||||
AssemblyName = $"PipelineEval_{id}",
|
||||
LineNumberOffset = BaseCodeLineOffset // Adjust diagnostics for wrapper code
|
||||
};
|
||||
|
||||
var result = RoslynCompilationService.Compile(request);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// Add execution context for eval-specific needs
|
||||
return new CompilationResult
|
||||
{
|
||||
Success = true,
|
||||
Assembly = result.Assembly,
|
||||
ExecutionContext = new ExecutionContext
|
||||
{
|
||||
AssemblyId = id,
|
||||
TypeName = $"PipelineEvaluation.PipelineEval_{id}",
|
||||
MethodName = "Execute"
|
||||
},
|
||||
Diagnostics = result.Diagnostics
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return new CompilationResult
|
||||
{
|
||||
Success = false,
|
||||
Diagnostics = result.Diagnostics
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate C# source code wrapper for user code with conditional editor support.
|
||||
/// </summary>
|
||||
private static string BuildSourceCode(string id, string userCode, bool isEditorContext)
|
||||
{
|
||||
var editorUsing = isEditorContext ? "using UnityEditor;" : "";
|
||||
|
||||
return $@"using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
{editorUsing}
|
||||
|
||||
namespace PipelineEvaluation
|
||||
{{
|
||||
public static class PipelineEval_{id}
|
||||
{{
|
||||
public static object Execute()
|
||||
{{
|
||||
{userCode}
|
||||
return null;
|
||||
}}
|
||||
}}
|
||||
}}";
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Execute compiled assembly and return result.
|
||||
/// Must run on Unity's main thread for safe Unity API access.
|
||||
/// </summary>
|
||||
private static ExecutionResult ExecuteCompiledAssembly(Assembly assembly)
|
||||
{
|
||||
try
|
||||
{
|
||||
var executionContext = GetExecutionContextFromAssembly(assembly);
|
||||
|
||||
var type = assembly.GetType(executionContext.TypeName);
|
||||
var method = type?.GetMethod(executionContext.MethodName, BindingFlags.Public | BindingFlags.Static);
|
||||
|
||||
if (type == null)
|
||||
{
|
||||
return new ExecutionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Assembly Load Error",
|
||||
ErrorDetails = $"Could not find type {executionContext.TypeName} in compiled assembly"
|
||||
};
|
||||
}
|
||||
|
||||
if (method == null)
|
||||
{
|
||||
return new ExecutionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Method Not Found",
|
||||
ErrorDetails = $"Could not find method {executionContext.MethodName} in compiled type"
|
||||
};
|
||||
}
|
||||
|
||||
var result = method.Invoke(null, null);
|
||||
|
||||
return new ExecutionResult
|
||||
{
|
||||
Success = true,
|
||||
ReturnValue = SerializeResult(result)
|
||||
};
|
||||
}
|
||||
catch (TargetInvocationException tie) when (tie.InnerException != null)
|
||||
{
|
||||
return new ExecutionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Runtime Error",
|
||||
ErrorDetails = tie.InnerException.Message,
|
||||
StackTrace = tie.InnerException.StackTrace
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ExecutionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Execution Error",
|
||||
ErrorDetails = ex.Message,
|
||||
StackTrace = ex.StackTrace
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get execution context from compiled assembly.
|
||||
/// </summary>
|
||||
private static ExecutionContext GetExecutionContextFromAssembly(Assembly assembly)
|
||||
{
|
||||
// Find PipelineEvaluation types
|
||||
var evalTypes = assembly.GetTypes()
|
||||
.Where(t => t.Namespace == "PipelineEvaluation" && t.Name.StartsWith("PipelineEval_"))
|
||||
.ToList();
|
||||
|
||||
if (evalTypes.Any())
|
||||
{
|
||||
var evalType = evalTypes.First();
|
||||
return new ExecutionContext
|
||||
{
|
||||
TypeName = evalType.FullName,
|
||||
MethodName = "Execute"
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return new ExecutionContext
|
||||
{
|
||||
TypeName = "PipelineEvaluation.PipelineEval_Unknown",
|
||||
MethodName = "Execute"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize result for JSON response.
|
||||
/// </summary>
|
||||
private static object SerializeResult(object value)
|
||||
{
|
||||
if (value == null) return null;
|
||||
|
||||
// Handle primitive types directly
|
||||
if (value is string || value is bool || value is int || value is long || value is float || value is double)
|
||||
return value;
|
||||
|
||||
// Handle Unity objects
|
||||
if (value is UnityEngine.Object unityObj)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Newtonsoft.Json.JsonConvert.DeserializeObject(UnityEngine.JsonUtility.ToJson(unityObj));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other objects via JSON serialization
|
||||
try
|
||||
{
|
||||
return Newtonsoft.Json.JsonConvert.DeserializeObject(
|
||||
Newtonsoft.Json.JsonConvert.SerializeObject(value));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from compilation process.
|
||||
/// </summary>
|
||||
private class CompilationResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public Assembly Assembly { get; set; }
|
||||
public ExecutionContext ExecutionContext { get; set; }
|
||||
public List<DiagnosticInfo> Diagnostics { get; set; } = new List<DiagnosticInfo>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from code execution.
|
||||
/// </summary>
|
||||
private class ExecutionResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public object ReturnValue { get; set; }
|
||||
public string Output { get; set; }
|
||||
public string Error { get; set; }
|
||||
public string ErrorDetails { get; set; }
|
||||
public string StackTrace { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execution context for compiled assembly.
|
||||
/// </summary>
|
||||
private class ExecutionContext
|
||||
{
|
||||
public string AssemblyId { get; set; }
|
||||
public string TypeName { get; set; }
|
||||
public string MethodName { get; set; }
|
||||
}
|
||||
|
||||
#else
|
||||
/// <summary>
|
||||
/// Runtime compilation not supported on this platform.
|
||||
/// Desktop development builds only (Windows/Mac/Linux).
|
||||
/// </summary>
|
||||
public static EvalResponse CompileAndExecuteOnMainThread(string code, int timeoutMs, System.Diagnostics.Stopwatch stopwatch)
|
||||
{
|
||||
return EvalResponse.EvalFailure(
|
||||
"Platform Not Supported",
|
||||
"Runtime code compilation only supported on Desktop development builds");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1ea6a44edd711194a968a40f1eb16f59
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+804
@@ -0,0 +1,804 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Unity.Pipeline.HotReload;
|
||||
using Unity.Pipeline.Models;
|
||||
using Unity.Pipeline.Threading;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Compilation
|
||||
{
|
||||
/// <summary>
|
||||
/// Compiles hot reload source files using shared RoslynCompilationService.
|
||||
/// Creates versioned DLLs and manages hot reload assembly lifecycle.
|
||||
/// Fixed deadlock issue by adding main thread detection like EvalCodeCompiler.
|
||||
/// </summary>
|
||||
public static class HotReloadCompiler
|
||||
{
|
||||
#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG)
|
||||
|
||||
private static readonly Dictionary<string, int> _versionTracker = new();
|
||||
private const string HotReloadTempDir = "Temp/HotReload";
|
||||
|
||||
/// <summary>
|
||||
/// Compile a hot reload source file and apply the overrides immediately.
|
||||
/// Fixed deadlock by detecting main thread and running synchronously when needed.
|
||||
/// </summary>
|
||||
/// <param name="filename">Hot reload file to compile</param>
|
||||
/// <param name="assemblyDir">Optional directory to save compiled assembly to disk. If null, assembly stays in memory only.</param>
|
||||
public static Task<HotReloadCompileResult> CompileAndApplyAsync(string filename, string assemblyDir = null)
|
||||
{
|
||||
// Hot reload commands are MainThreadRequired, so we are already on the main thread.
|
||||
// Run synchronously (no background thread / dispatcher). Returns a completed Task for
|
||||
// signature compatibility.
|
||||
return Task.FromResult(CompileAndApplyOnMainThread(filename, assemblyDir));
|
||||
}
|
||||
|
||||
public static string GetHotReloadPath(string filePath)
|
||||
{
|
||||
if (Path.IsPathRooted(filePath))
|
||||
return filePath;
|
||||
|
||||
// Relative paths are resolved against the current working directory (the Unity
|
||||
// project root). Override files can live in any folder; there is no special
|
||||
// "HotReload" location.
|
||||
return Path.GetFullPath(filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compile and apply on main thread synchronously to avoid deadlocks.
|
||||
/// </summary>
|
||||
/// <param name="filename">Hot reload file to compile</param>
|
||||
/// <param name="assemblyDir">Optional directory to save compiled assembly to disk. If null, assembly stays in memory only.</param>
|
||||
public static HotReloadCompileResult CompileAndApplyOnMainThread(string filename, string assemblyDir = null)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Resolve the override file path (absolute, or relative to the project root).
|
||||
var hotReloadPath = GetHotReloadPath(filename);
|
||||
if (!File.Exists(hotReloadPath))
|
||||
{
|
||||
return HotReloadCompileResult.Failure(
|
||||
"File Not Found",
|
||||
$"Hot reload file not found: {hotReloadPath}",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
Debug.Log($"HotReload: Starting synchronous compilation of {filename}");
|
||||
|
||||
// Read source code
|
||||
var sourceCode = File.ReadAllText(hotReloadPath);
|
||||
if (string.IsNullOrWhiteSpace(sourceCode))
|
||||
{
|
||||
return HotReloadCompileResult.Failure(
|
||||
"Empty File",
|
||||
$"Hot reload file is empty: {hotReloadPath}",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
// Generate versioned assembly name
|
||||
var baseFilename = Path.GetFileNameWithoutExtension(filename);
|
||||
var nextVersion = GetNextVersion(baseFilename);
|
||||
var assemblyName = $"{baseFilename}_{nextVersion:D3}";
|
||||
|
||||
// Ensure temp directory exists
|
||||
Directory.CreateDirectory(HotReloadTempDir);
|
||||
|
||||
// Compile using shared RoslynCompilationService
|
||||
var compilationResult = CompileHotReloadAssembly(sourceCode, assemblyName);
|
||||
|
||||
if (!compilationResult.Success)
|
||||
{
|
||||
return HotReloadCompileResult.Failure(
|
||||
"Compilation Failed",
|
||||
"Hot reload file compilation failed",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
compilationResult.Diagnostics.Select(d => d.Message).ToList());
|
||||
}
|
||||
|
||||
// Register hot reload methods from compiled assembly
|
||||
var registeredMethods = RegisterHotReloadMethods(compilationResult.Assembly, assemblyName, out var skippedOverrides);
|
||||
|
||||
// Save assembly to disk if assemblyDir is specified
|
||||
string actualAssemblyPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(assemblyDir))
|
||||
{
|
||||
Directory.CreateDirectory(assemblyDir);
|
||||
actualAssemblyPath = Path.Combine(assemblyDir, $"{assemblyName}.dll");
|
||||
File.WriteAllBytes(actualAssemblyPath, compilationResult.AssemblyBytes);
|
||||
Debug.Log($"HotReload: Assembly saved to disk: {actualAssemblyPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
actualAssemblyPath = Path.Combine(HotReloadTempDir, $"{assemblyName}.dll"); // For compatibility (in-memory)
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
Debug.Log($"HotReload: Successfully compiled {filename} -> {assemblyName}.dll with {registeredMethods.Count} methods in {stopwatch.ElapsedMilliseconds}ms");
|
||||
|
||||
var compileResult = HotReloadCompileResult.Success(
|
||||
assemblyName,
|
||||
actualAssemblyPath,
|
||||
registeredMethods,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
compileResult.Diagnostics = skippedOverrides;
|
||||
return compileResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"HotReload: Synchronous compilation failed for {filename}: {ex.Message}");
|
||||
return HotReloadCompileResult.Failure(
|
||||
"Exception",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal async compilation implementation for background threads.
|
||||
/// </summary>
|
||||
/// <param name="filename">Hot reload file to compile</param>
|
||||
/// <param name="assemblyDir">Optional directory to save compiled assembly to disk. If null, assembly stays in memory only.</param>
|
||||
private static async Task<HotReloadCompileResult> CompileAndApplyInternalAsync(string filename, string assemblyDir = null)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Resolve the override file path (absolute, or relative to the project root).
|
||||
var hotReloadPath = GetHotReloadPath(filename);
|
||||
if (!File.Exists(hotReloadPath))
|
||||
{
|
||||
return HotReloadCompileResult.Failure(
|
||||
"File Not Found",
|
||||
$"Hot reload file not found: {hotReloadPath}",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
Debug.Log($"HotReload: Starting async compilation of {filename}");
|
||||
|
||||
// Read source code
|
||||
var sourceCode = File.ReadAllText(hotReloadPath);
|
||||
if (string.IsNullOrWhiteSpace(sourceCode))
|
||||
{
|
||||
return HotReloadCompileResult.Failure(
|
||||
"Empty File",
|
||||
$"Hot reload file is empty: {hotReloadPath}",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
// Generate versioned assembly name
|
||||
var baseFilename = Path.GetFileNameWithoutExtension(filename);
|
||||
var nextVersion = GetNextVersion(baseFilename);
|
||||
var assemblyName = $"{baseFilename}_{nextVersion:D3}";
|
||||
|
||||
// Ensure temp directory exists
|
||||
Directory.CreateDirectory(HotReloadTempDir);
|
||||
|
||||
// Compile using shared RoslynCompilationService async
|
||||
var compilationResult = await CompileHotReloadAssemblyAsync(sourceCode, assemblyName);
|
||||
|
||||
if (!compilationResult.Success)
|
||||
{
|
||||
return HotReloadCompileResult.Failure(
|
||||
"Compilation Failed",
|
||||
"Hot reload file compilation failed",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
compilationResult.Diagnostics.Select(d => d.Message).ToList());
|
||||
}
|
||||
|
||||
// Register hot reload methods from compiled assembly
|
||||
var registeredMethods = RegisterHotReloadMethods(compilationResult.Assembly, assemblyName, out var skippedOverrides);
|
||||
|
||||
// Save assembly to disk if assemblyDir is specified
|
||||
string actualAssemblyPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(assemblyDir))
|
||||
{
|
||||
Directory.CreateDirectory(assemblyDir);
|
||||
actualAssemblyPath = Path.Combine(assemblyDir, $"{assemblyName}.dll");
|
||||
File.WriteAllBytes(actualAssemblyPath, compilationResult.AssemblyBytes);
|
||||
Debug.Log($"HotReload: Assembly saved to disk: {actualAssemblyPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
actualAssemblyPath = Path.Combine(HotReloadTempDir, $"{assemblyName}.dll"); // For compatibility (in-memory)
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
Debug.Log($"HotReload: Successfully compiled {filename} -> {assemblyName}.dll with {registeredMethods.Count} methods in {stopwatch.ElapsedMilliseconds}ms");
|
||||
|
||||
var compileResult = HotReloadCompileResult.Success(
|
||||
assemblyName,
|
||||
actualAssemblyPath,
|
||||
registeredMethods,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
compileResult.Diagnostics = skippedOverrides;
|
||||
return compileResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"HotReload: Async compilation failed for {filename}: {ex.Message}");
|
||||
return HotReloadCompileResult.Failure(
|
||||
"Exception",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compile hot reload source code directly (for in-place editing workflow).
|
||||
/// </summary>
|
||||
/// <param name="sourceCode">Hot reload source code to compile</param>
|
||||
/// <param name="baseFileName">Base filename for versioned assembly naming</param>
|
||||
/// <param name="assemblyDir">Optional directory to save compiled assembly to disk</param>
|
||||
public static Task<HotReloadCompilationResult> CompileSourceCodeAsync(string sourceCode, string baseFileName, string assemblyDir = null, bool emitPdb = false, string documentPath = null)
|
||||
{
|
||||
// Runs synchronously on the calling (main) thread; returns a completed Task.
|
||||
return Task.FromResult(CompileSourceCodeOnMainThread(sourceCode, baseFileName, assemblyDir, emitPdb, documentPath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compile source code on main thread synchronously to avoid deadlocks.
|
||||
/// </summary>
|
||||
/// <param name="emitPdb">Emit a portable PDB and load symbols so breakpoints can bind.</param>
|
||||
/// <param name="documentPath">Source document path recorded in the PDB (the original .cs file).</param>
|
||||
public static HotReloadCompilationResult CompileSourceCodeOnMainThread(string sourceCode, string baseFileName, string assemblyDir = null, bool emitPdb = false, string documentPath = null)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
Debug.Log($"HotReload: Starting synchronous source code compilation for {baseFileName}");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sourceCode))
|
||||
{
|
||||
return HotReloadCompilationResult.Failure(
|
||||
"Empty Source Code",
|
||||
"Source code cannot be empty",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
// Generate versioned assembly name
|
||||
var nextVersion = GetNextVersion(baseFileName);
|
||||
var assemblyName = $"{baseFileName}_{nextVersion:D3}";
|
||||
|
||||
// Ensure temp directory exists
|
||||
Directory.CreateDirectory(HotReloadTempDir);
|
||||
|
||||
// Compile using shared RoslynCompilationService
|
||||
var compilationResult = CompileHotReloadAssembly(sourceCode, assemblyName, emitPdb, documentPath);
|
||||
|
||||
if (!compilationResult.Success)
|
||||
{
|
||||
return HotReloadCompilationResult.Failure(
|
||||
"Compilation Failed",
|
||||
"Hot reload source code compilation failed",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
compilationResult.Diagnostics.Select(d => d.Message).ToList());
|
||||
}
|
||||
|
||||
// Register hot reload methods from compiled assembly
|
||||
var registeredMethods = RegisterHotReloadMethods(compilationResult.Assembly, assemblyName, out var skippedOverrides);
|
||||
|
||||
// Save assembly to disk if assemblyDir is specified
|
||||
string actualAssemblyPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(assemblyDir))
|
||||
{
|
||||
Directory.CreateDirectory(assemblyDir);
|
||||
actualAssemblyPath = Path.Combine(assemblyDir, $"{assemblyName}.dll");
|
||||
File.WriteAllBytes(actualAssemblyPath, compilationResult.AssemblyBytes);
|
||||
Debug.Log($"HotReload: Assembly saved to disk: {actualAssemblyPath}");
|
||||
|
||||
// Symbols are loaded in-memory; also write the .pdb next to the .dll for convenience.
|
||||
if (compilationResult.PdbBytes != null)
|
||||
{
|
||||
var pdbPath = Path.Combine(assemblyDir, $"{assemblyName}.pdb");
|
||||
File.WriteAllBytes(pdbPath, compilationResult.PdbBytes);
|
||||
Debug.Log($"HotReload: Symbols saved to disk: {pdbPath}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
actualAssemblyPath = Path.Combine(HotReloadTempDir, $"{assemblyName}.dll"); // For compatibility (in-memory)
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
Debug.Log($"HotReload: Successfully compiled source code -> {assemblyName}.dll with {registeredMethods.Count} methods in {stopwatch.ElapsedMilliseconds}ms");
|
||||
|
||||
var sourceCompileResult = HotReloadCompilationResult.Success(
|
||||
assemblyName,
|
||||
actualAssemblyPath,
|
||||
registeredMethods,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
sourceCompileResult.Diagnostics = skippedOverrides;
|
||||
return sourceCompileResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"HotReload: Synchronous source code compilation failed for {baseFileName}: {ex.Message}");
|
||||
return HotReloadCompilationResult.Failure(
|
||||
"Exception",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal async source code compilation implementation for background threads.
|
||||
/// </summary>
|
||||
private static async Task<HotReloadCompilationResult> CompileSourceCodeInternalAsync(string sourceCode, string baseFileName, string assemblyDir = null)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
Debug.Log($"HotReload: Starting async source code compilation for {baseFileName}");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sourceCode))
|
||||
{
|
||||
return HotReloadCompilationResult.Failure(
|
||||
"Empty Source Code",
|
||||
"Source code cannot be empty",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
// Generate versioned assembly name
|
||||
var nextVersion = GetNextVersion(baseFileName);
|
||||
var assemblyName = $"{baseFileName}_{nextVersion:D3}";
|
||||
|
||||
// Ensure temp directory exists
|
||||
Directory.CreateDirectory(HotReloadTempDir);
|
||||
|
||||
// Compile using shared RoslynCompilationService async
|
||||
var compilationResult = await CompileHotReloadAssemblyAsync(sourceCode, assemblyName);
|
||||
|
||||
if (!compilationResult.Success)
|
||||
{
|
||||
return HotReloadCompilationResult.Failure(
|
||||
"Compilation Failed",
|
||||
"Hot reload source code compilation failed",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
compilationResult.Diagnostics.Select(d => d.Message).ToList());
|
||||
}
|
||||
|
||||
// Register hot reload methods from compiled assembly
|
||||
var registeredMethods = RegisterHotReloadMethods(compilationResult.Assembly, assemblyName, out var skippedOverrides);
|
||||
|
||||
// Save assembly to disk if assemblyDir is specified
|
||||
string actualAssemblyPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(assemblyDir))
|
||||
{
|
||||
Directory.CreateDirectory(assemblyDir);
|
||||
actualAssemblyPath = Path.Combine(assemblyDir, $"{assemblyName}.dll");
|
||||
File.WriteAllBytes(actualAssemblyPath, compilationResult.AssemblyBytes);
|
||||
Debug.Log($"HotReload: Assembly saved to disk: {actualAssemblyPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
actualAssemblyPath = Path.Combine(HotReloadTempDir, $"{assemblyName}.dll"); // For compatibility (in-memory)
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
Debug.Log($"HotReload: Successfully compiled source code -> {assemblyName}.dll with {registeredMethods.Count} methods in {stopwatch.ElapsedMilliseconds}ms");
|
||||
|
||||
var sourceCompileResult = HotReloadCompilationResult.Success(
|
||||
assemblyName,
|
||||
actualAssemblyPath,
|
||||
registeredMethods,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
sourceCompileResult.Diagnostics = skippedOverrides;
|
||||
return sourceCompileResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"HotReload: Async source code compilation failed for {baseFileName}: {ex.Message}");
|
||||
return HotReloadCompilationResult.Failure(
|
||||
"Exception",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clean up old hot reload DLL versions, keeping only the latest for each base filename.
|
||||
/// </summary>
|
||||
public static HotReloadCleanupResult CleanupHotReloadDlls(string assemblyDir = null)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var deletedFiles = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// Use specified assemblyDir or default to HotReloadTempDir
|
||||
var targetDir = !string.IsNullOrWhiteSpace(assemblyDir) ? assemblyDir : HotReloadTempDir;
|
||||
|
||||
if (!Directory.Exists(targetDir))
|
||||
{
|
||||
return new HotReloadCleanupResult
|
||||
{
|
||||
Success = true,
|
||||
DeletedFiles = deletedFiles,
|
||||
Message = $"No hot reload directory found: {targetDir}",
|
||||
ExecutionTimeMs = stopwatch.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
var dllFiles = Directory.GetFiles(targetDir, "*.dll");
|
||||
var groupedFiles = dllFiles
|
||||
.Select(f => new
|
||||
{
|
||||
FilePath = f,
|
||||
FileName = Path.GetFileNameWithoutExtension(f),
|
||||
BaseFilename = ExtractBaseFilename(Path.GetFileNameWithoutExtension(f)),
|
||||
Version = ExtractVersion(Path.GetFileNameWithoutExtension(f))
|
||||
})
|
||||
.Where(f => f.Version > 0) // Only versioned hot reload DLLs
|
||||
.GroupBy(f => f.BaseFilename)
|
||||
.ToList();
|
||||
|
||||
foreach (var group in groupedFiles)
|
||||
{
|
||||
var sortedFiles = group.OrderByDescending(f => f.Version).ToList();
|
||||
|
||||
// Keep the latest version, delete older ones
|
||||
for (int i = 1; i < sortedFiles.Count; i++)
|
||||
{
|
||||
var fileToDelete = sortedFiles[i];
|
||||
File.Delete(fileToDelete.FilePath);
|
||||
deletedFiles.Add(fileToDelete.FileName + ".dll");
|
||||
Debug.Log($"HotReload: Deleted old version {fileToDelete.FileName}.dll");
|
||||
}
|
||||
}
|
||||
|
||||
// Clear registry and reset version tracker
|
||||
HotReloadRegistry.ClearAllOverrides();
|
||||
_versionTracker.Clear();
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
Debug.Log($"HotReload: Cleanup completed - deleted {deletedFiles.Count} files in {stopwatch.ElapsedMilliseconds}ms");
|
||||
|
||||
return new HotReloadCleanupResult
|
||||
{
|
||||
Success = true,
|
||||
DeletedFiles = deletedFiles,
|
||||
Message = $"Deleted {deletedFiles.Count} old DLL versions",
|
||||
ExecutionTimeMs = stopwatch.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"HotReload: Cleanup failed: {ex.Message}");
|
||||
|
||||
return new HotReloadCleanupResult
|
||||
{
|
||||
Success = false,
|
||||
DeletedFiles = deletedFiles,
|
||||
Message = $"Cleanup failed: {ex.Message}",
|
||||
ExecutionTimeMs = stopwatch.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the next version number for a base filename.
|
||||
/// </summary>
|
||||
private static int GetNextVersion(string baseFilename)
|
||||
{
|
||||
if (!_versionTracker.ContainsKey(baseFilename))
|
||||
{
|
||||
_versionTracker[baseFilename] = 0;
|
||||
}
|
||||
|
||||
return ++_versionTracker[baseFilename];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrap hot reload user code with proper using statements and namespace.
|
||||
/// </summary>
|
||||
|
||||
/// <summary>
|
||||
/// Compile hot reload source code using shared RoslynCompilationService.
|
||||
/// Synchronous compilation for main thread use.
|
||||
/// </summary>
|
||||
private static CompilationResult CompileHotReloadAssembly(string sourceCode, string assemblyName, bool emitPdb = false, string documentPath = null)
|
||||
{
|
||||
var request = new CompilationRequest
|
||||
{
|
||||
SourceCode = sourceCode,
|
||||
AssemblyName = assemblyName,
|
||||
// Ensure Unity.Pipeline assemblies are included (contains HotReloadOverrideMethodAttribute)
|
||||
// Also include test assemblies for testing scenarios
|
||||
AdditionalAssemblyPrefixes = new[] { "Unity.Pipeline", "Unity.Pipeline.Tests" },
|
||||
EmitDebugInformation = emitPdb,
|
||||
DocumentPath = documentPath
|
||||
};
|
||||
|
||||
return RoslynCompilationService.Compile(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compile hot reload source code using shared RoslynCompilationService.
|
||||
/// Async compilation for background thread use.
|
||||
/// </summary>
|
||||
private static async Task<CompilationResult> CompileHotReloadAssemblyAsync(string sourceCode, string assemblyName)
|
||||
{
|
||||
var request = new CompilationRequest
|
||||
{
|
||||
SourceCode = sourceCode,
|
||||
AssemblyName = assemblyName,
|
||||
// Ensure Unity.Pipeline assemblies are included (contains HotReloadOverrideMethodAttribute)
|
||||
// Also include test assemblies for testing scenarios
|
||||
AdditionalAssemblyPrefixes = new[] { "Unity.Pipeline", "Unity.Pipeline.Tests" }
|
||||
};
|
||||
|
||||
return await RoslynCompilationService.CompileAsync(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register hot reload methods from compiled assembly with the registry.
|
||||
/// Also registers any [HotReloadWithOverrides] target methods found in the assembly.
|
||||
/// Only overrides that actually bind are returned; overrides that were skipped (e.g. the
|
||||
/// target is not currently [HotReloadWithOverrides], or a signature mismatch) are reported via
|
||||
/// <paramref name="skipped"/> with a user-facing reason.
|
||||
/// </summary>
|
||||
private static List<string> RegisterHotReloadMethods(Assembly assembly, string assemblyId, out List<string> skipped)
|
||||
{
|
||||
var registeredMethods = new List<string>();
|
||||
skipped = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
Debug.Log($"HotReload: Registering methods from assembly {assemblyId} with {assembly.GetTypes().Length} types");
|
||||
|
||||
// Register assembly types for discovery
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
Debug.Log($"HotReload: Processing type {type.Name} in {type.Namespace}");
|
||||
HotReloadRegistry.RegisterHotReloadType(type, assemblyId);
|
||||
|
||||
// First, scan for [HotReloadWithOverrides] methods to register as targets
|
||||
var allMethods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static);
|
||||
foreach (var method in allMethods)
|
||||
{
|
||||
var reloadableAttr = method.GetCustomAttribute<HotReloadWithOverridesAttribute>();
|
||||
if (reloadableAttr != null)
|
||||
{
|
||||
Debug.Log($"HotReload: Found reloadable method {method.Name} with ID {reloadableAttr.Id}");
|
||||
HotReloadRegistry.RegisterReloadableMethod(method, reloadableAttr);
|
||||
}
|
||||
}
|
||||
|
||||
// Then, find methods with [HotReloadOverrideMethod] attribute for overrides
|
||||
var staticMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Static);
|
||||
Debug.Log($"HotReload: Found {staticMethods.Length} static methods in {type.Name}");
|
||||
|
||||
foreach (var method in staticMethods)
|
||||
{
|
||||
var customAttributes = method.GetCustomAttributes(true);
|
||||
Debug.Log($"HotReload: Method {method.Name} has {customAttributes.Length} custom attributes: {string.Join(", ", customAttributes.Select(a => a.GetType().Name))}");
|
||||
|
||||
// Try multiple ways to find the attribute
|
||||
var hotReloadAttr = method.GetCustomAttribute<HotReloadOverrideMethodAttribute>();
|
||||
if (hotReloadAttr == null)
|
||||
{
|
||||
// Try by name in case of type loading issues
|
||||
var attrByName = customAttributes.FirstOrDefault(a => a.GetType().Name == "HotReloadOverrideMethodAttribute");
|
||||
if (attrByName != null)
|
||||
{
|
||||
Debug.Log($"HotReload: Found HotReloadOverrideMethodAttribute by name on {method.Name}");
|
||||
// Cast to the attribute type
|
||||
hotReloadAttr = attrByName as HotReloadOverrideMethodAttribute;
|
||||
}
|
||||
}
|
||||
|
||||
if (hotReloadAttr != null)
|
||||
{
|
||||
Debug.Log($"HotReload: Registering method override {method.Name} -> {hotReloadAttr.TargetMethodId}");
|
||||
if (HotReloadRegistry.RegisterMethodOverride(method, hotReloadAttr, type, out var skipReason))
|
||||
{
|
||||
registeredMethods.Add(hotReloadAttr.TargetMethodId);
|
||||
}
|
||||
else
|
||||
{
|
||||
skipped.Add($"{method.Name} -> {hotReloadAttr.TargetMethodId}: {skipReason}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"HotReload: Registered {registeredMethods.Count} override methods: {string.Join(", ", registeredMethods)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"HotReload: Error registering methods from assembly {assemblyId}: {ex.Message}");
|
||||
Debug.LogError($"HotReload: Stack trace: {ex.StackTrace}");
|
||||
}
|
||||
|
||||
return registeredMethods;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract base filename from versioned filename (e.g., "PlayerMovement_001" -> "PlayerMovement").
|
||||
/// </summary>
|
||||
private static string ExtractBaseFilename(string versionedFilename)
|
||||
{
|
||||
var lastUnderscoreIndex = versionedFilename.LastIndexOf('_');
|
||||
if (lastUnderscoreIndex > 0)
|
||||
{
|
||||
var potentialVersion = versionedFilename.Substring(lastUnderscoreIndex + 1);
|
||||
if (int.TryParse(potentialVersion, out _))
|
||||
{
|
||||
return versionedFilename.Substring(0, lastUnderscoreIndex);
|
||||
}
|
||||
}
|
||||
return versionedFilename;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract version number from versioned filename (e.g., "PlayerMovement_001" -> 1).
|
||||
/// </summary>
|
||||
private static int ExtractVersion(string versionedFilename)
|
||||
{
|
||||
var lastUnderscoreIndex = versionedFilename.LastIndexOf('_');
|
||||
if (lastUnderscoreIndex > 0)
|
||||
{
|
||||
var potentialVersion = versionedFilename.Substring(lastUnderscoreIndex + 1);
|
||||
if (int.TryParse(potentialVersion, out var version))
|
||||
{
|
||||
return version;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
#else
|
||||
// Hot reload requires Roslyn compilation, which is only available in the Editor and in
|
||||
// Desktop development builds. In all other builds the methods below are compiled instead,
|
||||
// matching the public surface used by callers (HotReloadCommands, InPlaceReloadProcessor)
|
||||
// so the project still builds, and returning a clear "not supported" failure at runtime.
|
||||
const string NotSupportedMessage =
|
||||
"Hot reload compilation is only supported on Desktop development builds (Windows/Mac/Linux).";
|
||||
|
||||
/// <summary>
|
||||
/// Hot reload compilation not supported on this build.
|
||||
/// </summary>
|
||||
public static Task<HotReloadCompileResult> CompileAndApplyAsync(string filename, string assemblyDir = null)
|
||||
{
|
||||
return Task.FromResult(HotReloadCompileResult.Failure("Platform Not Supported", NotSupportedMessage));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hot reload source compilation (in-place editing) not supported on this build.
|
||||
/// </summary>
|
||||
public static HotReloadCompilationResult CompileSourceCodeOnMainThread(string sourceCode, string baseFileName, string assemblyDir = null, bool emitPdb = false, string documentPath = null)
|
||||
{
|
||||
return HotReloadCompilationResult.Failure("Platform Not Supported", NotSupportedMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hot reload source compilation (in-place editing) not supported on this build.
|
||||
/// </summary>
|
||||
public static Task<HotReloadCompilationResult> CompileSourceCodeAsync(string sourceCode, string baseFileName, string assemblyDir = null, bool emitPdb = false, string documentPath = null)
|
||||
{
|
||||
return Task.FromResult(HotReloadCompilationResult.Failure("Platform Not Supported", NotSupportedMessage));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hot reload cleanup not supported on this build.
|
||||
/// </summary>
|
||||
public static HotReloadCleanupResult CleanupHotReloadDlls(string assemblyDir = null)
|
||||
{
|
||||
return new HotReloadCleanupResult
|
||||
{
|
||||
Success = false,
|
||||
Message = NotSupportedMessage,
|
||||
ExecutionTimeMs = 0
|
||||
};
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from hot reload compilation operation.
|
||||
/// </summary>
|
||||
public class HotReloadCompileResult
|
||||
{
|
||||
public bool IsSuccess { get; set; }
|
||||
public string AssemblyName { get; set; }
|
||||
public string OutputPath { get; set; }
|
||||
public List<string> RegisteredMethods { get; set; } = new List<string>();
|
||||
public long ExecutionTimeMs { get; set; }
|
||||
public string Error { get; set; }
|
||||
public string ErrorDetails { get; set; }
|
||||
public List<string> Diagnostics { get; set; } = new List<string>();
|
||||
|
||||
public static HotReloadCompileResult Success(string assemblyName, string outputPath, List<string> registeredMethods, long executionTimeMs)
|
||||
{
|
||||
return new HotReloadCompileResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
AssemblyName = assemblyName,
|
||||
OutputPath = outputPath,
|
||||
RegisteredMethods = registeredMethods,
|
||||
ExecutionTimeMs = executionTimeMs
|
||||
};
|
||||
}
|
||||
|
||||
public static HotReloadCompileResult Failure(string error, string errorDetails, long executionTimeMs = 0, List<string> diagnostics = null)
|
||||
{
|
||||
return new HotReloadCompileResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Error = error,
|
||||
ErrorDetails = errorDetails,
|
||||
ExecutionTimeMs = executionTimeMs,
|
||||
Diagnostics = diagnostics ?? new List<string>()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from hot reload cleanup operation.
|
||||
/// </summary>
|
||||
public class HotReloadCleanupResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public List<string> DeletedFiles { get; set; } = new List<string>();
|
||||
public string Message { get; set; }
|
||||
public long ExecutionTimeMs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from hot reload source code compilation operation (for in-place editing).
|
||||
/// </summary>
|
||||
public class HotReloadCompilationResult
|
||||
{
|
||||
public bool IsSuccess { get; set; }
|
||||
public string AssemblyName { get; set; }
|
||||
public string OutputPath { get; set; }
|
||||
public List<string> RegisteredMethods { get; set; } = new List<string>();
|
||||
public long ExecutionTimeMs { get; set; }
|
||||
public string Error { get; set; }
|
||||
public string ErrorDetails { get; set; }
|
||||
public List<string> Diagnostics { get; set; } = new List<string>();
|
||||
|
||||
public static HotReloadCompilationResult Success(string assemblyName, string outputPath, List<string> registeredMethods, long executionTimeMs)
|
||||
{
|
||||
return new HotReloadCompilationResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
AssemblyName = assemblyName,
|
||||
OutputPath = outputPath,
|
||||
RegisteredMethods = registeredMethods,
|
||||
ExecutionTimeMs = executionTimeMs
|
||||
};
|
||||
}
|
||||
|
||||
public static HotReloadCompilationResult Failure(string error, string errorDetails, long executionTimeMs = 0, List<string> diagnostics = null)
|
||||
{
|
||||
return new HotReloadCompilationResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Error = error,
|
||||
ErrorDetails = errorDetails,
|
||||
ExecutionTimeMs = executionTimeMs,
|
||||
Diagnostics = diagnostics ?? new List<string>()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 29ede4b57fcc6c64b84e5f6ee75df373
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.Emit;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Compilation
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared Roslyn compilation service for both code evaluation and hot reload systems.
|
||||
/// Eliminates code duplication by providing common compilation infrastructure.
|
||||
/// Thread-safe and can be called from any thread.
|
||||
/// </summary>
|
||||
public static class RoslynCompilationService
|
||||
{
|
||||
#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG)
|
||||
|
||||
/// <summary>
|
||||
/// Compile source code to assembly with customizable options.
|
||||
/// Thread-safe compilation that can run on any thread.
|
||||
/// </summary>
|
||||
public static CompilationResult Compile(CompilationRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Parse syntax tree. When emitting debug info, the tree needs a document path and
|
||||
// UTF-8 encoding so the portable PDB can reference a source document.
|
||||
var syntaxTree = request.EmitDebugInformation
|
||||
? CSharpSyntaxTree.ParseText(
|
||||
SourceText.From(request.SourceCode, Encoding.UTF8),
|
||||
path: request.DocumentPath ?? request.AssemblyName + ".cs")
|
||||
: CSharpSyntaxTree.ParseText(request.SourceCode);
|
||||
|
||||
// Get metadata references with optional additional prefixes
|
||||
var references = GetMetadataReferences(request.AdditionalAssemblyPrefixes);
|
||||
|
||||
// Create compilation. Disable optimizations when emitting debug info so the JIT
|
||||
// keeps full sequence points (otherwise breakpoints can't bind).
|
||||
var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary);
|
||||
if (request.EmitDebugInformation)
|
||||
options = options.WithOptimizationLevel(OptimizationLevel.Debug);
|
||||
|
||||
var compilation = CSharpCompilation.Create(
|
||||
request.AssemblyName,
|
||||
new[] { syntaxTree },
|
||||
references,
|
||||
options);
|
||||
|
||||
// Get diagnostics
|
||||
var diagnostics = compilation.GetDiagnostics();
|
||||
var diagnosticInfos = ConvertDiagnostics(diagnostics, request.LineNumberOffset);
|
||||
|
||||
// Check for compilation errors
|
||||
var errors = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList();
|
||||
if (errors.Any())
|
||||
{
|
||||
return new CompilationResult
|
||||
{
|
||||
Success = false,
|
||||
Diagnostics = diagnosticInfos
|
||||
};
|
||||
}
|
||||
|
||||
// Compile to memory
|
||||
using (var peStream = new MemoryStream())
|
||||
{
|
||||
byte[] pdbBytes = null;
|
||||
EmitResult emitResult;
|
||||
|
||||
if (request.EmitDebugInformation)
|
||||
{
|
||||
using (var pdbStream = new MemoryStream())
|
||||
{
|
||||
emitResult = compilation.Emit(
|
||||
peStream,
|
||||
pdbStream,
|
||||
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb));
|
||||
|
||||
if (emitResult.Success)
|
||||
pdbBytes = pdbStream.ToArray();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
emitResult = compilation.Emit(peStream);
|
||||
}
|
||||
|
||||
if (!emitResult.Success)
|
||||
{
|
||||
var emitDiagnostics = ConvertDiagnostics(emitResult.Diagnostics, request.LineNumberOffset);
|
||||
return new CompilationResult
|
||||
{
|
||||
Success = false,
|
||||
Diagnostics = diagnosticInfos.Concat(emitDiagnostics).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
// Load assembly from memory. When symbols are present, load them alongside the
|
||||
// assembly so an attached debugger can bind breakpoints into the emitted code.
|
||||
var assemblyBytes = peStream.ToArray();
|
||||
var assembly = pdbBytes != null
|
||||
? PipelineUtils.LoadFromBytes(assemblyBytes, pdbBytes)
|
||||
: PipelineUtils.LoadFromBytes(assemblyBytes);
|
||||
|
||||
return new CompilationResult
|
||||
{
|
||||
Success = true,
|
||||
Assembly = assembly,
|
||||
AssemblyBytes = assemblyBytes,
|
||||
PdbBytes = pdbBytes,
|
||||
Diagnostics = diagnosticInfos
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CompilationResult
|
||||
{
|
||||
Success = false,
|
||||
Diagnostics = new List<DiagnosticInfo>
|
||||
{
|
||||
new DiagnosticInfo
|
||||
{
|
||||
Severity = "error",
|
||||
Message = $"Compilation exception: {ex.Message}",
|
||||
Line = 0,
|
||||
Column = 0,
|
||||
Id = "ROSLYN001"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Async wrapper for compilation that runs on background thread.
|
||||
/// Use when you need to avoid blocking the calling thread.
|
||||
/// </summary>
|
||||
public static Task<CompilationResult> CompileAsync(CompilationRequest request)
|
||||
{
|
||||
return Task.Run(() => Compile(request));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get metadata references for compilation with optional additional assembly prefixes.
|
||||
/// Handles both Editor and Runtime scenarios with appropriate filtering.
|
||||
/// </summary>
|
||||
public static List<MetadataReference> GetMetadataReferences(string[] additionalPrefixes = null)
|
||||
{
|
||||
var references = new List<MetadataReference>();
|
||||
var assemblies = PipelineUtils.GetLoadedAssemblies()
|
||||
.Where(a => !a.IsDynamic && !string.IsNullOrEmpty(PipelineUtils.GetLoadedAssemblyPath(a)));
|
||||
|
||||
if (Application.isEditor)
|
||||
{
|
||||
// Editor: Include all loaded assemblies for maximum compatibility
|
||||
foreach (var assembly in assemblies)
|
||||
{
|
||||
try
|
||||
{
|
||||
references.Add(MetadataReference.CreateFromFile(PipelineUtils.GetLoadedAssemblyPath(assembly)));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip problematic assemblies
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Runtime: Use curated filtering for Unity + user assemblies
|
||||
var allowedPrefixes = new List<string>
|
||||
{
|
||||
"UnityEngine",
|
||||
"Assembly-CSharp",
|
||||
"netstandard",
|
||||
"mscorlib",
|
||||
"System.",
|
||||
"Unity.Pipeline"
|
||||
};
|
||||
|
||||
// Add any additional prefixes (e.g., for hot reload: "Microsoft.CodeAnalysis")
|
||||
if (additionalPrefixes != null)
|
||||
{
|
||||
allowedPrefixes.AddRange(additionalPrefixes);
|
||||
}
|
||||
|
||||
foreach (var assembly in assemblies)
|
||||
{
|
||||
try
|
||||
{
|
||||
var assemblyName = assembly.GetName().Name;
|
||||
if (allowedPrefixes.Any(prefix => assemblyName.StartsWith(prefix)))
|
||||
{
|
||||
references.Add(MetadataReference.CreateFromFile(PipelineUtils.GetLoadedAssemblyPath(assembly)));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip problematic assemblies
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert Roslyn diagnostics to DiagnosticInfo list with optional line number adjustment.
|
||||
/// Handles line number offset for wrapped code scenarios.
|
||||
/// </summary>
|
||||
private static List<DiagnosticInfo> ConvertDiagnostics(IEnumerable<Diagnostic> diagnostics, int lineOffset = 0)
|
||||
{
|
||||
var result = new List<DiagnosticInfo>();
|
||||
|
||||
foreach (var diagnostic in diagnostics.Where(d => d.Severity >= DiagnosticSeverity.Warning))
|
||||
{
|
||||
var location = diagnostic.Location.GetLineSpan();
|
||||
var line = Math.Max(0, location.StartLinePosition.Line - lineOffset);
|
||||
var column = location.StartLinePosition.Character;
|
||||
|
||||
result.Add(new DiagnosticInfo
|
||||
{
|
||||
Severity = diagnostic.Severity.ToString().ToLower(),
|
||||
Message = diagnostic.GetMessage(),
|
||||
Line = line,
|
||||
Column = column,
|
||||
Id = diagnostic.Id
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#else
|
||||
/// <summary>
|
||||
/// Runtime compilation not supported on this platform.
|
||||
/// Desktop development builds only (Windows/Mac/Linux).
|
||||
/// </summary>
|
||||
public static CompilationResult Compile(CompilationRequest request)
|
||||
{
|
||||
return new CompilationResult
|
||||
{
|
||||
Success = false,
|
||||
Diagnostics = new List<DiagnosticInfo>
|
||||
{
|
||||
new DiagnosticInfo
|
||||
{
|
||||
Severity = "error",
|
||||
Message = "Runtime code compilation only supported on Desktop development builds",
|
||||
Line = 0,
|
||||
Column = 0,
|
||||
Id = "PLATFORM001"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime compilation not supported on this platform.
|
||||
/// </summary>
|
||||
public static Task<CompilationResult> CompileAsync(CompilationRequest request)
|
||||
{
|
||||
return Task.FromResult(Compile(request));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime compilation not supported on this platform.
|
||||
/// </summary>
|
||||
public static List<MetadataReference> GetMetadataReferences(string[] additionalPrefixes = null)
|
||||
{
|
||||
return new List<MetadataReference>();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for Roslyn compilation with customizable options.
|
||||
/// </summary>
|
||||
public class CompilationRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Source code to compile.
|
||||
/// </summary>
|
||||
public string SourceCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Name for the generated assembly (should be unique).
|
||||
/// </summary>
|
||||
public string AssemblyName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional assembly prefixes to include in metadata references.
|
||||
/// Useful for hot reload scenarios that need "Microsoft.CodeAnalysis" etc.
|
||||
/// </summary>
|
||||
public string[] AdditionalAssemblyPrefixes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Line number offset to subtract from diagnostic line numbers.
|
||||
/// Used when source code has wrapper code that should be hidden from diagnostics.
|
||||
/// </summary>
|
||||
public int LineNumberOffset { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, emit a portable PDB and load the assembly with its symbols so an attached
|
||||
/// managed debugger can bind breakpoints. Compiles unoptimized. Default false (no symbols).
|
||||
/// </summary>
|
||||
public bool EmitDebugInformation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Document path recorded in the syntax tree when <see cref="EmitDebugInformation"/> is set.
|
||||
/// Source that is not covered by explicit <c>#line</c> directives maps to this path.
|
||||
/// </summary>
|
||||
public string DocumentPath { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from Roslyn compilation.
|
||||
/// </summary>
|
||||
public class CompilationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether compilation was successful.
|
||||
/// </summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Compiled assembly (only available if Success = true).
|
||||
/// </summary>
|
||||
public Assembly Assembly { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw assembly bytes (for potential disk saving or caching).
|
||||
/// </summary>
|
||||
public byte[] AssemblyBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Portable PDB bytes when <see cref="CompilationRequest.EmitDebugInformation"/> was set;
|
||||
/// null otherwise.
|
||||
/// </summary>
|
||||
public byte[] PdbBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Compilation diagnostics (errors, warnings, info).
|
||||
/// </summary>
|
||||
public List<DiagnosticInfo> Diagnostics { get; set; } = new List<DiagnosticInfo>();
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 957feb25e68236e45b15cd5e9f095c31
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7c1f4a9e2b6d4c8f9a0e3d5b7c1a2f48
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Console
|
||||
{
|
||||
/// <summary>
|
||||
/// Bounded, thread-safe ring buffer of captured Unity console entries that backs the
|
||||
/// <c>console</c> command.
|
||||
///
|
||||
/// Why thread-safe: entries arrive via <see cref="Application.logMessageReceivedThreaded"/>,
|
||||
/// which can fire from background threads, while the command reads the buffer from an HTTP
|
||||
/// request thread. All access is guarded by a single lock.
|
||||
///
|
||||
/// Each entry gets a monotonic <see cref="ConsoleLogEntry.Seq"/> that never repeats and only
|
||||
/// increases — including across domain reloads, because the buffer (and the seq counter) is
|
||||
/// persisted via <see cref="Save"/>/<see cref="Load"/>. The seq is the cursor a "--follow"
|
||||
/// client uses to fetch only newer entries.
|
||||
/// </summary>
|
||||
public class ConsoleLogBuffer
|
||||
{
|
||||
/// <summary>Maximum number of entries retained. Older entries are evicted first.</summary>
|
||||
public const int Capacity = 2000;
|
||||
|
||||
// Severity levels, ordered so a "minimum severity" filter is a simple >= comparison.
|
||||
public const int SeverityLog = 0;
|
||||
public const int SeverityWarn = 1;
|
||||
public const int SeverityError = 2;
|
||||
|
||||
public const string LevelLog = "log";
|
||||
public const string LevelWarn = "warn";
|
||||
public const string LevelError = "error";
|
||||
|
||||
readonly object m_Lock = new object();
|
||||
readonly Queue<StoredEntry> m_Entries = new Queue<StoredEntry>();
|
||||
long m_LastSeq;
|
||||
|
||||
struct StoredEntry
|
||||
{
|
||||
public int Severity;
|
||||
public ConsoleLogEntry Entry;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture a console entry. Assigns the next sequence number and evicts the oldest entry if
|
||||
/// the buffer is full. Safe to call from any thread.
|
||||
/// </summary>
|
||||
public void Add(LogType type, string message, string stackTrace, DateTime timestampUtc)
|
||||
{
|
||||
var severity = SeverityFromLogType(type);
|
||||
lock (m_Lock)
|
||||
{
|
||||
m_LastSeq++;
|
||||
if (m_Entries.Count >= Capacity)
|
||||
m_Entries.Dequeue();
|
||||
|
||||
m_Entries.Enqueue(new StoredEntry
|
||||
{
|
||||
Severity = severity,
|
||||
Entry = new ConsoleLogEntry
|
||||
{
|
||||
Seq = m_LastSeq,
|
||||
TimestampUtc = timestampUtc,
|
||||
Level = LevelName(severity),
|
||||
Message = message ?? string.Empty,
|
||||
StackTrace = stackTrace ?? string.Empty
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query the buffer for the <c>console</c> command.
|
||||
/// </summary>
|
||||
/// <param name="since">
|
||||
/// Cursor: return only entries with <c>Seq > since</c>. Pass a negative value (e.g. -1)
|
||||
/// for a snapshot of the most recent entries with no cursor filtering.
|
||||
/// </param>
|
||||
/// <param name="tail">
|
||||
/// Maximum number of entries to return (the most recent matches). Values <= 0 mean "no
|
||||
/// limit" (up to <see cref="Capacity"/>).
|
||||
/// </param>
|
||||
/// <param name="minSeverity">Minimum severity to include (see the Severity* constants).</param>
|
||||
public ConsoleLogResponse Query(long since, int tail, int minSeverity)
|
||||
{
|
||||
lock (m_Lock)
|
||||
{
|
||||
var matches = new List<ConsoleLogEntry>();
|
||||
foreach (var stored in m_Entries)
|
||||
{
|
||||
if (stored.Severity < minSeverity)
|
||||
continue;
|
||||
if (since >= 0 && stored.Entry.Seq <= since)
|
||||
continue;
|
||||
matches.Add(stored.Entry);
|
||||
}
|
||||
|
||||
// Keep only the most recent `tail` matches.
|
||||
if (tail > 0 && matches.Count > tail)
|
||||
matches.RemoveRange(0, matches.Count - tail);
|
||||
|
||||
return new ConsoleLogResponse
|
||||
{
|
||||
Entries = matches.ToArray(),
|
||||
Cursor = m_LastSeq,
|
||||
Returned = matches.Count,
|
||||
Dropped = ComputeDropped(since)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="since"/> refers to entries that have already been evicted, so
|
||||
/// the caller missed some output. Caller must hold <see cref="m_Lock"/>.
|
||||
/// </summary>
|
||||
bool ComputeDropped(long since)
|
||||
{
|
||||
if (since < 0)
|
||||
return false;
|
||||
if (since >= m_LastSeq)
|
||||
return false;
|
||||
|
||||
// Everything after `since` was evicted (or never retained): buffer empty but seq moved on.
|
||||
if (m_Entries.Count == 0)
|
||||
return m_LastSeq > since;
|
||||
|
||||
// The next entry the caller expects is since+1; if the oldest entry we still hold is
|
||||
// newer than that, the gap in between was evicted.
|
||||
var oldestSeq = m_Entries.Peek().Entry.Seq;
|
||||
return oldestSeq > since + 1;
|
||||
}
|
||||
|
||||
/// <summary>Remove all entries. Does NOT reset the sequence counter (cursors stay monotonic).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (m_Lock)
|
||||
{
|
||||
m_Entries.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Current number of retained entries. For diagnostics/tests.</summary>
|
||||
public int Count
|
||||
{
|
||||
get { lock (m_Lock) { return m_Entries.Count; } }
|
||||
}
|
||||
|
||||
/// <summary>The highest sequence number assigned so far (the live cursor).</summary>
|
||||
public long LastSeq
|
||||
{
|
||||
get { lock (m_Lock) { return m_LastSeq; } }
|
||||
}
|
||||
|
||||
#region Persistence
|
||||
|
||||
[Serializable]
|
||||
class Snapshot
|
||||
{
|
||||
[JsonProperty("lastSeq")] public long LastSeq;
|
||||
[JsonProperty("entries")] public ConsoleLogEntry[] Entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persist the buffer (entries + sequence counter) to <paramref name="path"/> so it survives
|
||||
/// a domain reload. Failures are swallowed (logging is best-effort, never fatal).
|
||||
/// </summary>
|
||||
public void Save(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Snapshot snapshot;
|
||||
lock (m_Lock)
|
||||
{
|
||||
var entries = new ConsoleLogEntry[m_Entries.Count];
|
||||
var i = 0;
|
||||
foreach (var stored in m_Entries)
|
||||
entries[i++] = stored.Entry;
|
||||
snapshot = new Snapshot { LastSeq = m_LastSeq, Entries = entries };
|
||||
}
|
||||
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(path, JsonConvert.SerializeObject(snapshot));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[Pipeline] Failed to persist console log buffer: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore a buffer previously written by <see cref="Save"/>. Missing or unreadable files
|
||||
/// leave the buffer empty. Returns true if a snapshot was loaded.
|
||||
/// </summary>
|
||||
public bool Load(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return false;
|
||||
|
||||
var snapshot = JsonConvert.DeserializeObject<Snapshot>(File.ReadAllText(path));
|
||||
if (snapshot == null)
|
||||
return false;
|
||||
|
||||
lock (m_Lock)
|
||||
{
|
||||
m_Entries.Clear();
|
||||
long lastRestoredSeq = 0;
|
||||
if (snapshot.Entries != null)
|
||||
{
|
||||
foreach (var entry in snapshot.Entries)
|
||||
{
|
||||
if (entry == null)
|
||||
continue;
|
||||
if (m_Entries.Count >= Capacity)
|
||||
m_Entries.Dequeue();
|
||||
|
||||
m_Entries.Enqueue(new StoredEntry
|
||||
{
|
||||
Severity = SeverityFromLevelName(entry.Level),
|
||||
Entry = entry
|
||||
});
|
||||
lastRestoredSeq = entry.Seq;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the counter ahead of any restored entry so seq stays monotonic.
|
||||
m_LastSeq = snapshot.LastSeq;
|
||||
if (m_Entries.Count > 0)
|
||||
m_LastSeq = Math.Max(m_LastSeq, lastRestoredSeq);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[Pipeline] Failed to restore console log buffer: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Severity mapping
|
||||
|
||||
/// <summary>Map a Unity <see cref="LogType"/> to an ordered severity.</summary>
|
||||
public static int SeverityFromLogType(LogType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case LogType.Warning:
|
||||
return SeverityWarn;
|
||||
case LogType.Error:
|
||||
case LogType.Exception:
|
||||
case LogType.Assert:
|
||||
return SeverityError;
|
||||
default:
|
||||
return SeverityLog;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a "--level" value ("log"/"warn"/"error", plus common aliases) into a minimum
|
||||
/// severity. Unknown values fall back to <see cref="SeverityLog"/> (everything), matching the
|
||||
/// lenient behavior of the existing log command.
|
||||
/// </summary>
|
||||
public static int SeverityFromLevelName(string level)
|
||||
{
|
||||
switch ((level ?? string.Empty).Trim().ToLowerInvariant())
|
||||
{
|
||||
case "error":
|
||||
case "err":
|
||||
case "exception":
|
||||
return SeverityError;
|
||||
case "warn":
|
||||
case "warning":
|
||||
return SeverityWarn;
|
||||
default:
|
||||
return SeverityLog;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The canonical level name for a severity.</summary>
|
||||
public static string LevelName(int severity)
|
||||
{
|
||||
switch (severity)
|
||||
{
|
||||
case SeverityError:
|
||||
return LevelError;
|
||||
case SeverityWarn:
|
||||
return LevelWarn;
|
||||
default:
|
||||
return LevelLog;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1e014974635df5fe6a6787c4ba983f80
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Console
|
||||
{
|
||||
/// <summary>
|
||||
/// Captures Unity console output into a process-wide <see cref="ConsoleLogBuffer"/> so the
|
||||
/// <c>console</c> command can serve it to CLI clients. This type lives in the runtime
|
||||
/// assembly, so it ships in player builds as well as the Editor.
|
||||
///
|
||||
/// Lifecycle:
|
||||
/// - In a Player, the <c>[RuntimeInitializeOnLoadMethod]</c> hook starts capture as the app boots.
|
||||
/// - In the Editor, the editor-only <c>EditorConsoleCaptureBootstrap</c> starts capture on every
|
||||
/// domain reload and layers on persistence so entries survive reloads (e.g. after a recompile).
|
||||
/// Both paths funnel through <see cref="EnsureCapturing"/>, which is idempotent — entering Play
|
||||
/// mode in the Editor fires the runtime hook too, and the guard makes that a no-op.
|
||||
/// - <see cref="Application.logMessageReceivedThreaded"/> is used (not the non-threaded variant)
|
||||
/// so logs emitted from background threads are captured too. The buffer is thread-safe.
|
||||
///
|
||||
/// Capture starts when this type first initializes. Console entries produced before that — or
|
||||
/// before the package's first import — are not retroactively captured; this reads from the public
|
||||
/// log callback, not Unity's internal console store.
|
||||
/// </summary>
|
||||
public static class ConsoleLogCapture
|
||||
{
|
||||
static readonly ConsoleLogBuffer s_Buffer = new ConsoleLogBuffer();
|
||||
static readonly object s_SubscriptionLock = new object();
|
||||
static bool s_Subscribed;
|
||||
|
||||
/// <summary>The shared buffer holding captured console entries.</summary>
|
||||
public static ConsoleLogBuffer Buffer => s_Buffer;
|
||||
|
||||
/// <summary>
|
||||
/// Player entry point. Runs as the application boots so console output is captured from the
|
||||
/// start. In the Editor this also fires when entering Play mode, but <see cref="EnsureCapturing"/>
|
||||
/// is idempotent so it does not double-subscribe.
|
||||
/// </summary>
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
static void RuntimeBootstrap()
|
||||
{
|
||||
EnsureCapturing();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to Unity's log callback if not already subscribed. Safe to call repeatedly and
|
||||
/// from either the runtime bootstrap or the Editor bootstrap.
|
||||
/// </summary>
|
||||
public static void EnsureCapturing()
|
||||
{
|
||||
lock (s_SubscriptionLock)
|
||||
{
|
||||
if (s_Subscribed)
|
||||
return;
|
||||
|
||||
// Defensive: remove before adding in case a prior subscription leaked across a reload.
|
||||
Application.logMessageReceivedThreaded -= OnLogMessage;
|
||||
Application.logMessageReceivedThreaded += OnLogMessage;
|
||||
s_Subscribed = true;
|
||||
}
|
||||
}
|
||||
|
||||
static void OnLogMessage(string condition, string stackTrace, LogType type)
|
||||
{
|
||||
s_Buffer.Add(type, condition, stackTrace, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3d9b6e1a4f2c40a78c5e0b1d2a6f9c84
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c29607a31a43d7149b8296de99b81d8e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.HotReload
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that [HotReload] method bodies only access PUBLIC instance members of the
|
||||
/// declaring type. In-place overrides are compiled into a separate assembly, so they can only
|
||||
/// reach public members of the original type; private/internal/protected access would fail to
|
||||
/// compile. This check runs up front (via a Roslyn semantic model) to produce a clear message
|
||||
/// instead of a raw compiler error.
|
||||
/// </summary>
|
||||
public static class AccessibilityValidator
|
||||
{
|
||||
public static AccessibilityValidationResult ValidatePublicAccess(
|
||||
string sourceCode,
|
||||
Dictionary<string, string> methodBodies,
|
||||
string originalTypeName)
|
||||
{
|
||||
var result = new AccessibilityValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Violations = new List<AccessibilityViolation>()
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var tree = CSharpSyntaxTree.ParseText(sourceCode);
|
||||
var root = tree.GetRoot();
|
||||
|
||||
var compilation = CSharpCompilation.Create(
|
||||
"HotReloadInPlaceValidation",
|
||||
new[] { tree },
|
||||
RoslynCompilationService.GetMetadataReferences(),
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
|
||||
var classDecl = root.DescendantNodes()
|
||||
.OfType<ClassDeclarationSyntax>()
|
||||
.FirstOrDefault(c => c.Identifier.ValueText == originalTypeName);
|
||||
if (classDecl == null)
|
||||
{
|
||||
result.IsValid = false;
|
||||
result.ValidationError = $"Could not find class '{originalTypeName}' in source.";
|
||||
return result;
|
||||
}
|
||||
|
||||
var classSymbol = model.GetDeclaredSymbol(classDecl);
|
||||
|
||||
foreach (var method in classDecl.Members.OfType<MethodDeclarationSyntax>())
|
||||
{
|
||||
var methodName = method.Identifier.ValueText;
|
||||
if (!methodBodies.ContainsKey(methodName) || method.Body == null)
|
||||
continue;
|
||||
|
||||
var seen = new HashSet<string>();
|
||||
foreach (var name in method.Body.DescendantNodes().OfType<SimpleNameSyntax>())
|
||||
{
|
||||
if (IsMemberName(name))
|
||||
continue;
|
||||
|
||||
var symbol = model.GetSymbolInfo(name).Symbol;
|
||||
if (!IsInstanceMemberOf(symbol, classSymbol))
|
||||
continue;
|
||||
|
||||
if (symbol.DeclaredAccessibility != Accessibility.Public && seen.Add(symbol.Name))
|
||||
{
|
||||
result.Violations.Add(new AccessibilityViolation
|
||||
{
|
||||
MemberName = symbol.Name,
|
||||
MethodName = methodName,
|
||||
AccessLevel = symbol.DeclaredAccessibility,
|
||||
ViolationType = AccessibilityViolationType.PrivateAccess,
|
||||
ErrorMessage = $"Cannot access non-public member '{symbol.Name}' " +
|
||||
$"({symbol.DeclaredAccessibility}) in [HotReload] method '{methodName}'",
|
||||
Suggestion = $"Make '{symbol.Name}' public in {originalTypeName}, or use a " +
|
||||
"public property/method. In-place overrides compile in a separate assembly " +
|
||||
"and can only access public members."
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.IsValid = result.Violations.Count == 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"HotReload: Accessibility validation error: {ex.Message}");
|
||||
return new AccessibilityValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ValidationError = ex.Message,
|
||||
Violations = new List<AccessibilityViolation>()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True if the name is the right-hand member name of an access (foo.Bar -> Bar).</summary>
|
||||
private static bool IsMemberName(SimpleNameSyntax node)
|
||||
{
|
||||
if (node.Parent is MemberAccessExpressionSyntax ma && ma.Name == node)
|
||||
return true;
|
||||
if (node.Parent is QualifiedNameSyntax)
|
||||
return true;
|
||||
if (node.Parent is MemberBindingExpressionSyntax)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>True if the symbol is an instance field/property/method/event of the type or a base.</summary>
|
||||
private static bool IsInstanceMemberOf(ISymbol symbol, INamedTypeSymbol type)
|
||||
{
|
||||
if (symbol == null || symbol.IsStatic)
|
||||
return false;
|
||||
|
||||
switch (symbol.Kind)
|
||||
{
|
||||
case SymbolKind.Field:
|
||||
case SymbolKind.Property:
|
||||
case SymbolKind.Method:
|
||||
case SymbolKind.Event:
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var t = type; t != null; t = t.BaseType)
|
||||
{
|
||||
if (SymbolEqualityComparer.Default.Equals(t, symbol.ContainingType))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of accessibility validation for hot reload methods.
|
||||
/// </summary>
|
||||
public class AccessibilityValidationResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public List<AccessibilityViolation> Violations { get; set; } = new List<AccessibilityViolation>();
|
||||
public string ValidationError { get; set; }
|
||||
|
||||
public string GetFormattedErrorMessage()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(ValidationError))
|
||||
return $"HotReload Validation Error: {ValidationError}";
|
||||
|
||||
if (Violations.Count == 0)
|
||||
return "All member access is valid for hot reload.";
|
||||
|
||||
var errorMessage = $"HotReload Validation Failed: {Violations.Count} accessibility violation(s) found\n\n";
|
||||
for (int i = 0; i < Violations.Count; i++)
|
||||
{
|
||||
var violation = Violations[i];
|
||||
errorMessage += $"{i + 1}. Method '{violation.MethodName}': {violation.ErrorMessage}\n";
|
||||
errorMessage += $" → Suggestion: {violation.Suggestion}\n";
|
||||
if (i < Violations.Count - 1)
|
||||
errorMessage += "\n";
|
||||
}
|
||||
|
||||
errorMessage += "\nFix these accessibility issues and run reload_file again.";
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a specific accessibility violation in hot reload code.
|
||||
/// </summary>
|
||||
public class AccessibilityViolation
|
||||
{
|
||||
public string MemberName { get; set; }
|
||||
public string MethodName { get; set; }
|
||||
public Accessibility AccessLevel { get; set; }
|
||||
public AccessibilityViolationType ViolationType { get; set; }
|
||||
public string ErrorMessage { get; set; }
|
||||
public string Suggestion { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of accessibility violations that can occur in hot reload methods.
|
||||
/// </summary>
|
||||
public enum AccessibilityViolationType
|
||||
{
|
||||
PrivateAccess,
|
||||
InternalAccess,
|
||||
ProtectedAccess,
|
||||
ParseError
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1bdaafa6e8d172648a57310104db179a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
|
||||
namespace Unity.Pipeline.HotReload
|
||||
{
|
||||
/// <summary>
|
||||
/// Marks a method as hot reloadable. The method can be overridden at runtime
|
||||
/// through the hot reload system without triggering Unity domain reload.
|
||||
///
|
||||
/// Belongs to the helper (separate override file) workflow, together with
|
||||
/// <see cref="HotReloadOverrideMethodAttribute"/>. For the in-place workflow use
|
||||
/// <see cref="HotReloadAttribute"/> instead.
|
||||
///
|
||||
/// Pattern A: Attribute-Based Method Override
|
||||
/// - Individual methods can be surgically replaced
|
||||
/// - Hot reload methods must have same signature + instance parameter
|
||||
/// - Access limited to public fields/properties for simplicity
|
||||
/// - Best for: Quick gameplay tweaks, formula adjustments
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
|
||||
public class HotReloadWithOverridesAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional identifier for this hot reloadable method.
|
||||
/// If not specified, uses TypeName.MethodName format.
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this method requires main thread execution.
|
||||
/// Defaults to true for Unity API safety.
|
||||
/// </summary>
|
||||
public bool RequireMainThread { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a method for the in-place hot reload workflow: the method body is edited directly
|
||||
/// in the original source file and applied via the <c>reload_file</c> command.
|
||||
///
|
||||
/// This attribute is exclusive to the in-place workflow and is independent of
|
||||
/// <see cref="HotReloadWithOverridesAttribute"/> / <see cref="HotReloadOverrideMethodAttribute"/>, which
|
||||
/// belong to the helper (separate override file) workflow.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
|
||||
public class HotReloadAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional identifier for this in-place hot reloadable method.
|
||||
/// If not specified, uses TypeName.MethodName format.
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this method requires main thread execution.
|
||||
/// Defaults to true for Unity API safety.
|
||||
/// </summary>
|
||||
public bool RequireMainThread { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a static method as a hot reload override for a specific method.
|
||||
/// Used in hot reload files to define replacement logic for [HotReloadWithOverrides] methods.
|
||||
///
|
||||
/// Method signature must match original method plus instance parameter as first argument.
|
||||
/// Example: void Update() becomes static void Update(TargetType instance)
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
|
||||
public class HotReloadOverrideMethodAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Target method identifier in format "TypeName.MethodName"
|
||||
/// Must match a method marked with [HotReloadWithOverrides].
|
||||
/// </summary>
|
||||
public string TargetMethodId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description of what this hot reload does.
|
||||
/// Useful for debugging and hot reload management.
|
||||
/// </summary>
|
||||
public string Description { get; set; }
|
||||
|
||||
public HotReloadOverrideMethodAttribute(string targetMethodId)
|
||||
{
|
||||
TargetMethodId = targetMethodId ?? throw new ArgumentNullException(nameof(targetMethodId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a component class as hot reloadable via complete behavior replacement.
|
||||
/// The component becomes a proxy that delegates to hot reload implementations.
|
||||
///
|
||||
/// Pattern C: Component Override
|
||||
/// - Complete component behavior replacement via interfaces
|
||||
/// - Maximum flexibility for major behavioral experiments
|
||||
/// - Pattern C wins when multiple patterns apply to same component
|
||||
/// - Best for: Major AI changes, experimental features
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
|
||||
public class HotReloadableComponentAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional identifier for this hot reloadable component.
|
||||
/// If not specified, uses the class name.
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Interface type that hot reload implementations must implement.
|
||||
/// If not specified, generates interface automatically.
|
||||
/// </summary>
|
||||
public Type InterfaceType { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a class as a hot reload implementation for a specific component.
|
||||
/// Used in hot reload files to provide complete component behavior replacement.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
|
||||
public class HotReloadComponentAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Target component type to override.
|
||||
/// Must be a type marked with [HotReloadableComponent].
|
||||
/// </summary>
|
||||
public Type TargetType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description of what this hot reload implementation does.
|
||||
/// </summary>
|
||||
public string Description { get; set; }
|
||||
|
||||
public HotReloadComponentAttribute(Type targetType)
|
||||
{
|
||||
TargetType = targetType ?? throw new ArgumentNullException(nameof(targetType));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a static method as a hot reload target for Pattern B explicit wrapper system.
|
||||
/// Used with HotReloadMethod<T> wrapper for type-safe delegate hot reload.
|
||||
///
|
||||
/// Pattern B: Explicit Wrapper
|
||||
/// - HotReloadMethod<T> wrapper with type-safe delegate calls
|
||||
/// - Built-in fallback logic, zero method signature changes
|
||||
/// - Explicit opt-in with performance-optimized direct calls
|
||||
/// - Best for: Performance-critical paths, type-safe hot reload
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
|
||||
public class HotReloadTargetAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier that matches HotReloadMethod<T> wrapper registration.
|
||||
/// This ID connects the wrapper to the hot reload implementation.
|
||||
/// </summary>
|
||||
public string TargetId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description of what this hot reload target does.
|
||||
/// </summary>
|
||||
public string Description { get; set; }
|
||||
|
||||
public HotReloadTargetAttribute(string targetId)
|
||||
{
|
||||
TargetId = targetId ?? throw new ArgumentNullException(nameof(targetId));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0bd43f6237214774ab4a178b811eb453
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.HotReload
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper utilities for integrating hot reload calls into methods marked with [HotReloadWithOverrides].
|
||||
/// Provides convenience methods for checking and invoking hot reload overrides.
|
||||
/// </summary>
|
||||
public static class HotReloadHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Execute method with hot reload override check.
|
||||
/// If hot reload override exists, invokes it; otherwise invokes original method.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the instance</typeparam>
|
||||
/// <param name="instance">Instance to invoke method on</param>
|
||||
/// <param name="methodName">Name of the method to invoke</param>
|
||||
/// <param name="originalMethod">Original method implementation</param>
|
||||
/// <param name="parameters">Method parameters</param>
|
||||
/// <returns>Result of method invocation</returns>
|
||||
public static object ExecuteWithHotReload<T>(T instance, string methodName, Func<object> originalMethod, params object[] parameters)
|
||||
{
|
||||
var methodId = GetMethodId<T>(methodName);
|
||||
|
||||
// Try hot reload first
|
||||
if (HotReloadRegistry.TryInvokeHotReload(methodId, instance, parameters))
|
||||
{
|
||||
return null; // Hot reload methods currently don't return values (can be enhanced later)
|
||||
}
|
||||
|
||||
// Fall back to original method
|
||||
return originalMethod?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute void method with hot reload override check.
|
||||
/// Convenience overload for void methods.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the instance</typeparam>
|
||||
/// <param name="instance">Instance to invoke method on</param>
|
||||
/// <param name="methodName">Name of the method to invoke</param>
|
||||
/// <param name="originalMethod">Original method implementation</param>
|
||||
/// <param name="parameters">Method parameters</param>
|
||||
public static void ExecuteWithHotReload<T>(T instance, string methodName, Action originalMethod, params object[] parameters)
|
||||
{
|
||||
var methodId = GetMethodId<T>(methodName);
|
||||
|
||||
// Try hot reload first
|
||||
if (HotReloadRegistry.TryInvokeHotReload(methodId, instance, parameters))
|
||||
{
|
||||
return; // Hot reload override was invoked
|
||||
}
|
||||
|
||||
// Fall back to original method
|
||||
originalMethod?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute method with custom method ID and hot reload override check.
|
||||
/// Use this when the method has a custom ID specified in [HotReloadWithOverrides(Id = "...")].
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the instance</typeparam>
|
||||
/// <param name="instance">Instance to invoke method on</param>
|
||||
/// <param name="customMethodId">Custom method ID from HotReloadWithOverrides attribute</param>
|
||||
/// <param name="originalMethod">Original method implementation</param>
|
||||
/// <param name="parameters">Method parameters</param>
|
||||
/// <returns>Result of method invocation</returns>
|
||||
public static object ExecuteWithHotReloadCustomId<T>(T instance, string customMethodId, Func<object> originalMethod, params object[] parameters)
|
||||
{
|
||||
// Try hot reload first
|
||||
if (HotReloadRegistry.TryInvokeHotReload(customMethodId, instance, parameters))
|
||||
{
|
||||
return null; // Hot reload methods currently don't return values
|
||||
}
|
||||
|
||||
// Fall back to original method
|
||||
return originalMethod?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute void method with custom method ID and hot reload override check.
|
||||
/// Use this when the method has a custom ID specified in [HotReloadWithOverrides(Id = "...")].
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the instance</typeparam>
|
||||
/// <param name="instance">Instance to invoke method on</param>
|
||||
/// <param name="customMethodId">Custom method ID from HotReloadWithOverrides attribute</param>
|
||||
/// <param name="originalMethod">Original method implementation</param>
|
||||
/// <param name="parameters">Method parameters</param>
|
||||
public static void ExecuteWithHotReloadCustomId<T>(T instance, string customMethodId, Action originalMethod, params object[] parameters)
|
||||
{
|
||||
// Try hot reload first
|
||||
if (HotReloadRegistry.TryInvokeHotReload(customMethodId, instance, parameters))
|
||||
{
|
||||
return; // Hot reload override was invoked
|
||||
}
|
||||
|
||||
// Fall back to original method
|
||||
originalMethod?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a hot reload override is active for a specific method.
|
||||
/// Useful for debugging or conditional logic based on hot reload state.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the instance</typeparam>
|
||||
/// <param name="methodName">Name of the method to check</param>
|
||||
/// <returns>True if hot reload override is active</returns>
|
||||
public static bool IsHotReloadActive<T>(string methodName)
|
||||
{
|
||||
var methodId = GetMethodId<T>(methodName);
|
||||
var stats = HotReloadRegistry.GetStats();
|
||||
return stats.ActiveOverrideIds.Contains(methodId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a hot reload override is active for a specific custom method ID.
|
||||
/// </summary>
|
||||
/// <param name="customMethodId">Custom method ID to check</param>
|
||||
/// <returns>True if hot reload override is active</returns>
|
||||
public static bool IsHotReloadActive(string customMethodId)
|
||||
{
|
||||
var stats = HotReloadRegistry.GetStats();
|
||||
return stats.ActiveOverrideIds.Contains(customMethodId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate standard method ID from type and method name.
|
||||
/// Format: TypeName.MethodName
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type containing the method</typeparam>
|
||||
/// <param name="methodName">Name of the method</param>
|
||||
/// <returns>Standard method ID</returns>
|
||||
private static string GetMethodId<T>(string methodName)
|
||||
{
|
||||
return $"{typeof(T).Name}.{methodName}";
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5a2835bfdec4fc24cb67a208766efcff
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Unity.Pipeline.Threading;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.HotReload
|
||||
{
|
||||
/// <summary>
|
||||
/// Central registry for managing hot reload method and component overrides.
|
||||
/// Handles runtime method resolution, override registration, and fallback logic.
|
||||
/// Thread-safe for runtime hot reload operations.
|
||||
/// </summary>
|
||||
public static class HotReloadRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Dispatcher used to marshal main-thread-required hot-reload overrides to the main thread.
|
||||
/// Injected by RuntimePipelineManager (= its server's dispatcher). Null = run on the
|
||||
/// current thread (no marshaling).
|
||||
/// </summary>
|
||||
public static Dispatcher Dispatcher { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Absolute roots a running Player is allowed to hot reload source files from (Assets folder
|
||||
/// + loaded package locations). Baked into RuntimePipelineManager at build time (a running
|
||||
/// Player cannot resolve the project layout) and published here when the runtime server
|
||||
/// starts, so reload commands can validate incoming file paths. Null until published.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> AllowedReloadRoots { get; set; }
|
||||
|
||||
// Thread-safe collections for runtime hot reload switching
|
||||
private static readonly ConcurrentDictionary<string, MethodOverride> m_MethodOverrides = new();
|
||||
private static readonly ConcurrentDictionary<string, List<MethodInfo>> m_ReloadableMethods = new();
|
||||
private static readonly ConcurrentDictionary<string, Type> m_LoadedHotReloadTypes = new();
|
||||
|
||||
/// <summary>
|
||||
/// Register a method as hot reloadable. Called during discovery phase.
|
||||
/// </summary>
|
||||
public static void RegisterReloadableMethod(MethodInfo method, HotReloadWithOverridesAttribute attribute)
|
||||
{
|
||||
var methodId = GetMethodId(method, attribute.Id);
|
||||
|
||||
if (!m_ReloadableMethods.ContainsKey(methodId))
|
||||
{
|
||||
m_ReloadableMethods[methodId] = new List<MethodInfo>();
|
||||
}
|
||||
|
||||
m_ReloadableMethods[methodId].Add(method);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a hot reload method override from compiled hot reload assembly.
|
||||
/// Returns true if the override was registered, false if it was skipped.
|
||||
/// </summary>
|
||||
public static bool RegisterMethodOverride(MethodInfo overrideMethod, HotReloadOverrideMethodAttribute attribute, Type sourceType)
|
||||
{
|
||||
return RegisterMethodOverride(overrideMethod, attribute, sourceType, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a hot reload method override from compiled hot reload assembly.
|
||||
/// Returns true if the override was registered, false if it was skipped (target not
|
||||
/// reloadable or signature mismatch). The out parameter carries a user-facing reason
|
||||
/// when registration is skipped.
|
||||
/// </summary>
|
||||
public static bool RegisterMethodOverride(MethodInfo overrideMethod, HotReloadOverrideMethodAttribute attribute, Type sourceType, out string skipReason)
|
||||
{
|
||||
skipReason = null;
|
||||
var targetMethodId = attribute.TargetMethodId;
|
||||
|
||||
// Validate that target method exists and is reloadable
|
||||
if (!m_ReloadableMethods.ContainsKey(targetMethodId))
|
||||
{
|
||||
Debug.LogWarning($"HotReload: Target method '{targetMethodId}' not found or not marked [HotReloadWithOverrides]");
|
||||
skipReason = $"target '{targetMethodId}' is not registered as [HotReloadWithOverrides]. " +
|
||||
"Ensure the component is in the scene and in play mode, and that it calls " +
|
||||
"HotReloadRegistry.RegisterReloadableType(...) in Awake.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var originalMethods = m_ReloadableMethods[targetMethodId];
|
||||
if (!originalMethods.Any())
|
||||
{
|
||||
Debug.LogWarning($"HotReload: No original methods registered for '{targetMethodId}'");
|
||||
skipReason = $"no original methods registered for '{targetMethodId}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate signature compatibility (basic check - instance parameter + matching return type)
|
||||
var originalMethod = originalMethods.First();
|
||||
if (!ValidateSignatureCompatibility(originalMethod, overrideMethod))
|
||||
{
|
||||
Debug.LogError($"HotReload: Signature mismatch for '{targetMethodId}'. Override method must have instance parameter as first argument.");
|
||||
skipReason = $"signature mismatch for '{targetMethodId}'. The override must be " +
|
||||
$"'public static {originalMethod.ReturnType.Name} {overrideMethod.Name}" +
|
||||
$"({originalMethod.DeclaringType?.Name} instance, ...)'. " +
|
||||
"A common cause is the override file redeclaring the target type.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var methodOverride = new MethodOverride
|
||||
{
|
||||
TargetMethodId = targetMethodId,
|
||||
OverrideMethod = overrideMethod,
|
||||
SourceType = sourceType,
|
||||
RequireMainThread = GetMainThreadRequirement(originalMethod),
|
||||
Description = attribute.Description
|
||||
};
|
||||
|
||||
m_MethodOverrides[targetMethodId] = methodOverride;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to invoke hot reload override if available, otherwise invoke original method.
|
||||
/// Returns true if hot reload override was invoked, false if original method should be called.
|
||||
/// </summary>
|
||||
public static bool TryInvokeHotReload<T>(string methodId, T instance, object[] parameters = null)
|
||||
{
|
||||
return TryInvokeHotReload(methodId, (object)instance, parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-generic dispatch entry point. This is the method woven into [HotReload]
|
||||
/// methods at compile time (a non-generic signature keeps the injected IL simple).
|
||||
/// Returns true if a hot reload override was invoked, false if the original body should run.
|
||||
/// </summary>
|
||||
public static bool TryInvokeHotReload(string methodId, object instance, object[] parameters = null)
|
||||
{
|
||||
if (!m_MethodOverrides.TryGetValue(methodId, out var methodOverride))
|
||||
{
|
||||
return false; // No hot reload override available
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Prepare parameters with instance as first parameter
|
||||
var invokeParams = new object[parameters?.Length + 1 ?? 1];
|
||||
invokeParams[0] = instance;
|
||||
if (parameters != null && parameters.Length > 0)
|
||||
{
|
||||
Array.Copy(parameters, 0, invokeParams, 1, parameters.Length);
|
||||
}
|
||||
|
||||
// Invoke on appropriate thread. The dispatcher is injected by the runtime manager
|
||||
// (HotReloadRegistry is reached from hot-reloaded runtime code, not a command).
|
||||
// If no dispatcher was injected, run on the current thread.
|
||||
if (methodOverride.RequireMainThread && Dispatcher != null && !Dispatcher.IsMainThread())
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
methodOverride.OverrideMethod.Invoke(null, invokeParams);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
methodOverride.OverrideMethod.Invoke(null, invokeParams);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"HotReload: Error invoking override for '{methodId}': {ex.Message}");
|
||||
Debug.LogError($"HotReload: Stack trace: {ex.StackTrace}");
|
||||
return false; // Fall back to original method
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register all methods in a type that have the HotReloadWithOverrides attribute.
|
||||
/// Uses reflection to discover and register methods marked with [HotReloadWithOverrides].
|
||||
/// </summary>
|
||||
public static void RegisterReloadableType(System.Type type)
|
||||
{
|
||||
if (type == null)
|
||||
{
|
||||
Debug.LogWarning("HotReload: Cannot register null type");
|
||||
return;
|
||||
}
|
||||
|
||||
int registeredCount = 0;
|
||||
|
||||
// Get all instance methods (public and non-public)
|
||||
var instanceMethods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
foreach (var method in instanceMethods)
|
||||
{
|
||||
var hotReloadAttr = method.GetCustomAttribute<HotReloadWithOverridesAttribute>();
|
||||
if (hotReloadAttr != null)
|
||||
{
|
||||
RegisterReloadableMethod(method, hotReloadAttr);
|
||||
registeredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all static methods (public and non-public)
|
||||
var staticMethods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
|
||||
foreach (var method in staticMethods)
|
||||
{
|
||||
var hotReloadAttr = method.GetCustomAttribute<HotReloadWithOverridesAttribute>();
|
||||
if (hotReloadAttr != null)
|
||||
{
|
||||
RegisterReloadableMethod(method, hotReloadAttr);
|
||||
registeredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (registeredCount > 0)
|
||||
{
|
||||
Debug.Log($"HotReload: Registered {registeredCount} reloadable methods from type {type.Name}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log($"HotReload: No methods with [HotReloadWithOverrides] attribute found in type {type.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a type from loaded hot reload assembly for discovery scanning.
|
||||
/// </summary>
|
||||
public static void RegisterHotReloadType(Type type, string assemblyId)
|
||||
{
|
||||
var typeKey = $"{assemblyId}:{type.FullName}";
|
||||
m_LoadedHotReloadTypes[typeKey] = type;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all hot reload overrides. Used by cleanup_hotreload command.
|
||||
/// </summary>
|
||||
public static void ClearAllOverrides()
|
||||
{
|
||||
var overrideCount = m_MethodOverrides.Count;
|
||||
var typeCount = m_LoadedHotReloadTypes.Count;
|
||||
|
||||
m_MethodOverrides.Clear();
|
||||
m_LoadedHotReloadTypes.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all registry state including reloadable methods.
|
||||
/// FOR TESTING ONLY - this should not be called in production code.
|
||||
/// </summary>
|
||||
public static void ClearAllForTesting()
|
||||
{
|
||||
var overrideCount = m_MethodOverrides.Count;
|
||||
var typeCount = m_LoadedHotReloadTypes.Count;
|
||||
var reloadableCount = m_ReloadableMethods.Sum(kvp => kvp.Value.Count);
|
||||
|
||||
m_MethodOverrides.Clear();
|
||||
m_LoadedHotReloadTypes.Clear();
|
||||
m_ReloadableMethods.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get statistics about current hot reload state.
|
||||
/// </summary>
|
||||
public static HotReloadStats GetStats()
|
||||
{
|
||||
return new HotReloadStats
|
||||
{
|
||||
ReloadableMethodCount = m_ReloadableMethods.Sum(kvp => kvp.Value.Count),
|
||||
ActiveOverrideCount = m_MethodOverrides.Count,
|
||||
LoadedTypeCount = m_LoadedHotReloadTypes.Count,
|
||||
ReloadableMethodIds = m_ReloadableMethods.Keys.ToList(),
|
||||
ActiveOverrideIds = m_MethodOverrides.Keys.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate method ID from MethodInfo and optional custom ID.
|
||||
/// Format: TypeName.MethodName or custom ID if provided.
|
||||
/// </summary>
|
||||
private static string GetMethodId(MethodInfo method, string customId = null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(customId))
|
||||
{
|
||||
return customId;
|
||||
}
|
||||
|
||||
return $"{method.DeclaringType?.Name}.{method.Name}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate that hot reload method signature is compatible with original method.
|
||||
/// Hot reload method must have instance parameter as first argument.
|
||||
/// </summary>
|
||||
private static bool ValidateSignatureCompatibility(MethodInfo originalMethod, MethodInfo overrideMethod)
|
||||
{
|
||||
var originalParams = originalMethod.GetParameters();
|
||||
var overrideParams = overrideMethod.GetParameters();
|
||||
|
||||
// Hot reload method must have at least one parameter (the instance)
|
||||
if (overrideParams.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// First parameter must be compatible with declaring type of original method
|
||||
var firstParam = overrideParams[0];
|
||||
if (!originalMethod.DeclaringType.IsAssignableFrom(firstParam.ParameterType))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remaining parameters must match original method parameters
|
||||
if (overrideParams.Length - 1 != originalParams.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < originalParams.Length; i++)
|
||||
{
|
||||
if (overrideParams[i + 1].ParameterType != originalParams[i].ParameterType)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Return types must match
|
||||
return originalMethod.ReturnType == overrideMethod.ReturnType;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determine if original method requires main thread execution.
|
||||
/// </summary>
|
||||
private static bool GetMainThreadRequirement(MethodInfo originalMethod)
|
||||
{
|
||||
// Check for HotReloadWithOverridesAttribute main thread requirement
|
||||
var hotReloadAttr = originalMethod.GetCustomAttribute<HotReloadWithOverridesAttribute>();
|
||||
if (hotReloadAttr != null)
|
||||
{
|
||||
return hotReloadAttr.RequireMainThread;
|
||||
}
|
||||
|
||||
// Default to main thread requirement for safety
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a registered method override.
|
||||
/// </summary>
|
||||
private class MethodOverride
|
||||
{
|
||||
public string TargetMethodId { get; set; }
|
||||
public MethodInfo OverrideMethod { get; set; }
|
||||
public Type SourceType { get; set; }
|
||||
public bool RequireMainThread { get; set; }
|
||||
public string Description { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about current hot reload registry state.
|
||||
/// </summary>
|
||||
public class HotReloadStats
|
||||
{
|
||||
public int ReloadableMethodCount { get; set; }
|
||||
public int ActiveOverrideCount { get; set; }
|
||||
public int LoadedTypeCount { get; set; }
|
||||
public List<string> ReloadableMethodIds { get; set; } = new();
|
||||
public List<string> ActiveOverrideIds { get; set; } = new();
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 23cd8d0c782f1974e8a3b4095a178092
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+560
@@ -0,0 +1,560 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.HotReload
|
||||
{
|
||||
/// <summary>
|
||||
/// Main orchestrator for in-place hot reload processing.
|
||||
/// Handles the complete pipeline: source parsing -> validation -> transformation -> compilation.
|
||||
/// </summary>
|
||||
public static class InPlaceReloadProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Process a source file for in-place hot reload.
|
||||
/// Extracts [HotReloadWithOverrides] methods, validates accessibility, transforms to static overrides, and compiles.
|
||||
/// DEADLOCK FIX: Detects main thread and uses synchronous processing when needed.
|
||||
/// </summary>
|
||||
/// <param name="sourceFilePath">Path to source file containing [HotReloadWithOverrides] methods</param>
|
||||
/// <param name="assemblyDir">Optional directory to save compiled assembly</param>
|
||||
/// <param name="pdb">Emit debug symbols mapped to the original source so breakpoints bind.</param>
|
||||
/// <returns>Compilation result with success/failure and diagnostic information</returns>
|
||||
public static Task<InPlaceReloadResult> ProcessSourceFileAsync(string sourceFilePath, string assemblyDir = null, bool pdb = false)
|
||||
{
|
||||
// Runs synchronously on the calling (main) thread; returns a completed Task.
|
||||
return Task.FromResult(ProcessSourceFileOnMainThread(sourceFilePath, assemblyDir, pdb));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process source file on main thread synchronously to avoid deadlocks.
|
||||
/// </summary>
|
||||
public static InPlaceReloadResult ProcessSourceFileOnMainThread(string sourceFilePath, string assemblyDir = null, bool pdb = false)
|
||||
{
|
||||
var result = new InPlaceReloadResult
|
||||
{
|
||||
SourceFilePath = sourceFilePath,
|
||||
Success = false
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
Debug.Log($"HotReload: Processing source file synchronously on main thread: {sourceFilePath}");
|
||||
|
||||
// 1. Read and parse the source file (synchronous)
|
||||
var sourceCode = ReadSourceFile(sourceFilePath);
|
||||
if (string.IsNullOrEmpty(sourceCode))
|
||||
{
|
||||
result.ErrorMessage = $"Could not read source file: {sourceFilePath}";
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. Extract [HotReloadWithOverrides] methods
|
||||
var extractionResult = ExtractHotReloadableMethods(sourceCode);
|
||||
if (!extractionResult.HasMethods)
|
||||
{
|
||||
result.ErrorMessage = $"No [HotReload] methods found in {sourceFilePath}";
|
||||
return result;
|
||||
}
|
||||
|
||||
result.OriginalTypeName = extractionResult.TypeName;
|
||||
result.ExtractedMethods = extractionResult.Methods.Keys.ToList();
|
||||
|
||||
Debug.Log($"HotReload: Extracted {extractionResult.Methods.Count} [HotReloadWithOverrides] methods from {extractionResult.TypeName}");
|
||||
|
||||
// 3. Validate accessibility (public members only)
|
||||
var validationResult = AccessibilityValidator.ValidatePublicAccess(
|
||||
sourceCode,
|
||||
extractionResult.Methods,
|
||||
extractionResult.TypeName);
|
||||
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
result.ErrorMessage = validationResult.GetFormattedErrorMessage();
|
||||
result.ValidationViolations = validationResult.Violations;
|
||||
Debug.LogWarning($"HotReload: Accessibility validation failed: {result.ErrorMessage}");
|
||||
return result;
|
||||
}
|
||||
|
||||
Debug.Log($"HotReload: Accessibility validation passed for {extractionResult.TypeName}");
|
||||
|
||||
// 4. Transform method bodies to static overrides
|
||||
var originalSource = File.ReadAllText(sourceFilePath);
|
||||
var transformedCode = SourceCodeTransformer.TransformMethodBodies(
|
||||
extractionResult.Methods,
|
||||
extractionResult.TypeName,
|
||||
extractionResult.MethodSignatures,
|
||||
originalSource,
|
||||
emitLineDirectives: pdb,
|
||||
originalFilePath: sourceFilePath);
|
||||
|
||||
result.TransformedCode = transformedCode;
|
||||
|
||||
Debug.Log($"HotReload: Code transformation completed for {extractionResult.TypeName}");
|
||||
|
||||
// 5. Compile the transformed code (synchronous)
|
||||
var compilationResult = CompileTransformedCode(
|
||||
transformedCode,
|
||||
extractionResult.TypeName,
|
||||
assemblyDir,
|
||||
pdb,
|
||||
sourceFilePath);
|
||||
|
||||
result.Success = compilationResult.IsSuccess;
|
||||
result.AssemblyName = compilationResult.AssemblyName;
|
||||
result.RegisteredMethods = compilationResult.RegisteredMethods;
|
||||
result.CompilationDiagnostics = compilationResult.Diagnostics;
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
Debug.Log($"HotReload: In-place reload successful for {sourceFilePath} - {result.RegisteredMethods.Count} methods registered");
|
||||
}
|
||||
else
|
||||
{
|
||||
result.ErrorMessage = compilationResult.ErrorDetails ?? "Compilation failed";
|
||||
Debug.LogError($"HotReload: In-place reload compilation failed: {result.ErrorMessage}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"HotReload: Error processing source file {sourceFilePath}: {ex.Message}");
|
||||
Debug.LogError($"HotReload: Stack trace: {ex.StackTrace}");
|
||||
|
||||
result.ErrorMessage = $"Processing error: {ex.Message}";
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal async processing implementation for background threads.
|
||||
/// </summary>
|
||||
private static async Task<InPlaceReloadResult> ProcessSourceFileInternalAsync(string sourceFilePath, string assemblyDir = null)
|
||||
{
|
||||
var result = new InPlaceReloadResult
|
||||
{
|
||||
SourceFilePath = sourceFilePath,
|
||||
Success = false
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
Debug.Log($"HotReload: Processing source file asynchronously: {sourceFilePath}");
|
||||
|
||||
// 1. Read and parse the source file (async)
|
||||
var sourceCode = await ReadSourceFileAsync(sourceFilePath);
|
||||
if (string.IsNullOrEmpty(sourceCode))
|
||||
{
|
||||
result.ErrorMessage = $"Could not read source file: {sourceFilePath}";
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2-4. Same processing as main thread version
|
||||
var extractionResult = ExtractHotReloadableMethods(sourceCode);
|
||||
if (!extractionResult.HasMethods)
|
||||
{
|
||||
result.ErrorMessage = $"No [HotReload] methods found in {sourceFilePath}";
|
||||
return result;
|
||||
}
|
||||
|
||||
result.OriginalTypeName = extractionResult.TypeName;
|
||||
result.ExtractedMethods = extractionResult.Methods.Keys.ToList();
|
||||
|
||||
var validationResult = AccessibilityValidator.ValidatePublicAccess(
|
||||
sourceCode,
|
||||
extractionResult.Methods,
|
||||
extractionResult.TypeName);
|
||||
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
result.ErrorMessage = validationResult.GetFormattedErrorMessage();
|
||||
result.ValidationViolations = validationResult.Violations;
|
||||
return result;
|
||||
}
|
||||
|
||||
var originalSource = File.ReadAllText(sourceFilePath);
|
||||
var transformedCode = SourceCodeTransformer.TransformMethodBodies(
|
||||
extractionResult.Methods,
|
||||
extractionResult.TypeName,
|
||||
extractionResult.MethodSignatures,
|
||||
originalSource);
|
||||
|
||||
result.TransformedCode = transformedCode;
|
||||
|
||||
// 5. Compile the transformed code (async)
|
||||
var compilationResult = await CompileTransformedCodeAsync(
|
||||
transformedCode,
|
||||
extractionResult.TypeName,
|
||||
assemblyDir);
|
||||
|
||||
result.Success = compilationResult.IsSuccess;
|
||||
result.AssemblyName = compilationResult.AssemblyName;
|
||||
result.RegisteredMethods = compilationResult.RegisteredMethods;
|
||||
result.CompilationDiagnostics = compilationResult.Diagnostics;
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
result.ErrorMessage = compilationResult.ErrorDetails ?? "Compilation failed";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.ErrorMessage = $"Processing error: {ex.Message}";
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a source file contains [HotReload] methods.
|
||||
/// Simple synchronous check to avoid async deadlocks in tests.
|
||||
/// </summary>
|
||||
/// <param name="sourceFilePath">Path to source file to check</param>
|
||||
/// <returns>True if file contains [HotReload] methods</returns>
|
||||
public static Task<bool> ContainsHotReloadableMethodsAsync(string sourceFilePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(sourceFilePath))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// Use synchronous read for simple attribute check to avoid deadlocks
|
||||
var sourceCode = File.ReadAllText(sourceFilePath);
|
||||
if (string.IsNullOrEmpty(sourceCode))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// Quick check for [HotReload] attribute
|
||||
var result = sourceCode.Contains("[HotReload]");
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"HotReload: Error checking for [HotReload] methods in {sourceFilePath}: {ex.Message}");
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read source file content synchronously (for main thread).
|
||||
/// </summary>
|
||||
private static string ReadSourceFile(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
Debug.LogError($"HotReload: Source file not found: {filePath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return File.ReadAllText(filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"HotReload: Error reading source file {filePath}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read source file content asynchronously (for background threads).
|
||||
/// </summary>
|
||||
private static Task<string> ReadSourceFileAsync(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
Debug.LogError($"HotReload: Source file not found: {filePath}");
|
||||
return Task.FromResult<string>(null);
|
||||
}
|
||||
|
||||
// Use synchronous read wrapped in Task.FromResult to avoid deadlock issues
|
||||
var content = File.ReadAllText(filePath);
|
||||
return Task.FromResult(content);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"HotReload: Error reading source file {filePath}: {ex.Message}");
|
||||
return Task.FromResult<string>(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract [HotReloadWithOverrides] methods from source code.
|
||||
/// </summary>
|
||||
private static HotReloadableExtractionResult ExtractHotReloadableMethods(string sourceCode)
|
||||
{
|
||||
var result = new HotReloadableExtractionResult();
|
||||
|
||||
try
|
||||
{
|
||||
var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);
|
||||
var root = syntaxTree.GetRoot();
|
||||
|
||||
// Find the class containing [HotReload] methods
|
||||
var classDeclaration = root.DescendantNodes()
|
||||
.OfType<ClassDeclarationSyntax>()
|
||||
.FirstOrDefault(c => c.DescendantNodes()
|
||||
.OfType<MethodDeclarationSyntax>()
|
||||
.Any(m => HasHotReloadAttribute(m)));
|
||||
|
||||
if (classDeclaration == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result.TypeName = classDeclaration.Identifier.ValueText;
|
||||
|
||||
// Extract all [HotReload] methods
|
||||
var hotReloadableMethods = classDeclaration.DescendantNodes()
|
||||
.OfType<MethodDeclarationSyntax>()
|
||||
.Where(m => HasHotReloadAttribute(m));
|
||||
|
||||
foreach (var method in hotReloadableMethods)
|
||||
{
|
||||
var methodName = method.Identifier.ValueText;
|
||||
var methodBody = ExtractMethodBody(method);
|
||||
var signature = ExtractMethodSignature(method);
|
||||
|
||||
if (!string.IsNullOrEmpty(methodBody))
|
||||
{
|
||||
result.Methods[methodName] = methodBody;
|
||||
result.MethodSignatures[methodName] = signature;
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"HotReload: Extracted {result.Methods.Count} [HotReload] methods from class {result.TypeName}");
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"HotReload: Error extracting [HotReload] methods: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if method has [HotReload] attribute.
|
||||
/// </summary>
|
||||
private static bool HasHotReloadAttribute(MethodDeclarationSyntax method)
|
||||
{
|
||||
return method.AttributeLists
|
||||
.SelectMany(al => al.Attributes)
|
||||
.Any(a => a.Name.ToString().EndsWith("HotReload") || a.Name.ToString().EndsWith("HotReloadAttribute"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract method body content (excluding braces).
|
||||
/// </summary>
|
||||
private static string ExtractMethodBody(MethodDeclarationSyntax method)
|
||||
{
|
||||
if (method.Body != null)
|
||||
{
|
||||
var bodyText = method.Body.ToString().Trim();
|
||||
// Remove outer braces
|
||||
if (bodyText.StartsWith("{") && bodyText.EndsWith("}"))
|
||||
{
|
||||
bodyText = bodyText.Substring(1, bodyText.Length - 2).Trim();
|
||||
}
|
||||
return bodyText;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract method signature information.
|
||||
/// </summary>
|
||||
private static MethodSignatureInfo ExtractMethodSignature(MethodDeclarationSyntax method)
|
||||
{
|
||||
var signature = new MethodSignatureInfo
|
||||
{
|
||||
ReturnType = method.ReturnType.ToString()
|
||||
};
|
||||
|
||||
foreach (var parameter in method.ParameterList.Parameters)
|
||||
{
|
||||
var paramInfo = new ParameterInfo
|
||||
{
|
||||
Type = parameter.Type?.ToString() ?? "object",
|
||||
Name = parameter.Identifier.ValueText,
|
||||
HasDefaultValue = parameter.Default != null,
|
||||
DefaultValue = parameter.Default?.Value?.ToString()
|
||||
};
|
||||
|
||||
signature.Parameters.Add(paramInfo);
|
||||
}
|
||||
|
||||
return signature;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compile transformed code synchronously (for main thread).
|
||||
/// </summary>
|
||||
private static HotReloadCompilationResult CompileTransformedCode(
|
||||
string transformedCode,
|
||||
string originalTypeName,
|
||||
string assemblyDir,
|
||||
bool emitPdb = false,
|
||||
string documentPath = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Generate a temporary file name for the transformed code
|
||||
var tempFileName = $"InPlace_{originalTypeName}_{DateTime.Now:yyyyMMdd_HHmmss}";
|
||||
|
||||
// Use HotReloadCompiler synchronous method
|
||||
var compileResult = HotReloadCompiler.CompileSourceCodeOnMainThread(
|
||||
transformedCode,
|
||||
tempFileName,
|
||||
assemblyDir,
|
||||
emitPdb,
|
||||
documentPath);
|
||||
|
||||
return compileResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"HotReload: Error compiling transformed code for {originalTypeName}: {ex.Message}");
|
||||
|
||||
return HotReloadCompilationResult.Failure(
|
||||
"Compilation Error",
|
||||
ex.Message,
|
||||
0,
|
||||
new List<string> { ex.ToString() });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compile transformed code asynchronously (for background threads).
|
||||
/// </summary>
|
||||
private static async Task<HotReloadCompilationResult> CompileTransformedCodeAsync(
|
||||
string transformedCode,
|
||||
string originalTypeName,
|
||||
string assemblyDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Generate a temporary file name for the transformed code
|
||||
var tempFileName = $"InPlace_{originalTypeName}_{DateTime.Now:yyyyMMdd_HHmmss}";
|
||||
|
||||
// Use HotReloadCompiler to compile the transformed source code
|
||||
var compileResult = await HotReloadCompiler.CompileSourceCodeAsync(
|
||||
transformedCode,
|
||||
tempFileName,
|
||||
assemblyDir);
|
||||
|
||||
return compileResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"HotReload: Error compiling transformed code for {originalTypeName}: {ex.Message}");
|
||||
|
||||
return HotReloadCompilationResult.Failure(
|
||||
"Compilation Error",
|
||||
ex.Message,
|
||||
0,
|
||||
new List<string> { ex.ToString() });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting [HotReloadWithOverrides] methods from source code.
|
||||
/// </summary>
|
||||
private class HotReloadableExtractionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the class containing [HotReloadWithOverrides] methods.
|
||||
/// </summary>
|
||||
public string TypeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dictionary of method names to their extracted body code.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Methods { get; set; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Dictionary of method names to their signature information.
|
||||
/// </summary>
|
||||
public Dictionary<string, MethodSignatureInfo> MethodSignatures { get; set; } = new Dictionary<string, MethodSignatureInfo>();
|
||||
|
||||
/// <summary>
|
||||
/// Whether any [HotReloadWithOverrides] methods were found.
|
||||
/// </summary>
|
||||
public bool HasMethods => Methods.Count > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of in-place hot reload processing.
|
||||
/// </summary>
|
||||
public class InPlaceReloadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the source file that was processed.
|
||||
/// </summary>
|
||||
public string SourceFilePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the processing was successful.
|
||||
/// </summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Name of the original type containing [HotReloadWithOverrides] methods.
|
||||
/// </summary>
|
||||
public string OriginalTypeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of method names that were extracted and processed.
|
||||
/// </summary>
|
||||
public List<string> ExtractedMethods { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Generated transformed code for hot reload assembly.
|
||||
/// </summary>
|
||||
public string TransformedCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Name of the compiled assembly (if successful).
|
||||
/// </summary>
|
||||
public string AssemblyName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of registered method IDs (if successful).
|
||||
/// </summary>
|
||||
public List<string> RegisteredMethods { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Error message if processing failed.
|
||||
/// </summary>
|
||||
public string ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Accessibility validation violations (if any).
|
||||
/// </summary>
|
||||
public List<AccessibilityViolation> ValidationViolations { get; set; } = new List<AccessibilityViolation>();
|
||||
|
||||
/// <summary>
|
||||
/// Compilation diagnostics (warnings, errors).
|
||||
/// </summary>
|
||||
public List<string> CompilationDiagnostics { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Execution time in milliseconds.
|
||||
/// </summary>
|
||||
public long ExecutionTimeMs { get; set; }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 411fb44c3329710449bcbfc2af627524
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
|
||||
namespace Unity.Pipeline.HotReload
|
||||
{
|
||||
/// <summary>
|
||||
/// Up-front, parse-only validation for the helper (separate override file) workflow used by
|
||||
/// the <c>reload_file_override</c> command. Catches the common setup mistakes before compilation so
|
||||
/// the user gets an actionable message instead of a confusing compiler error.
|
||||
///
|
||||
/// Rules:
|
||||
/// 1. The file must contain at least one method annotated [HotReloadOverrideMethod(...)].
|
||||
/// 2. The file must not redeclare a type that an override targets (the override's first
|
||||
/// parameter type). Redeclaring the target type binds the override to a duplicate type
|
||||
/// and the registration silently mismatches.
|
||||
/// 3. Each [HotReloadOverrideMethod] method must be 'public static' and take the instance as its
|
||||
/// first parameter.
|
||||
/// </summary>
|
||||
public static class OverrideFileValidator
|
||||
{
|
||||
public static OverrideFileValidationResult Validate(string sourceCode, string displayName)
|
||||
{
|
||||
var result = new OverrideFileValidationResult { DisplayName = displayName };
|
||||
|
||||
var root = CSharpSyntaxTree.ParseText(sourceCode ?? string.Empty).GetRoot();
|
||||
|
||||
// All type names declared in this file (class/struct), by simple name.
|
||||
var declaredTypeNames = new HashSet<string>(root.DescendantNodes()
|
||||
.OfType<TypeDeclarationSyntax>()
|
||||
.Select(t => t.Identifier.ValueText));
|
||||
|
||||
// All methods annotated [HotReloadOverrideMethod(...)].
|
||||
var overrideMethods = root.DescendantNodes()
|
||||
.OfType<MethodDeclarationSyntax>()
|
||||
.Where(HasHotReloadOverrideMethodAttribute)
|
||||
.ToList();
|
||||
|
||||
// Rule 1: must contain at least one override method.
|
||||
if (overrideMethods.Count == 0)
|
||||
{
|
||||
result.Errors.Add(
|
||||
$"No [HotReloadOverrideMethod] overrides found in '{displayName}'. An override file must contain " +
|
||||
"public static methods annotated [HotReloadOverrideMethod(\"Type.Method\")]. " +
|
||||
"If you meant to edit a method body directly in its own source file, that is the in-place " +
|
||||
"workflow (reload_file), not reload_file_override.");
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var method in overrideMethods)
|
||||
{
|
||||
var name = method.Identifier.ValueText;
|
||||
var modifiers = method.Modifiers.Select(m => m.ValueText).ToList();
|
||||
|
||||
// Rule 3: must be public static.
|
||||
if (!modifiers.Contains("static") || !modifiers.Contains("public"))
|
||||
{
|
||||
result.Errors.Add(
|
||||
$"Override method '{name}' must be declared 'public static'.");
|
||||
}
|
||||
|
||||
// Rule 3: must take the instance as its first parameter.
|
||||
var parameters = method.ParameterList.Parameters;
|
||||
if (parameters.Count == 0)
|
||||
{
|
||||
result.Errors.Add(
|
||||
$"Override method '{name}' must take the target instance as its first parameter, " +
|
||||
$"e.g. 'public static void {name}(MyComponent instance, ...)'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rule 2: the file must not redeclare the target type (the first parameter type).
|
||||
var targetTypeName = GetSimpleTypeName(parameters[0].Type);
|
||||
if (targetTypeName != null && declaredTypeNames.Contains(targetTypeName))
|
||||
{
|
||||
result.Errors.Add(
|
||||
$"'{displayName}' declares '{targetTypeName}', which is the type that override " +
|
||||
$"'{name}' targets. Put the override in a separate file (any folder) that does not " +
|
||||
$"redeclare your gameplay type '{targetTypeName}'.");
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool HasHotReloadOverrideMethodAttribute(MethodDeclarationSyntax method)
|
||||
{
|
||||
return method.AttributeLists
|
||||
.SelectMany(al => al.Attributes)
|
||||
.Any(a => a.Name.ToString().Contains("HotReloadOverrideMethod"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract the simple (unqualified) name of a parameter type, e.g. "BossController" from
|
||||
/// "Namespace.BossController". Returns null for types we cannot reduce to a single name.
|
||||
/// </summary>
|
||||
private static string GetSimpleTypeName(TypeSyntax type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case IdentifierNameSyntax id:
|
||||
return id.Identifier.ValueText;
|
||||
case QualifiedNameSyntax qualified:
|
||||
return qualified.Right.Identifier.ValueText;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of validating a helper-workflow override file.
|
||||
/// </summary>
|
||||
public class OverrideFileValidationResult
|
||||
{
|
||||
public string DisplayName { get; set; }
|
||||
public List<string> Errors { get; } = new List<string>();
|
||||
public bool IsValid => Errors.Count == 0;
|
||||
|
||||
public string GetFormattedErrorMessage()
|
||||
{
|
||||
if (IsValid)
|
||||
{
|
||||
return "Override file is valid.";
|
||||
}
|
||||
|
||||
var message = $"reload_file_override validation failed for '{DisplayName}': {Errors.Count} issue(s)\n";
|
||||
for (int i = 0; i < Errors.Count; i++)
|
||||
{
|
||||
message += $"\n{i + 1}. {Errors[i]}";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f111479a805a488b81989f5451e4b203
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.HotReload
|
||||
{
|
||||
/// <summary>
|
||||
/// Transforms [HotReload] method bodies (edited in place on a MonoBehaviour) into static
|
||||
/// hot reload override methods that the registry can dispatch to.
|
||||
///
|
||||
/// The transform is driven by a Roslyn <see cref="SemanticModel"/>: every identifier that binds
|
||||
/// to an instance member of the declaring type (or a base, e.g. <c>transform</c> on Component) is
|
||||
/// rewritten to <c>instance.<member></c>; locals, parameters, static members and Unity
|
||||
/// built-ins are left untouched. The output mirrors the helper workflow's override shape:
|
||||
///
|
||||
/// [HotReloadOverrideMethod("Type.Method")]
|
||||
/// public static <ret> Method(Type instance, <params>) { ...rewritten body... }
|
||||
/// </summary>
|
||||
public static class SourceCodeTransformer
|
||||
{
|
||||
/// <summary>
|
||||
/// Transform the [HotReload] methods named in <paramref name="methodBodies"/> into a
|
||||
/// static override class. <paramref name="originalTypeDefinition"/> (the full source of the
|
||||
/// file) is required so the semantic model can resolve member bindings in context.
|
||||
/// </summary>
|
||||
/// <param name="emitLineDirectives">
|
||||
/// When true, each rewritten body is bracketed by <c>#line</c> directives that map it back to
|
||||
/// the original source file so an attached debugger binds breakpoints in the file the user
|
||||
/// edited. Requires <paramref name="originalFilePath"/>. The body is emitted with its original
|
||||
/// whitespace (no <c>NormalizeWhitespace</c>) so line positions survive the transform.
|
||||
/// </param>
|
||||
/// <param name="originalFilePath">Absolute path of the source file, recorded in the #line directives.</param>
|
||||
public static string TransformMethodBodies(
|
||||
Dictionary<string, string> methodBodies,
|
||||
string originalTypeName,
|
||||
Dictionary<string, MethodSignatureInfo> originalMethodSignatures,
|
||||
string originalTypeDefinition = null,
|
||||
bool emitLineDirectives = false,
|
||||
string originalFilePath = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(originalTypeDefinition))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"In-place transformation requires the full original source to build a semantic model.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tree = CSharpSyntaxTree.ParseText(originalTypeDefinition);
|
||||
var root = tree.GetRoot();
|
||||
|
||||
var compilation = CSharpCompilation.Create(
|
||||
"HotReloadInPlaceTransform",
|
||||
new[] { tree },
|
||||
RoslynCompilationService.GetMetadataReferences(),
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
|
||||
var classDecl = root.DescendantNodes()
|
||||
.OfType<ClassDeclarationSyntax>()
|
||||
.FirstOrDefault(c => c.Identifier.ValueText == originalTypeName);
|
||||
if (classDecl == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Could not find class '{originalTypeName}' in source.");
|
||||
}
|
||||
|
||||
var classSymbol = model.GetDeclaredSymbol(classDecl);
|
||||
var rewriter = new InstanceMemberQualifier(model, classSymbol);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var u in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
|
||||
sb.AppendLine(u.ToString());
|
||||
sb.AppendLine("using Unity.Pipeline.HotReload;");
|
||||
|
||||
var namespaceName = GetNamespace(classDecl);
|
||||
if (!string.IsNullOrEmpty(namespaceName))
|
||||
sb.AppendLine($"using {namespaceName};");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine($"public static class {originalTypeName}HotReloadOverrides");
|
||||
sb.AppendLine("{");
|
||||
|
||||
foreach (var method in classDecl.Members.OfType<MethodDeclarationSyntax>())
|
||||
{
|
||||
var methodName = method.Identifier.ValueText;
|
||||
if (!methodBodies.ContainsKey(methodName) || method.Body == null)
|
||||
continue;
|
||||
|
||||
var rewrittenBody = (BlockSyntax)rewriter.Visit(method.Body);
|
||||
var returnType = method.ReturnType.ToString();
|
||||
|
||||
var parameters = new List<string> { $"{originalTypeName} instance" };
|
||||
parameters.AddRange(method.ParameterList.Parameters.Select(
|
||||
p => $"{p.Type} {p.Identifier.ValueText}"));
|
||||
|
||||
sb.AppendLine($" [HotReloadOverrideMethod(\"{originalTypeName}.{methodName}\")]");
|
||||
sb.AppendLine($" public static {returnType} {methodName}({string.Join(", ", parameters)})");
|
||||
|
||||
if (emitLineDirectives && !string.IsNullOrEmpty(originalFilePath))
|
||||
{
|
||||
// Map the body back to the original file. The rewriter only makes intra-line
|
||||
// edits, so a single #line at the body's opening brace maps every line of the
|
||||
// block; emit the body with original trivia (not NormalizeWhitespace) to keep
|
||||
// line counts intact. #line hidden masks the generated scaffolding that follows.
|
||||
var bodyLine = method.Body.GetLocation().GetLineSpan().StartLinePosition.Line + 1;
|
||||
var escapedPath = originalFilePath.Replace("\\", "\\\\");
|
||||
sb.AppendLine($"#line {bodyLine} \"{escapedPath}\"");
|
||||
sb.AppendLine(rewrittenBody.ToString());
|
||||
sb.AppendLine("#line hidden");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine(" " + rewrittenBody.NormalizeWhitespace().ToString());
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine("}");
|
||||
|
||||
var result = sb.ToString();
|
||||
Debug.Log($"HotReload: Transformed {methodBodies.Count} in-place method(s) for {originalTypeName}");
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"HotReload: Error transforming in-place methods for {originalTypeName}: {ex.Message}");
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to transform in-place methods for {originalTypeName}: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetNamespace(SyntaxNode node)
|
||||
{
|
||||
for (var current = node.Parent; current != null; current = current.Parent)
|
||||
{
|
||||
if (current is NamespaceDeclarationSyntax ns)
|
||||
return ns.Name.ToString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites implicit instance-member access (this.x or bare x) to instance.x using the
|
||||
/// semantic model. Locals, parameters, static members and built-ins are left untouched.
|
||||
/// </summary>
|
||||
private class InstanceMemberQualifier : CSharpSyntaxRewriter
|
||||
{
|
||||
private readonly SemanticModel m_Model;
|
||||
private readonly INamedTypeSymbol m_Type;
|
||||
|
||||
public InstanceMemberQualifier(SemanticModel model, INamedTypeSymbol type)
|
||||
{
|
||||
m_Model = model;
|
||||
m_Type = type;
|
||||
}
|
||||
|
||||
public override SyntaxNode VisitThisExpression(ThisExpressionSyntax node)
|
||||
{
|
||||
return SyntaxFactory.IdentifierName("instance").WithTriviaFrom(node);
|
||||
}
|
||||
|
||||
public override SyntaxNode VisitIdentifierName(IdentifierNameSyntax node)
|
||||
{
|
||||
if (ShouldSkip(node))
|
||||
return base.VisitIdentifierName(node);
|
||||
|
||||
if (IsImplicitInstanceMember(node))
|
||||
return Qualify(node);
|
||||
|
||||
return base.VisitIdentifierName(node);
|
||||
}
|
||||
|
||||
public override SyntaxNode VisitGenericName(GenericNameSyntax node)
|
||||
{
|
||||
// e.g. GetComponent<Renderer>() — visit type arguments first.
|
||||
var visited = (GenericNameSyntax)base.VisitGenericName(node);
|
||||
|
||||
if (ShouldSkip(node))
|
||||
return visited;
|
||||
|
||||
if (IsImplicitInstanceMember(node))
|
||||
return Qualify(visited);
|
||||
|
||||
return visited;
|
||||
}
|
||||
|
||||
private static bool ShouldSkip(SimpleNameSyntax node)
|
||||
{
|
||||
// Right-hand side of a member access (foo.Bar -> Bar), or part of a qualified name.
|
||||
if (node.Parent is MemberAccessExpressionSyntax ma && ma.Name == node)
|
||||
return true;
|
||||
if (node.Parent is QualifiedNameSyntax)
|
||||
return true;
|
||||
if (node.Parent is MemberBindingExpressionSyntax)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool IsImplicitInstanceMember(SimpleNameSyntax node)
|
||||
{
|
||||
var symbol = m_Model.GetSymbolInfo(node).Symbol;
|
||||
if (symbol == null || symbol.IsStatic)
|
||||
return false;
|
||||
|
||||
switch (symbol.Kind)
|
||||
{
|
||||
case SymbolKind.Field:
|
||||
case SymbolKind.Property:
|
||||
case SymbolKind.Method:
|
||||
case SymbolKind.Event:
|
||||
return InTypeHierarchy(symbol.ContainingType);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool InTypeHierarchy(INamedTypeSymbol containingType)
|
||||
{
|
||||
if (containingType == null)
|
||||
return false;
|
||||
for (var t = m_Type; t != null; t = t.BaseType)
|
||||
{
|
||||
if (SymbolEqualityComparer.Default.Equals(t, containingType))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static SyntaxNode Qualify(SimpleNameSyntax node)
|
||||
{
|
||||
return SyntaxFactory.MemberAccessExpression(
|
||||
SyntaxKind.SimpleMemberAccessExpression,
|
||||
SyntaxFactory.IdentifierName("instance"),
|
||||
node.WithoutTrivia())
|
||||
.WithTriviaFrom(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a method's signature for transformation purposes.
|
||||
/// </summary>
|
||||
public class MethodSignatureInfo
|
||||
{
|
||||
public string ReturnType { get; set; }
|
||||
public List<ParameterInfo> Parameters { get; set; } = new List<ParameterInfo>();
|
||||
public bool ReturnsValue => !string.IsNullOrEmpty(ReturnType) && ReturnType != "void";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a method parameter.
|
||||
/// </summary>
|
||||
public class ParameterInfo
|
||||
{
|
||||
public string Type { get; set; }
|
||||
public string Name { get; set; }
|
||||
public bool HasDefaultValue { get; set; }
|
||||
public string DefaultValue { get; set; }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 425cdc175cccce547a6a3bf6f73c1f4f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 68a343bc078aa5e459201276f7813b32
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Unity.Pipeline.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical identity of an object an authoring command created or acted on, returned as the
|
||||
/// command result so an agent can reference it in a follow-up call (via <see cref="ObjectRef"/>).
|
||||
/// Asset objects carry assetPath/guid(/fileId); loaded/scene objects carry instanceId/hierarchyPath.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class AuthoringResult
|
||||
{
|
||||
/// <summary>Canonical GlobalObjectId string for the object.</summary>
|
||||
[JsonProperty("globalId")]
|
||||
public string GlobalId { get; set; }
|
||||
|
||||
/// <summary>Project-relative asset path, if the object is an asset.</summary>
|
||||
[JsonProperty("assetPath")]
|
||||
public string AssetPath { get; set; }
|
||||
|
||||
/// <summary>Asset GUID, if the object is an asset.</summary>
|
||||
[JsonProperty("guid")]
|
||||
public string Guid { get; set; }
|
||||
|
||||
/// <summary>Local file id within the asset, for sub-assets.</summary>
|
||||
[JsonProperty("fileId")]
|
||||
public long? FileId { get; set; }
|
||||
|
||||
/// <summary>Instance id, if the object is loaded/in a scene.</summary>
|
||||
[JsonProperty("instanceId")]
|
||||
public ObjectId? InstanceId { get; set; }
|
||||
|
||||
/// <summary>Scene hierarchy path, for scene objects.</summary>
|
||||
[JsonProperty("hierarchyPath")]
|
||||
public string HierarchyPath { get; set; }
|
||||
|
||||
/// <summary>Object type name (e.g. "GameObject", "Material", "DefaultAsset").</summary>
|
||||
[JsonProperty("type")]
|
||||
public string Type { get; set; }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6b92c58d435dcee4e8312f67b90af25c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Unity.Pipeline.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for all responses. Serve also as the base class for all errors.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class BaseResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Response message if no error
|
||||
/// </summary>
|
||||
[JsonProperty("message")]
|
||||
public string Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the command failed.
|
||||
/// </summary>
|
||||
[JsonProperty("error")]
|
||||
public string Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional error details for debugging.
|
||||
/// </summary>
|
||||
[JsonProperty("errorDetails")]
|
||||
public string ErrorDetails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When was the response created.
|
||||
/// </summary>
|
||||
[JsonProperty("executedAt")]
|
||||
public DateTime ExecutedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a failed execution response.
|
||||
/// </summary>
|
||||
public static BaseResponse Failure(string error, string errorDetails)
|
||||
{
|
||||
return new BaseResponse
|
||||
{
|
||||
ExecutedAt = DateTime.UtcNow,
|
||||
Error = error,
|
||||
ErrorDetails = errorDetails
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 00ec05951f34dac4494abb6eb6c90026
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Unity.Pipeline.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Request model for /api/exec endpoint.
|
||||
/// Contains command name and parameters for remote command execution.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class CommandExecutionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the command to execute.
|
||||
/// Must match a registered [CliCommand] name.
|
||||
/// </summary>
|
||||
[JsonProperty("command", Required = Required.Always)]
|
||||
public string Command { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameters for the command execution.
|
||||
/// Contains key-value pairs matching command parameter names.
|
||||
/// </summary>
|
||||
[JsonProperty("parameters")]
|
||||
public JObject Parameters { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional timeout for command execution in milliseconds.
|
||||
/// Default: 60000 (60 seconds)
|
||||
/// </summary>
|
||||
[JsonProperty("timeout")]
|
||||
public int? Timeout { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a new command execution request.
|
||||
/// </summary>
|
||||
public CommandExecutionRequest()
|
||||
{
|
||||
Parameters = new JObject();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a command execution request with specified command and parameters.
|
||||
/// </summary>
|
||||
public CommandExecutionRequest(string command, JObject parameters = null)
|
||||
{
|
||||
Command = command ?? throw new ArgumentNullException(nameof(command));
|
||||
Parameters = parameters ?? new JObject();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a parameter value as the specified type.
|
||||
/// Returns the default value if the parameter is not found or cannot be converted.
|
||||
/// </summary>
|
||||
public T GetParameter<T>(string name, T defaultValue = default(T))
|
||||
{
|
||||
if (Parameters == null || !Parameters.ContainsKey(name))
|
||||
return defaultValue;
|
||||
|
||||
try
|
||||
{
|
||||
return Parameters[name].ToObject<T>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a parameter exists in the request.
|
||||
/// </summary>
|
||||
public bool HasParameter(string name)
|
||||
{
|
||||
return Parameters != null && Parameters.ContainsKey(name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate the request structure.
|
||||
/// </summary>
|
||||
public string Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Command))
|
||||
return "Command name is required";
|
||||
|
||||
if (Timeout.HasValue && Timeout.Value <= 0)
|
||||
return "Timeout must be a positive number";
|
||||
|
||||
return null; // No validation errors
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cb08d2e5232819b43bbec0549c02a689
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Unity.Pipeline.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Response model for /api/exec endpoint.
|
||||
/// Contains execution result and metadata for remote command execution.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class CommandExecutionResponse : BaseResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the command executed successfully.
|
||||
/// </summary>
|
||||
[JsonProperty("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Name of the command that was executed.
|
||||
/// </summary>
|
||||
[JsonProperty("command")]
|
||||
public string Command { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Result returned by the command (if any).
|
||||
/// </summary>
|
||||
[JsonProperty("result")]
|
||||
public object Result { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Execution duration in milliseconds.
|
||||
/// </summary>
|
||||
[JsonProperty("executionTimeMs")]
|
||||
public long? ExecutionTimeMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a successful execution response.
|
||||
/// </summary>
|
||||
public static CommandExecutionResponse CmdSuccess(string command, object result = null, long? executionTimeMs = null)
|
||||
{
|
||||
return new CommandExecutionResponse
|
||||
{
|
||||
Success = true,
|
||||
Command = command,
|
||||
ExecutedAt = DateTime.UtcNow,
|
||||
Result = result,
|
||||
ExecutionTimeMs = executionTimeMs
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a failed execution response.
|
||||
/// </summary>
|
||||
public static CommandExecutionResponse CmdFailure(string cmd, string error, string errorDetails)
|
||||
{
|
||||
return new CommandExecutionResponse
|
||||
{
|
||||
Command = cmd,
|
||||
Success = false,
|
||||
ExecutedAt = DateTime.UtcNow,
|
||||
Error = error,
|
||||
ErrorDetails = errorDetails
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1911e492692621e4998635d7ddce0489
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Unity.Pipeline.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// A single captured Unity console entry, as returned by the <c>console</c> command.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class ConsoleLogEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Monotonic sequence number assigned when the entry was captured. Acts as the cursor for
|
||||
/// incremental ("--follow") retrieval: pass the highest seq seen back as the <c>since</c>
|
||||
/// parameter to get only newer entries. Never reused, and increasing for the life of the
|
||||
/// buffer (including across domain reloads, since the buffer is persisted).
|
||||
/// </summary>
|
||||
[JsonProperty("seq")]
|
||||
public long Seq { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the entry was captured (UTC).
|
||||
/// </summary>
|
||||
[JsonProperty("timestampUtc")]
|
||||
public DateTime TimestampUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalized severity: "log", "warn", or "error". Unity's Error/Exception/Assert log types
|
||||
/// all map to "error"; Warning maps to "warn"; Log maps to "log".
|
||||
/// </summary>
|
||||
[JsonProperty("level")]
|
||||
public string Level { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The log message text.
|
||||
/// </summary>
|
||||
[JsonProperty("message")]
|
||||
public string Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The stack trace associated with the entry, if any. Empty for most plain logs.
|
||||
/// </summary>
|
||||
[JsonProperty("stackTrace")]
|
||||
public string StackTrace { get; set; }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bd1bd665815e523ee0da6504d78923df
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Unity.Pipeline.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Response model for the <c>console</c> command: a page of captured console entries plus
|
||||
/// the cursor needed to fetch the next page.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class ConsoleLogResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The matching entries, oldest first, after applying the level filter, <c>since</c> cursor,
|
||||
/// and <c>tail</c> limit.
|
||||
/// </summary>
|
||||
[JsonProperty("entries")]
|
||||
public ConsoleLogEntry[] Entries { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The highest <see cref="ConsoleLogEntry.Seq"/> currently held in the buffer (regardless of
|
||||
/// the level filter). A "--follow" client passes this back as <c>since</c> on the next poll
|
||||
/// so it resumes exactly where it left off, even if the only new entries were filtered out.
|
||||
/// </summary>
|
||||
[JsonProperty("cursor")]
|
||||
public long Cursor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of entries returned in <see cref="Entries"/>.
|
||||
/// </summary>
|
||||
[JsonProperty("returned")]
|
||||
public int Returned { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the requested <c>since</c> cursor pointed at entries that have already been
|
||||
/// evicted from the bounded buffer, meaning the consumer missed some entries. Lets a
|
||||
/// "--follow" client warn about a gap instead of silently skipping output.
|
||||
/// </summary>
|
||||
[JsonProperty("dropped")]
|
||||
public bool Dropped { get; set; }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3382b91cda66d80db6ab090f9fbeac16
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Unity.Pipeline.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Response from code evaluation commands containing results, output, and diagnostics.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class EvalResponse :CommandExecutionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Console output captured during execution.
|
||||
/// </summary>
|
||||
[JsonProperty("output")]
|
||||
public string Output { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Compilation diagnostics (errors, warnings) from Roslyn.
|
||||
/// </summary>
|
||||
[JsonProperty("diagnostics")]
|
||||
public List<DiagnosticInfo> Diagnostics { get; set; } = new List<DiagnosticInfo>();
|
||||
|
||||
/// <summary>
|
||||
/// Create a successful evaluation response.
|
||||
/// </summary>
|
||||
public static EvalResponse EvalSuccess(object result, string output = null, long executionTimeMs = 0, List<DiagnosticInfo> diagnostics = null)
|
||||
{
|
||||
return new EvalResponse
|
||||
{
|
||||
Success = true,
|
||||
Result = result,
|
||||
Output = output,
|
||||
ExecutionTimeMs = executionTimeMs,
|
||||
Diagnostics = diagnostics ?? new List<DiagnosticInfo>()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a failed evaluation response.
|
||||
/// </summary>
|
||||
public static EvalResponse EvalFailure(string error, string errorDetails = null, long executionTimeMs = 0, List<DiagnosticInfo> diagnostics = null)
|
||||
{
|
||||
return new EvalResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = error,
|
||||
ErrorDetails = errorDetails,
|
||||
ExecutionTimeMs = executionTimeMs,
|
||||
Diagnostics = diagnostics ?? new List<DiagnosticInfo>()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compilation diagnostic information from Roslyn.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class DiagnosticInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Severity level: "error", "warning", "info".
|
||||
/// </summary>
|
||||
[JsonProperty("severity")]
|
||||
public string Severity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic message text.
|
||||
/// </summary>
|
||||
[JsonProperty("message")]
|
||||
public string Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Line number (0-based).
|
||||
/// </summary>
|
||||
[JsonProperty("line")]
|
||||
public int Line { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Column number (0-based).
|
||||
/// </summary>
|
||||
[JsonProperty("column")]
|
||||
public int Column { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic identifier (e.g., "CS1002").
|
||||
/// </summary>
|
||||
[JsonProperty("id")]
|
||||
public string Id { get; set; }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7f93e07b346878148b61c36fd3ece752
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Diagnostics;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
using Unity.Pipeline.Security;
|
||||
|
||||
namespace Unity.Pipeline.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages instance descriptor files for CLI discovery.
|
||||
/// Written to Library/Pipeline/.unity-pipeline-port (under the project's git-ignored Library
|
||||
/// folder) for CLI tools to find running Editor instances.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class InstanceDescriptor
|
||||
{
|
||||
private const string DescriptorFileName = ".unity-pipeline-port";
|
||||
|
||||
/// <summary>
|
||||
/// Process ID of the Unity Editor
|
||||
/// </summary>
|
||||
[JsonProperty("pid")]
|
||||
public int Pid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP port the pipeline server is listening on
|
||||
/// </summary>
|
||||
[JsonProperty("port")]
|
||||
public int Port { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Full path to the Unity project
|
||||
/// </summary>
|
||||
[JsonProperty("projectPath")]
|
||||
public string ProjectPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Name of the Unity project
|
||||
/// </summary>
|
||||
[JsonProperty("projectName")]
|
||||
public string ProjectName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unity Editor version string
|
||||
/// </summary>
|
||||
[JsonProperty("unityVersion")]
|
||||
public string UnityVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Editor mode: "editor", "batchmode"
|
||||
/// </summary>
|
||||
[JsonProperty("mode")]
|
||||
public string Mode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the Editor instance was started
|
||||
/// </summary>
|
||||
[JsonProperty("startedAt")]
|
||||
public DateTime StartedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Last heartbeat timestamp
|
||||
/// </summary>
|
||||
[JsonProperty("lastHeartbeat")]
|
||||
public DateTime LastHeartbeat { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Security token for code evaluation commands
|
||||
/// </summary>
|
||||
[JsonProperty("evalToken")]
|
||||
public string EvalToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Create instance descriptor for current Editor session
|
||||
/// </summary>
|
||||
public static InstanceDescriptor CreateCurrent(int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
var projectPath = Path.GetDirectoryName(Application.dataPath);
|
||||
var projectName = Path.GetFileName(projectPath);
|
||||
var pid = Process.GetCurrentProcess().Id;
|
||||
var unityVersion = Application.unityVersion;
|
||||
var isBatchMode = Application.isBatchMode;
|
||||
|
||||
// Generate eval token at server startup for CLI auto-discovery
|
||||
var evalToken = SecurityTokenManager.GetOrCreateToken();
|
||||
|
||||
return new InstanceDescriptor
|
||||
{
|
||||
Pid = pid,
|
||||
Port = port,
|
||||
ProjectPath = projectPath,
|
||||
ProjectName = projectName,
|
||||
UnityVersion = unityVersion,
|
||||
Mode = isBatchMode ? "batchmode" : "editor",
|
||||
StartedAt = DateTime.UtcNow,
|
||||
LastHeartbeat = DateTime.UtcNow,
|
||||
EvalToken = evalToken
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogError($"CreateCurrent failed: {ex.Message}");
|
||||
UnityEngine.Debug.LogError($"Stack trace: {ex.StackTrace}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write instance descriptor to project root
|
||||
/// </summary>
|
||||
public static void WriteToProjectRoot(InstanceDescriptor descriptor)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filePath = GetDescriptorFilePath(descriptor.ProjectPath);
|
||||
var isNewFile = !File.Exists(filePath);
|
||||
// The descriptor lives under Library/Pipeline, which may not exist yet.
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(filePath));
|
||||
var json = JsonConvert.SerializeObject(descriptor, Formatting.Indented);
|
||||
File.WriteAllText(filePath, json);
|
||||
|
||||
// The descriptor carries the auth token; keep it readable only by the current user.
|
||||
// Applied once on creation (heartbeat rewrites preserve the existing permissions).
|
||||
if (isNewFile)
|
||||
FilePermissions.RestrictToCurrentUser(filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogError($"WriteToProjectRoot failed: {ex.Message}");
|
||||
UnityEngine.Debug.LogError($"Descriptor: {descriptor?.ProjectPath}, PID: {descriptor?.Pid}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read instance descriptor from project root
|
||||
/// </summary>
|
||||
public static InstanceDescriptor ReadFromProjectRoot(string projectPath)
|
||||
{
|
||||
var filePath = GetDescriptorFilePath(projectPath);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
return JsonConvert.DeserializeObject<InstanceDescriptor>(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Invalid or corrupted file
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove instance descriptor from project root
|
||||
/// </summary>
|
||||
public static void RemoveFromProjectRoot(string projectPath)
|
||||
{
|
||||
var filePath = GetDescriptorFilePath(projectPath);
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update heartbeat timestamp in existing descriptor file
|
||||
/// </summary>
|
||||
public static void UpdateHeartbeat(string projectPath)
|
||||
{
|
||||
var descriptor = ReadFromProjectRoot(projectPath);
|
||||
if (descriptor != null)
|
||||
{
|
||||
descriptor.LastHeartbeat = DateTime.UtcNow;
|
||||
WriteToProjectRoot(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Absolute path to the editor descriptor file for a project: the git-ignored
|
||||
/// Library/Pipeline folder under the project root. Public so discovery code and tests
|
||||
/// derive the location from one place.
|
||||
/// </summary>
|
||||
public static string GetDescriptorFilePath(string projectPath)
|
||||
{
|
||||
return Path.Combine(projectPath, "Library", "Pipeline", DescriptorFileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c19a2cde67d600341b17d8056b8393e2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Unity.Pipeline.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Agent-supplied handle that references an existing Unity object — an asset or a
|
||||
/// loaded/scene object. Supply ONE form; <see cref="Unity.Pipeline.Editor.Authoring.ObjectResolver"/>
|
||||
/// tries them in order: globalId, path, guid (+ optional fileId), instanceId, hierarchyPath.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
[JsonConverter(typeof(ObjectRefConverter))]
|
||||
public class ObjectRef
|
||||
{
|
||||
/// <summary>Canonical GlobalObjectId string (addresses assets and scene objects uniformly).</summary>
|
||||
[JsonProperty("globalId")]
|
||||
public string GlobalId { get; set; }
|
||||
|
||||
/// <summary>Project-relative asset path, e.g. "Assets/Foo/Bar.prefab".</summary>
|
||||
[JsonProperty("path")]
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>Asset GUID.</summary>
|
||||
[JsonProperty("guid")]
|
||||
public string Guid { get; set; }
|
||||
|
||||
/// <summary>Local file id, used with <see cref="Guid"/> to address a sub-asset.</summary>
|
||||
[JsonProperty("fileId")]
|
||||
public long? FileId { get; set; }
|
||||
|
||||
/// <summary>Instance id of a loaded/scene object.</summary>
|
||||
[JsonProperty("instanceId")]
|
||||
public ObjectId? InstanceId { get; set; }
|
||||
|
||||
/// <summary>Scene hierarchy path, e.g. "/Root/Child/Leaf".</summary>
|
||||
[JsonProperty("hierarchyPath")]
|
||||
public string HierarchyPath { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsEmpty =>
|
||||
string.IsNullOrEmpty(GlobalId) &&
|
||||
string.IsNullOrEmpty(Path) &&
|
||||
string.IsNullOrEmpty(Guid) &&
|
||||
InstanceId == null &&
|
||||
string.IsNullOrEmpty(HierarchyPath);
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(GlobalId)) return GlobalId;
|
||||
if (!string.IsNullOrEmpty(Path)) return Path;
|
||||
if (!string.IsNullOrEmpty(Guid)) return FileId.HasValue ? $"guid:{Guid}:{FileId}" : $"guid:{Guid}";
|
||||
if (InstanceId.HasValue) return $"instanceId:{InstanceId}";
|
||||
if (!string.IsNullOrEmpty(HierarchyPath)) return HierarchyPath;
|
||||
return "<empty>";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e970b2bf3a890d743bea6785f8af3765
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Unity.Pipeline.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON converter for <see cref="ObjectRef"/> that accepts BOTH a structured object
|
||||
/// (all six fields via their JSON property names) AND a single canonical string handle.
|
||||
///
|
||||
/// The MCP tool schemas intentionally expose object-reference parameters as plain strings, so an
|
||||
/// agent typically passes e.g. <c>"target": "/Player"</c> or <c>"target": "GlobalObjectId_V1-..."</c>.
|
||||
/// Without this converter, Newtonsoft's <c>ToObject<ObjectRef>()</c> (used by
|
||||
/// <c>BasePipelineServer.ExtractCommandParameters</c>) gets a JValue(string), returns null, and the
|
||||
/// command fails Required-parameter validation. This converter coerces the string into the matching
|
||||
/// field, mirroring the priority order of <c>ObjectResolver.TryResolve</c> and <c>ObjectRef.ToString()</c>.
|
||||
///
|
||||
/// Registered on the class itself via <c>[JsonConverter(typeof(ObjectRefConverter))]</c> so it applies
|
||||
/// everywhere an <see cref="ObjectRef"/> is (de)serialized — no server or command changes required.
|
||||
/// </summary>
|
||||
public class ObjectRefConverter : JsonConverter<ObjectRef>
|
||||
{
|
||||
private static readonly Regex s_GuidWithFileId = new Regex(@"^guid:([0-9a-fA-F]+):([0-9]+)$", RegexOptions.Compiled);
|
||||
private static readonly Regex s_GuidOnly = new Regex(@"^guid:([0-9a-fA-F]+)$", RegexOptions.Compiled);
|
||||
private static readonly Regex s_InstanceId = new Regex(@"^instanceId:(-?[0-9]+)$", RegexOptions.Compiled);
|
||||
private static readonly Regex s_PlainInt = new Regex(@"^-?[0-9]+$", RegexOptions.Compiled);
|
||||
private static readonly Regex s_Hex32 = new Regex(@"^[0-9a-fA-F]{32}$", RegexOptions.Compiled);
|
||||
|
||||
public override ObjectRef ReadJson(JsonReader reader, Type objectType, ObjectRef existingValue,
|
||||
bool hasExistingValue, JsonSerializer serializer)
|
||||
{
|
||||
var token = JToken.Load(reader);
|
||||
switch (token.Type)
|
||||
{
|
||||
case JTokenType.Null:
|
||||
return null;
|
||||
case JTokenType.String:
|
||||
return FromString((string)token);
|
||||
case JTokenType.Integer:
|
||||
// A bare JSON number is treated as an instance id (e.g. "target": 48184).
|
||||
return new ObjectRef { InstanceId = token.ToObject<ObjectId>() };
|
||||
case JTokenType.Object:
|
||||
return FromObject((JObject)token);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public override void WriteJson(JsonWriter writer, ObjectRef value, JsonSerializer serializer)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
writer.WriteNull();
|
||||
return;
|
||||
}
|
||||
|
||||
// Write a standard JSON object (matching the [JsonProperty] names on ObjectRef). Done
|
||||
// manually rather than via serializer.Serialize to avoid re-entering this converter.
|
||||
writer.WriteStartObject();
|
||||
writer.WritePropertyName("globalId");
|
||||
writer.WriteValue(value.GlobalId);
|
||||
writer.WritePropertyName("path");
|
||||
writer.WriteValue(value.Path);
|
||||
writer.WritePropertyName("guid");
|
||||
writer.WriteValue(value.Guid);
|
||||
writer.WritePropertyName("fileId");
|
||||
if (value.FileId.HasValue) writer.WriteValue(value.FileId.Value); else writer.WriteNull();
|
||||
writer.WritePropertyName("instanceId");
|
||||
// Route the id through ObjectIdConverter so it serializes as its raw numeric form
|
||||
// (the 64-bit EntityId on 6000.4+, the int below). Not this converter — no recursion.
|
||||
if (value.InstanceId.HasValue) serializer.Serialize(writer, value.InstanceId.Value); else writer.WriteNull();
|
||||
writer.WritePropertyName("hierarchyPath");
|
||||
writer.WriteValue(value.HierarchyPath);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a structured JSON object to an ObjectRef by reading each field directly (rather than
|
||||
/// <c>ToObject<ObjectRef>()</c>, which would recurse back into this converter).
|
||||
///
|
||||
/// Keys are matched case-INSENSITIVELY: agents commonly vary the casing (e.g. "instanceID" for
|
||||
/// instanceId, "fileID" for fileId), and because this custom converter bypasses Newtonsoft's
|
||||
/// default case-insensitive property binding, an exact-case lookup would otherwise drop the
|
||||
/// field and yield an empty (silently unresolved) handle.
|
||||
/// </summary>
|
||||
private static ObjectRef FromObject(JObject jo)
|
||||
{
|
||||
return new ObjectRef
|
||||
{
|
||||
GlobalId = Str(jo, "globalId"),
|
||||
Path = Str(jo, "path"),
|
||||
Guid = Str(jo, "guid"),
|
||||
FileId = Long(jo, "fileId"),
|
||||
InstanceId = ObjId(jo, "instanceId"),
|
||||
HierarchyPath = Str(jo, "hierarchyPath"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Find a property value by name, ignoring case (Newtonsoft's JObject lookup is case-sensitive).</summary>
|
||||
private static JToken Get(JObject jo, string key)
|
||||
{
|
||||
foreach (var p in jo.Properties())
|
||||
{
|
||||
if (string.Equals(p.Name, key, StringComparison.OrdinalIgnoreCase))
|
||||
return p.Value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string Str(JObject jo, string key)
|
||||
{
|
||||
var t = Get(jo, key);
|
||||
return t == null || t.Type == JTokenType.Null ? null : t.Value<string>();
|
||||
}
|
||||
|
||||
private static ObjectId? ObjId(JObject jo, string key)
|
||||
{
|
||||
var t = Get(jo, key);
|
||||
return t == null || t.Type == JTokenType.Null ? (ObjectId?)null : t.ToObject<ObjectId>();
|
||||
}
|
||||
|
||||
private static long? Long(JObject jo, string key)
|
||||
{
|
||||
var t = Get(jo, key);
|
||||
return t == null || t.Type == JTokenType.Null ? (long?)null : t.Value<long?>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a single canonical string handle into the matching field. Priority order matches
|
||||
/// <c>ObjectResolver.TryResolve</c> / <c>ObjectRef.ToString()</c>; a bare name falls back to
|
||||
/// the hierarchy path (the most common agent input).
|
||||
/// </summary>
|
||||
internal static ObjectRef FromString(string s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
return null;
|
||||
|
||||
s = s.Trim();
|
||||
|
||||
if (s.StartsWith("GlobalObjectId_V1", StringComparison.Ordinal))
|
||||
return new ObjectRef { GlobalId = s };
|
||||
|
||||
// "Assets/..." and "Packages/..." (and the bare roots) are asset paths that
|
||||
// AssetDatabase.LoadMainAssetAtPath can resolve; everything else falls through to the
|
||||
// hierarchy-path fallback at the end.
|
||||
if (s.StartsWith("Assets/", StringComparison.Ordinal)
|
||||
|| s.StartsWith("Packages/", StringComparison.Ordinal)
|
||||
|| s == "Assets" || s == "Packages")
|
||||
return new ObjectRef { Path = s };
|
||||
|
||||
var guidFile = s_GuidWithFileId.Match(s);
|
||||
if (guidFile.Success && long.TryParse(guidFile.Groups[2].Value, out var fileId))
|
||||
return new ObjectRef { Guid = guidFile.Groups[1].Value, FileId = fileId };
|
||||
|
||||
var guidOnly = s_GuidOnly.Match(s);
|
||||
if (guidOnly.Success)
|
||||
return new ObjectRef { Guid = guidOnly.Groups[1].Value };
|
||||
|
||||
var inst = s_InstanceId.Match(s);
|
||||
if (inst.Success && ObjectId.TryParse(inst.Groups[1].Value, out var prefixedId))
|
||||
return new ObjectRef { InstanceId = prefixedId };
|
||||
|
||||
// A plain integer is an instance id. On 6000.0-6000.3 negative ids are valid (unsaved scene
|
||||
// objects); on 6000.4+ the id is the unsigned 64-bit EntityId, so a leading '-' won't parse
|
||||
// and falls through to the hierarchy-path handling below.
|
||||
if (s_PlainInt.IsMatch(s) && ObjectId.TryParse(s, out var plainId))
|
||||
return new ObjectRef { InstanceId = plainId };
|
||||
|
||||
// Exactly 32 hex chars with no other prefix is a bare asset GUID.
|
||||
if (s_Hex32.IsMatch(s))
|
||||
return new ObjectRef { Guid = s };
|
||||
|
||||
// A relative string with a file extension (e.g. "Materials/Floor.mat", "Enemy.prefab") is
|
||||
// most likely an authoring-root-relative asset path — route it to Path so the resolver can
|
||||
// normalize it under the authoring root. A leading "/" always means a scene hierarchy path,
|
||||
// and an extension-less string ("Player", "Root/Child") stays one. Dotted GameObject names
|
||||
// ("Cube.001") land here too, but the resolver falls back to a hierarchy lookup for those.
|
||||
if (!s.StartsWith("/", StringComparison.Ordinal) && Path.HasExtension(s))
|
||||
return new ObjectRef { Path = s };
|
||||
|
||||
// Leading-"/" canonical paths and bare names resolve as a scene hierarchy path.
|
||||
return new ObjectRef { HierarchyPath = s };
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e31382a242c2a084e97ba76716798aa3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
using Unity.Pipeline.Config;
|
||||
using System.IO;
|
||||
using Unity.Pipeline.Security;
|
||||
|
||||
namespace Unity.Pipeline.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Instance descriptor for runtime Unity Player builds with Pipeline server.
|
||||
/// Different from Editor InstanceDescriptor to reflect runtime context and security.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class RuntimeInstanceDescriptor
|
||||
{
|
||||
private const string RuntimeDescriptorFileName = ".unity-pipeline-runtime-port";
|
||||
|
||||
/// <summary>
|
||||
/// Process ID of the Unity Player application
|
||||
/// </summary>
|
||||
[JsonProperty("pid")]
|
||||
public int Pid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP port the pipeline server is listening on (7900-7999 range)
|
||||
/// </summary>
|
||||
[JsonProperty("port")]
|
||||
public int Port { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unity runtime platform (Windows, macOS, Linux, etc.)
|
||||
/// </summary>
|
||||
[JsonProperty("platform")]
|
||||
public string Platform { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unity Player version string
|
||||
/// </summary>
|
||||
[JsonProperty("unityVersion")]
|
||||
public string UnityVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique build identifier
|
||||
/// </summary>
|
||||
[JsonProperty("buildGuid")]
|
||||
public string BuildGuid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the runtime instance was started
|
||||
/// </summary>
|
||||
[JsonProperty("startedAt")]
|
||||
public DateTime StartedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Last heartbeat timestamp
|
||||
/// </summary>
|
||||
[JsonProperty("lastHeartbeat")]
|
||||
public DateTime LastHeartbeat { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Working directory where the application is running
|
||||
/// </summary>
|
||||
[JsonProperty("workingDirectory")]
|
||||
public string WorkingDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Security token used to authorize requests to this runtime server.
|
||||
/// </summary>
|
||||
[JsonProperty("evalToken")]
|
||||
public string EvalToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Create runtime instance descriptor for current Player application.
|
||||
/// </summary>
|
||||
public static RuntimeInstanceDescriptor CreateCurrent(int port, RuntimePipelineConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pid = System.Diagnostics.Process.GetCurrentProcess().Id;
|
||||
var unityVersion = Application.unityVersion;
|
||||
var platform = Application.platform.ToString();
|
||||
var buildGuid = Application.buildGUID;
|
||||
var workingDir = Directory.GetCurrentDirectory();
|
||||
|
||||
var token = SecurityTokenManager.GetOrCreateToken();
|
||||
|
||||
return new RuntimeInstanceDescriptor
|
||||
{
|
||||
Pid = pid,
|
||||
Port = port,
|
||||
Platform = platform,
|
||||
UnityVersion = unityVersion,
|
||||
BuildGuid = buildGuid,
|
||||
StartedAt = DateTime.UtcNow,
|
||||
LastHeartbeat = DateTime.UtcNow,
|
||||
WorkingDirectory = workingDir,
|
||||
EvalToken = token
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogError($"Pipeline: CreateCurrent failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write runtime instance descriptor to working directory.
|
||||
/// </summary>
|
||||
public static void WriteToWorkingDirectory(RuntimeInstanceDescriptor descriptor)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filePath = GetDescriptorFilePath();
|
||||
var isNewFile = !File.Exists(filePath);
|
||||
var json = JsonConvert.SerializeObject(descriptor, Formatting.Indented);
|
||||
|
||||
File.WriteAllText(filePath, json);
|
||||
|
||||
// The descriptor carries the auth token; keep it readable only by the current user.
|
||||
// Applied once on creation (heartbeat rewrites preserve the existing permissions).
|
||||
if (isNewFile)
|
||||
FilePermissions.RestrictToCurrentUser(filePath);
|
||||
|
||||
System.Console.WriteLine($"Pipeline: Runtime descriptor written to {filePath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogError($"Pipeline: Failed to write runtime descriptor: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read runtime instance descriptor from working directory.
|
||||
/// </summary>
|
||||
public static RuntimeInstanceDescriptor ReadFromWorkingDirectory()
|
||||
{
|
||||
var filePath = GetDescriptorFilePath();
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
return JsonConvert.DeserializeObject<RuntimeInstanceDescriptor>(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning($"Pipeline: Failed to read runtime descriptor: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove runtime instance descriptor from working directory.
|
||||
/// </summary>
|
||||
public static void RemoveFromWorkingDirectory()
|
||||
{
|
||||
var filePath = GetDescriptorFilePath();
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning($"Pipeline: Failed to remove runtime descriptor: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update heartbeat timestamp in existing descriptor file.
|
||||
/// </summary>
|
||||
public static void UpdateHeartbeat()
|
||||
{
|
||||
try
|
||||
{
|
||||
var descriptor = ReadFromWorkingDirectory();
|
||||
if (descriptor != null)
|
||||
{
|
||||
descriptor.LastHeartbeat = DateTime.UtcNow;
|
||||
WriteToWorkingDirectory(descriptor);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning($"Pipeline: Failed to update runtime heartbeat: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetDescriptorFilePath()
|
||||
{
|
||||
var workingDir = new FileInfo($"{Application.dataPath}/..").FullName;
|
||||
return Path.Combine(workingDir, RuntimeDescriptorFileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a7787403e02096545b7798cd3d5d076d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Unity.Pipeline.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Response model for /api/status endpoint.
|
||||
/// Provides Editor state information for CLI tools and automation scripts.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class StatusResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Current Editor status: "ready", "compiling", "busy"
|
||||
/// </summary>
|
||||
[JsonProperty("status")]
|
||||
public string Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether Unity is currently compiling scripts
|
||||
/// </summary>
|
||||
[JsonProperty("compiling")]
|
||||
public bool Compiling { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a domain reload is in progress
|
||||
/// </summary>
|
||||
[JsonProperty("domainReloadInProgress")]
|
||||
public bool DomainReloadInProgress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current play mode state: "stopped", "playing", "paused"
|
||||
/// </summary>
|
||||
[JsonProperty("playMode")]
|
||||
public string PlayMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of last heartbeat update
|
||||
/// </summary>
|
||||
[JsonProperty("lastHeartbeat")]
|
||||
public DateTime LastHeartbeat { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Full path to the Unity project
|
||||
/// </summary>
|
||||
[JsonProperty("projectPath")]
|
||||
public string ProjectPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unity Editor version string
|
||||
/// </summary>
|
||||
[JsonProperty("unityVersion")]
|
||||
public string UnityVersion { get; set; }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 86f7bba2bb7101544a486545cb8e2f70
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user