Add Unity Pipeline package support

This commit is contained in:
ud18010
2026-07-31 17:34:04 +08:00
parent d1a526094e
commit 786b7ffff9
590 changed files with 51995 additions and 2 deletions
@@ -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:
@@ -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
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 54dc75c59cc13424c9e6e0e7b237e412
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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);
}
}
}
}
@@ -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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 92be7cfe398a263a7b06a4a8a00a802c
@@ -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 };
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 33ec6e594cb07434cb51fbc751ca37f2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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);
}
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e508f97d464c66347816d5281c68e276
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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."
};
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fa9e0d957acd4b1293e4d40e83bca73f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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}";
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8e82ea92b18fc264faf68d664212e013
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 846a260c41f6d8f4583f1473632c1d62
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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>&gt;</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(&lt;state&gt;)</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>&lt;prefix&gt;_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
@@ -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:
@@ -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));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ac8886d9f7e92d844b4f1728ce2e2cab
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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));
}
}
}
@@ -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}");
}
}
}
}
@@ -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);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e310dee2532338d4e914d3c909ea4db0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c6e1392c576ec4d4d9b389d5fc899303
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
{
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 366b2704c3b5c044aa544fe545d462f0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -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}";
}
}
}
@@ -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>&lt;-&gt;<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
}
}
@@ -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:
@@ -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
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1ea6a44edd711194a968a40f1eb16f59
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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>()
};
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 29ede4b57fcc6c64b84e5f6ee75df373
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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>();
}
}
@@ -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:
@@ -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 &gt; 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 &lt;= 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
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1e014974635df5fe6a6787c4ba983f80
@@ -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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3d9b6e1a4f2c40a78c5e0b1d2a6f9c84
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c29607a31a43d7149b8296de99b81d8e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1bdaafa6e8d172648a57310104db179a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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&lt;T&gt; wrapper for type-safe delegate hot reload.
///
/// Pattern B: Explicit Wrapper
/// - HotReloadMethod&lt;T&gt; 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&lt;T&gt; 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));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0bd43f6237214774ab4a178b811eb453
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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}";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5a2835bfdec4fc24cb67a208766efcff
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 23cd8d0c782f1974e8a3b4095a178092
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 411fb44c3329710449bcbfc2af627524
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f111479a805a488b81989f5451e4b203
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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.&lt;member&gt;</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 &lt;ret&gt; Method(Type instance, &lt;params&gt;) { ...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; }
}
}
@@ -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; }
}
}
@@ -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
};
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 00ec05951f34dac4494abb6eb6c90026
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cb08d2e5232819b43bbec0549c02a689
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
};
}
}
}
@@ -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; }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bd1bd665815e523ee0da6504d78923df
@@ -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; }
}
}
@@ -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; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7f93e07b346878148b61c36fd3ece752
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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);
}
}
}
@@ -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:
@@ -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&lt;ObjectRef&gt;()</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&lt;ObjectRef&gt;()</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 };
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e31382a242c2a084e97ba76716798aa3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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);
}
}
}
@@ -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; }
}
}
@@ -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