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,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: