Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a83ffb4c25f355a419ba91b8fc12ca48
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
@@ -0,0 +1,39 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 33fa77d2af5e469ca652745160e4c35d
|
||||
labels:
|
||||
- RoslynAnalyzer
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
iconMap: {}
|
||||
executionOrder: {}
|
||||
defineConstraints: []
|
||||
isPreloaded: 0
|
||||
isOverridable: 0
|
||||
isExplicitlyReferenced: 0
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
- first:
|
||||
: Any
|
||||
second:
|
||||
enabled: 0
|
||||
settings:
|
||||
Exclude Editor: 1
|
||||
Exclude Linux64: 1
|
||||
Exclude OSXUniversal: 1
|
||||
Exclude Win: 1
|
||||
Exclude Win64: 1
|
||||
- first:
|
||||
Any:
|
||||
second:
|
||||
enabled: 0
|
||||
settings: {}
|
||||
- first:
|
||||
Editor: Editor
|
||||
second:
|
||||
enabled: 0
|
||||
settings:
|
||||
DefaultValueInitialized: true
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,10 @@
|
||||
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")]
|
||||
// Lets the Editor assembly feed CliProgress's internal ambient mirror (EditorProgressMirror)
|
||||
// and consume internal interpreter glue (e.g. IlInterpreterHostBindings, which the code-reload
|
||||
// link.xml generator derives its preservation set from).
|
||||
[assembly: InternalsVisibleTo("Unity.Pipeline.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: 3da432e2a5ea462089d41e21aa49f59b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
|
||||
namespace Unity.Pipeline.CodeReload
|
||||
{
|
||||
/// <summary>
|
||||
/// Marks a method as reloadable: edit its body directly in the original source file and
|
||||
/// apply the file with the <c>reload_file</c> command (or the interpreter-backed variants). The
|
||||
/// running instance picks up the new body without a domain reload. Bodies may touch members of
|
||||
/// any accessibility, including private/protected/internal ones of the target type.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
|
||||
public class CodeReloadAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional identifier for this 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 parameterless instance method to be invoked after a code reload is applied to its
|
||||
/// declaring component. Fires once per <c>reload_file</c> that binds at least one override on
|
||||
/// the type, on every live instance — a place to re-initialize or refresh state when the code is
|
||||
/// swapped (analogous to a domain-reload-free OnEnable). The method runs on the main thread;
|
||||
/// exceptions are logged, not propagated. Only UnityEngine.Object-derived types are supported
|
||||
/// (instances are discovered via FindObjectsByType).
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
|
||||
[JetBrains.Annotations.MeansImplicitUse]
|
||||
public class OnCodeReloadAttribute : Attribute
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a compiled static method as the code reload override for a specific <c>[CodeReload]</c>
|
||||
/// method. Emitted by the reload pipeline on the code it generates from an edited source file
|
||||
/// (see <c>SourceCodeTransformer</c>); user code does not write it by hand.
|
||||
///
|
||||
/// Override signature: the original method's signature plus the target instance as the first
|
||||
/// parameter. Example: <c>void Update()</c> becomes <c>static void Update(TargetType instance)</c>.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
|
||||
public class CodeReloadOverrideMethodAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Target method identifier in format "TypeName.MethodName"
|
||||
/// Must match a method marked with [CodeReload].
|
||||
/// </summary>
|
||||
public string TargetMethodId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description of what this code reload does.
|
||||
/// Useful for debugging and code reload management.
|
||||
/// </summary>
|
||||
public string Description { get; set; }
|
||||
|
||||
/// <summary>Mark a method as the code-reload override for the given target method.</summary>
|
||||
/// <param name="targetMethodId">Target method identifier, "TypeName.MethodName".</param>
|
||||
public CodeReloadOverrideMethodAttribute(string targetMethodId)
|
||||
{
|
||||
TargetMethodId = targetMethodId ?? throw new ArgumentNullException(nameof(targetMethodId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0bd43f6237214774ab4a178b811eb453
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Unity.Pipeline.Attributes",
|
||||
"rootNamespace": "Unity.Pipeline.CodeReload",
|
||||
"references": [],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b522baaf20314499ba2deea8dd838496
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c29607a31a43d7149b8296de99b81d8e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,211 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityPipeline.Microsoft.CodeAnalysis;
|
||||
using UnityPipeline.Microsoft.CodeAnalysis.CSharp;
|
||||
using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.CodeReload
|
||||
{
|
||||
/// <summary>
|
||||
/// Accessibility gate for [CodeReload] method bodies on the compiled (Assembly.Load) backend.
|
||||
///
|
||||
/// The compiled backend cannot use non-public members of the target type: even if the override
|
||||
/// compiled with Roslyn's access checks relaxed, Unity's Mono enforces accessibility when
|
||||
/// JIT-ing the loaded override IL and does not honor <c>[assembly: IgnoresAccessChecksTo]</c>
|
||||
/// (verified on Mono 6.13) — the access would throw (e.g. <c>FieldAccessException</c>) on first
|
||||
/// dispatch and silently fall back to the original body. Compiled-backend overrides therefore
|
||||
/// compile with standard access checks (see
|
||||
/// <c>RoslynCompilationService.AllowNonPublicMemberAccess</c>, interpreter-only), and this
|
||||
/// validator runs up front — only when the compiled backend will execute the override — to turn
|
||||
/// the eventual raw CS0122 into a clear reload error. The interpreter backend reaches
|
||||
/// non-public members via reflection and must not be gated.
|
||||
/// </summary>
|
||||
static class AccessibilityValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Check that every [CodeReload] method body only accesses public instance members of its
|
||||
/// declaring type. Runs over an already-built semantic model + class declaration, so the
|
||||
/// in-place reload path shares one compilation between validation and transformation
|
||||
/// instead of building two.
|
||||
/// </summary>
|
||||
/// <param name="model">The semantic model built over the source containing the declaring type.</param>
|
||||
/// <param name="classDecl">The declaring type's class declaration within <paramref name="model"/>.</param>
|
||||
/// <param name="methodBodies">Map of method name to body source, for the methods being validated.</param>
|
||||
/// <param name="originalTypeName">Name of the declaring type to validate against.</param>
|
||||
/// <returns>The validation result, including any violations found.</returns>
|
||||
public static AccessibilityValidationResult ValidatePublicAccess(
|
||||
SemanticModel model,
|
||||
ClassDeclarationSyntax classDecl,
|
||||
Dictionary<string, string> methodBodies,
|
||||
string originalTypeName)
|
||||
{
|
||||
var result = new AccessibilityValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Violations = new List<AccessibilityViolation>()
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
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 [CodeReload] method '{methodName}'",
|
||||
Suggestion = $"Make '{symbol.Name}' public in {originalTypeName}, or reload " +
|
||||
"through the interpreter backend (reload_file_editor_interpreter), which " +
|
||||
"reaches non-public members. The compiled backend loads the override " +
|
||||
"as a separate assembly and Mono enforces accessibility at dispatch."
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.IsValid = result.Violations.Count == 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"CodeReload: 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 code reload methods.
|
||||
/// </summary>
|
||||
class AccessibilityValidationResult
|
||||
{
|
||||
/// <summary>True when no accessibility violations were found.</summary>
|
||||
public bool IsValid { get; set; }
|
||||
/// <summary>Violations found, if any.</summary>
|
||||
public List<AccessibilityViolation> Violations { get; set; } = new List<AccessibilityViolation>();
|
||||
/// <summary>Set when validation itself could not run (e.g. a parse failure), rather than a violation being found.</summary>
|
||||
public string ValidationError { get; set; }
|
||||
|
||||
/// <summary>Human-readable summary of <see cref="ValidationError"/> or all <see cref="Violations"/>.</summary>
|
||||
public string GetFormattedErrorMessage()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(ValidationError))
|
||||
return $"CodeReload Validation Error: {ValidationError}";
|
||||
|
||||
if (Violations.Count == 0)
|
||||
return "All member access is valid for code reload.";
|
||||
|
||||
var errorMessage = $"CodeReload 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 code reload code.
|
||||
/// </summary>
|
||||
class AccessibilityViolation
|
||||
{
|
||||
/// <summary>Name of the non-public member that was accessed.</summary>
|
||||
public string MemberName { get; set; }
|
||||
/// <summary>Name of the [CodeReload] method containing the access.</summary>
|
||||
public string MethodName { get; set; }
|
||||
/// <summary>The member's actual accessibility.</summary>
|
||||
public Accessibility AccessLevel { get; set; }
|
||||
/// <summary>Category of violation.</summary>
|
||||
public AccessibilityViolationType ViolationType { get; set; }
|
||||
/// <summary>Human-readable description of the violation.</summary>
|
||||
public string ErrorMessage { get; set; }
|
||||
/// <summary>Suggested fix.</summary>
|
||||
public string Suggestion { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of accessibility violations that can occur in code reload methods.
|
||||
/// </summary>
|
||||
enum AccessibilityViolationType
|
||||
{
|
||||
/// <summary>A private member was accessed.</summary>
|
||||
PrivateAccess,
|
||||
/// <summary>An internal member was accessed.</summary>
|
||||
InternalAccess,
|
||||
/// <summary>A protected member was accessed.</summary>
|
||||
ProtectedAccess,
|
||||
/// <summary>The source could not be parsed to determine accessibility.</summary>
|
||||
ParseError
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1bdaafa6e8d172648a57310104db179a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
|
||||
namespace Unity.Pipeline.CodeReload
|
||||
{
|
||||
/// <summary>
|
||||
/// Thread-safe snapshot of the most recent code-reload activity, for on-screen display by
|
||||
/// <see cref="CodeReloadStatusOverlay"/>. Fed from two sides: the PlayerConnection receiver
|
||||
/// (compile-started / compile-failed notices arrive on a background thread) and the apply path
|
||||
/// in RuntimePipelineDriver (main thread). Timestamps use <see cref="Environment.TickCount"/>
|
||||
/// because Unity's Time API is main-thread-only and reporters run off it.
|
||||
/// </summary>
|
||||
static class CodeReloadActivity
|
||||
{
|
||||
public enum Phase
|
||||
{
|
||||
/// <summary>No reload in flight; nothing to show beyond the override count.</summary>
|
||||
Idle = 0,
|
||||
/// <summary>The editor announced a compile for this player (ReloadPendingMsg).</summary>
|
||||
Compiling = 1,
|
||||
/// <summary>A pushed override was applied; message is the apply summary.</summary>
|
||||
Applied = 2,
|
||||
/// <summary>Editor compile failed or on-device apply failed; message says why.</summary>
|
||||
Failed = 3,
|
||||
}
|
||||
|
||||
private static readonly object s_Lock = new object();
|
||||
private static Phase s_Phase = Phase.Idle;
|
||||
private static string s_Message = "";
|
||||
private static int s_PhaseTick = Environment.TickCount;
|
||||
|
||||
public static void ReportCompileStarted(string name) => Set(Phase.Compiling, name);
|
||||
public static void ReportApplied(string summary) => Set(Phase.Applied, summary);
|
||||
public static void ReportFailed(string summary) => Set(Phase.Failed, summary);
|
||||
|
||||
private static void Set(Phase phase, string message)
|
||||
{
|
||||
lock (s_Lock)
|
||||
{
|
||||
s_Phase = phase;
|
||||
s_Message = message ?? "";
|
||||
s_PhaseTick = Environment.TickCount;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Current phase, its message, and milliseconds since the phase changed.</summary>
|
||||
public static Phase Snapshot(out string message, out int ageMs)
|
||||
{
|
||||
lock (s_Lock)
|
||||
{
|
||||
message = s_Message;
|
||||
ageMs = Environment.TickCount - s_PhaseTick;
|
||||
return s_Phase;
|
||||
}
|
||||
}
|
||||
|
||||
private static long s_OverrideCalls;
|
||||
|
||||
/// <summary>Called by the dispatch path once per overridden-method invocation. The running
|
||||
/// total lets the overlay show a call rate — "N overrides · 0 calls/s" exposes the
|
||||
/// bound-but-inert case (override registered but the woven prologue never dispatching),
|
||||
/// which no log line catches.</summary>
|
||||
public static void CountOverrideCall() => System.Threading.Interlocked.Increment(ref s_OverrideCalls);
|
||||
|
||||
/// <summary>Total overridden-method invocations this session.</summary>
|
||||
public static long OverrideCallCount => System.Threading.Interlocked.Read(ref s_OverrideCalls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 689f041b295d86a4b8ffd6249abf5a7a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,295 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using UnityPipeline.Microsoft.CodeAnalysis;
|
||||
using UnityPipeline.Microsoft.CodeAnalysis.CSharp;
|
||||
using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using UnityPipeline.Microsoft.CodeAnalysis.Text;
|
||||
using Unity.Pipeline.Compilation;
|
||||
|
||||
namespace Unity.Pipeline.CodeReload
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-method snapshot of a reloadable file's last-compiled source, so a reload can skip
|
||||
/// methods whose bodies still match the code the target already runs compiled. Without this,
|
||||
/// every save re-registers an interpreter override for every [CodeReload] method in the file,
|
||||
/// and methods the user never touched pay the interpreter's per-call cost.
|
||||
///
|
||||
/// A baseline is captured per (file, preprocessor symbols) pair — the same file diffs
|
||||
/// differently under editor and player defines. For each [CodeReload] method it stores a hash
|
||||
/// of the declaration's token stream (trivia excluded, so whitespace and comment edits don't
|
||||
/// count as changes), plus one context hash over the rest of the file with all executable
|
||||
/// bodies removed. A context change (fields, consts, signatures, usings) conservatively marks
|
||||
/// every method changed: an override compiles against the current file, so same-file
|
||||
/// declaration edits can shift an untouched body's meaning.
|
||||
///
|
||||
/// Owned by whoever knows when disk matches the compiled state (the editor watch); with no
|
||||
/// baseline captured, classification returns null and reloads behave as before. The store is
|
||||
/// static, so the editor persists/restores it across domain reloads itself.
|
||||
/// </summary>
|
||||
static class CodeReloadBaseline
|
||||
{
|
||||
private sealed class Entry
|
||||
{
|
||||
public ulong ContextHash;
|
||||
public readonly Dictionary<string, ulong> MethodHashes = new Dictionary<string, ulong>();
|
||||
}
|
||||
|
||||
// Key: normalized full path + '\u001f' + define key.
|
||||
private static readonly Dictionary<string, Entry> s_Entries =
|
||||
new Dictionary<string, Entry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Number of captured (file, defines) baselines.</summary>
|
||||
public static int Count => s_Entries.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot <paramref name="filePath"/> as the compiled reference state for the given
|
||||
/// preprocessor symbols (null = project defaults). Call only when the file on disk is known
|
||||
/// to match what the reload target runs compiled. Returns false (and drops any stale entry)
|
||||
/// when the file can't be read or has no [CodeReload] method.
|
||||
/// </summary>
|
||||
public static bool Capture(string filePath, string[] preprocessorSymbols = null)
|
||||
{
|
||||
string source;
|
||||
try { source = File.ReadAllText(filePath); }
|
||||
catch { source = null; }
|
||||
return CaptureFromSource(filePath, source, preprocessorSymbols);
|
||||
}
|
||||
|
||||
/// <summary>See <see cref="Capture"/>; source text supplied by the caller (tests).</summary>
|
||||
public static bool CaptureFromSource(string filePath, string sourceCode, string[] preprocessorSymbols = null)
|
||||
{
|
||||
var key = KeyFor(filePath, preprocessorSymbols);
|
||||
var entry = sourceCode == null ? null : ComputeEntry(sourceCode, preprocessorSymbols);
|
||||
if (entry == null)
|
||||
{
|
||||
s_Entries.Remove(key);
|
||||
return false;
|
||||
}
|
||||
s_Entries[key] = entry;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff <paramref name="sourceCode"/> against the captured baseline for
|
||||
/// (<paramref name="filePath"/>, <paramref name="preprocessorSymbols"/>). Returns the
|
||||
/// [CodeReload] method names whose declarations still match it — safe to leave running
|
||||
/// compiled. Null when no baseline was captured for that pair (treat everything as
|
||||
/// changed); empty when the file's context changed, so nothing is skippable.
|
||||
/// </summary>
|
||||
public static HashSet<string> GetUnchangedMethods(
|
||||
string filePath, string sourceCode, string[] preprocessorSymbols = null)
|
||||
{
|
||||
if (!s_Entries.TryGetValue(KeyFor(filePath, preprocessorSymbols), out var baseline))
|
||||
return null;
|
||||
|
||||
var unchanged = new HashSet<string>(StringComparer.Ordinal);
|
||||
var current = sourceCode == null ? null : ComputeEntry(sourceCode, preprocessorSymbols);
|
||||
if (current == null || current.ContextHash != baseline.ContextHash)
|
||||
return unchanged;
|
||||
|
||||
foreach (var kv in current.MethodHashes)
|
||||
if (baseline.MethodHashes.TryGetValue(kv.Key, out var h) && h == kv.Value)
|
||||
unchanged.Add(kv.Key);
|
||||
return unchanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when every [CodeReload] method in <paramref name="sourceCode"/> matches the baseline
|
||||
/// and none of them is in <paramref name="mustReload"/> (methods that must reload anyway —
|
||||
/// e.g. already overridden on a device that can't unregister). Callers use this to skip a
|
||||
/// whole compile-and-push. False when no baseline was captured.
|
||||
/// </summary>
|
||||
public static bool IsFileUpToDate(
|
||||
string filePath, string sourceCode, string[] preprocessorSymbols = null,
|
||||
ICollection<string> mustReload = null)
|
||||
{
|
||||
if (!s_Entries.TryGetValue(KeyFor(filePath, preprocessorSymbols), out var baseline))
|
||||
return false;
|
||||
var current = sourceCode == null ? null : ComputeEntry(sourceCode, preprocessorSymbols);
|
||||
if (current == null || current.ContextHash != baseline.ContextHash)
|
||||
return false;
|
||||
|
||||
foreach (var kv in current.MethodHashes)
|
||||
{
|
||||
if (!baseline.MethodHashes.TryGetValue(kv.Key, out var h) || h != kv.Value)
|
||||
return false;
|
||||
if (mustReload != null && mustReload.Contains(kv.Key))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Drop every captured baseline (watch stopped, or about to recapture).</summary>
|
||||
public static void Clear() => s_Entries.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Flatten the store to one string so the editor can stash it in SessionState across the
|
||||
/// play-mode domain reload — the reload wipes these statics while disk keeps the user's
|
||||
/// edits, so the baseline can't be recaptured from disk at that point.
|
||||
/// </summary>
|
||||
public static string Serialize()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var kv in s_Entries)
|
||||
{
|
||||
sb.Append(kv.Key).Append('\u001e').Append(kv.Value.ContextHash.ToString("x", CultureInfo.InvariantCulture));
|
||||
foreach (var m in kv.Value.MethodHashes)
|
||||
sb.Append('\u001e').Append(m.Key).Append('=').Append(m.Value.ToString("x", CultureInfo.InvariantCulture));
|
||||
sb.Append('\n');
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Replace the store with a <see cref="Serialize"/> snapshot. Unparseable lines are dropped.</summary>
|
||||
public static void Restore(string serialized)
|
||||
{
|
||||
s_Entries.Clear();
|
||||
if (string.IsNullOrEmpty(serialized)) return;
|
||||
|
||||
foreach (var line in serialized.Split('\n'))
|
||||
{
|
||||
if (line.Length == 0) continue;
|
||||
var fields = line.Split('\u001e');
|
||||
if (fields.Length < 2) continue;
|
||||
if (!ulong.TryParse(fields[1], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var contextHash))
|
||||
continue;
|
||||
|
||||
var entry = new Entry { ContextHash = contextHash };
|
||||
bool ok = true;
|
||||
for (int i = 2; i < fields.Length; i++)
|
||||
{
|
||||
int eq = fields[i].LastIndexOf('=');
|
||||
if (eq <= 0 || !ulong.TryParse(fields[i].Substring(eq + 1),
|
||||
NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var h))
|
||||
{
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
entry.MethodHashes[fields[i].Substring(0, eq)] = h;
|
||||
}
|
||||
if (ok)
|
||||
s_Entries[fields[0]] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
private static string KeyFor(string filePath, string[] preprocessorSymbols)
|
||||
{
|
||||
string path;
|
||||
try { path = Path.GetFullPath(filePath); }
|
||||
catch { path = filePath ?? ""; }
|
||||
var defines = preprocessorSymbols == null ? "<project>" : string.Join(";", preprocessorSymbols);
|
||||
return path + '\u001f' + defines;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hash the file: one hash per [CodeReload] method name (overload hashes fold together —
|
||||
/// method ids are name-keyed everywhere else too), and a context hash over every token
|
||||
/// outside an executable body. Null when no class carries a [CodeReload] method.
|
||||
/// </summary>
|
||||
private static Entry ComputeEntry(string sourceCode, string[] preprocessorSymbols)
|
||||
{
|
||||
SyntaxNode root;
|
||||
try
|
||||
{
|
||||
root = CSharpSyntaxTree.ParseText(sourceCode,
|
||||
RoslynCompilationService.ProjectParseOptions(preprocessorSymbols)).GetRoot();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var entry = new Entry();
|
||||
foreach (var node in root.DescendantNodes())
|
||||
{
|
||||
if (node is not MethodDeclarationSyntax method ||
|
||||
!InPlaceReloadProcessor.HasCodeReloadAttribute(method))
|
||||
continue;
|
||||
var name = method.Identifier.ValueText;
|
||||
var declHash = HashTokens(method, null);
|
||||
entry.MethodHashes[name] = entry.MethodHashes.TryGetValue(name, out var prior)
|
||||
? MixHash(prior, declHash)
|
||||
: declHash;
|
||||
}
|
||||
if (entry.MethodHashes.Count == 0)
|
||||
return null;
|
||||
|
||||
entry.ContextHash = HashTokens(root, CollectBodySpans(root));
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spans of every executable body in the file: method/ctor/operator bodies, accessor
|
||||
/// bodies, and expression bodies (incl. expression-bodied properties/indexers). Reloadable
|
||||
/// bodies are covered per-method; the others bind to compiled code from an override either
|
||||
/// way, so their edits can't change what an override does and must not poison the context.
|
||||
/// </summary>
|
||||
private static List<TextSpan> CollectBodySpans(SyntaxNode root)
|
||||
{
|
||||
var spans = new List<TextSpan>();
|
||||
foreach (var node in root.DescendantNodes())
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case BaseMethodDeclarationSyntax m:
|
||||
if (m.Body != null) spans.Add(m.Body.Span);
|
||||
if (m.ExpressionBody != null) spans.Add(m.ExpressionBody.Span);
|
||||
break;
|
||||
case AccessorDeclarationSyntax a:
|
||||
if (a.Body != null) spans.Add(a.Body.Span);
|
||||
if (a.ExpressionBody != null) spans.Add(a.ExpressionBody.Span);
|
||||
break;
|
||||
case PropertyDeclarationSyntax p when p.ExpressionBody != null:
|
||||
spans.Add(p.ExpressionBody.Span);
|
||||
break;
|
||||
case IndexerDeclarationSyntax ix when ix.ExpressionBody != null:
|
||||
spans.Add(ix.ExpressionBody.Span);
|
||||
break;
|
||||
}
|
||||
}
|
||||
spans.Sort((a, b) => a.Start.CompareTo(b.Start));
|
||||
return spans;
|
||||
}
|
||||
|
||||
// FNV-1a 64 over the token stream. Trivia never enters the hash, so formatting and
|
||||
// comments can't mark a method changed. A separator byte between tokens keeps ("ab","c")
|
||||
// and ("a","bc") distinct even though C# tokenization already makes that unlikely.
|
||||
private const ulong FnvOffset = 14695981039346656037UL;
|
||||
private const ulong FnvPrime = 1099511628211UL;
|
||||
|
||||
private static ulong HashTokens(SyntaxNode node, List<TextSpan> excludedSpans)
|
||||
{
|
||||
ulong h = FnvOffset;
|
||||
int next = 0; // excludedSpans is sorted; tokens come in document order
|
||||
foreach (var token in node.DescendantTokens())
|
||||
{
|
||||
if (excludedSpans != null)
|
||||
{
|
||||
while (next < excludedSpans.Count && excludedSpans[next].End <= token.Span.Start)
|
||||
next++;
|
||||
if (next < excludedSpans.Count && excludedSpans[next].Contains(token.Span))
|
||||
continue;
|
||||
}
|
||||
|
||||
h = (h ^ 0xFF) * FnvPrime;
|
||||
var text = token.Text;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
h = (h ^ (byte)text[i]) * FnvPrime;
|
||||
h = (h ^ (byte)(text[i] >> 8)) * FnvPrime;
|
||||
}
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
private static ulong MixHash(ulong a, ulong b)
|
||||
{
|
||||
for (int i = 0; i < 8; i++)
|
||||
a = (a ^ (byte)(b >> (i * 8))) * FnvPrime;
|
||||
return a;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9844e60b4d0144ffa632f3433a159737
|
||||
@@ -0,0 +1,673 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using Unity.Pipeline.Threading;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.CodeReload
|
||||
{
|
||||
/// <summary>
|
||||
/// Central registry for managing code reload method and component overrides.
|
||||
/// Handles runtime method resolution, override registration, and fallback logic.
|
||||
/// Thread-safe for runtime code reload operations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately NOT [NoAutoStaticsCleanup]: RegisterReloadableMethod appends without deduplicating,
|
||||
/// and RuntimePipelineDriver.Awake re-registers on every Play Mode entry, so opting out would
|
||||
/// accumulate duplicates across FEPM sessions. Let AutoStaticsCleanup reset these statics instead.
|
||||
/// </remarks>
|
||||
public static class CodeReloadRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Dispatcher used to marshal main-thread-required code-reload overrides to the main thread.
|
||||
/// Injected by RuntimePipelineDriver (= 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 code reload source files from (Assets folder
|
||||
/// + loaded package locations). Baked into RuntimePipelineBuildInfo 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; }
|
||||
|
||||
// Facts established at domain-reload / Play Mode entry that only matter once the user
|
||||
// actually reloads (target count, receiver state, connected players). Collected here and
|
||||
// printed as ONE console line on the first override registration ATTEMPT of the session —
|
||||
// attempt, not success, so a reload against an empty registry still gets the summary next
|
||||
// to its skip reasons. Statics reset with the domain, so each session collects afresh.
|
||||
private static readonly List<string> s_FirstReloadNotes = new();
|
||||
private static bool s_FirstReloadNotesFlushed;
|
||||
|
||||
/// <summary>
|
||||
/// Record a startup fact for the "first reload this session" summary line. Once that line
|
||||
/// has been printed, a late note (e.g. a player connecting mid-session) is logged directly.
|
||||
/// </summary>
|
||||
/// <param name="note">Short clause, no trailing period, e.g. "3 reloadable method(s) registered".</param>
|
||||
public static void NoteForFirstReload(string note)
|
||||
{
|
||||
if (string.IsNullOrEmpty(note)) return;
|
||||
lock (s_FirstReloadNotes)
|
||||
{
|
||||
if (s_FirstReloadNotesFlushed)
|
||||
{
|
||||
Debug.Log($"CodeReload: {note}.");
|
||||
return;
|
||||
}
|
||||
s_FirstReloadNotes.Add(note);
|
||||
}
|
||||
}
|
||||
|
||||
private static void FlushFirstReloadNotes()
|
||||
{
|
||||
string line = null;
|
||||
lock (s_FirstReloadNotes)
|
||||
{
|
||||
if (s_FirstReloadNotesFlushed) return;
|
||||
s_FirstReloadNotesFlushed = true;
|
||||
if (s_FirstReloadNotes.Count > 0)
|
||||
line = "CodeReload: first reload this session — " + string.Join("; ", s_FirstReloadNotes) + ".";
|
||||
s_FirstReloadNotes.Clear();
|
||||
}
|
||||
if (line != null)
|
||||
Debug.Log(line);
|
||||
}
|
||||
|
||||
// Thread-safe collections for runtime code 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_LoadedCodeReloadTypes = new();
|
||||
|
||||
/// <summary>
|
||||
/// Raised after a method override is registered or removed (once per method — a
|
||||
/// multi-method reload fires it several times back to back, so debounce reactions).
|
||||
/// Lets UI that reflects reloadable code (e.g. a debug panel overlay) rebuild when a
|
||||
/// compiled method's body is swapped.
|
||||
///
|
||||
/// INTERNAL for now — deliberately held back from the first public API surface; make it
|
||||
/// public when the introspection surface ships (external consumers poll
|
||||
/// GetStats().ActiveOverrideCount meanwhile). The raise sites stay: register, unregister
|
||||
/// (revert), and ClearAllOverrides — the transitions a subscriber needs.
|
||||
/// </summary>
|
||||
internal static event Action OverridesChanged;
|
||||
|
||||
private static void RaiseOverridesChanged()
|
||||
{
|
||||
try
|
||||
{
|
||||
OverridesChanged?.Invoke();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"CodeReload: OverridesChanged handler threw: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a method as reloadable. Called during discovery phase.
|
||||
/// </summary>
|
||||
/// <param name="method">The method marked with <see cref="CodeReloadAttribute"/>.</param>
|
||||
/// <param name="attribute">The attribute instance (supplies the override id and main-thread requirement).</param>
|
||||
public static void RegisterReloadableMethod(MethodInfo method, CodeReloadAttribute 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 code reload method override from compiled code reload assembly.
|
||||
/// Returns true if the override was registered, false if it was skipped.
|
||||
/// </summary>
|
||||
/// <param name="overrideMethod">The override method marked with <see cref="CodeReloadOverrideMethodAttribute"/>.</param>
|
||||
/// <param name="attribute">The attribute instance (identifies the target method).</param>
|
||||
/// <param name="sourceType">The type the override method was compiled into.</param>
|
||||
/// <returns>True if the override was registered.</returns>
|
||||
public static bool RegisterMethodOverride(MethodInfo overrideMethod, CodeReloadOverrideMethodAttribute attribute, Type sourceType)
|
||||
{
|
||||
return RegisterMethodOverride(overrideMethod, attribute, sourceType, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a code reload method override from compiled code 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>
|
||||
/// <param name="overrideMethod">The override method marked with <see cref="CodeReloadOverrideMethodAttribute"/>.</param>
|
||||
/// <param name="attribute">The attribute instance (identifies the target method).</param>
|
||||
/// <param name="sourceType">The type the override method was compiled into.</param>
|
||||
/// <param name="skipReason">A user-facing reason when registration is skipped, or null on success.</param>
|
||||
/// <returns>True if the override was registered.</returns>
|
||||
public static bool RegisterMethodOverride(MethodInfo overrideMethod, CodeReloadOverrideMethodAttribute attribute, Type sourceType, out string skipReason)
|
||||
{
|
||||
FlushFirstReloadNotes();
|
||||
skipReason = null;
|
||||
var targetMethodId = attribute.TargetMethodId;
|
||||
|
||||
// Validate that target method exists and is reloadable
|
||||
if (!m_ReloadableMethods.ContainsKey(targetMethodId))
|
||||
{
|
||||
Debug.LogWarning($"CodeReload: Target method '{targetMethodId}' not found or not marked [CodeReload]");
|
||||
skipReason = $"target '{targetMethodId}' is not registered as a reloadable [CodeReload] method. " +
|
||||
"Ensure the component is in the scene and in play mode (RuntimePipelineDriver auto-discovers it).";
|
||||
return false;
|
||||
}
|
||||
|
||||
var originalMethods = m_ReloadableMethods[targetMethodId];
|
||||
if (!originalMethods.Any())
|
||||
{
|
||||
Debug.LogWarning($"CodeReload: 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($"CodeReload: 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, ...)'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var methodOverride = new MethodOverride
|
||||
{
|
||||
TargetMethodId = targetMethodId,
|
||||
OverrideMethod = overrideMethod,
|
||||
SourceType = sourceType,
|
||||
RequireMainThread = GetMainThreadRequirement(originalMethod),
|
||||
Description = attribute.Description
|
||||
};
|
||||
|
||||
m_MethodOverrides[targetMethodId] = methodOverride;
|
||||
RaiseOverridesChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register an IL2CPP-safe override that dispatches through <paramref name="interpreterInvoke"/>
|
||||
/// (a IlInterpreter interpreter invoke) instead of a loaded <see cref="MethodInfo"/>. The target
|
||||
/// must already be registered reloadable (same precondition as <see cref="RegisterMethodOverride"/>).
|
||||
/// </summary>
|
||||
internal static bool RegisterInterpreterMethodOverride(
|
||||
string targetMethodId, Func<object, object[], object> interpreterInvoke, out string skipReason)
|
||||
{
|
||||
FlushFirstReloadNotes();
|
||||
skipReason = null;
|
||||
|
||||
if (!m_ReloadableMethods.TryGetValue(targetMethodId, out var originalMethods) || originalMethods.Count == 0)
|
||||
{
|
||||
skipReason = $"target '{targetMethodId}' is not registered as a reloadable [CodeReload] method. " +
|
||||
"Ensure the component is in the scene and in play mode (RuntimePipelineDriver auto-discovers it).";
|
||||
return false;
|
||||
}
|
||||
|
||||
m_MethodOverrides[targetMethodId] = new MethodOverride
|
||||
{
|
||||
TargetMethodId = targetMethodId,
|
||||
InterpreterInvoke = interpreterInvoke,
|
||||
RequireMainThread = GetMainThreadRequirement(originalMethods.First()),
|
||||
};
|
||||
|
||||
Debug.Log($"CodeReload: Registered interpreter override for '{targetMethodId}'");
|
||||
RaiseOverridesChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove one active override so the woven prologue falls through to the original compiled
|
||||
/// body again. Used when a reloaded file's method matches the compiled baseline again (the
|
||||
/// edit was reverted) — keeping the override would leave a correct but slower interpreter
|
||||
/// dispatch in place. Returns false when no override was registered for the id.
|
||||
/// </summary>
|
||||
internal static bool UnregisterMethodOverride(string targetMethodId)
|
||||
{
|
||||
if (!m_MethodOverrides.TryRemove(targetMethodId, out _))
|
||||
return false;
|
||||
Debug.Log($"CodeReload: Removed override for '{targetMethodId}' — original compiled body is active again");
|
||||
RaiseOverridesChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After a reload binds, invoke every <c>[OnCodeReload]</c> method on all live instances of the
|
||||
/// types that own the given reloaded methods. Lets a component re-init state when its code is
|
||||
/// swapped. Parameterless instance methods only; exceptions are logged, not propagated.
|
||||
/// </summary>
|
||||
internal static void InvokeReloadCallbacks(IEnumerable<string> reloadedTargetMethodIds)
|
||||
{
|
||||
if (reloadedTargetMethodIds == null) return;
|
||||
|
||||
var types = new HashSet<Type>();
|
||||
foreach (var id in reloadedTargetMethodIds)
|
||||
{
|
||||
if (m_ReloadableMethods.TryGetValue(id, out var methods) && methods.Count > 0
|
||||
&& methods[0].DeclaringType != null)
|
||||
{
|
||||
types.Add(methods[0].DeclaringType);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var type in types)
|
||||
InvokeReloadCallbacksOnType(type);
|
||||
}
|
||||
|
||||
private static void InvokeReloadCallbacksOnType(Type type)
|
||||
{
|
||||
var callbacks = type
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.Where(m => m.GetParameters().Length == 0 && m.GetCustomAttribute<OnCodeReloadAttribute>() != null)
|
||||
.ToList();
|
||||
if (callbacks.Count == 0) return;
|
||||
|
||||
if (!typeof(UnityEngine.Object).IsAssignableFrom(type))
|
||||
{
|
||||
Debug.LogWarning($"CodeReload: [OnCodeReload] on '{type.Name}' ignored — only " +
|
||||
"UnityEngine.Object-derived types are supported (instances are found via FindObjectsByType).");
|
||||
return;
|
||||
}
|
||||
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
var instances = UnityEngine.Object.FindObjectsByType(type);
|
||||
#else
|
||||
var instances = UnityEngine.Object.FindObjectsByType(type, FindObjectsSortMode.None);
|
||||
#endif
|
||||
foreach (var instance in instances)
|
||||
{
|
||||
foreach (var cb in callbacks)
|
||||
{
|
||||
try
|
||||
{
|
||||
cb.Invoke(instance, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"CodeReload: [OnCodeReload] {type.Name}.{cb.Name} threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when at least one [CodeReload] target has been registered this session.</summary>
|
||||
internal static bool HasReloadableMethods => m_ReloadableMethods.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the runtime <see cref="Type"/> that declares a reloadable target method, so an
|
||||
/// interpreter host binding can be built for it. Returns false if not registered.
|
||||
/// </summary>
|
||||
internal static bool TryGetReloadableDeclaringType(string targetMethodId, out Type declaringType)
|
||||
{
|
||||
declaringType = null;
|
||||
if (m_ReloadableMethods.TryGetValue(targetMethodId, out var methods) && methods.Count > 0)
|
||||
{
|
||||
declaringType = methods[0].DeclaringType;
|
||||
return declaringType != null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to invoke code reload override if available, otherwise invoke original method.
|
||||
/// Returns true if code reload override was invoked, false if original method should be called.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The instance's type.</typeparam>
|
||||
/// <param name="methodId">Method identifier, "TypeName.MethodName".</param>
|
||||
/// <param name="instance">The instance to invoke the override on.</param>
|
||||
/// <param name="parameters">The original call's arguments, boxed.</param>
|
||||
/// <returns>True if a code reload override was invoked.</returns>
|
||||
public static bool TryInvokeCodeReload<T>(string methodId, T instance, object[] parameters = null)
|
||||
{
|
||||
return TryInvokeCodeReload(methodId, (object)instance, parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cheap, allocation-free check for an active override. The woven prologue calls this
|
||||
/// before building the boxed argument array, so a woven method with no active override
|
||||
/// allocates nothing per call.
|
||||
/// </summary>
|
||||
/// <param name="methodId">Method identifier, "TypeName.MethodName".</param>
|
||||
/// <returns>True if an override is currently registered for <paramref name="methodId"/>.</returns>
|
||||
public static bool HasOverride(string methodId) => m_MethodOverrides.ContainsKey(methodId);
|
||||
|
||||
/// <summary>
|
||||
/// Non-generic dispatch entry point. This is the method woven into [CodeReload]
|
||||
/// methods at compile time (a non-generic signature keeps the injected IL simple).
|
||||
/// Returns true if a code reload override was invoked, false if the original body should run.
|
||||
/// </summary>
|
||||
/// <param name="methodId">Method identifier, "TypeName.MethodName".</param>
|
||||
/// <param name="instance">The instance to invoke the override on.</param>
|
||||
/// <param name="parameters">The original call's arguments, boxed.</param>
|
||||
/// <returns>True if a code reload override was invoked.</returns>
|
||||
public static bool TryInvokeCodeReload(string methodId, object instance, object[] parameters = null)
|
||||
=> TryInvokeCodeReloadResult(methodId, instance, parameters, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Called by the woven prologue when an override returned null for a method whose return
|
||||
/// type is a value type — unboxing null would throw an opaque NullReferenceException at
|
||||
/// the call site. Public only for woven code; not meant to be called by hand.
|
||||
/// </summary>
|
||||
/// <param name="methodId">Method identifier, "TypeName.MethodName", named in the thrown message.</param>
|
||||
public static void ThrowOverrideReturnedNull(string methodId)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"CodeReload: the override for '{methodId}' returned null, but the method returns " +
|
||||
"a value type, so there is no value to unbox. Fix the override to return a value, " +
|
||||
"or revert the reload.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Value-returning dispatch: woven into value-returning [CodeReload] methods. Like
|
||||
/// <see cref="TryInvokeCodeReload(string, object, object[])"/> but also hands back the
|
||||
/// override's (boxed) return value in <paramref name="result"/>. Returns true if an override
|
||||
/// ran (and the woven prologue should return <paramref name="result"/>), false if the
|
||||
/// original body should run.
|
||||
/// </summary>
|
||||
/// <param name="methodId">Method identifier, "TypeName.MethodName".</param>
|
||||
/// <param name="instance">The instance to invoke the override on.</param>
|
||||
/// <param name="parameters">The original call's arguments, boxed.</param>
|
||||
/// <param name="result">The override's (boxed) return value, if one ran.</param>
|
||||
/// <returns>True if a code reload override was invoked.</returns>
|
||||
public static bool TryInvokeCodeReloadResult(string methodId, object instance, object[] parameters, out object result)
|
||||
{
|
||||
result = null;
|
||||
if (!m_MethodOverrides.TryGetValue(methodId, out var methodOverride))
|
||||
{
|
||||
return false; // No code reload override available
|
||||
}
|
||||
|
||||
CodeReloadActivity.CountOverrideCall(); // feeds the overlay's calls/s rate
|
||||
|
||||
try
|
||||
{
|
||||
result = InvokeOverrideMarshalled(methodOverride, instance, parameters);
|
||||
return true;
|
||||
}
|
||||
catch (TargetInvocationException ex) when (ex.InnerException != null)
|
||||
{
|
||||
// Exception from mono backend - rethrow for cases like imgui ExitGui
|
||||
ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
|
||||
throw; // unreachable
|
||||
}
|
||||
catch (IlInterpreter.ScriptRuntimeException ex) when (ex.InnerException != null)
|
||||
{
|
||||
// Exception from IlInterpreter backend - rethrow for cases like imgui ExitGui.
|
||||
// The inner exception alone has no script location; log the interpreter's
|
||||
// message (which carries "at line N (file)") first so the failure is traceable.
|
||||
if (!(ex.InnerException is ExitGUIException))
|
||||
Debug.LogError($"CodeReload: override '{methodId}' threw {ex.Message}");
|
||||
ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
|
||||
throw; // unreachable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Invocation infrastructure failed (interpreter-detected error, reflection arg
|
||||
// marshaling): log and fall back to the original body.
|
||||
Debug.LogError($"CodeReload: Error invoking override for '{methodId}': " +
|
||||
$"{ex.GetType().Name}: {ex.Message}");
|
||||
Debug.LogError($"CodeReload: Stack trace: {ex.StackTrace}");
|
||||
result = null;
|
||||
return false; // Fall back to original method
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run the override, marshalling to the main thread when required, and return its (boxed)
|
||||
/// result. The common case is already on the main thread: invoke directly with no per-call
|
||||
/// closure allocation; only the rare cross-thread marshal pays for a closure.
|
||||
/// </summary>
|
||||
private static object InvokeOverrideMarshalled(MethodOverride methodOverride, object instance, object[] parameters)
|
||||
{
|
||||
if (methodOverride.RequireMainThread && Dispatcher != null && !Dispatcher.IsMainThread())
|
||||
{
|
||||
object marshalled = null;
|
||||
var mo = methodOverride;
|
||||
Dispatcher.Invoke(() => marshalled = InvokeOverride(mo, instance, parameters));
|
||||
return marshalled;
|
||||
}
|
||||
return InvokeOverride(methodOverride, instance, parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoke the override: through the IlInterpreter interpreter (IL2CPP-safe; the delegate marshals
|
||||
/// args itself, so no array is built here) or via reflection on the loaded MethodInfo (which
|
||||
/// needs an instance-prepended argument array). Returns the (boxed) result, null for void.
|
||||
/// </summary>
|
||||
private static object InvokeOverride(MethodOverride methodOverride, object instance, object[] parameters)
|
||||
{
|
||||
if (methodOverride.InterpreterInvoke != null)
|
||||
return methodOverride.InterpreterInvoke(instance, parameters);
|
||||
|
||||
var invokeParams = new object[(parameters?.Length ?? 0) + 1];
|
||||
invokeParams[0] = instance;
|
||||
if (parameters != null && parameters.Length > 0)
|
||||
Array.Copy(parameters, 0, invokeParams, 1, parameters.Length);
|
||||
return methodOverride.OverrideMethod.Invoke(null, invokeParams);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register all methods in a type that have the [CodeReload] attribute.
|
||||
/// Uses reflection to discover and register them as reload targets.
|
||||
/// </summary>
|
||||
/// <param name="type">The type to scan for [CodeReload] methods.</param>
|
||||
public static void RegisterReloadableType(System.Type type)
|
||||
{
|
||||
if (type == null)
|
||||
{
|
||||
Debug.LogWarning("CodeReload: 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 codeReloadAttr = method.GetCustomAttribute<CodeReloadAttribute>();
|
||||
if (codeReloadAttr != null)
|
||||
{
|
||||
RegisterReloadableMethod(method, codeReloadAttr);
|
||||
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 codeReloadAttr = method.GetCustomAttribute<CodeReloadAttribute>();
|
||||
if (codeReloadAttr != null)
|
||||
{
|
||||
RegisterReloadableMethod(method, codeReloadAttr);
|
||||
registeredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (registeredCount > 0)
|
||||
{
|
||||
Debug.Log($"CodeReload: Registered {registeredCount} reloadable methods from type {type.Name}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log($"CodeReload: No methods with [CodeReload] attribute found in type {type.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a type from loaded code reload assembly for discovery scanning.
|
||||
/// </summary>
|
||||
/// <param name="type">The loaded code-reload assembly's type.</param>
|
||||
/// <param name="assemblyId">Identifier of the compiled code-reload assembly the type came from.</param>
|
||||
public static void RegisterCodeReloadType(Type type, string assemblyId)
|
||||
{
|
||||
var typeKey = $"{assemblyId}:{type.FullName}";
|
||||
m_LoadedCodeReloadTypes[typeKey] = type;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all code reload overrides. Used by cleanup_codereload command.
|
||||
/// </summary>
|
||||
public static void ClearAllOverrides()
|
||||
{
|
||||
var overrideCount = m_MethodOverrides.Count;
|
||||
var typeCount = m_LoadedCodeReloadTypes.Count;
|
||||
|
||||
m_MethodOverrides.Clear();
|
||||
m_LoadedCodeReloadTypes.Clear();
|
||||
if (overrideCount > 0)
|
||||
RaiseOverridesChanged();
|
||||
}
|
||||
|
||||
/// <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_LoadedCodeReloadTypes.Count;
|
||||
var reloadableCount = m_ReloadableMethods.Sum(kvp => kvp.Value.Count);
|
||||
|
||||
m_MethodOverrides.Clear();
|
||||
m_LoadedCodeReloadTypes.Clear();
|
||||
m_ReloadableMethods.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get statistics about current code reload state.
|
||||
/// </summary>
|
||||
/// <returns>Current reloadable/override/loaded-type counts.</returns>
|
||||
public static CodeReloadStats GetStats()
|
||||
{
|
||||
return new CodeReloadStats
|
||||
{
|
||||
ReloadableMethodCount = m_ReloadableMethods.Sum(kvp => kvp.Value.Count),
|
||||
ActiveOverrideCount = m_MethodOverrides.Count,
|
||||
LoadedTypeCount = m_LoadedCodeReloadTypes.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 code reload method signature is compatible with original method.
|
||||
/// Code 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();
|
||||
|
||||
// Code 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)
|
||||
{
|
||||
// Honour the [CodeReload] attribute's main thread requirement
|
||||
var codeReloadAttr = originalMethod.GetCustomAttribute<CodeReloadAttribute>();
|
||||
if (codeReloadAttr != null)
|
||||
{
|
||||
return codeReloadAttr.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; }
|
||||
|
||||
/// <summary>
|
||||
/// IL2CPP-safe dispatch: when set, the override runs through this delegate (a IlInterpreter
|
||||
/// interpreter invoke) instead of <see cref="OverrideMethod"/> reflection. Receives the
|
||||
/// target instance and the original call arguments; returns the method's (boxed) result,
|
||||
/// or null for a void method.
|
||||
/// </summary>
|
||||
public Func<object, object[], object> InterpreterInvoke { get; set; }
|
||||
|
||||
public Type SourceType { get; set; }
|
||||
public bool RequireMainThread { get; set; }
|
||||
public string Description { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about current code reload registry state.
|
||||
/// </summary>
|
||||
public class CodeReloadStats
|
||||
{
|
||||
/// <summary>Number of methods registered as reloadable.</summary>
|
||||
public int ReloadableMethodCount { get; set; }
|
||||
/// <summary>Number of active method overrides.</summary>
|
||||
public int ActiveOverrideCount { get; set; }
|
||||
/// <summary>Number of code-reload assembly types currently loaded.</summary>
|
||||
public int LoadedTypeCount { get; set; }
|
||||
/// <summary>Method ids registered as reloadable.</summary>
|
||||
public List<string> ReloadableMethodIds { get; set; } = new();
|
||||
/// <summary>Method ids with an active override.</summary>
|
||||
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,703 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using UnityPipeline.Microsoft.CodeAnalysis;
|
||||
using UnityPipeline.Microsoft.CodeAnalysis.CSharp;
|
||||
using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.CodeReload
|
||||
{
|
||||
/// <summary>
|
||||
/// Main orchestrator for in-place code reload processing.
|
||||
/// Handles the complete pipeline: source parsing -> transformation -> compilation.
|
||||
/// </summary>
|
||||
static class InPlaceReloadProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Process a source file for in-place code reload.
|
||||
/// Extracts [CodeReload] methods, 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 [CodeReload] 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, bool useInterpreter = false)
|
||||
{
|
||||
// Runs synchronously on the calling (main) thread; returns a completed Task.
|
||||
return Task.FromResult(ProcessSourceFileOnMainThread(sourceFilePath, assemblyDir, pdb, useInterpreter));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process source file on main thread synchronously to avoid deadlocks.
|
||||
/// </summary>
|
||||
/// <param name="sourceFilePath">Path to source file containing [CodeReload] 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>
|
||||
/// <param name="useInterpreter">Route the override through the interpreter backend instead of Assembly.Load.</param>
|
||||
/// <returns>Compilation result with success/failure and diagnostic information.</returns>
|
||||
public static InPlaceReloadResult ProcessSourceFileOnMainThread(string sourceFilePath, string assemblyDir = null, bool pdb = false, bool useInterpreter = false)
|
||||
{
|
||||
var result = new InPlaceReloadResult
|
||||
{
|
||||
SourceFilePath = sourceFilePath,
|
||||
Success = false
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
Debug.Log($"CodeReload: Processing source file synchronously on main thread: {sourceFilePath}");
|
||||
|
||||
// The interpreter backend needs #line directives when source-line errors are
|
||||
// enabled: its runtime errors resolve locations from the embedded PDB, which are
|
||||
// only meaningful mapped to the user's file.
|
||||
var lineInfo = useInterpreter && CodeReloadCompiler.EmitSourceLineInfo;
|
||||
var prep = PrepareOverride(sourceFilePath, pdb || lineInfo, enforcePublicAccess: !useInterpreter);
|
||||
if (!prep.Success)
|
||||
{
|
||||
result.ErrorMessage = prep.Error;
|
||||
result.CompilationDiagnostics = prep.Diagnostics;
|
||||
return result;
|
||||
}
|
||||
|
||||
result.OriginalTypeName = prep.TypeName;
|
||||
result.ExtractedMethods = prep.MethodNames;
|
||||
result.UpToDateMethods = prep.UpToDateMethods;
|
||||
result.TransformedCode = prep.TransformedCode;
|
||||
|
||||
// A method that matches the baseline again (the edit was undone) may still carry an
|
||||
// override from an earlier reload — drop it so the compiled body runs at full speed.
|
||||
foreach (var name in prep.UpToDateMethods)
|
||||
{
|
||||
var id = $"{prep.TypeName}.{name}";
|
||||
if (CodeReloadRegistry.UnregisterMethodOverride(id))
|
||||
result.RevertedMethods.Add(id);
|
||||
}
|
||||
|
||||
if (prep.MethodNames.Count == 0)
|
||||
{
|
||||
result.Success = true;
|
||||
result.CompilationDiagnostics = prep.Diagnostics;
|
||||
Debug.Log($"CodeReload: {sourceFilePath} is up to date — all " +
|
||||
$"{prep.UpToDateMethods.Count} [CodeReload] method(s) match the compiled baseline" +
|
||||
(result.RevertedMethods.Count > 0
|
||||
? $"; removed {result.RevertedMethods.Count} stale override(s)" : "") + ".");
|
||||
return result;
|
||||
}
|
||||
|
||||
var compilationResult = CompileTransformedCode(prep, assemblyDir, pdb, sourceFilePath, useInterpreter);
|
||||
|
||||
result.Success = compilationResult.IsSuccess;
|
||||
result.AssemblyName = compilationResult.AssemblyName;
|
||||
result.RegisteredMethods = compilationResult.RegisteredMethods;
|
||||
result.CompilationDiagnostics = prep.Diagnostics.Concat(compilationResult.Diagnostics).ToList();
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
Debug.Log($"CodeReload: In-place reload successful for {sourceFilePath} - {result.RegisteredMethods.Count} methods registered" +
|
||||
(result.UpToDateMethods.Count > 0 ? $" ({result.UpToDateMethods.Count} up to date, left compiled)" : ""));
|
||||
}
|
||||
else
|
||||
{
|
||||
result.ErrorMessage = compilationResult.ErrorDetails ?? "Compilation failed";
|
||||
Debug.LogError($"CodeReload: In-place reload compilation failed: {result.ErrorMessage}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"CodeReload: Error processing source file {sourceFilePath}: {ex.Message}\n{ex.StackTrace}");
|
||||
|
||||
result.ErrorMessage = $"Processing error: {ex.Message}";
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What <see cref="PrepareOverride"/> hands back: the extracted type and surviving methods,
|
||||
/// the transformed source (null when everything was up to date), and the diagnostics
|
||||
/// gathered on the way. <see cref="Success"/> false means a hard failure with
|
||||
/// <see cref="Error"/> set.
|
||||
/// </summary>
|
||||
private sealed class OverridePreparation
|
||||
{
|
||||
public bool Success;
|
||||
public string Error;
|
||||
public string TypeName;
|
||||
public List<string> MethodNames;
|
||||
public List<string> UpToDateMethods = new List<string>();
|
||||
public string TransformedCode;
|
||||
public List<string> Diagnostics = new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared prefix for in-place reload and push-to-player: read the file, extract [CodeReload]
|
||||
/// methods, drop the ones still matching the captured <see cref="CodeReloadBaseline"/> (they
|
||||
/// run correct AND faster compiled — <see cref="OverridePreparation.UpToDateMethods"/>
|
||||
/// reports them, and <paramref name="forceInclude"/> keeps named ones in anyway), reject
|
||||
/// non-public member access when the compiled backend will run the override
|
||||
/// (<paramref name="enforcePublicAccess"/>), and transform bodies into a static override
|
||||
/// class. When every method is up to date, succeeds with an empty
|
||||
/// <see cref="OverridePreparation.MethodNames"/> and a null
|
||||
/// <see cref="OverridePreparation.TransformedCode"/> — nothing to compile.
|
||||
/// </summary>
|
||||
private static OverridePreparation PrepareOverride(
|
||||
string sourceFilePath, bool pdb, bool enforcePublicAccess,
|
||||
string[] preprocessorSymbols = null, ICollection<string> forceInclude = null,
|
||||
bool useBaseline = true)
|
||||
{
|
||||
var prep = new OverridePreparation();
|
||||
|
||||
var sourceCode = ReadSourceFile(sourceFilePath);
|
||||
if (string.IsNullOrEmpty(sourceCode))
|
||||
{
|
||||
prep.Error = $"Could not read source file: {sourceFilePath}";
|
||||
return prep;
|
||||
}
|
||||
|
||||
var extraction = ExtractCodeReloadableMethods(sourceCode, preprocessorSymbols);
|
||||
// Each skip entry carries its own reason (return-type gate, nested type, …).
|
||||
foreach (var skipped in extraction.SkippedMethods)
|
||||
prep.Diagnostics.Add($"[CodeReload] {skipped} was skipped.");
|
||||
|
||||
if (!extraction.HasMethods)
|
||||
{
|
||||
prep.Error = extraction.SkippedMethods.Count > 0
|
||||
? $"No reloadable [CodeReload] methods in {sourceFilePath}. Skipped: " +
|
||||
$"{string.Join(", ", extraction.SkippedMethods)}."
|
||||
: $"No [CodeReload] methods found in {sourceFilePath}";
|
||||
return prep;
|
||||
}
|
||||
|
||||
// Skip methods whose declarations still match the captured baseline: their compiled
|
||||
// bodies are already what the source says, and the compiled body is strictly faster
|
||||
// than any override (the interpreter backend pays per call). No baseline captured
|
||||
// (returns null) = no filtering, the pre-baseline behavior.
|
||||
var unchanged = useBaseline
|
||||
? CodeReloadBaseline.GetUnchangedMethods(sourceFilePath, sourceCode, preprocessorSymbols)
|
||||
: null;
|
||||
if (unchanged != null && unchanged.Count > 0)
|
||||
{
|
||||
foreach (var name in extraction.Methods.Keys.ToList())
|
||||
{
|
||||
if (!unchanged.Contains(name)) continue;
|
||||
if (forceInclude != null && forceInclude.Contains(name)) continue;
|
||||
extraction.Methods.Remove(name);
|
||||
extraction.MethodSignatures.Remove(name);
|
||||
prep.UpToDateMethods.Add(name);
|
||||
}
|
||||
|
||||
if (extraction.Methods.Count == 0)
|
||||
{
|
||||
prep.TypeName = extraction.TypeName;
|
||||
prep.MethodNames = new List<string>();
|
||||
prep.Success = true;
|
||||
return prep;
|
||||
}
|
||||
}
|
||||
|
||||
// Build one compilation + semantic model for the transform, so Roslyn's metadata bind
|
||||
// is paid once per reload (shared with the string TransformMethodBodies overload).
|
||||
var model = SourceCodeTransformer.BuildSemanticModel(
|
||||
sourceCode, extraction.TypeName, out var classDecl, preprocessorSymbols);
|
||||
if (classDecl == null)
|
||||
{
|
||||
prep.Error = $"Could not find class '{extraction.TypeName}' in source.";
|
||||
return prep;
|
||||
}
|
||||
|
||||
// Only the interpreter backend compiles with relaxed accessibility (see
|
||||
// RoslynCompilationService.AllowNonPublicMemberAccess) and reaches non-public members at
|
||||
// runtime, via reflection. The Assembly.Load backend keeps standard access checks — a
|
||||
// non-public access would only surface later as a raw CS0122 — so reject it up front
|
||||
// with an actionable message.
|
||||
if (enforcePublicAccess)
|
||||
{
|
||||
var validation = AccessibilityValidator.ValidatePublicAccess(
|
||||
model, classDecl, extraction.Methods, extraction.TypeName);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
prep.Error = validation.GetFormattedErrorMessage();
|
||||
return prep;
|
||||
}
|
||||
}
|
||||
|
||||
// enforcePublicAccess doubles as the backend discriminator (true == Assembly.Load, see
|
||||
// the callers): only the interpreter needs interpolation lowered to string.Format —
|
||||
// the compiled backend keeps interpolations native.
|
||||
prep.TransformedCode = SourceCodeTransformer.TransformMethodBodies(
|
||||
model, classDecl, extraction.Methods, extraction.TypeName, extraction.MethodSignatures,
|
||||
emitLineDirectives: pdb, originalFilePath: sourceFilePath,
|
||||
rewriteInterpolations: !enforcePublicAccess);
|
||||
|
||||
prep.TypeName = extraction.TypeName;
|
||||
prep.MethodNames = extraction.Methods.Keys.ToList();
|
||||
prep.Success = true;
|
||||
return prep;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compile a source file's [CodeReload] override(s) to raw assembly IL bytes without
|
||||
/// registering them locally — for pushing to a player that runs them through the
|
||||
/// interpreter. Editor-side only (needs Roslyn). <paramref name="useBaseline"/> false
|
||||
/// bypasses the <see cref="CodeReloadBaseline"/> filter — the caller can't vouch that the
|
||||
/// target player was built from the baselined sources.
|
||||
/// </summary>
|
||||
internal static PushCompileResult CompileOverrideForPush(string sourceFilePath, string[] preprocessorSymbols = null,
|
||||
ICollection<string> forceInclude = null, bool useBaseline = true)
|
||||
{
|
||||
var r = new PushCompileResult();
|
||||
try
|
||||
{
|
||||
// When source-line errors are enabled, #line directives map each body to the
|
||||
// original file so the embedded PDB's sequence points — used by the player-side
|
||||
// interpreter for runtime error messages — carry the user's real line numbers.
|
||||
// enforcePublicAccess: false — pushed overrides run through the on-device
|
||||
// interpreter, which reaches non-public members by reflection.
|
||||
var prep = PrepareOverride(sourceFilePath, pdb: CodeReloadCompiler.EmitSourceLineInfo,
|
||||
enforcePublicAccess: false, preprocessorSymbols, forceInclude, useBaseline);
|
||||
if (!prep.Success)
|
||||
{
|
||||
r.Error = prep.Error;
|
||||
r.Diagnostics = prep.Diagnostics;
|
||||
return r;
|
||||
}
|
||||
|
||||
r.UpToDateMethods = prep.UpToDateMethods;
|
||||
if (prep.MethodNames.Count == 0)
|
||||
{
|
||||
// Everything matches the baseline — a push would only replace compiled bodies
|
||||
// with slower interpreted ones on the device.
|
||||
r.Success = true;
|
||||
r.TypeName = prep.TypeName;
|
||||
r.Diagnostics = prep.Diagnostics;
|
||||
return r;
|
||||
}
|
||||
|
||||
var tempName = $"PushReload_{prep.TypeName}_{DateTime.Now:yyyyMMdd_HHmmss}";
|
||||
if (!CodeReloadCompiler.TryCompileOverrideBytes(prep.TransformedCode, tempName, out var il, out var compileDiagnostics, documentPath: sourceFilePath, preprocessorSymbols: preprocessorSymbols))
|
||||
{
|
||||
r.Error = "Compilation failed";
|
||||
r.Diagnostics = prep.Diagnostics.Concat(compileDiagnostics).ToList();
|
||||
return r;
|
||||
}
|
||||
|
||||
r.Success = true;
|
||||
r.TypeName = prep.TypeName;
|
||||
r.MethodNames = prep.MethodNames;
|
||||
r.IlBytes = il;
|
||||
r.Diagnostics = prep.Diagnostics.Concat(compileDiagnostics).ToList();
|
||||
return r;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
r.Error = $"Push compile error: {ex.Message}";
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a source file contains [CodeReload] 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 [CodeReload] methods</returns>
|
||||
public static Task<bool> ContainsCodeReloadableMethodsAsync(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 the [CodeReload] attribute. Prefix match (no closing bracket) so
|
||||
// combined attribute lists like [CodeReload, DebugPanel("x")] still pass.
|
||||
var result = sourceCode.Contains("[CodeReload");
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"CodeReload: Error checking for [CodeReload] 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($"CodeReload: Source file not found: {filePath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return File.ReadAllText(filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"CodeReload: Error reading source file {filePath}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract [CodeReload] methods from source code.
|
||||
/// </summary>
|
||||
private static CodeReloadableExtractionResult ExtractCodeReloadableMethods(
|
||||
string sourceCode, string[] preprocessorSymbols = null)
|
||||
{
|
||||
var result = new CodeReloadableExtractionResult();
|
||||
|
||||
try
|
||||
{
|
||||
var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode,
|
||||
RoslynCompilationService.ProjectParseOptions(preprocessorSymbols));
|
||||
var root = syntaxTree.GetRoot();
|
||||
|
||||
// Find the class containing [CodeReload] methods.
|
||||
var classDeclaration = root.DescendantNodes()
|
||||
.OfType<ClassDeclarationSyntax>()
|
||||
.FirstOrDefault(c => c.DescendantNodes().OfType<MethodDeclarationSyntax>().Any(HasCodeReloadAttribute));
|
||||
|
||||
if (classDeclaration == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result.TypeName = classDeclaration.Identifier.ValueText;
|
||||
|
||||
// The transform lifts bodies into ONE override class for the top-level type chosen
|
||||
// above. Tagged methods anywhere else in the file — in a NESTED type (which the
|
||||
// weaver still weaves: Cecil's GetTypes() is recursive) or in a sibling top-level
|
||||
// type — would otherwise be dropped without a word, leaving a woven prologue that
|
||||
// never finds an override. Surface each one loudly; when nothing else is
|
||||
// reloadable, these become the reload's error.
|
||||
foreach (var method in root.DescendantNodes()
|
||||
.OfType<MethodDeclarationSyntax>().Where(HasCodeReloadAttribute))
|
||||
{
|
||||
var owner = method.Ancestors().OfType<TypeDeclarationSyntax>().FirstOrDefault();
|
||||
if (owner == null || owner == classDeclaration)
|
||||
continue;
|
||||
var ownerChain = string.Join(".", method.Ancestors()
|
||||
.OfType<TypeDeclarationSyntax>().Reverse().Select(t => t.Identifier.ValueText));
|
||||
result.SkippedMethods.Add(owner.Parent is TypeDeclarationSyntax
|
||||
? $"'{method.Identifier.ValueText}' (declared in nested type '{ownerChain}' — " +
|
||||
"code reload only supports methods declared directly in a top-level class)"
|
||||
: $"'{method.Identifier.ValueText}' (declared in '{ownerChain}' — only the first " +
|
||||
$"type with [CodeReload] methods, '{result.TypeName}', is reloaded per file; split the file)");
|
||||
}
|
||||
|
||||
var codeReloadableMethods = classDeclaration.Members
|
||||
.OfType<MethodDeclarationSyntax>()
|
||||
.Where(HasCodeReloadAttribute);
|
||||
|
||||
foreach (var method in codeReloadableMethods)
|
||||
{
|
||||
var methodName = method.Identifier.ValueText;
|
||||
|
||||
// Tagged methods must pass the same gate the weaver applies (void or
|
||||
// IEnumerator returns; instance or static). The weaver never weaves a
|
||||
// dispatch prologue into the others, so their override would register and
|
||||
// then never run — reload would report success while silently no-opping.
|
||||
if (!IsMethodLevelSupported(method, out var skipReason))
|
||||
{
|
||||
result.SkippedMethods.Add($"'{methodName}' ({skipReason})");
|
||||
continue;
|
||||
}
|
||||
var methodBody = ExtractMethodBody(method);
|
||||
var signature = ExtractMethodSignature(method);
|
||||
|
||||
if (methodBody != null)
|
||||
{
|
||||
result.Methods[methodName] = methodBody;
|
||||
result.MethodSignatures[methodName] = signature;
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"CodeReload: Extracted {result.Methods.Count} [CodeReload] methods from class {result.TypeName}");
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"CodeReload: Error extracting [CodeReload] methods: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if method has [CodeReload] attribute. The unqualified name must match exactly: a
|
||||
/// suffix match also catches [OnCodeReload] — the post-reload callback, not a reload target —
|
||||
/// and pushing that name makes the player-side registry lookup fail for the whole file.
|
||||
/// Internal so <see cref="CodeReloadBaseline"/> hashes exactly the methods extraction picks.
|
||||
/// </summary>
|
||||
internal static bool HasCodeReloadAttribute(MethodDeclarationSyntax method)
|
||||
{
|
||||
return method.AttributeLists
|
||||
.SelectMany(al => al.Attributes)
|
||||
.Any(a => UnqualifiedAttributeName(a) == "CodeReload");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attribute name as the source wrote it, minus any namespace qualifier and the optional
|
||||
/// "Attribute" suffix — [Foo.BarAttribute] and [Bar] both yield "Bar".
|
||||
/// </summary>
|
||||
private static string UnqualifiedAttributeName(AttributeSyntax attr)
|
||||
{
|
||||
var name = attr.Name.ToString();
|
||||
var lastDot = name.LastIndexOf('.');
|
||||
if (lastDot >= 0)
|
||||
name = name.Substring(lastDot + 1);
|
||||
if (name.EndsWith("Attribute"))
|
||||
name = name.Substring(0, name.Length - "Attribute".Length);
|
||||
return name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate for explicitly-tagged [CodeReload] methods, mirroring the skips the weaver applies
|
||||
/// (CodeReloadInPlaceILPostProcessor.Process): unsupported return types get no dispatch
|
||||
/// prologue, so extracting them would produce an override that never runs. Instance and
|
||||
/// STATIC methods both weave (a static dispatches with a null instance). Coroutines
|
||||
/// (System.Collections.IEnumerator) are supported: the weaver routes them through the
|
||||
/// result dispatch, and the override's iterator — compiled or interpreted — is what
|
||||
/// StartCoroutine drives.
|
||||
/// </summary>
|
||||
private static bool IsMethodLevelSupported(MethodDeclarationSyntax method, out string reason)
|
||||
{
|
||||
var returnType = method.ReturnType.ToString();
|
||||
if (returnType != "void" &&
|
||||
returnType is not ("IEnumerator" or "System.Collections.IEnumerator"))
|
||||
{
|
||||
reason = $"returns {returnType} — void methods and IEnumerator coroutines only";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract method body content (excluding braces).
|
||||
/// null when the method has no block body at all (expression-bodied, abstract).
|
||||
/// </summary>
|
||||
private static string ExtractMethodBody(MethodDeclarationSyntax method)
|
||||
{
|
||||
if (method.Body == null)
|
||||
return 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;
|
||||
}
|
||||
|
||||
/// <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 CodeReloadCompileResult CompileTransformedCode(
|
||||
OverridePreparation prep,
|
||||
string assemblyDir,
|
||||
bool emitPdb,
|
||||
string documentPath,
|
||||
bool useInterpreter)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Generate a temporary file name for the transformed code
|
||||
var tempFileName = $"InPlace_{prep.TypeName}_{DateTime.Now:yyyyMMdd_HHmmss}";
|
||||
|
||||
return CodeReloadCompiler.CompileSourceCodeOnMainThread(
|
||||
prep.TransformedCode,
|
||||
tempFileName,
|
||||
assemblyDir,
|
||||
emitPdb,
|
||||
documentPath,
|
||||
useInterpreter
|
||||
? new InterpreterOverrideSet
|
||||
{
|
||||
TypeName = prep.TypeName,
|
||||
MethodNames = prep.MethodNames
|
||||
}
|
||||
: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"CodeReload: Error compiling transformed code for {prep.TypeName}: {ex.Message}");
|
||||
|
||||
return CodeReloadCompileResult.Failure(
|
||||
"Compilation Error",
|
||||
ex.Message,
|
||||
0,
|
||||
new List<string> { ex.ToString() });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting [CodeReload] methods from source code.
|
||||
/// </summary>
|
||||
private class CodeReloadableExtractionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the class containing [CodeReload] 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 [CodeReload] methods were found.
|
||||
/// </summary>
|
||||
public bool HasMethods => Methods.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Explicitly-tagged methods that were rejected with the reason ("'Total' (returns
|
||||
/// int — void methods and IEnumerator coroutines only)"), so the caller can report
|
||||
/// them instead of failing opaquely.
|
||||
/// </summary>
|
||||
public List<string> SkippedMethods { get; } = new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of in-place code reload processing.
|
||||
/// </summary>
|
||||
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 [CodeReload] 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 code 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>
|
||||
/// [CodeReload] method names skipped because they still match the compiled baseline —
|
||||
/// their compiled bodies keep running (no interpreter cost).
|
||||
/// </summary>
|
||||
internal List<string> UpToDateMethods { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Method ids whose stale override was removed because the source matches the compiled
|
||||
/// baseline again (the edit was reverted).
|
||||
/// </summary>
|
||||
internal List<string> RevertedMethods { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Every extracted method matched the baseline; nothing was compiled or registered.</summary>
|
||||
internal bool AllUpToDate => Success && ExtractedMethods.Count == 0 && UpToDateMethods.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Error message if processing failed.
|
||||
/// </summary>
|
||||
public string ErrorMessage { get; set; }
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of compiling an override for pushing to a player (no local registration).
|
||||
/// </summary>
|
||||
internal class PushCompileResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string TypeName { get; set; }
|
||||
public List<string> MethodNames { get; set; } = new List<string>();
|
||||
public byte[] IlBytes { get; set; }
|
||||
public string Error { get; set; }
|
||||
public List<string> Diagnostics { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>[CodeReload] method names skipped because they still match the compiled baseline.</summary>
|
||||
public List<string> UpToDateMethods { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Every extracted method matched the baseline; there is nothing to push.</summary>
|
||||
public bool AllUpToDate => Success && MethodNames.Count == 0 && UpToDateMethods.Count > 0;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 411fb44c3329710449bcbfc2af627524
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,195 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Unity.Pipeline.CodeReload
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared contract for pushing a compiled in-place code-reload override from the editor to a
|
||||
/// running player over Unity's PlayerConnection — the same editor↔player message bus the Profiler
|
||||
/// and Console use (tunnels over USB for mobile, no IP or open port required). Lives in Runtime so
|
||||
/// both the player (<c>PlayerConnection.Register</c>) and the editor push command
|
||||
/// (<c>EditorConnection.Send</c>) reference the same GUIDs and wire format.
|
||||
///
|
||||
/// The editor compiles the edited method(s) to override-assembly IL bytes (Roslyn, editor-only)
|
||||
/// and ships {typeName, methodNames, il}; the player feeds them to
|
||||
/// <c>InterpreterCodeReloadExecutor.Register</c> and runs them through the IlInterpreter interpreter —
|
||||
/// no <c>Assembly.Load</c>, so it works under IL2CPP.
|
||||
///
|
||||
/// This type touches no Unity API, so it compiles unconditionally on both editor and player.
|
||||
/// </summary>
|
||||
static class PipelineCodeReloadConnect
|
||||
{
|
||||
/// <summary>editor → player: apply a compiled override (typeName + method names + IL).</summary>
|
||||
public static readonly Guid ApplyReloadMsg = new Guid("b6f1e0d24c7a4e13a9f2c8d5017e3b44");
|
||||
|
||||
/// <summary>player → editor: an ack (ok + human-readable summary) for a received push.</summary>
|
||||
public static readonly Guid ResultMsg = new Guid("c2a7d4e916b84f0db3e5a1c6072f8d99");
|
||||
|
||||
/// <summary>editor → player: a save is being compiled for a push (payload = utf8 file name).
|
||||
/// Compilation happens editor-side, so this notice is the only way an on-device overlay can
|
||||
/// show "compiling…" progress.</summary>
|
||||
public static readonly Guid ReloadPendingMsg = new Guid("d4c9f2a1385e46b7a0d1e8c4529b6f77");
|
||||
|
||||
/// <summary>editor → player: the compile for a pending reload failed (payload = utf8 summary).
|
||||
/// Without it the on-device overlay would sit on "compiling…" forever after a syntax error.</summary>
|
||||
public static readonly Guid ReloadFailedMsg = new Guid("e7b3a8d05c214f9e8b6d2f0a1c435588");
|
||||
|
||||
/// <summary>player → editor: session announcement for push signing — protocol version, the
|
||||
/// key fingerprint the player's baked public key expects, and the random session nonce every
|
||||
/// signed push must carry. Sent on connect and in answer to <see cref="RequestHandshakeMsg"/>.</summary>
|
||||
public static readonly Guid HandshakeMsg = new Guid("f19c5b8a2d7e40c6b8a3f0d612c94e55");
|
||||
|
||||
/// <summary>editor → player: re-announce your handshake (empty payload). Sent when the editor's
|
||||
/// nonce table is empty for a target — typically right after an editor domain reload.</summary>
|
||||
public static readonly Guid RequestHandshakeMsg = new Guid("a8d2c7f4691e4b0f9c5d3a80e17b6c22");
|
||||
|
||||
/// <summary>
|
||||
/// Wire format: [typeNameLen:int32-LE][utf8 typeName][methodCount:int32-LE]
|
||||
/// (per method: [len:int32-LE][utf8 name]) [il bytes…].
|
||||
/// </summary>
|
||||
public static byte[] Encode(string typeName, IReadOnlyList<string> methodNames, byte[] il)
|
||||
{
|
||||
var methods = methodNames ?? Array.Empty<string>();
|
||||
|
||||
var nameBytes = Encoding.UTF8.GetBytes(typeName ?? "");
|
||||
var methodBytes = new byte[methods.Count][];
|
||||
int methodsSize = 4; // count
|
||||
for (int i = 0; i < methods.Count; i++)
|
||||
{
|
||||
methodBytes[i] = Encoding.UTF8.GetBytes(methods[i] ?? "");
|
||||
methodsSize += 4 + methodBytes[i].Length;
|
||||
}
|
||||
|
||||
int ilLen = il?.Length ?? 0;
|
||||
var buf = new byte[4 + nameBytes.Length + methodsSize + ilLen];
|
||||
int o = 0;
|
||||
o = WriteInt32(buf, o, nameBytes.Length);
|
||||
o = WriteBytes(buf, o, nameBytes);
|
||||
o = WriteInt32(buf, o, methods.Count);
|
||||
for (int i = 0; i < methods.Count; i++)
|
||||
{
|
||||
o = WriteInt32(buf, o, methodBytes[i].Length);
|
||||
o = WriteBytes(buf, o, methodBytes[i]);
|
||||
}
|
||||
if (ilLen > 0) Buffer.BlockCopy(il, 0, buf, o, ilLen);
|
||||
return buf;
|
||||
}
|
||||
|
||||
public static bool TryDecode(byte[] data, out string typeName, out string[] methodNames, out byte[] il)
|
||||
{
|
||||
typeName = null; methodNames = null; il = null;
|
||||
if (data == null || data.Length < 4) return false;
|
||||
try
|
||||
{
|
||||
int o = 0;
|
||||
int nameLen = ReadInt32(data, ref o);
|
||||
if (nameLen < 0 || o + nameLen > data.Length) return false;
|
||||
typeName = Encoding.UTF8.GetString(data, o, nameLen); o += nameLen;
|
||||
|
||||
if (o + 4 > data.Length) return false;
|
||||
int methodCount = ReadInt32(data, ref o);
|
||||
if (methodCount < 0) return false;
|
||||
methodNames = new string[methodCount];
|
||||
for (int i = 0; i < methodCount; i++)
|
||||
{
|
||||
if (o + 4 > data.Length) return false;
|
||||
int len = ReadInt32(data, ref o);
|
||||
if (len < 0 || o + len > data.Length) return false;
|
||||
methodNames[i] = Encoding.UTF8.GetString(data, o, len); o += len;
|
||||
}
|
||||
|
||||
int ilLen = data.Length - o;
|
||||
il = new byte[ilLen];
|
||||
if (ilLen > 0) Buffer.BlockCopy(data, o, il, 0, ilLen);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Handshake wire format: [protocolVersion:int32-LE][fpLen:int32-LE][utf8 fingerprint][nonce…].</summary>
|
||||
public static byte[] EncodeHandshake(int protocolVersion, string keyFingerprint, byte[] sessionNonce)
|
||||
{
|
||||
var fp = Encoding.UTF8.GetBytes(keyFingerprint ?? "");
|
||||
var nonce = sessionNonce ?? Array.Empty<byte>();
|
||||
var buf = new byte[4 + 4 + fp.Length + nonce.Length];
|
||||
int o = 0;
|
||||
o = WriteInt32(buf, o, protocolVersion);
|
||||
o = WriteInt32(buf, o, fp.Length);
|
||||
o = WriteBytes(buf, o, fp);
|
||||
if (nonce.Length > 0) Buffer.BlockCopy(nonce, 0, buf, o, nonce.Length);
|
||||
return buf;
|
||||
}
|
||||
|
||||
public static bool TryDecodeHandshake(byte[] data, out int protocolVersion, out string keyFingerprint, out byte[] sessionNonce)
|
||||
{
|
||||
protocolVersion = 0; keyFingerprint = null; sessionNonce = null;
|
||||
if (data == null || data.Length < 8) return false;
|
||||
try
|
||||
{
|
||||
int o = 0;
|
||||
protocolVersion = ReadInt32(data, ref o);
|
||||
int fpLen = ReadInt32(data, ref o);
|
||||
if (fpLen < 0 || fpLen > 256 || o + fpLen > data.Length) return false;
|
||||
keyFingerprint = Encoding.UTF8.GetString(data, o, fpLen); o += fpLen;
|
||||
sessionNonce = new byte[data.Length - o];
|
||||
if (sessionNonce.Length > 0) Buffer.BlockCopy(data, o, sessionNonce, 0, sessionNonce.Length);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Payload for the plain-text notices (<see cref="ReloadPendingMsg"/>, <see cref="ReloadFailedMsg"/>).</summary>
|
||||
public static byte[] EncodeText(string text) => Encoding.UTF8.GetBytes(text ?? "");
|
||||
|
||||
public static string DecodeText(byte[] data) =>
|
||||
data == null || data.Length == 0 ? "" : Encoding.UTF8.GetString(data);
|
||||
|
||||
/// <summary>Result wire format: [ok:1 byte][utf8 message…].</summary>
|
||||
public static byte[] EncodeResult(bool ok, string message)
|
||||
{
|
||||
var msgBytes = Encoding.UTF8.GetBytes(message ?? "");
|
||||
var buf = new byte[1 + msgBytes.Length];
|
||||
buf[0] = (byte)(ok ? 1 : 0);
|
||||
Buffer.BlockCopy(msgBytes, 0, buf, 1, msgBytes.Length);
|
||||
return buf;
|
||||
}
|
||||
|
||||
public static bool TryDecodeResult(byte[] data, out bool ok, out string message)
|
||||
{
|
||||
ok = false; message = null;
|
||||
if (data == null || data.Length < 1) return false;
|
||||
ok = data[0] != 0;
|
||||
message = data.Length > 1 ? Encoding.UTF8.GetString(data, 1, data.Length - 1) : "";
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int WriteInt32(byte[] buf, int off, int v)
|
||||
{
|
||||
buf[off] = (byte)v;
|
||||
buf[off + 1] = (byte)(v >> 8);
|
||||
buf[off + 2] = (byte)(v >> 16);
|
||||
buf[off + 3] = (byte)(v >> 24);
|
||||
return off + 4;
|
||||
}
|
||||
|
||||
private static int WriteBytes(byte[] buf, int off, byte[] src)
|
||||
{
|
||||
if (src.Length > 0) Buffer.BlockCopy(src, 0, buf, off, src.Length);
|
||||
return off + src.Length;
|
||||
}
|
||||
|
||||
private static int ReadInt32(byte[] buf, ref int off)
|
||||
{
|
||||
int v = buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24);
|
||||
off += 4;
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ec832e9c0db7b84db213582f2ebc203
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,336 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Unity.Pipeline.CodeReload
|
||||
{
|
||||
/// <summary>Outcome of opening a signed push envelope. Everything except <see cref="Ok"/> is a
|
||||
/// rejection; the receiver reports them generically on the wire and specifically in its own log.</summary>
|
||||
enum PushVerifyStatus
|
||||
{
|
||||
Ok,
|
||||
/// <summary>The receiver has no baked public key — every push is rejected (strict policy).</summary>
|
||||
NoKey,
|
||||
/// <summary>Structurally not a v1 envelope (bad magic, truncated, absurd lengths).</summary>
|
||||
Malformed,
|
||||
/// <summary>Envelope parses but the RSA signature does not verify under the baked key.</summary>
|
||||
BadSignature,
|
||||
/// <summary>Signature is valid but was made for a different session nonce (replay across
|
||||
/// sessions/devices, or the editor's nonce table is stale).</summary>
|
||||
NonceMismatch,
|
||||
/// <summary>Signature and nonce are valid but the counter did not increase (replay within
|
||||
/// the session, or out-of-order delivery).</summary>
|
||||
CounterNotIncreasing,
|
||||
/// <summary>The platform's RSA implementation threw — crypto is unusable here. Surfaced by
|
||||
/// the boot self-test; should never be the steady state.</summary>
|
||||
CryptoUnavailable,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticated envelope for editor → player code-reload pushes, plus the project signing-key
|
||||
/// primitives. Pure BCL (System.Security.Cryptography, no Unity API) so it compiles on both
|
||||
/// sides and is unit-testable without a connection.
|
||||
///
|
||||
/// Wire format (little-endian):
|
||||
/// [magic "UPP1" 4B][session nonce 16B][counter int64][payloadLen int32][payload][RSA signature]
|
||||
/// The signature is RSA-2048 / SHA-256 / PKCS#1 v1.5 over UTF8(<see cref="DomainTag"/>) followed
|
||||
/// by every envelope byte before the signature, so a signature can never be repurposed by a
|
||||
/// future feature signing other content with the same project key.
|
||||
///
|
||||
/// Keys are serialized in a deliberately trivial private format (base64 RSAParameters fields)
|
||||
/// rather than XML/PKCS#8: RSA.To/FromXmlString and ImportPkcs8PrivateKey both have per-platform
|
||||
/// availability gaps across Unity scripting backends; base64ing the raw parameters has none.
|
||||
/// </summary>
|
||||
static class PushEnvelope
|
||||
{
|
||||
public const string DomainTag = "unity-pipeline/push-reload/v1";
|
||||
public const int ProtocolVersion = 1;
|
||||
public const int NonceSize = 16;
|
||||
public const int RsaKeySizeBits = 2048;
|
||||
|
||||
private const string PrivatePrefix = "upp1-rsa:";
|
||||
private const string PublicPrefix = "upp1-rsa-pub:";
|
||||
private const int HeaderSize = 4 + NonceSize + 8 + 4; // magic + nonce + counter + payloadLen
|
||||
private const int MaxPayload = 64 * 1024 * 1024; // sanity bound, far above any real push
|
||||
|
||||
private static readonly byte[] Magic = { (byte)'U', (byte)'P', (byte)'P', (byte)'1' };
|
||||
|
||||
// ---- keys ----
|
||||
|
||||
/// <summary>Generate a fresh RSA-2048 private key (contains the public half).</summary>
|
||||
public static string CreatePrivateKey()
|
||||
{
|
||||
using (var rsa = RSA.Create())
|
||||
{
|
||||
rsa.KeySize = RsaKeySizeBits;
|
||||
var p = rsa.ExportParameters(true);
|
||||
return PrivatePrefix + string.Join(".",
|
||||
B64(p.Modulus), B64(p.Exponent), B64(p.D), B64(p.P), B64(p.Q),
|
||||
B64(p.DP), B64(p.DQ), B64(p.InverseQ));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Extract the shippable public half of a private key.</summary>
|
||||
public static string DerivePublicKey(string privateKey)
|
||||
{
|
||||
var p = ParsePrivate(privateKey);
|
||||
return PublicPrefix + B64(p.Modulus) + "." + B64(p.Exponent);
|
||||
}
|
||||
|
||||
public static bool IsPublicKey(string key) =>
|
||||
!string.IsNullOrEmpty(key) && key.StartsWith(PublicPrefix, StringComparison.Ordinal);
|
||||
|
||||
public static bool IsPrivateKey(string key) =>
|
||||
!string.IsNullOrEmpty(key) && key.StartsWith(PrivatePrefix, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>Short stable id of a keypair (SHA-256 over modulus+exponent, first 8 bytes hex).
|
||||
/// Accepts the public or the private serialization and gives the same answer.</summary>
|
||||
public static string Fingerprint(string key)
|
||||
{
|
||||
RSAParameters p = IsPublicKey(key) ? ParsePublic(key) : ParsePrivate(key);
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var data = new byte[p.Modulus.Length + p.Exponent.Length];
|
||||
Buffer.BlockCopy(p.Modulus, 0, data, 0, p.Modulus.Length);
|
||||
Buffer.BlockCopy(p.Exponent, 0, data, p.Modulus.Length, p.Exponent.Length);
|
||||
var hash = sha.ComputeHash(data);
|
||||
var sb = new StringBuilder(16);
|
||||
for (int i = 0; i < 8; i++) sb.Append(hash[i].ToString("x2"));
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cryptographically random session nonce.</summary>
|
||||
public static byte[] CreateNonce()
|
||||
{
|
||||
var nonce = new byte[NonceSize];
|
||||
using (var rng = RandomNumberGenerator.Create())
|
||||
rng.GetBytes(nonce);
|
||||
return nonce;
|
||||
}
|
||||
|
||||
// ---- signing (editor side) ----
|
||||
|
||||
public static byte[] Sign(string privateKey, byte[] sessionNonce, long counter, byte[] payload) =>
|
||||
Sign(privateKey, sessionNonce, counter, payload, DomainTag);
|
||||
|
||||
/// <summary>Domain-tag overload: exists so tests can prove a signature made under a different
|
||||
/// tag is rejected. Production code always signs with <see cref="DomainTag"/>.</summary>
|
||||
public static byte[] Sign(string privateKey, byte[] sessionNonce, long counter, byte[] payload, string domainTag)
|
||||
{
|
||||
if (sessionNonce == null || sessionNonce.Length != NonceSize)
|
||||
throw new ArgumentException($"session nonce must be {NonceSize} bytes", nameof(sessionNonce));
|
||||
payload = payload ?? Array.Empty<byte>();
|
||||
|
||||
var preamble = new byte[HeaderSize + payload.Length];
|
||||
Buffer.BlockCopy(Magic, 0, preamble, 0, 4);
|
||||
Buffer.BlockCopy(sessionNonce, 0, preamble, 4, NonceSize);
|
||||
WriteInt64(preamble, 4 + NonceSize, counter);
|
||||
WriteInt32(preamble, 4 + NonceSize + 8, payload.Length);
|
||||
Buffer.BlockCopy(payload, 0, preamble, HeaderSize, payload.Length);
|
||||
|
||||
byte[] sig;
|
||||
using (var rsa = RSA.Create())
|
||||
{
|
||||
rsa.ImportParameters(ParsePrivate(privateKey));
|
||||
sig = rsa.SignData(Tagged(domainTag, preamble), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
|
||||
var envelope = new byte[preamble.Length + sig.Length];
|
||||
Buffer.BlockCopy(preamble, 0, envelope, 0, preamble.Length);
|
||||
Buffer.BlockCopy(sig, 0, envelope, preamble.Length, sig.Length);
|
||||
return envelope;
|
||||
}
|
||||
|
||||
// ---- opening (player side) ----
|
||||
|
||||
/// <summary>Cheap structural check: does this look like a v1 envelope (as opposed to a
|
||||
/// legacy raw payload from a pre-signing editor)?</summary>
|
||||
public static bool LooksLikeEnvelope(byte[] data) =>
|
||||
data != null && data.Length >= HeaderSize &&
|
||||
data[0] == Magic[0] && data[1] == Magic[1] && data[2] == Magic[2] && data[3] == Magic[3];
|
||||
|
||||
/// <summary>Parse without any crypto. Editor-play-mode strip only — never a security gate.</summary>
|
||||
public static bool TryParse(byte[] envelope, out byte[] nonce, out long counter, out byte[] payload)
|
||||
{
|
||||
nonce = null; counter = 0; payload = null;
|
||||
if (!LooksLikeEnvelope(envelope)) return false;
|
||||
|
||||
nonce = new byte[NonceSize];
|
||||
Buffer.BlockCopy(envelope, 4, nonce, 0, NonceSize);
|
||||
counter = ReadInt64(envelope, 4 + NonceSize);
|
||||
int payloadLen = ReadInt32(envelope, 4 + NonceSize + 8);
|
||||
if (payloadLen < 0 || payloadLen > MaxPayload || HeaderSize + payloadLen > envelope.Length)
|
||||
return false;
|
||||
|
||||
payload = new byte[payloadLen];
|
||||
Buffer.BlockCopy(envelope, HeaderSize, payload, 0, payloadLen);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full verification: structure, signature under <paramref name="publicKey"/>, session nonce
|
||||
/// equality, strictly increasing counter. On <see cref="PushVerifyStatus.Ok"/>,
|
||||
/// <paramref name="payload"/> and <paramref name="counter"/> are set. Stateless — callers own
|
||||
/// the last-counter bookkeeping (see <see cref="PushVerifier"/>).
|
||||
/// </summary>
|
||||
public static PushVerifyStatus TryOpen(string publicKey, byte[] envelope, byte[] expectedNonce,
|
||||
long lastCounter, out long counter, out byte[] payload)
|
||||
{
|
||||
counter = 0; payload = null;
|
||||
if (string.IsNullOrEmpty(publicKey)) return PushVerifyStatus.NoKey;
|
||||
if (!LooksLikeEnvelope(envelope)) return PushVerifyStatus.Malformed;
|
||||
|
||||
int payloadLen = ReadInt32(envelope, 4 + NonceSize + 8);
|
||||
if (payloadLen < 0 || payloadLen > MaxPayload) return PushVerifyStatus.Malformed;
|
||||
int preambleLen = HeaderSize + payloadLen;
|
||||
if (preambleLen >= envelope.Length) return PushVerifyStatus.Malformed; // signature must be non-empty
|
||||
|
||||
// Signature first: nothing later is meaningful on unauthenticated bytes.
|
||||
try
|
||||
{
|
||||
var preamble = new byte[preambleLen];
|
||||
Buffer.BlockCopy(envelope, 0, preamble, 0, preambleLen);
|
||||
var sig = new byte[envelope.Length - preambleLen];
|
||||
Buffer.BlockCopy(envelope, preambleLen, sig, 0, sig.Length);
|
||||
|
||||
using (var rsa = RSA.Create())
|
||||
{
|
||||
rsa.ImportParameters(ParsePublic(publicKey));
|
||||
if (!rsa.VerifyData(Tagged(DomainTag, preamble), sig, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
|
||||
return PushVerifyStatus.BadSignature;
|
||||
}
|
||||
}
|
||||
catch (FormatException) { return PushVerifyStatus.Malformed; } // corrupt key string
|
||||
catch (Exception) { return PushVerifyStatus.CryptoUnavailable; }
|
||||
|
||||
if (expectedNonce == null || expectedNonce.Length != NonceSize)
|
||||
return PushVerifyStatus.NonceMismatch;
|
||||
// Constant-time not required (the nonce is not secret; it is a liveness binding), but
|
||||
// the full compare is trivial anyway.
|
||||
int diff = 0;
|
||||
for (int i = 0; i < NonceSize; i++) diff |= envelope[4 + i] ^ expectedNonce[i];
|
||||
if (diff != 0) return PushVerifyStatus.NonceMismatch;
|
||||
|
||||
long c = ReadInt64(envelope, 4 + NonceSize);
|
||||
if (c <= lastCounter) return PushVerifyStatus.CounterNotIncreasing;
|
||||
|
||||
counter = c;
|
||||
payload = new byte[payloadLen];
|
||||
Buffer.BlockCopy(envelope, HeaderSize, payload, 0, payloadLen);
|
||||
return PushVerifyStatus.Ok;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Boot-time probe that the platform can run the verify path at all (RSA import + VerifyData
|
||||
/// on a garbage signature must return false, not throw). Catches an IL2CPP/platform crypto
|
||||
/// gap on day one instead of as mysterious per-push rejects.
|
||||
/// </summary>
|
||||
public static bool VerifySelfTest(string publicKey, out string error)
|
||||
{
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
using (var rsa = RSA.Create())
|
||||
{
|
||||
rsa.ImportParameters(ParsePublic(publicKey));
|
||||
var garbageSig = new byte[RsaKeySizeBits / 8];
|
||||
if (rsa.VerifyData(new byte[] { 1, 2, 3 }, garbageSig, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
|
||||
{
|
||||
error = "garbage signature verified — RSA implementation is broken here";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.GetType().Name + ": " + ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
private static byte[] Tagged(string domainTag, byte[] preamble)
|
||||
{
|
||||
var tag = Encoding.UTF8.GetBytes(domainTag ?? "");
|
||||
var signed = new byte[tag.Length + preamble.Length];
|
||||
Buffer.BlockCopy(tag, 0, signed, 0, tag.Length);
|
||||
Buffer.BlockCopy(preamble, 0, signed, tag.Length, preamble.Length);
|
||||
return signed;
|
||||
}
|
||||
|
||||
private static RSAParameters ParsePrivate(string key)
|
||||
{
|
||||
if (!IsPrivateKey(key)) throw new FormatException("not a " + PrivatePrefix + " private key");
|
||||
var f = key.Substring(PrivatePrefix.Length).Split('.');
|
||||
if (f.Length != 8) throw new FormatException("private key must have 8 fields");
|
||||
return new RSAParameters
|
||||
{
|
||||
Modulus = D64(f[0]), Exponent = D64(f[1]), D = D64(f[2]), P = D64(f[3]),
|
||||
Q = D64(f[4]), DP = D64(f[5]), DQ = D64(f[6]), InverseQ = D64(f[7]),
|
||||
};
|
||||
}
|
||||
|
||||
private static RSAParameters ParsePublic(string key)
|
||||
{
|
||||
if (!IsPublicKey(key)) throw new FormatException("not a " + PublicPrefix + " public key");
|
||||
var f = key.Substring(PublicPrefix.Length).Split('.');
|
||||
if (f.Length != 2) throw new FormatException("public key must have 2 fields");
|
||||
return new RSAParameters { Modulus = D64(f[0]), Exponent = D64(f[1]) };
|
||||
}
|
||||
|
||||
private static string B64(byte[] b) => Convert.ToBase64String(b ?? Array.Empty<byte>());
|
||||
private static byte[] D64(string s) => Convert.FromBase64String(s);
|
||||
|
||||
private static void WriteInt32(byte[] buf, int off, int v)
|
||||
{
|
||||
buf[off] = (byte)v; buf[off + 1] = (byte)(v >> 8);
|
||||
buf[off + 2] = (byte)(v >> 16); buf[off + 3] = (byte)(v >> 24);
|
||||
}
|
||||
|
||||
private static void WriteInt64(byte[] buf, int off, long v)
|
||||
{
|
||||
for (int i = 0; i < 8; i++) buf[off + i] = (byte)(v >> (8 * i));
|
||||
}
|
||||
|
||||
private static int ReadInt32(byte[] buf, int off) =>
|
||||
buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24);
|
||||
|
||||
private static long ReadInt64(byte[] buf, int off)
|
||||
{
|
||||
long v = 0;
|
||||
for (int i = 0; i < 8; i++) v |= (long)buf[off + i] << (8 * i);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The player-side gate: one per receiver session. Owns the session nonce and the last-accepted
|
||||
/// counter, so <see cref="Verify"/> is the single call the receiver makes per push.
|
||||
/// </summary>
|
||||
sealed class PushVerifier
|
||||
{
|
||||
private readonly string m_PublicKey;
|
||||
private readonly byte[] m_SessionNonce;
|
||||
private long m_LastCounter;
|
||||
|
||||
public PushVerifier(string publicKey, byte[] sessionNonce)
|
||||
{
|
||||
m_PublicKey = publicKey;
|
||||
m_SessionNonce = sessionNonce;
|
||||
}
|
||||
|
||||
public byte[] SessionNonce => m_SessionNonce;
|
||||
|
||||
public PushVerifyStatus Verify(byte[] envelope, out byte[] payload)
|
||||
{
|
||||
var status = PushEnvelope.TryOpen(m_PublicKey, envelope, m_SessionNonce, m_LastCounter,
|
||||
out var counter, out payload);
|
||||
if (status == PushVerifyStatus.Ok)
|
||||
m_LastCounter = counter;
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3f7a1c92be504d8fa2617e0c4b9d5a21
|
||||
@@ -0,0 +1,590 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
// The package bundles Roslyn renamed with a "UnityPipeline." prefix (Runtime/Plugins/CodeAnalysis);
|
||||
// the dotnet test projects reference the same DLLs (IlInterpreter/BundledRoslyn.props).
|
||||
using UnityPipeline.Microsoft.CodeAnalysis;
|
||||
using UnityPipeline.Microsoft.CodeAnalysis.CSharp;
|
||||
using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.CodeReload
|
||||
{
|
||||
/// <summary>
|
||||
/// Transforms [CodeReload] method bodies (edited in place on a MonoBehaviour) into static
|
||||
/// code reload override methods that the registry can dispatch to.
|
||||
///
|
||||
/// The transform is driven by a Roslyn <see cref="SemanticModel"/>. Because the body is moved into
|
||||
/// a standalone static class (no <c>this</c>, no base type, not nested in the original type), every
|
||||
/// reference that relied on the declaring type's scope is re-qualified so it still binds:
|
||||
/// <list type="bullet">
|
||||
/// <item>instance members of the type hierarchy (e.g. <c>transform</c> on Component) -> <c>instance.<member></c></item>
|
||||
/// <item>static members inherited or declared on the type hierarchy (e.g. <c>FindObjectsByType</c> on
|
||||
/// Object) -> <c><ContainingType>.<member></c></item>
|
||||
/// <item>nested types (enum/class) of the type hierarchy (e.g. a private <c>enum State</c>) -> <c><ContainingType>.<NestedType></c></item>
|
||||
/// </list>
|
||||
/// Locals, parameters, and types/members reachable via the file's <c>using</c> directives are left
|
||||
/// untouched. The output is a static override class the registry can bind:
|
||||
///
|
||||
/// [CodeReloadOverrideMethod("Type.Method")]
|
||||
/// public static <ret> Method(Type instance, <params>) { ...rewritten body... }
|
||||
/// </summary>
|
||||
static class SourceCodeTransformer
|
||||
{
|
||||
/// <summary>
|
||||
/// Transform the [CodeReload] 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>
|
||||
/// <param name="methodBodies">Map of method name to edited body source.</param>
|
||||
/// <param name="originalTypeName">Name of the declaring type.</param>
|
||||
/// <param name="originalMethodSignatures">Map of method name to its original signature.</param>
|
||||
/// <param name="originalTypeDefinition">The full original source file, needed to build the semantic model.</param>
|
||||
/// <param name="rewriteInterpolations">
|
||||
/// Rewrite string interpolation to <c>string.Format(...)</c>. Required by the interpreter
|
||||
/// backend, which cannot run the compiler's <c>DefaultInterpolatedStringHandler</c>
|
||||
/// lowering; pass false on the Assembly.Load backend so interpolations compile natively
|
||||
/// (e.g. a <c>FormattableString</c>-targeted interpolation survives).
|
||||
/// </param>
|
||||
/// <returns>Source for a static override class matching the shape the registry dispatches to.</returns>
|
||||
internal static string TransformMethodBodies(
|
||||
Dictionary<string, string> methodBodies,
|
||||
string originalTypeName,
|
||||
Dictionary<string, MethodSignatureInfo> originalMethodSignatures,
|
||||
string originalTypeDefinition = null,
|
||||
bool emitLineDirectives = false,
|
||||
string originalFilePath = null,
|
||||
bool rewriteInterpolations = true)
|
||||
{
|
||||
if (string.IsNullOrEmpty(originalTypeDefinition))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"In-place transformation requires the full original source to build a semantic model.");
|
||||
}
|
||||
|
||||
var model = BuildSemanticModel(originalTypeDefinition, originalTypeName, out var classDecl);
|
||||
if (classDecl == null)
|
||||
throw new InvalidOperationException($"Could not find class '{originalTypeName}' in source.");
|
||||
|
||||
return TransformMethodBodies(model, classDecl, methodBodies, originalTypeName,
|
||||
originalMethodSignatures, emitLineDirectives, originalFilePath, rewriteInterpolations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse <paramref name="source"/> and build the one compilation + semantic model the
|
||||
/// transform (and its callers' validation) runs against, plus the declaration of
|
||||
/// <paramref name="typeName"/> — null when the source has no such class. Imports ALL
|
||||
/// metadata members, not the Public default: the transform's new-method check diffs the
|
||||
/// source against the compiled type, and with public-only import every PRIVATE compiled
|
||||
/// method would look new and get wrongly co-emitted. <paramref name="preprocessorSymbols"/>
|
||||
/// (the project's, or a push target's) makes #if regions match the assembly being patched.
|
||||
/// </summary>
|
||||
internal static SemanticModel BuildSemanticModel(
|
||||
string source, string typeName, out ClassDeclarationSyntax classDecl,
|
||||
string[] preprocessorSymbols = null)
|
||||
{
|
||||
var tree = CSharpSyntaxTree.ParseText(source,
|
||||
RoslynCompilationService.ProjectParseOptions(preprocessorSymbols));
|
||||
var compilation = CSharpCompilation.Create(
|
||||
"CodeReloadInPlaceTransform",
|
||||
new[] { tree },
|
||||
RoslynCompilationService.GetMetadataReferences(),
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
|
||||
.WithMetadataImportOptions(MetadataImportOptions.All));
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
classDecl = tree.GetRoot().DescendantNodes()
|
||||
.OfType<ClassDeclarationSyntax>()
|
||||
.FirstOrDefault(c => c.Identifier.ValueText == typeName);
|
||||
return model;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core transform over an already-built semantic model + class declaration, so the in-place
|
||||
/// reload path can share one compilation between validation and transformation instead of
|
||||
/// building two.
|
||||
/// </summary>
|
||||
internal static string TransformMethodBodies(
|
||||
SemanticModel model,
|
||||
ClassDeclarationSyntax classDecl,
|
||||
Dictionary<string, string> methodBodies,
|
||||
string originalTypeName,
|
||||
Dictionary<string, MethodSignatureInfo> originalMethodSignatures,
|
||||
bool emitLineDirectives = false,
|
||||
string originalFilePath = null,
|
||||
bool rewriteInterpolations = true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = classDecl.SyntaxTree.GetRoot();
|
||||
var classSymbol = model.GetDeclaredSymbol(classDecl);
|
||||
var compiledTwin = FindCompiledType(model.Compilation, classSymbol);
|
||||
// A top-level type with no compiled twin is entirely NEW (the file was added since
|
||||
// the build): every method is co-emittable, and the receiver parameter can't name
|
||||
// the type — it doesn't exist in any reference — so it degrades to object (the
|
||||
// dispatch passes null for statics anyway). Instance-member access still can't
|
||||
// compile in that state; new-type reloads are for self-contained static methods.
|
||||
var wholeTypeIsNew = compiledTwin == null && classSymbol?.ContainingType == null;
|
||||
var newMethods = ComputeNewMethodNames(classDecl, compiledTwin, wholeTypeIsNew);
|
||||
// A static type can't appear as a parameter (CS0721), so its receiver slot — always
|
||||
// dispatched null anyway — degrades to object. Check the compiled twin too: the
|
||||
// source may have dropped the static keyword while the running build still has it.
|
||||
var typeIsStatic = classDecl.Modifiers.Any(SyntaxKind.StaticKeyword)
|
||||
|| (compiledTwin?.IsStatic ?? false);
|
||||
var instanceTypeName = wholeTypeIsNew || typeIsStatic ? "object" : originalTypeName;
|
||||
var rewriter = new ImplicitScopeQualifier(model, classSymbol, newMethods,
|
||||
new HashSet<string>(methodBodies.Keys));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var emittedUsings = new HashSet<string>();
|
||||
foreach (var u in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
|
||||
{
|
||||
if (emittedUsings.Add(u.Name?.ToString() ?? u.ToString()))
|
||||
sb.AppendLine(u.ToString());
|
||||
}
|
||||
if (emittedUsings.Add("Unity.Pipeline.CodeReload"))
|
||||
sb.AppendLine("using Unity.Pipeline.CodeReload;");
|
||||
|
||||
var namespaceName = GetNamespace(classDecl);
|
||||
if (!string.IsNullOrEmpty(namespaceName) && emittedUsings.Add(namespaceName))
|
||||
sb.AppendLine($"using {namespaceName};");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine($"public static class {originalTypeName}CodeReloadOverrides");
|
||||
sb.AppendLine("{");
|
||||
|
||||
foreach (var method in classDecl.Members.OfType<MethodDeclarationSyntax>())
|
||||
{
|
||||
var methodName = method.Identifier.ValueText;
|
||||
var isOverride = methodBodies.ContainsKey(methodName) && method.Body != null;
|
||||
// A method the compiled type doesn't declare is NEW — nothing compiled can
|
||||
// dispatch to it, but a reloaded body can: co-emit it into the override class
|
||||
// so it becomes an interpreted method the rewritten call sites reach directly.
|
||||
var isNewMethod = !isOverride && newMethods.Contains(methodName)
|
||||
&& (method.Body != null || method.ExpressionBody != null);
|
||||
if (!isOverride && !isNewMethod)
|
||||
continue;
|
||||
|
||||
// Qualify instance members first: the semantic model is bound to the original
|
||||
// syntax tree, so this must run before any rewrite that produces new nodes.
|
||||
// Interpolation-to-string.Format is purely syntactic — no semantic model — so
|
||||
// it can safely run on the qualified (synthesized) tree. It runs only for the
|
||||
// interpreter backend (see the rewriteInterpolations doc).
|
||||
// New methods may be expression-bodied; emitted text is `=> expr;` then.
|
||||
SyntaxNode rewrittenBody = method.Body != null
|
||||
? rewriter.Visit(method.Body)
|
||||
: rewriter.Visit(method.ExpressionBody);
|
||||
if (rewriteInterpolations)
|
||||
rewrittenBody = new InterpolationToFormatRewriter().Visit(rewrittenBody);
|
||||
var bodySuffix = method.Body != null ? "" : ";";
|
||||
var returnType = method.ReturnType.ToString();
|
||||
|
||||
// Overrides always take the receiver first (the dispatch passes null for
|
||||
// statics); a new STATIC method has no receiver at all — call sites stay bare.
|
||||
var parameters = new List<string>();
|
||||
if (isOverride || !method.Modifiers.Any(SyntaxKind.StaticKeyword))
|
||||
parameters.Add($"{instanceTypeName} instance");
|
||||
parameters.AddRange(isOverride
|
||||
? method.ParameterList.Parameters.Select(p => $"{p.Type} {p.Identifier.ValueText}")
|
||||
// New methods keep their parameter list verbatim (modifiers, defaults).
|
||||
: method.ParameterList.Parameters.Select(p => p.ToString()));
|
||||
|
||||
if (isOverride)
|
||||
sb.AppendLine($" [CodeReloadOverrideMethod(\"{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 bodyNode = (SyntaxNode)method.Body ?? method.ExpressionBody;
|
||||
var bodyLine = bodyNode.GetLocation().GetLineSpan().StartLinePosition.Line + 1;
|
||||
var escapedPath = originalFilePath.Replace("\\", "\\\\");
|
||||
sb.AppendLine($"#line {bodyLine} \"{escapedPath}\"");
|
||||
sb.AppendLine(rewrittenBody.ToString() + bodySuffix);
|
||||
sb.AppendLine("#line hidden");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine(" " + rewrittenBody.NormalizeWhitespace().ToString() + bodySuffix);
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine("}");
|
||||
|
||||
var result = sb.ToString();
|
||||
Debug.Log($"CodeReload: Transformed {methodBodies.Count} in-place method(s) for {originalTypeName}");
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"CodeReload: 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>
|
||||
/// Names of methods the edited source declares but the COMPILED type doesn't — methods the
|
||||
/// user added since the last compile. Their call sites can't bind to the host object, so
|
||||
/// they are co-emitted into the override class and called directly. Generic methods are
|
||||
/// excluded (outside the interpreter subset). When the whole type is new
|
||||
/// (<paramref name="wholeTypeIsNew"/>) every method qualifies; when only the compiled twin
|
||||
/// lookup failed (a nested type) the set is empty — every call site then binds to the
|
||||
/// host, the pre-existing behavior.
|
||||
/// </summary>
|
||||
private static HashSet<string> ComputeNewMethodNames(
|
||||
ClassDeclarationSyntax classDecl, INamedTypeSymbol compiledType, bool wholeTypeIsNew)
|
||||
{
|
||||
var result = new HashSet<string>();
|
||||
if (compiledType == null && !wholeTypeIsNew)
|
||||
return result;
|
||||
|
||||
foreach (var method in classDecl.Members.OfType<MethodDeclarationSyntax>())
|
||||
{
|
||||
if (method.Body == null && method.ExpressionBody == null)
|
||||
continue;
|
||||
if (method.TypeParameterList != null)
|
||||
continue;
|
||||
var name = method.Identifier.ValueText;
|
||||
// Declared-members check only: a name that exists compiled (any overload, or on a
|
||||
// base type but redeclared here) stays host-bound. A source method hiding a base
|
||||
// member counts as new — the co-emitted copy preserves the source's binding.
|
||||
if (wholeTypeIsNew || compiledType.GetMembers(name).Length == 0)
|
||||
result.Add(name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The compiled (metadata) symbol for <paramref name="classSymbol"/>, resolved from the
|
||||
/// compilation's references — the source declaration shadows it inside the compilation, so
|
||||
/// the reference assemblies are probed directly. Null when no compiled twin exists.
|
||||
/// </summary>
|
||||
private static INamedTypeSymbol FindCompiledType(
|
||||
UnityPipeline.Microsoft.CodeAnalysis.Compilation compilation, INamedTypeSymbol classSymbol)
|
||||
{
|
||||
if (classSymbol == null || classSymbol.ContainingType != null)
|
||||
return null;
|
||||
var metadataName = classSymbol.ContainingNamespace is { IsGlobalNamespace: false } ns
|
||||
? $"{ns.ToDisplayString()}.{classSymbol.MetadataName}"
|
||||
: classSymbol.MetadataName;
|
||||
foreach (var reference in compilation.References)
|
||||
{
|
||||
if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol asm
|
||||
&& asm.GetTypeByMetadataName(metadataName) is { } compiled)
|
||||
return compiled;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites bare references that depended on the original type's scope so they still bind after
|
||||
/// the body is moved into a standalone static class, using the semantic model:
|
||||
/// instance members -> <c>instance.x</c>, static members of the type hierarchy ->
|
||||
/// <c>ContainingType.x</c>, and nested types of the type hierarchy -> <c>ContainingType.Nested</c>.
|
||||
/// Locals, parameters, and anything reachable via <c>using</c> are left untouched.
|
||||
/// </summary>
|
||||
private class ImplicitScopeQualifier : CSharpSyntaxRewriter
|
||||
{
|
||||
private readonly SemanticModel m_Model;
|
||||
private readonly INamedTypeSymbol m_Type;
|
||||
private readonly HashSet<string> m_NewMethods;
|
||||
private readonly HashSet<string> m_ReloadedMethods;
|
||||
|
||||
public ImplicitScopeQualifier(SemanticModel model, INamedTypeSymbol type,
|
||||
HashSet<string> newMethods = null, HashSet<string> reloadedMethods = null)
|
||||
{
|
||||
m_Model = model;
|
||||
m_Type = type;
|
||||
m_NewMethods = newMethods ?? new HashSet<string>();
|
||||
m_ReloadedMethods = reloadedMethods ?? new HashSet<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls to NEW methods (declared in the edited source, absent from the compiled type)
|
||||
/// can't bind to the host object — the callee only exists as a co-emitted static in the
|
||||
/// override class. Rebuild the call as a direct static invocation, threading the
|
||||
/// receiver (the implicit <c>instance</c>, or an explicit one like <c>other.Helper()</c>)
|
||||
/// as the first argument to match the co-emitted signature.
|
||||
/// </summary>
|
||||
public override SyntaxNode VisitInvocationExpression(InvocationExpressionSyntax node)
|
||||
{
|
||||
// Resolve on the original node — the model is bound to the original tree.
|
||||
var target = m_NewMethods.Count > 0
|
||||
? m_Model.GetSymbolInfo(node.Expression).Symbol as IMethodSymbol
|
||||
: null;
|
||||
var visited = (InvocationExpressionSyntax)base.VisitInvocationExpression(node);
|
||||
if (target == null || !m_NewMethods.Contains(target.Name)
|
||||
|| !SymbolEqualityComparer.Default.Equals(target.ContainingType, m_Type))
|
||||
return visited;
|
||||
// Conditional calls (`x?.NewHelper()`) have no receiver expression to thread as an
|
||||
// argument; leave them — the compile fails with a clear CS1061 instead of silently
|
||||
// running the helper on the wrong instance.
|
||||
if (visited.Expression is MemberBindingExpressionSyntax)
|
||||
return visited;
|
||||
|
||||
var arguments = visited.ArgumentList;
|
||||
// A new method that is itself reloaded is emitted in the override shape — leading
|
||||
// instance parameter even when static (the dispatch it gets after the next compile
|
||||
// passes null there) — so its call sites must pass a receiver either way.
|
||||
if (!target.IsStatic || m_ReloadedMethods.Contains(target.Name))
|
||||
{
|
||||
// The visited expression is either `receiver.Helper` (this -> instance already
|
||||
// applied by the base visit) or a bare/qualified name; a member access carries
|
||||
// the receiver to thread through, anything else means the implicit `instance`.
|
||||
// A STATIC call has no receiver — its qualifier is a type name — so the
|
||||
// caller's own `instance` is what the override-shaped callee gets.
|
||||
var receiver = !target.IsStatic && visited.Expression is MemberAccessExpressionSyntax ma
|
||||
? ma.Expression
|
||||
: (ExpressionSyntax)SyntaxFactory.IdentifierName("instance");
|
||||
arguments = arguments.WithArguments(
|
||||
arguments.Arguments.Insert(0, SyntaxFactory.Argument(receiver)));
|
||||
}
|
||||
return SyntaxFactory.InvocationExpression(
|
||||
SyntaxFactory.IdentifierName(target.Name), arguments)
|
||||
.WithTriviaFrom(visited);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Symbol is resolved from the original node (the semantic model is bound to the original
|
||||
// tree); the same node is what gets wrapped since an identifier has no children to rewrite.
|
||||
return TryQualify(node, node) ?? base.VisitIdentifierName(node);
|
||||
}
|
||||
|
||||
public override SyntaxNode VisitGenericName(GenericNameSyntax node)
|
||||
{
|
||||
// e.g. GetComponent<Renderer>() or FindObjectsByType<T>() — visit type arguments first
|
||||
// (a type arg may itself be a nested type needing qualification), then wrap the result.
|
||||
var visited = (GenericNameSyntax)base.VisitGenericName(node);
|
||||
|
||||
if (ShouldSkip(node))
|
||||
return visited;
|
||||
|
||||
return TryQualify(node, visited) ?? 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the qualified replacement for <paramref name="toWrap"/> if <paramref name="original"/>
|
||||
/// binds to something that needs the original type's scope, otherwise null (leave as-is).
|
||||
/// </summary>
|
||||
private SyntaxNode TryQualify(SimpleNameSyntax original, SimpleNameSyntax toWrap)
|
||||
{
|
||||
var symbol = m_Model.GetSymbolInfo(original).Symbol;
|
||||
if (symbol == null)
|
||||
return null;
|
||||
|
||||
// `var` (and a `using Alias = Outer.Nested;` alias) binds to a type spelled
|
||||
// differently from the identifier. Qualifying the identifier would emit
|
||||
// `Outer.var` / `Outer.Alias`, which does not exist. Roslyn re-infers `var` on
|
||||
// the rewritten initializer, so leave the spelling alone.
|
||||
if (symbol is ITypeSymbol && symbol.Name != original.Identifier.ValueText)
|
||||
return null;
|
||||
|
||||
// Nested type of the type hierarchy (e.g. a private `enum State`) -> ContainingType.Nested.
|
||||
if (symbol is INamedTypeSymbol nested && nested.ContainingType != null
|
||||
&& InTypeHierarchy(nested.ContainingType))
|
||||
return QualifyWithType(toWrap, nested.ContainingType, original);
|
||||
|
||||
if (!IsMemberSymbol(symbol))
|
||||
return null;
|
||||
|
||||
if (!InTypeHierarchy(symbol.ContainingType))
|
||||
return null;
|
||||
|
||||
// A method GROUP over a new method (delegate argument, event `+=`): the member
|
||||
// doesn't exist on the compiled type, so `instance.M` can't bind. Wrap it as a
|
||||
// lambda over the co-emitted static instead.
|
||||
if (symbol is IMethodSymbol group && m_NewMethods.Contains(group.Name)
|
||||
&& !(original.Parent is InvocationExpressionSyntax inv && inv.Expression == original)
|
||||
&& MethodGroupLambda(group, original, receiver: null) is { } lambda)
|
||||
return lambda.WithTriviaFrom(toWrap);
|
||||
|
||||
return symbol.IsStatic
|
||||
? QualifyWithType(toWrap, symbol.ContainingType, original)
|
||||
: QualifyWith(toWrap, SyntaxFactory.IdentifierName("instance"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// `x.Click` where Click is NEW and not invoked — the member-access form of a method
|
||||
/// group over a new method. Same lambda rewrite as the bare-identifier form, but the
|
||||
/// (visited) receiver expression is threaded as the co-emitted static's first argument.
|
||||
/// </summary>
|
||||
public override SyntaxNode VisitMemberAccessExpression(MemberAccessExpressionSyntax node)
|
||||
{
|
||||
if (m_NewMethods.Count > 0
|
||||
&& !(node.Parent is InvocationExpressionSyntax inv && inv.Expression == node)
|
||||
&& m_Model.GetSymbolInfo(node).Symbol is IMethodSymbol group
|
||||
&& m_NewMethods.Contains(group.Name)
|
||||
&& SymbolEqualityComparer.Default.Equals(group.ContainingType, m_Type))
|
||||
{
|
||||
var visited = (MemberAccessExpressionSyntax)base.VisitMemberAccessExpression(node);
|
||||
if (MethodGroupLambda(group, node, visited.Expression) is { } lambda)
|
||||
return lambda.WithTriviaFrom(node);
|
||||
return visited;
|
||||
}
|
||||
return base.VisitMemberAccessExpression(node);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <c>((a0, a1) => M(receiver, a0, a1))</c> for a method group over a new
|
||||
/// method, shaped by the delegate type the group converts to (the semantic model's
|
||||
/// ConvertedType). Null when the conversion target isn't a delegate — the caller
|
||||
/// falls through, and the compile reports the site. One semantic difference from a
|
||||
/// real method-group conversion: the receiver expression is evaluated per invocation,
|
||||
/// not captured at conversion time.
|
||||
/// </summary>
|
||||
private ExpressionSyntax MethodGroupLambda(
|
||||
IMethodSymbol group, ExpressionSyntax original, ExpressionSyntax receiver)
|
||||
{
|
||||
if (m_Model.GetTypeInfo(original).ConvertedType is not INamedTypeSymbol delegateType
|
||||
|| delegateType.DelegateInvokeMethod is not { } invoke)
|
||||
return null;
|
||||
|
||||
var argList = new List<ArgumentSyntax>();
|
||||
// Matches the co-emitted signature: instance methods and reloaded (override-shaped)
|
||||
// methods take the receiver first; new plain statics don't.
|
||||
if (!group.IsStatic || m_ReloadedMethods.Contains(group.Name))
|
||||
argList.Add(SyntaxFactory.Argument(receiver ?? SyntaxFactory.IdentifierName("instance")));
|
||||
var parameters = new List<ParameterSyntax>();
|
||||
for (int i = 0; i < invoke.Parameters.Length; i++)
|
||||
{
|
||||
var name = "__mg" + i;
|
||||
parameters.Add(SyntaxFactory.Parameter(SyntaxFactory.Identifier(name)));
|
||||
argList.Add(SyntaxFactory.Argument(SyntaxFactory.IdentifierName(name)));
|
||||
}
|
||||
var call = SyntaxFactory.InvocationExpression(
|
||||
SyntaxFactory.IdentifierName(group.Name),
|
||||
SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(argList)));
|
||||
return SyntaxFactory.ParenthesizedExpression(
|
||||
SyntaxFactory.ParenthesizedLambdaExpression(
|
||||
SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(parameters)),
|
||||
call));
|
||||
}
|
||||
|
||||
private static bool IsMemberSymbol(ISymbol symbol)
|
||||
{
|
||||
switch (symbol.Kind)
|
||||
{
|
||||
case SymbolKind.Field:
|
||||
case SymbolKind.Property:
|
||||
case SymbolKind.Method:
|
||||
case SymbolKind.Event:
|
||||
return true;
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Qualify with a fully-qualified type name (e.g. global::UnityEngine.Object.X).</summary>
|
||||
private static SyntaxNode QualifyWithType(SimpleNameSyntax node, INamedTypeSymbol type, SimpleNameSyntax original)
|
||||
{
|
||||
var typeName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
|
||||
|
||||
// In a type position (local/foreach declaration, cast, array type, generic argument,
|
||||
// typeof) the parent's slot holds a TypeSyntax, so the replacement must be a
|
||||
// QualifiedName — a MemberAccessExpression there makes the rewriter's typed rebuild
|
||||
// throw InvalidCastException. Expression positions keep MemberAccess; both print the
|
||||
// same text.
|
||||
if (SyntaxFacts.IsInNamespaceOrTypeContext(original))
|
||||
return SyntaxFactory.QualifiedName(
|
||||
SyntaxFactory.ParseName(typeName),
|
||||
node.WithoutTrivia())
|
||||
.WithTriviaFrom(node);
|
||||
|
||||
return QualifyWith(node, SyntaxFactory.ParseExpression(typeName));
|
||||
}
|
||||
|
||||
private static SyntaxNode QualifyWith(SimpleNameSyntax node, ExpressionSyntax qualifier)
|
||||
{
|
||||
return SyntaxFactory.MemberAccessExpression(
|
||||
SyntaxKind.SimpleMemberAccessExpression,
|
||||
qualifier,
|
||||
node.WithoutTrivia())
|
||||
.WithTriviaFrom(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a method's signature for transformation purposes.
|
||||
/// </summary>
|
||||
class MethodSignatureInfo
|
||||
{
|
||||
/// <summary>The method's return type name, or "void".</summary>
|
||||
public string ReturnType { get; set; }
|
||||
/// <summary>The method's parameters.</summary>
|
||||
public List<ParameterInfo> Parameters { get; set; } = new List<ParameterInfo>();
|
||||
/// <summary>True unless <see cref="ReturnType"/> is "void".</summary>
|
||||
public bool ReturnsValue => !string.IsNullOrEmpty(ReturnType) && ReturnType != "void";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a method parameter.
|
||||
/// </summary>
|
||||
class ParameterInfo
|
||||
{
|
||||
/// <summary>The parameter's type name.</summary>
|
||||
public string Type { get; set; }
|
||||
/// <summary>The parameter's name.</summary>
|
||||
public string Name { get; set; }
|
||||
/// <summary>Whether the parameter has a default value.</summary>
|
||||
public bool HasDefaultValue { get; set; }
|
||||
/// <summary>The parameter's default value expression, if any.</summary>
|
||||
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: 67704748e63416c4c959bd6de99b4c4f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
#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>
|
||||
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,
|
||||
Tags = new[] { "capture", "ui" })]
|
||||
public static CaptureElementResponse CaptureRuntimeElement(
|
||||
[CliArg("panel", "Name of the target panel: matches the PanelSettings asset name or the host GameObject name (UIDocument or PanelRenderer). Optional when exactly one panel exists.")] string panel = "",
|
||||
[CliArg("selector", "Element selector: '#name', '.class', a type name (e.g. Button), descendant (space) / child ('>') chains, optional pseudo-states (:checked,:hover,:focus,:active,:enabled,:disabled,:not(...)).", Required = true)] string selector = "",
|
||||
[CliArg("output", "Output PNG path (absolute, or relative to Application.persistentDataPath). Defaults to a timestamped file under Application.persistentDataPath.")] string output = "")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selector))
|
||||
return CaptureElementResponse.Fail("A 'selector' is required.");
|
||||
|
||||
var hosts = GatherHosts();
|
||||
if (hosts.Count == 0)
|
||||
return CaptureElementResponse.Fail("No live runtime UI panels found (UIDocument or PanelRenderer). Show the UI first.");
|
||||
|
||||
List<Host> matched;
|
||||
string resolvedName;
|
||||
if (string.IsNullOrWhiteSpace(panel))
|
||||
{
|
||||
if (hosts.Count > 1)
|
||||
{
|
||||
var names = string.Join(", ", hosts.Select(h => h.DisplayName).Distinct());
|
||||
return CaptureElementResponse.Fail(
|
||||
$"Multiple runtime panels exist; specify --panel as one of: {names}.");
|
||||
}
|
||||
|
||||
matched = hosts;
|
||||
resolvedName = hosts[0].DisplayName;
|
||||
}
|
||||
else
|
||||
{
|
||||
matched = hosts.Where(h => h.PanelSettingsName == panel || h.GameObjectName == panel).ToList();
|
||||
if (matched.Count == 0)
|
||||
{
|
||||
var names = string.Join(", ", hosts.Select(h => h.DisplayName).Distinct());
|
||||
return CaptureElementResponse.Fail(
|
||||
$"No live runtime panel named '{panel}'. Available: {names}.");
|
||||
}
|
||||
|
||||
resolvedName = panel;
|
||||
}
|
||||
|
||||
// Lower sorting order is drawn first (further back); query in that order so the topmost
|
||||
// match is found last only if earlier panels miss. Roots that can't be resolved are skipped.
|
||||
var roots = matched
|
||||
.OrderBy(h => h.SortingOrder)
|
||||
.Select(h => h.GetRoot())
|
||||
.Where(r => r != null)
|
||||
.ToList();
|
||||
|
||||
if (roots.Count == 0)
|
||||
return CaptureElementResponse.Fail(
|
||||
$"Panel '{resolvedName}' has no initialized root VisualElement yet. Show the UI first.");
|
||||
|
||||
var element = VisualElementCaptureSupport.ResolveElement(roots, selector, out _);
|
||||
if (element == null)
|
||||
return CaptureElementResponse.Fail(
|
||||
$"No element matched selector '{selector}' in panel '{resolvedName}'.");
|
||||
|
||||
var dir = Application.persistentDataPath;
|
||||
return VisualElementCaptureSupport.CaptureAndRespond(
|
||||
element, selector, $"panel:{resolvedName}", output,
|
||||
relativeBaseDir: dir, defaultDir: dir, prefix: "element");
|
||||
}
|
||||
|
||||
/// <summary>A live runtime UI host (UIDocument or PanelRenderer) and how to reach its root.</summary>
|
||||
class Host
|
||||
{
|
||||
public string PanelSettingsName;
|
||||
public string GameObjectName;
|
||||
public float SortingOrder;
|
||||
public System.Func<VisualElement> GetRoot;
|
||||
|
||||
public string DisplayName => PanelSettingsName ?? GameObjectName;
|
||||
}
|
||||
|
||||
static List<Host> GatherHosts()
|
||||
{
|
||||
var hosts = new List<Host>();
|
||||
|
||||
foreach (var doc in PipelineUtils.FindObjectsByType<UIDocument>())
|
||||
{
|
||||
var d = doc;
|
||||
hosts.Add(new Host
|
||||
{
|
||||
PanelSettingsName = d.panelSettings != null ? d.panelSettings.name : null,
|
||||
GameObjectName = d.gameObject.name,
|
||||
SortingOrder = d.sortingOrder,
|
||||
GetRoot = () => d.rootVisualElement
|
||||
});
|
||||
}
|
||||
|
||||
// PanelRenderer is a 6000.5+ UI Toolkit API; on older editors only UIDocument panels exist.
|
||||
foreach (var renderer in PipelineUtils.FindObjectsByType<PanelRenderer>())
|
||||
{
|
||||
var r = renderer;
|
||||
hosts.Add(new Host
|
||||
{
|
||||
PanelSettingsName = r.panelSettings != null ? r.panelSettings.name : null,
|
||||
GameObjectName = r.gameObject.name,
|
||||
SortingOrder = r.sortingOrder,
|
||||
GetRoot = () => GetPanelRendererRoot(r)
|
||||
});
|
||||
}
|
||||
|
||||
return hosts;
|
||||
}
|
||||
|
||||
// PanelRenderer.rootVisualElement is internal; the public route to its root is a UI-reload
|
||||
// callback, which fires synchronously when the panel is already initialized. Register, capture
|
||||
// the root, and immediately unregister. PanelRenderer is a 6000.5+ API.
|
||||
static VisualElement GetPanelRendererRoot(PanelRenderer renderer)
|
||||
{
|
||||
VisualElement captured = null;
|
||||
PanelRenderer.VersionedUIReloadCallback callback = (pr, root, version) => captured = root;
|
||||
renderer.RegisterUIReloadCallback(callback);
|
||||
renderer.UnregisterUIReloadCallback(callback);
|
||||
return captured;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 54dc75c59cc13424c9e6e0e7b237e412
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,163 @@
|
||||
using System;
|
||||
using Unity.Pipeline.Models;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using Unity.Pipeline.Telemetry;
|
||||
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>
|
||||
static class CodeEvalCommand
|
||||
{
|
||||
[CliCommand("eval", "Evaluate C# code dynamically using Roslyn compiler", MainThreadRequired = true, Tags = new[] { "scripts/eval" })]
|
||||
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 RejectedEval("eval", code, "Code parameter is required and cannot be empty");
|
||||
|
||||
return EvaluateSource(code, timeout, "eval");
|
||||
}
|
||||
|
||||
[CliCommand("eval_file", "Evaluate C# code read from a .cs file on disk", MainThreadRequired = true, Tags = new[] { "scripts/eval" })]
|
||||
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 RejectedEval("eval_file", null, "File parameter is required and cannot be empty");
|
||||
|
||||
if (!file.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
|
||||
return RejectedEval("eval_file", null, "File must have a .cs extension");
|
||||
|
||||
if (!System.IO.File.Exists(file))
|
||||
return RejectedEval("eval_file", null, $"File not found: {file}");
|
||||
|
||||
string code;
|
||||
try
|
||||
{
|
||||
code = System.IO.File.ReadAllText(file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return RejectedEval("eval_file", null, $"Failed to read file: {ex.Message}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return RejectedEval("eval_file", code, $"File is empty: {file}");
|
||||
|
||||
return EvaluateSource(code, timeout, "eval_file");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reject an invocation at validation time AND record it: a rejected eval is still an eval
|
||||
/// invocation, and skipping telemetry here would undercount report_evals' errorCount /
|
||||
/// errorRate for exactly the mistakes an agent makes most (empty code, wrong path).
|
||||
/// </summary>
|
||||
private static EvalResponse RejectedEval(string command, string code, string errorDetails)
|
||||
{
|
||||
// Guarded like the RecordInBackground call in EvaluateSource: telemetry must never
|
||||
// affect eval behavior, so a failure in its synchronous prelude (ResolveDirectory,
|
||||
// the write-chain lock) can't turn a clean Bad Request into an unhandled exception.
|
||||
try
|
||||
{
|
||||
EvalUsageTelemetry.RecordInBackground(command, code ?? string.Empty, success: false,
|
||||
error: "Bad Request", executionTimeMs: 0);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Telemetry must never affect eval behavior.
|
||||
}
|
||||
|
||||
return EvalResponse.EvalFailure("Bad Request", errorDetails);
|
||||
}
|
||||
|
||||
/// <summary>Upper bound on a single eval's timeout — 24 hours (CLI-335; was 30s).</summary>
|
||||
private const int MaxTimeoutMs = 86_400_000;
|
||||
|
||||
/// <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. Each invocation is recorded to local eval-usage telemetry
|
||||
/// (AUTHAPI-29) on a best-effort basis before returning.
|
||||
/// </summary>
|
||||
private static EvalResponse EvaluateSource(string code, int timeout, string commandName)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
EvalResponse response;
|
||||
|
||||
try
|
||||
{
|
||||
// Validate timeout
|
||||
// Cap raised from 30000ms (CLI-335): long evals are now legitimate — clients
|
||||
// set custom timeouts or run detached jobs and reattach. 24h bounds abuse.
|
||||
// (Supersedes the interim 300000ms raise from UUM-148641.)
|
||||
if (timeout <= 0 || timeout > MaxTimeoutMs)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
response = EvalResponse.EvalFailure(
|
||||
"Bad Request",
|
||||
$"Timeout must be between 1ms and {MaxTimeoutMs}ms",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
response = 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}");
|
||||
|
||||
response = EvalResponse.EvalFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
// Best-effort local telemetry (editor-only; a no-op in players): fingerprint + shape only
|
||||
// by default (no raw source unless the explicit opt-in is set). The eval primitives and
|
||||
// the current settings are snapshotted here on the main thread, then the Roslyn parse and
|
||||
// file append run on a background task — fire-and-forget, zero latency added to the
|
||||
// response. RecordInBackground swallows its own errors, but guard here too so an
|
||||
// unexpected failure can never turn a good eval into a failed command.
|
||||
try
|
||||
{
|
||||
EvalUsageTelemetry.RecordInBackground(
|
||||
commandName,
|
||||
code,
|
||||
response.Success,
|
||||
response.Error,
|
||||
response.ExecutionTimeMs ?? stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Telemetry must never affect eval behavior.
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bc1d3406462d2c246a5f84dd30dd02c7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,455 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// CLI commands for code reload operations: apply [CodeReload] edits from a source file, inspect
|
||||
/// the registry, and clean up saved reload assemblies.
|
||||
/// </summary>
|
||||
static class CodeReloadCommands
|
||||
{
|
||||
[CliCommand("reload_file", "Compile and apply in-place [CodeReload] edits from a source file", MainThreadRequired = true, Tags = new[] { "scripts/codereload" })]
|
||||
public static CodeReloadResponse ReloadFile(
|
||||
[CliArg("filename", "Source file containing [CodeReload] 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)
|
||||
{
|
||||
return ReloadFileCore("reload_file", filename, timeout, assemblyDir, pdb, useInterpreter: false);
|
||||
}
|
||||
|
||||
[CliCommand("reload_file_editor_interpreter", "Compile in-place [CodeReload] edits and run them through the IlInterpreter VM in this process instead of Assembly.Load (IL2CPP-safe; only a minimal host API + the target type are available)", MainThreadRequired = true, Tags = new[] { "scripts/codereload" })]
|
||||
public static CodeReloadResponse ReloadFileEditorInterpreter(
|
||||
[CliArg("filename", "Source file containing [CodeReload] 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)
|
||||
{
|
||||
return ReloadFileCore("reload_file_editor_interpreter", filename, timeout, assemblyDir, pdb, useInterpreter: true);
|
||||
}
|
||||
|
||||
/// <summary>Shared body of <c>reload_file</c> and <c>reload_file_editor_interpreter</c>:
|
||||
/// the two commands differ only in the backend the reloaded methods run on.</summary>
|
||||
private static CodeReloadResponse ReloadFileCore(string commandName, string filename, int timeout, string assemblyDir, bool pdb, bool useInterpreter)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filename))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return CodeReloadResponse.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 CodeReloadResponse.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, CodeReloadRegistry.AllowedReloadRoots);
|
||||
if (!scopeCheck.IsValid)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return CodeReloadResponse.CmdFailure(
|
||||
scopeCheck.Error,
|
||||
scopeCheck.ErrorDetails,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"CodeReload: Executing {commandName} command: {fullPath}, timeout: {timeout}ms, assemblyDir: {assemblyDir ?? "in-memory"}, pdb: {pdb}, interpreter: {useInterpreter}");
|
||||
|
||||
var task = InPlaceReloadProcessor.ProcessSourceFileAsync(fullPath, assemblyDir, pdb, useInterpreter);
|
||||
task.Wait();
|
||||
var result = task.Result;
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// Everything matches the compiled baseline: an honest no-op success, not
|
||||
// "reload successful with 0 methods" against a null assembly name. Reverting
|
||||
// stale overrides (the edit was undone) is part of the outcome — say so
|
||||
// instead of unregistering silently.
|
||||
if (result.AllUpToDate)
|
||||
{
|
||||
var upToDate = CodeReloadResponse.CmdSuccess(
|
||||
string.Empty,
|
||||
$"Up to date: all {result.UpToDateMethods.Count} [CodeReload] method(s) match the compiled baseline — nothing reloaded" +
|
||||
(result.RevertedMethods.Count > 0
|
||||
? $"; reverted {result.RevertedMethods.Count} stale override(s): {string.Join(", ", result.RevertedMethods)}"
|
||||
: "") + ".",
|
||||
result.RegisteredMethods,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
upToDate.Diagnostics = result.CompilationDiagnostics;
|
||||
return upToDate;
|
||||
}
|
||||
|
||||
// "Compiled OK but bound nothing" is a failed reload in practice: the compile
|
||||
// succeeded but every method was skipped downstream (e.g. an interpreter-unsupported
|
||||
// construct, or no RuntimePipelineDriver registered the target). Surface the
|
||||
// reasons from CompilationDiagnostics as a failure instead of a silent success.
|
||||
if (result.RegisteredMethods.Count == 0 && result.ExtractedMethods.Count > 0)
|
||||
{
|
||||
var details = result.CompilationDiagnostics != null && result.CompilationDiagnostics.Count > 0
|
||||
? "Compiled, but no methods were applied:\n- " + string.Join("\n- ", result.CompilationDiagnostics)
|
||||
: "Compiled, but no methods were applied. Is the target component in the scene " +
|
||||
"and in play mode (with the runtime Pipeline driver active)?";
|
||||
return CodeReloadResponse.CmdFailure(
|
||||
"No Methods Applied",
|
||||
details,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
result.CompilationDiagnostics);
|
||||
}
|
||||
|
||||
CodeReloadRegistry.InvokeReloadCallbacks(result.RegisteredMethods);
|
||||
|
||||
var response = CodeReloadResponse.CmdSuccess(
|
||||
result.AssemblyName,
|
||||
$"In-place code reload successful: {result.AssemblyName} with {result.RegisteredMethods.Count} methods" +
|
||||
(result.RevertedMethods.Count > 0
|
||||
? $"; reverted {result.RevertedMethods.Count} stale override(s): {string.Join(", ", result.RevertedMethods)}"
|
||||
: ""),
|
||||
result.RegisteredMethods,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
// Surface partial-skip reasons (some methods bound, others skipped) on success too.
|
||||
response.Diagnostics = result.CompilationDiagnostics;
|
||||
return response;
|
||||
}
|
||||
|
||||
return CodeReloadResponse.CmdFailure(
|
||||
"In-Place Reload Failed",
|
||||
result.ErrorMessage ?? "In-place code reload processing failed",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
result.CompilationDiagnostics);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"CodeReload: {commandName} command failed: {ex.Message}");
|
||||
Debug.LogError($"CodeReload: Stack trace: {ex.StackTrace}");
|
||||
|
||||
return CodeReloadResponse.CmdFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
[CliCommand("cleanup_codereload", "Remove old code reload DLL versions and clear registry", MainThreadRequired = true, RuntimeOnly = true, Tags = new[] { "scripts/codereload" })]
|
||||
public static CodeReloadResponse CleanupCodeReload(
|
||||
[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($"CodeReload: Executing cleanup_codereload command for directory: {assemblyDir}");
|
||||
|
||||
// Execute cleanup
|
||||
var cleanupResult = CodeReloadCompiler.CleanupCodeReloadDlls(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("CodeReload: Requesting Unity domain reload to clean memory state");
|
||||
UnityEditor.EditorUtility.RequestScriptReload();
|
||||
message += " Unity domain reload requested.";
|
||||
#endif
|
||||
}
|
||||
|
||||
Debug.Log($"CodeReload: cleanup_codereload completed successfully in {stopwatch.ElapsedMilliseconds}ms");
|
||||
|
||||
return CodeReloadResponse.CmdSuccess(
|
||||
"cleanup_completed",
|
||||
message,
|
||||
cleanupResult.DeletedFiles,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
return CodeReloadResponse.CmdFailure(
|
||||
"Cleanup Failed",
|
||||
cleanupResult.Message,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"CodeReload: cleanup_codereload command failed: {ex.Message}");
|
||||
|
||||
return CodeReloadResponse.CmdFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
[CliCommand("codereload_status", "Show current code reload registry status and statistics", MainThreadRequired = true, RuntimeOnly = true, Tags = new[] { "scripts/codereload" })]
|
||||
public static CodeReloadResponse CodeReloadStatus()
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Get current registry stats
|
||||
var stats = CodeReloadRegistry.GetStats();
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var statusMessage = $"Code Reload Status - " +
|
||||
$"Reloadable Methods: {stats.ReloadableMethodCount}, " +
|
||||
$"Active Overrides: {stats.ActiveOverrideCount}, " +
|
||||
$"Loaded Types: {stats.LoadedTypeCount}";
|
||||
|
||||
Debug.Log($"CodeReload: {statusMessage}");
|
||||
|
||||
return CodeReloadResponse.CmdSuccess(
|
||||
"status_retrieved",
|
||||
statusMessage,
|
||||
stats.ActiveOverrideIds,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"CodeReload: codereload_status command failed: {ex.Message}");
|
||||
|
||||
return CodeReloadResponse.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",
|
||||
$"Code 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.
|
||||
/// </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>
|
||||
/// Response model for code reload CLI commands.
|
||||
/// Provides consistent response format for all code reload operations.
|
||||
/// </summary>
|
||||
class CodeReloadResponse : 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 code reload response.
|
||||
/// </summary>
|
||||
public static CodeReloadResponse CmdSuccess(string assemblyName, string message, System.Collections.Generic.List<string> items, long executionTimeMs)
|
||||
{
|
||||
return new CodeReloadResponse
|
||||
{
|
||||
Success = true,
|
||||
AssemblyName = assemblyName,
|
||||
Message = message,
|
||||
Items = items ?? new System.Collections.Generic.List<string>(),
|
||||
ExecutionTimeMs = executionTimeMs,
|
||||
ExecutedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a failed code reload response.
|
||||
/// </summary>
|
||||
public static CodeReloadResponse CmdFailure(string error, string errorDetails, long executionTimeMs, System.Collections.Generic.List<string> diagnostics = null)
|
||||
{
|
||||
return new CodeReloadResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = error,
|
||||
ErrorDetails = errorDetails,
|
||||
ExecutionTimeMs = executionTimeMs,
|
||||
Diagnostics = diagnostics ?? new System.Collections.Generic.List<string>(),
|
||||
ExecutedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of validating a code 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="CodeReloadResponse.CmdFailure"/>.
|
||||
/// </summary>
|
||||
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,118 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Console;
|
||||
using Unity.Pipeline.Models;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// The console commands: <c>console</c> to read captured output, <c>console_status</c> for its
|
||||
/// counters alone, and <c>clear_console</c> to empty it. <c>console</c> backs the CLI's
|
||||
/// <c>console [--tail N] [--level log|warn|error] [--follow]</c>.
|
||||
///
|
||||
/// All three live in the runtime assembly, so they are 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 and since_session = previous session
|
||||
///
|
||||
/// Reads only the in-memory buffer and the last ground-truth sample (no Unity main-thread
|
||||
/// APIs), so it is marked <c>MainThreadRequired = false</c> and keeps answering while the
|
||||
/// Editor is blocked compiling or importing — which is when a client most needs it.
|
||||
/// </summary>
|
||||
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, Tags = new[] { "observability/console" })]
|
||||
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,
|
||||
[CliArg("since_session", "Session the 'since' cursor came from: pass the 'session' from a previous response. A cursor from another session is refused and the tail returned with reset=true.")] string since_session = null)
|
||||
{
|
||||
// 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);
|
||||
var response = ConsoleLogCapture.Buffer.Query(since, tail, minSeverity, since_session);
|
||||
response.GroundTruth = ReadGroundTruth();
|
||||
return response;
|
||||
}
|
||||
|
||||
[CliCommand("console_status", "Console ground truth and buffer counters without pulling entries: compile-failure flag, Editor console counts, and the buffer's retained counts and cursor.", MainThreadRequired = false, Tags = new[] { "observability/console" })]
|
||||
public static ConsoleLogResponse GetConsoleStatus()
|
||||
{
|
||||
// Same shape as `console` with no entries, so a client polling during a long compile
|
||||
// gets the counters and the compile-failure flag without paying for the payload.
|
||||
var response = ConsoleLogCapture.Buffer.Stats();
|
||||
response.GroundTruth = ReadGroundTruth();
|
||||
return response;
|
||||
}
|
||||
|
||||
[CliCommand("clear_console", "Clear the captured log buffer and the Unity Editor console.", Tags = new[] { "observability/console" })]
|
||||
public static object ClearConsole()
|
||||
{
|
||||
// Clearing keeps the sequence counter, so a client following the console sees dropped=true
|
||||
// on its next poll rather than silently skipping the window that was cleared.
|
||||
ConsoleLogCapture.Buffer.Clear();
|
||||
|
||||
// Best-effort: also clear Unity's own console window. UnityEditor.LogEntries is internal,
|
||||
// so reach it by name and swallow any failure (the API is not part of the public contract).
|
||||
// Resolving the type by string is also what keeps this command in the runtime assembly:
|
||||
// there is no compile-time UnityEditor dependency, and in a player GetType returns null so
|
||||
// the chain no-ops and only the captured buffer is cleared.
|
||||
// LogEntries.Clear is a static, non-public method, so include NonPublic in the binding
|
||||
// flags — GetMethod("Clear") with no flags only finds public methods and would silently
|
||||
// return null.
|
||||
// Note it leaves editor compile errors standing: those are logged as sticky entries, which
|
||||
// Unity's Clear skips, and only a completed compile removes them.
|
||||
try
|
||||
{
|
||||
Type.GetType("UnityEditor.LogEntries,UnityEditor")
|
||||
?.GetMethod("Clear", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
|
||||
?.Invoke(null, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore — clearing the Editor console is auxiliary; the buffer is already cleared.
|
||||
}
|
||||
|
||||
return new { cleared = true };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A copy of the last ground-truth sample with its age filled in. Copied rather than
|
||||
/// returned directly so the published snapshot stays immutable and shared between callers.
|
||||
/// </summary>
|
||||
static ConsoleGroundTruth ReadGroundTruth()
|
||||
{
|
||||
var sample = ConsoleLogCapture.GroundTruth;
|
||||
if (sample == null)
|
||||
return null;
|
||||
|
||||
var ageMs = (long)(DateTime.UtcNow - sample.SampledUtc).TotalMilliseconds;
|
||||
return new ConsoleGroundTruth
|
||||
{
|
||||
SampledUtc = sample.SampledUtc,
|
||||
AgeMs = ageMs < 0 ? 0 : ageMs,
|
||||
CompilationFailed = sample.CompilationFailed,
|
||||
Compiling = sample.Compiling,
|
||||
ConsoleErrors = sample.ConsoleErrors,
|
||||
ConsoleWarnings = sample.ConsoleWarnings,
|
||||
ConsoleLogs = sample.ConsoleLogs,
|
||||
Seeded = sample.Seeded
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 92be7cfe398a263a7b06a4a8a00a802c
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Telemetry;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-back command for the local eval-usage telemetry (AUTHAPI-29). Aggregates
|
||||
/// <c>Library/Pipeline/eval-usage.jsonl</c> — merged with the rotated <c>eval-usage.old.jsonl</c>,
|
||||
/// so the report sees the full retained history (~2× the size cap) — into a ranked report: API
|
||||
/// fingerprint frequency, one-liner vs multi-statement split, error rate. It cross-references the
|
||||
/// fingerprints against the live command catalog to flag patterns already coverable by an existing
|
||||
/// command and the top uncovered patterns. This is the artifact that steers eval-displacement
|
||||
/// backlog triage (AUTHAPI-24). Read-only and off the main thread, so it stays servable even while
|
||||
/// the Editor is settling. The ranked lists are capped by <c>top</c>; the report's
|
||||
/// <c>distinctFingerprints</c> total makes any truncation visible.
|
||||
/// </summary>
|
||||
static class EvalTelemetryCommand
|
||||
{
|
||||
[CliCommand("report_evals",
|
||||
"Aggregate local eval-usage telemetry into a ranked report: API fingerprint frequency, one-liner percentage, error rate, and command-coverage suggestions.",
|
||||
MainThreadRequired = false,
|
||||
Tags = new[] { "observability/eval_usage" })]
|
||||
public static EvalUsageReport ReportEvals(
|
||||
[CliArg("top", "Maximum entries in each ranked list (API fingerprints, and uncovered patterns suggested as commands). Accepted range: 0 or greater; negative values fall back to the default (50).")] int top = 50)
|
||||
{
|
||||
// Clamp, don't reject: a negative cap has no meaning, so fall back to the default rather
|
||||
// than fail the command or return an unbounded report.
|
||||
if (top < 0)
|
||||
top = EvalUsageAggregator.DefaultFingerprintLimit;
|
||||
|
||||
var records = EvalUsageTelemetry.ReadRecords(EvalUsageTelemetry.ResolveDirectory());
|
||||
|
||||
// Cross-reference against the live catalog. Discovery is already warm (the server queried
|
||||
// it to route this very request), so this reads the cache and touches no main-thread API.
|
||||
var availableCommands = new HashSet<string>(
|
||||
CommandRegistry.DiscoverCommands().Select(c => c.Name));
|
||||
|
||||
return EvalUsageAggregator.Build(records, availableCommands, topUncoveredLimit: top, fingerprintLimit: top);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7ecb5f9bf7384f0186d1e26cf4a355a3
|
||||
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>
|
||||
static class RuntimeApplicationCommand
|
||||
{
|
||||
[CliCommand("quit", "Gracefully quit the Unity application", MainThreadRequired = true, RuntimeOnly = true, Tags = new[] { "runtime/application" })]
|
||||
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, Tags = new[] { "runtime/application" })]
|
||||
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, Tags = new[] { "runtime/application" })]
|
||||
public static string SetTimeScale(
|
||||
[CliArg("scale", "Time scale multiplier (0.0 to pause, 1.0 for normal speed)", Required = true)] float scale)
|
||||
{
|
||||
if (scale < 0f)
|
||||
{
|
||||
return "Error: Time scale cannot be negative";
|
||||
}
|
||||
|
||||
var previousScale = Time.timeScale;
|
||||
Time.timeScale = scale;
|
||||
|
||||
var scaleDescription = scale switch
|
||||
{
|
||||
0f => "paused",
|
||||
1f => "normal speed",
|
||||
_ => $"{scale}x speed"
|
||||
};
|
||||
|
||||
return $"Time scale set to {scaleDescription} (was {previousScale}x)";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MonoBehaviour to handle delayed application quit.
|
||||
/// Allows HTTP response to be sent before application terminates.
|
||||
/// </summary>
|
||||
private class QuitScheduler : MonoBehaviour
|
||||
{
|
||||
private int m_ExitCode;
|
||||
private float m_QuitTime;
|
||||
|
||||
public void Initialize(int exitCode)
|
||||
{
|
||||
m_ExitCode = exitCode;
|
||||
m_QuitTime = Time.realtimeSinceStartup + 0.1f; // 100ms delay
|
||||
DontDestroyOnLoad(gameObject);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (Time.realtimeSinceStartup >= m_QuitTime)
|
||||
{
|
||||
Debug.Log($"Pipeline: Quitting application with exit code {m_ExitCode}");
|
||||
Application.Quit(m_ExitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e508f97d464c66347816d5281c68e276
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,182 @@
|
||||
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>
|
||||
static class RuntimeInputCommand
|
||||
{
|
||||
[CliCommand("simulate_key", "Simulate a keyboard key event (Input System). Drives the running app.",
|
||||
MainThreadRequired = true, RuntimeOnly = true,
|
||||
Tags = new[] { "runtime/input" })]
|
||||
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,
|
||||
Tags = new[] { "runtime/input" })]
|
||||
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]
|
||||
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>
|
||||
static class RuntimeLogCommand
|
||||
{
|
||||
[CliCommand("log", "Write a message to Unity console", MainThreadRequired = true, RuntimeOnly = true, Tags = new[] { "observability/console" })]
|
||||
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,217 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Unity.Pipeline;
|
||||
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>
|
||||
static class RuntimeStatusCommand
|
||||
{
|
||||
[CliCommand("runtime_status", "Get comprehensive runtime application status", MainThreadRequired = true, RuntimeOnly = true, Tags = new[] { "runtime" })]
|
||||
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(),
|
||||
|
||||
// The driver's actual state and in-effect config — not just what Project
|
||||
// Settings currently says, which can differ (live JSON in the Editor vs. the
|
||||
// snapshot frozen into the Player at build time).
|
||||
PipelineDriver = BuildDriverStatus(),
|
||||
|
||||
// Timestamp
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Pipeline: Runtime status command failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null when no driver was ever bootstrapped (e.g. enableInBuilds was false), otherwise the
|
||||
/// driver's actual running state and the config values presently governing it.
|
||||
/// maxWorkItemsPerFrame is read via CurrentMaxWorkItemsPerFrame rather than the config
|
||||
/// directly, since that field alone can be tuned live after the driver started.
|
||||
/// </summary>
|
||||
private static RuntimePipelineDriverStatus BuildDriverStatus()
|
||||
{
|
||||
var driver = RuntimePipelineBootstrap.Instance;
|
||||
if (driver == null)
|
||||
return null;
|
||||
|
||||
var config = driver.Config;
|
||||
return new RuntimePipelineDriverStatus
|
||||
{
|
||||
IsServerRunning = driver.IsServerRunning,
|
||||
ActualPort = driver.ActualPort,
|
||||
EnableInBuilds = config != null && config.enableInBuilds,
|
||||
RequestTimeoutMs = config != null ? config.requestTimeoutMs : 0,
|
||||
EnableAuditLogging = config != null && config.enableAuditLogging,
|
||||
AutoStart = config != null && config.autoStart,
|
||||
MaxWorkItemsPerFrame = driver.CurrentMaxWorkItemsPerFrame
|
||||
};
|
||||
}
|
||||
|
||||
/// <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="RuntimePipelineDriver"/> 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]
|
||||
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; }
|
||||
|
||||
// The runtime Pipeline driver's actual state, or null if none was ever bootstrapped.
|
||||
public RuntimePipelineDriverStatus PipelineDriver { get; set; }
|
||||
|
||||
// Metadata
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The runtime Pipeline driver's actual state: whether its server is running, the port it's
|
||||
/// actually on, and the config values presently governing it. Distinct from what Project
|
||||
/// Settings > Pipeline > Runtime currently shows — that's live-editable in the Editor, while a
|
||||
/// running driver is working from whatever it was initialized with (a Player's is additionally
|
||||
/// frozen at build time). This is the authoritative "what's actually happening right now" view.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
class RuntimePipelineDriverStatus
|
||||
{
|
||||
public bool IsServerRunning { get; set; }
|
||||
public int ActualPort { get; set; }
|
||||
public bool EnableInBuilds { get; set; }
|
||||
public int RequestTimeoutMs { get; set; }
|
||||
public bool EnableAuditLogging { get; set; }
|
||||
public bool AutoStart { get; set; }
|
||||
|
||||
/// <summary>The value actually governing the dispatcher right now — see RuntimePipelineDriver.CurrentMaxWorkItemsPerFrame.</summary>
|
||||
public int MaxWorkItemsPerFrame { 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]
|
||||
class RuntimePerformanceTelemetry
|
||||
{
|
||||
// Frame timing — sampled over a rolling window by the RuntimePipelineDriver.
|
||||
/// <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>></c>) combinators; each simple part is
|
||||
/// <c>Type#name.class1.class2</c> (any subset); optional pseudo-state suffixes
|
||||
/// <c>:checked :hover :focus :active :enabled :disabled</c> and <c>:not(<state>)</c>.
|
||||
/// </summary>
|
||||
static class VisualElementCaptureSupport
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a failure response when no GPU is available (batchmode/headless), where a capture
|
||||
/// would read back a blank image; otherwise returns null (capture may proceed).
|
||||
/// </summary>
|
||||
public static CaptureElementResponse GpuUnavailableResponse()
|
||||
{
|
||||
if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null)
|
||||
return CaptureElementResponse.Fail("No GPU available (batchmode/headless); cannot capture.");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the first <see cref="VisualElement"/> under <paramref name="root"/> matching
|
||||
/// <paramref name="selector"/>, or null if the selector is empty/invalid or nothing matches.
|
||||
/// </summary>
|
||||
public static VisualElement ResolveElement(VisualElement root, string selector)
|
||||
{
|
||||
if (root == null || string.IsNullOrWhiteSpace(selector))
|
||||
return null;
|
||||
|
||||
if (!TryBuildQuery(root, selector, out var query))
|
||||
return null;
|
||||
|
||||
return query.First();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the selector against each root in order, returning the first match (and the root
|
||||
/// it was found under via <paramref name="matchedRoot"/>), or null if none match.
|
||||
/// </summary>
|
||||
public static VisualElement ResolveElement(IEnumerable<VisualElement> roots, string selector,
|
||||
out VisualElement matchedRoot)
|
||||
{
|
||||
matchedRoot = null;
|
||||
if (roots == null)
|
||||
return null;
|
||||
|
||||
foreach (var root in roots)
|
||||
{
|
||||
var element = ResolveElement(root, selector);
|
||||
if (element != null)
|
||||
{
|
||||
matchedRoot = root;
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture <paramref name="element"/> to PNG bytes at its own pixel size. Restores the active
|
||||
/// RenderTexture and releases all temporaries so the capture leaves no global render state.
|
||||
/// Throws <see cref="InvalidOperationException"/> for camera-drawn (world-space) or detached
|
||||
/// panels (surfaced from <see cref="VisualElementCaptureExtensions.CaptureToRenderTexture"/>).
|
||||
/// </summary>
|
||||
public static byte[] CaptureElementToPng(VisualElement element, out int width, out int height)
|
||||
{
|
||||
if (element == null)
|
||||
throw new ArgumentNullException(nameof(element));
|
||||
|
||||
var rt = element.CaptureToRenderTexture();
|
||||
var prevActive = RenderTexture.active;
|
||||
Texture2D tex = null;
|
||||
try
|
||||
{
|
||||
width = rt.width;
|
||||
height = rt.height;
|
||||
|
||||
RenderTexture.active = rt;
|
||||
tex = new Texture2D(rt.width, rt.height, TextureFormat.RGBA32, false);
|
||||
tex.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0, false);
|
||||
tex.Apply(false, false);
|
||||
return tex.EncodeToPNG();
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderTexture.active = prevActive;
|
||||
rt.Release();
|
||||
DestroyObject(rt);
|
||||
if (tex != null)
|
||||
DestroyObject(tex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the output path. An explicit rooted path is used as-is; an explicit relative path
|
||||
/// is resolved against <paramref name="relativeBaseDir"/>; an empty path produces a
|
||||
/// timestamped <c><prefix>_yyyyMMdd_HHmmss_fff.png</c> file under
|
||||
/// <paramref name="defaultDir"/>.
|
||||
/// </summary>
|
||||
public static string ResolveOutputPath(string output, string relativeBaseDir, string defaultDir, string prefix)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return Path.IsPathRooted(output)
|
||||
? output
|
||||
: Path.GetFullPath(Path.Combine(relativeBaseDir, output));
|
||||
}
|
||||
|
||||
var stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff", CultureInfo.InvariantCulture);
|
||||
return Path.Combine(defaultDir, $"{prefix}_{stamp}.png");
|
||||
}
|
||||
|
||||
/// <summary>Write PNG bytes to <paramref name="path"/>, creating the directory if needed.</summary>
|
||||
public static void WritePng(string path, byte[] png)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllBytes(path, png);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture the resolved <paramref name="element"/>, write the PNG to disk, and build a
|
||||
/// populated success response (or a failure response if the panel cannot be captured). This
|
||||
/// is the shared tail both commands run once they have an element + a panel root.
|
||||
/// </summary>
|
||||
public static CaptureElementResponse CaptureAndRespond(VisualElement element, string selector,
|
||||
string source, string output, string relativeBaseDir, string defaultDir, string prefix)
|
||||
{
|
||||
var gpuGuard = GpuUnavailableResponse();
|
||||
if (gpuGuard != null)
|
||||
return gpuGuard;
|
||||
|
||||
try
|
||||
{
|
||||
var png = CaptureElementToPng(element, out var width, out var height);
|
||||
var path = ResolveOutputPath(output, relativeBaseDir, defaultDir, prefix);
|
||||
WritePng(path, png);
|
||||
|
||||
return new CaptureElementResponse
|
||||
{
|
||||
Success = true,
|
||||
Selector = selector,
|
||||
Source = source,
|
||||
Path = path,
|
||||
Width = width,
|
||||
Height = height,
|
||||
Encoding = "png",
|
||||
Base64 = Convert.ToBase64String(png),
|
||||
Bytes = png.Length,
|
||||
Message = $"Captured {source} to {path}"
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// The capture API throws this for camera-drawn (world-space) or detached panels.
|
||||
return CaptureElementResponse.Fail(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CaptureElementResponse.Fail($"Failed to capture '{selector}' from {source}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Selector parsing -------------------------------------------------------------------
|
||||
|
||||
// Build a UQuery from the selector, mapping tokens onto the public UQueryBuilder fluent API.
|
||||
static bool TryBuildQuery(VisualElement root, string selector, out UQueryBuilder<VisualElement> query)
|
||||
{
|
||||
query = root.Query();
|
||||
|
||||
var parts = Tokenize(selector);
|
||||
if (parts.Count == 0)
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var (isChild, partStr) = parts[i];
|
||||
if (!TryParsePart(partStr, out var part))
|
||||
return false;
|
||||
|
||||
if (i == 0)
|
||||
query = query.Name(part.Name);
|
||||
else
|
||||
query = isChild
|
||||
? query.Children<VisualElement>(part.Name)
|
||||
: query.Descendents<VisualElement>(part.Name);
|
||||
|
||||
foreach (var cls in part.Classes)
|
||||
query = query.Class(cls);
|
||||
|
||||
if (part.Type != null)
|
||||
{
|
||||
var typeName = part.Type;
|
||||
query = query.Where(e => TypeNameMatches(e, typeName));
|
||||
}
|
||||
|
||||
foreach (var pseudo in part.Pseudos)
|
||||
{
|
||||
if (!TryApplyPseudo(ref query, pseudo.Name, pseudo.Negate))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Split a selector into simple parts, flagging each with whether it follows a '>' (child)
|
||||
// combinator. Whitespace separates descendant parts; '>' is treated as its own token.
|
||||
static List<(bool isChild, string part)> Tokenize(string selector)
|
||||
{
|
||||
var result = new List<(bool, string)>();
|
||||
var raw = selector.Replace(">", " > ")
|
||||
.Split((char[])null, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
bool nextIsChild = false;
|
||||
foreach (var token in raw)
|
||||
{
|
||||
if (token == ">")
|
||||
{
|
||||
nextIsChild = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add((nextIsChild, token));
|
||||
nextIsChild = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
class SelectorPart
|
||||
{
|
||||
public string Type;
|
||||
public string Name;
|
||||
public readonly List<string> Classes = new List<string>();
|
||||
public readonly List<(bool Negate, string Name)> Pseudos = new List<(bool, string)>();
|
||||
}
|
||||
|
||||
// Parse "Type#name.class1.class2" with optional ":pseudo" / ":not(pseudo)" suffixes.
|
||||
static bool TryParsePart(string raw, out SelectorPart part)
|
||||
{
|
||||
part = new SelectorPart();
|
||||
|
||||
var simple = ExtractPseudos(raw, part.Pseudos);
|
||||
if (simple == null)
|
||||
return false;
|
||||
|
||||
int i = 0;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Leading run (before any '#' or '.') is the type token.
|
||||
while (i < simple.Length && simple[i] != '#' && simple[i] != '.')
|
||||
sb.Append(simple[i++]);
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
var type = sb.ToString();
|
||||
if (type != "*")
|
||||
part.Type = type;
|
||||
}
|
||||
|
||||
while (i < simple.Length)
|
||||
{
|
||||
char kind = simple[i++];
|
||||
sb.Clear();
|
||||
while (i < simple.Length && simple[i] != '#' && simple[i] != '.')
|
||||
sb.Append(simple[i++]);
|
||||
|
||||
var value = sb.ToString();
|
||||
if (value.Length == 0)
|
||||
return false; // dangling '#' or '.'
|
||||
|
||||
if (kind == '#')
|
||||
{
|
||||
if (part.Name != null)
|
||||
return false; // more than one id
|
||||
part.Name = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
part.Classes.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Strip ":pseudo" and ":not(pseudo)" suffixes into 'pseudos', returning the simple-selector
|
||||
// remainder, or null if a pseudo is malformed.
|
||||
static string ExtractPseudos(string raw, List<(bool Negate, string Name)> pseudos)
|
||||
{
|
||||
int idx;
|
||||
while ((idx = raw.IndexOf(':')) >= 0)
|
||||
{
|
||||
var head = raw.Substring(0, idx);
|
||||
var rest = raw.Substring(idx + 1);
|
||||
|
||||
if (rest.StartsWith("not(", StringComparison.Ordinal))
|
||||
{
|
||||
int close = rest.IndexOf(')');
|
||||
if (close < 0)
|
||||
return null;
|
||||
var inner = rest.Substring(4, close - 4).Trim().TrimStart(':');
|
||||
if (inner.Length == 0)
|
||||
return null;
|
||||
pseudos.Add((true, inner.ToLowerInvariant()));
|
||||
raw = head + rest.Substring(close + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
int next = rest.IndexOf(':');
|
||||
var name = next < 0 ? rest : rest.Substring(0, next);
|
||||
if (name.Length == 0)
|
||||
return null;
|
||||
pseudos.Add((false, name.ToLowerInvariant()));
|
||||
raw = head + (next < 0 ? string.Empty : rest.Substring(next));
|
||||
}
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
// Apply one pseudo-state to the query, honoring negation. Returns false for an unknown state.
|
||||
static bool TryApplyPseudo(ref UQueryBuilder<VisualElement> query, string name, bool negate)
|
||||
{
|
||||
bool on = !negate;
|
||||
switch (name)
|
||||
{
|
||||
case "checked": query = on ? query.Checked() : query.NotChecked(); return true;
|
||||
case "hover": query = on ? query.Hovered() : query.NotHovered(); return true;
|
||||
case "focus": query = on ? query.Focused() : query.NotFocused(); return true;
|
||||
case "active": query = on ? query.Active() : query.NotActive(); return true;
|
||||
case "enabled": query = on ? query.Enabled() : query.NotEnabled(); return true;
|
||||
case "disabled": query = on ? query.NotEnabled() : query.Enabled(); return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Match a type token against the element's runtime type name or any of its base type names,
|
||||
// so "Button" matches a Button and "VisualElement" matches everything. Avoids reflecting the
|
||||
// generic OfType<T> over a runtime-resolved Type.
|
||||
static bool TypeNameMatches(VisualElement element, string typeName)
|
||||
{
|
||||
for (var t = element.GetType(); t != null && t != typeof(object); t = t.BaseType)
|
||||
{
|
||||
if (string.Equals(t.Name, typeName, StringComparison.Ordinal))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void DestroyObject(Object obj)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
Object.DestroyImmediate(obj);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
Object.Destroy(obj);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a visual-element capture command: the PNG payload (base64), its dimensions, the
|
||||
/// panel/source it was rendered from, and the path it was written to. JSON property names mirror
|
||||
/// the GameView capture command's result shape (PR #13) for easy convergence later.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
class CaptureElementResponse
|
||||
{
|
||||
/// <summary>Whether the capture succeeded.</summary>
|
||||
[JsonProperty("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Human-readable message (the error text on failure).</summary>
|
||||
[JsonProperty("message")]
|
||||
public string Message { get; set; }
|
||||
|
||||
/// <summary>The selector used to locate the element.</summary>
|
||||
[JsonProperty("selector")]
|
||||
public string Selector { get; set; }
|
||||
|
||||
/// <summary>What was captured, e.g. "window:Inspector" or "panel:MainUI".</summary>
|
||||
[JsonProperty("source")]
|
||||
public string Source { get; set; }
|
||||
|
||||
/// <summary>Absolute filesystem path the PNG was written to.</summary>
|
||||
[JsonProperty("path")]
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>Captured width in pixels.</summary>
|
||||
[JsonProperty("width")]
|
||||
public int Width { get; set; }
|
||||
|
||||
/// <summary>Captured height in pixels.</summary>
|
||||
[JsonProperty("height")]
|
||||
public int Height { get; set; }
|
||||
|
||||
/// <summary>Image encoding; always "png".</summary>
|
||||
[JsonProperty("encoding")]
|
||||
public string Encoding { get; set; }
|
||||
|
||||
/// <summary>Base64-encoded PNG bytes.</summary>
|
||||
[JsonProperty("base64")]
|
||||
public string Base64 { get; set; }
|
||||
|
||||
/// <summary>Length of the raw PNG byte array.</summary>
|
||||
[JsonProperty("bytes")]
|
||||
public int Bytes { get; set; }
|
||||
|
||||
public static CaptureElementResponse Fail(string message) => new CaptureElementResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3e2225ea88f5b5a4c9be3afc9c5d9f4e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c9f4a11d82a4dd54d93200f6c3aa4678
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,57 @@
|
||||
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>
|
||||
/// Optional hierarchical tags used to group and browse commands.
|
||||
/// Path-style: a '/' separates tag from subtag (e.g. "assets", "assets/import").
|
||||
/// Default: empty (untagged).
|
||||
/// </summary>
|
||||
public string[] Tags { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <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,234 @@
|
||||
using System;
|
||||
|
||||
namespace Unity.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-server progress-reporting state. Owned one-per-<see cref="BasePipelineServer"/> (the
|
||||
/// same pattern as <c>Dispatcher</c>) so a test server's execution can never cross-attribute
|
||||
/// progress with the live server's. See <see cref="CliProgress"/> for the public static
|
||||
/// façade that arbitrary command code calls.
|
||||
/// </summary>
|
||||
internal sealed class CliProgressState
|
||||
{
|
||||
/// <summary>Immutable copy of the current progress state (thread-safe to hand across threads).</summary>
|
||||
internal readonly struct Snapshot
|
||||
{
|
||||
public readonly bool HasReport;
|
||||
public readonly string Title;
|
||||
public readonly string Info;
|
||||
public readonly long? Current;
|
||||
public readonly long? Total;
|
||||
public readonly double? Progress01;
|
||||
|
||||
public Snapshot(string title, string info, long? current, long? total, double? progress01)
|
||||
{
|
||||
HasReport = true;
|
||||
Title = title;
|
||||
Info = info;
|
||||
Current = current;
|
||||
Total = total;
|
||||
Progress01 = progress01;
|
||||
}
|
||||
}
|
||||
|
||||
readonly object m_Gate = new object();
|
||||
Snapshot m_Explicit;
|
||||
string m_ExplicitOwnerId;
|
||||
string m_CurrentOwnerId;
|
||||
Snapshot m_Ambient;
|
||||
int m_ActiveCount;
|
||||
|
||||
/// <summary>Whether any execution is currently in flight. Read by /api/progress.</summary>
|
||||
internal bool IsActive
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
return m_ActiveCount > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Marks one more execution in flight (see <see cref="EndExecutionCount"/>).</summary>
|
||||
internal void BeginExecutionCount()
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
m_ActiveCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks one execution finished, resetting the explicit-report state when this was the
|
||||
/// last one in flight. The decrement and the reset-on-zero check happen under the same
|
||||
/// lock as <see cref="BeginExecution"/>, so a new execution starting in the gap can never
|
||||
/// have its fresh BeginExecution/Report wiped by this reset — the previous implementation
|
||||
/// decremented a separate counter and reset separately, leaving a window where a finishing
|
||||
/// execution could decrement to zero, get preempted, and then wipe a new execution's
|
||||
/// owner id and early report once it resumed.
|
||||
/// </summary>
|
||||
internal void EndExecutionCount()
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
m_ActiveCount--;
|
||||
if (m_ActiveCount == 0)
|
||||
{
|
||||
m_Explicit = default;
|
||||
m_ExplicitOwnerId = null;
|
||||
m_CurrentOwnerId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Report the current task's progress so a connected CLI can render it live.
|
||||
/// Safe to call from any thread, at any frequency (the server samples on poll).
|
||||
/// </summary>
|
||||
/// <param name="title">Short task title, e.g. "Generating World".</param>
|
||||
/// <param name="info">Detail line, e.g. "Processing 42/100".</param>
|
||||
/// <param name="current">Current step, when the task has countable steps.</param>
|
||||
/// <param name="total">Total steps, when the task has countable steps.</param>
|
||||
/// <param name="progress">Completion in the 0–1 range; omit for indeterminate tasks
|
||||
/// (when omitted and both <paramref name="current"/> and <paramref name="total"/> are
|
||||
/// present, the CLI derives a percentage from them).</param>
|
||||
internal void Report(string title, string info = null, long? current = null, long? total = null, double? progress = null)
|
||||
{
|
||||
var clamped = progress.HasValue ? Math.Max(0d, Math.Min(1d, progress.Value)) : (double?)null;
|
||||
lock (m_Gate)
|
||||
{
|
||||
m_Explicit = new Snapshot(title, info, current, total, clamped);
|
||||
m_ExplicitOwnerId = m_CurrentOwnerId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clear an explicit <see cref="Report"/> (e.g. when the reported task finishes).</summary>
|
||||
internal void Clear()
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
m_Explicit = default;
|
||||
m_ExplicitOwnerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ambient-source mirror (UnityEditor.Progress items). Explicit reports win.</summary>
|
||||
internal void ReportAmbient(string title, string info, long? current, long? total, double? progress01)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
m_Ambient = new Snapshot(title, info, current, total, progress01);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clear the ambient mirror (no running UnityEditor.Progress items remain).</summary>
|
||||
internal void ClearAmbient()
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
m_Ambient = default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the start of the execution identified by <paramref name="ownerId"/> (a job id,
|
||||
/// or a synthetic id for a plain <c>/api/exec</c>). Clears any leftover explicit report
|
||||
/// from whatever ran before it and becomes the only owner <see cref="Current"/> will
|
||||
/// accept explicit reports from until <see cref="EndExecution"/> is called — so a queued
|
||||
/// execution can never surface the previous one's progress before reporting its own.
|
||||
/// </summary>
|
||||
internal void BeginExecution(string ownerId)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
m_CurrentOwnerId = ownerId;
|
||||
m_Explicit = default;
|
||||
m_ExplicitOwnerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the end of the execution identified by <paramref name="ownerId"/>. A no-op if
|
||||
/// some other execution is already current (e.g. called after a swallowed exception),
|
||||
/// so callers can call this unconditionally in a <c>finally</c>.
|
||||
/// </summary>
|
||||
internal void EndExecution(string ownerId)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
if (m_CurrentOwnerId == ownerId)
|
||||
{
|
||||
m_CurrentOwnerId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The snapshot the /api/progress endpoint serves: the explicit report, but only when it
|
||||
/// was made by the currently executing owner (see <see cref="BeginExecution"/>); falls
|
||||
/// back to the ambient mirror otherwise.
|
||||
/// </summary>
|
||||
internal Snapshot Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var explicitValid = m_Explicit.HasReport && m_ExplicitOwnerId == m_CurrentOwnerId;
|
||||
return explicitValid ? m_Explicit : m_Ambient;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structured progress reporting for long-running pipeline commands — the server side of the
|
||||
/// CLI's terminal progress bars (CLI-488, coordinated with CLI-335).
|
||||
///
|
||||
/// While a command executes over <c>/api/exec</c>, the CLI polls <c>GET /api/progress</c> and
|
||||
/// renders whatever is reported here (a progress bar in a terminal, NDJSON progress frames in
|
||||
/// machine formats). Command authors call <see cref="Report"/> from any thread — including a
|
||||
/// main thread that is blocked inside a long synchronous command, which is exactly the
|
||||
/// <c>EditorUtility.DisplayProgressBar</c> scenario this exists for. The state is read by the
|
||||
/// HTTP listener thread, never the main thread, so progress stays visible even while the
|
||||
/// Editor is busy.
|
||||
///
|
||||
/// This is a thin static façade over the executing server's own <see cref="CliProgressState"/>
|
||||
/// (resolved via <see cref="BasePipelineServer.CurrentServer"/>) so command code — which has
|
||||
/// no reference to "which server is running me" — can keep calling this the same way
|
||||
/// regardless of which server dispatched it.
|
||||
///
|
||||
/// Ambient <c>UnityEditor.Progress</c> items are mirrored in by the Editor assembly
|
||||
/// (<c>EditorProgressMirror</c>), targeting the live server directly; an explicit
|
||||
/// <see cref="Report"/> always wins over the ambient mirror. Every execution (a plain
|
||||
/// <c>/api/exec</c> or a detached job) is bracketed with BeginExecution/EndExecution on the
|
||||
/// server's own <see cref="CliProgressState"/>, which tags who the current explicit report is
|
||||
/// allowed to belong to — so a job queued behind one that just finished never inherits its
|
||||
/// stale progress before making its own first report, no matter how long that job takes to
|
||||
/// get around to reporting anything.
|
||||
/// </summary>
|
||||
public static class CliProgress
|
||||
{
|
||||
/// <summary>
|
||||
/// Report the current task's progress so a connected CLI can render it live.
|
||||
/// Safe to call from any thread, at any frequency (the server samples on poll). A no-op
|
||||
/// when called outside a command's own execution (no server to attribute it to).
|
||||
/// </summary>
|
||||
/// <param name="title">Short title for the operation.</param>
|
||||
/// <param name="info">Optional detail text (e.g. current step).</param>
|
||||
/// <param name="current">Current progress amount, if determinate.</param>
|
||||
/// <param name="total">Total amount, if determinate.</param>
|
||||
/// <param name="progress">Progress in [0, 1], used instead of current/total when set.</param>
|
||||
public static void Report(string title, string info = null, long? current = null, long? total = null, double? progress = null)
|
||||
{
|
||||
BasePipelineServer.CurrentServer?.Progress.Report(title, info, current, total, progress);
|
||||
}
|
||||
|
||||
/// <summary>Clear an explicit <see cref="Report"/> (e.g. when the reported task finishes).</summary>
|
||||
public static void Clear()
|
||||
{
|
||||
BasePipelineServer.CurrentServer?.Progress.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 314bec0f00e544458e7558fde1168e82
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetVersion: 1
|
||||
@@ -0,0 +1,91 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
|
||||
namespace Unity.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Everything known about one finished <c>/api/exec</c> interaction, handed to
|
||||
/// <see cref="BasePipelineServer.OnCommandDone"/>.
|
||||
///
|
||||
/// The transaction half and the execution half are independent, because the two are not the
|
||||
/// same set: a request can produce a transaction without executing anything (rejected for
|
||||
/// size, malformed JSON, unknown command, busy host, job queue full), and a detached job
|
||||
/// executes long after its own response was sent, producing no transaction at all. Consumers
|
||||
/// read the half they care about and skip the ones that lack it.
|
||||
/// </summary>
|
||||
public readonly struct CommandExecutionInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Raw request JSON, or null when there is no transaction to report — a detached job
|
||||
/// completing after its submission response was already sent.
|
||||
/// </summary>
|
||||
public string RequestJson { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw response JSON as it went on the wire, or null alongside a null
|
||||
/// <see cref="RequestJson"/>.
|
||||
/// </summary>
|
||||
public string ResponseJson { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The command that ran, or null when the request was rejected before any command
|
||||
/// executed.
|
||||
/// </summary>
|
||||
public CommandInfo Command { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The parameters the command was invoked with, or null when no command ran. Lets a consumer
|
||||
/// answer questions about WHAT was asked, not just which command served it — the analytics
|
||||
/// reporter reads an eval body from here to decide whether a concrete command already covers
|
||||
/// it. Never serialized anywhere: it is in-memory only, for the moment between the command
|
||||
/// finishing and the post-command work running.
|
||||
/// </summary>
|
||||
public JObject Parameters { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the command reported success. False when it threw AND when it returned a
|
||||
/// response carrying its own failure (eval, code reload and test runs all do the latter).
|
||||
/// Meaningless when <see cref="Command"/> is null.
|
||||
/// </summary>
|
||||
public bool Success { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Wall-clock milliseconds spent inside the command itself, excluding the wait for the
|
||||
/// one-command-at-a-time execution gate. Zero when <see cref="Command"/> is null.
|
||||
/// </summary>
|
||||
public long DurationMs { get; }
|
||||
|
||||
/// <summary>Create an info describing one finished interaction.</summary>
|
||||
/// <param name="requestJson">Raw request JSON, or null when there is no transaction.</param>
|
||||
/// <param name="responseJson">Raw response JSON, or null alongside a null request.</param>
|
||||
/// <param name="command">The command that ran, or null when none did.</param>
|
||||
/// <param name="success">Whether the command reported success.</param>
|
||||
/// <param name="durationMs">Milliseconds spent inside the command.</param>
|
||||
/// <param name="parameters">The parameters the command was invoked with, or null.</param>
|
||||
public CommandExecutionInfo(string requestJson, string responseJson, CommandInfo command,
|
||||
bool success, long durationMs, JObject parameters = null)
|
||||
{
|
||||
RequestJson = requestJson;
|
||||
ResponseJson = responseJson;
|
||||
Command = command;
|
||||
Success = success;
|
||||
DurationMs = durationMs;
|
||||
Parameters = parameters;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A detached job that just finished: an execution with no transaction, since the client
|
||||
/// already received its job handle.
|
||||
/// </summary>
|
||||
/// <param name="command">The command the job ran.</param>
|
||||
/// <param name="success">Whether the command reported success.</param>
|
||||
/// <param name="durationMs">Milliseconds spent inside the command.</param>
|
||||
/// <param name="parameters">The parameters the job ran the command with.</param>
|
||||
/// <returns>An info carrying the execution and no transaction.</returns>
|
||||
public static CommandExecutionInfo ForDetachedJob(CommandInfo command, bool success, long durationMs,
|
||||
JObject parameters = null)
|
||||
{
|
||||
return new CommandExecutionInfo(null, null, command, success, durationMs, parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 36cede5ed8322f54ab8790b9b19b6647
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,101 @@
|
||||
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>
|
||||
/// Hierarchical tags used to group and browse commands (path-style, '/'-separated).
|
||||
/// Empty for untagged commands; never null.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Tags { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Name of the assembly this command originates from, used to group and filter
|
||||
/// commands by contributing package/source.
|
||||
/// </summary>
|
||||
public string Package { 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>
|
||||
/// Instance to invoke <see cref="Method"/> on, or null when <see cref="Method"/> is static.
|
||||
/// Commands declared with a [CliCommand] attribute are always static and leave this null;
|
||||
/// a dynamically registered command bound to an instance method carries its receiver here.
|
||||
/// </summary>
|
||||
public object Target { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Create command information from discovery.
|
||||
/// </summary>
|
||||
/// <param name="name">Unique name of the command for CLI execution.</param>
|
||||
/// <param name="description">Human-readable description of the command.</param>
|
||||
/// <param name="mainThreadRequired">Whether this command requires Unity main thread execution.</param>
|
||||
/// <param name="method">Method that implements this command.</param>
|
||||
/// <param name="parameters">Parameters that this command accepts.</param>
|
||||
/// <param name="runtimeOnly">Whether this command is part of the runtime (Player) command surface only.</param>
|
||||
/// <param name="tags">Hierarchical tags used to group and browse commands.</param>
|
||||
/// <param name="package">Name of the assembly this command originates from.</param>
|
||||
/// <param name="target">
|
||||
/// Instance to invoke <paramref name="method"/> on, or null when it is static. Only a
|
||||
/// dynamically registered command bound to an instance method supplies one.
|
||||
/// </param>
|
||||
public CommandInfo(string name, string description, bool mainThreadRequired,
|
||||
MethodInfo method, IReadOnlyList<CommandParameterInfo> parameters, bool runtimeOnly = false,
|
||||
IReadOnlyList<string> tags = null, string package = null, object target = null)
|
||||
{
|
||||
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;
|
||||
Tags = tags ?? Array.Empty<string>();
|
||||
Package = package;
|
||||
Target = target;
|
||||
}
|
||||
|
||||
/// <summary>A short diagnostic summary of the command.</summary>
|
||||
/// <returns>The summary string.</returns>
|
||||
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,537 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
|
||||
namespace Unity.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Binds argv tokens (the tokens AFTER the command name) to a command's declared
|
||||
/// parameters, producing the same <see cref="JObject"/> a client would have sent as
|
||||
/// structured <c>parameters</c>.
|
||||
///
|
||||
/// <b>Values are stored as the structured form would carry them.</b> For a parameter with a
|
||||
/// declared type that means a string JValue, never pre-converted — the single most important
|
||||
/// constraint here, because it makes <c>{"commandLine":"log_editor hi"}</c> and
|
||||
/// <c>{"command":"log_editor","parameters":{"message":"hi"}}</c> indistinguishable from
|
||||
/// <c>ExtractCommandParameters</c> downwards, so there is exactly one execution path and
|
||||
/// one place where coercion can differ. An UNTYPED parameter has no type to coerce toward, so
|
||||
/// there the token's shape decides — see <c>StoreValue</c>.
|
||||
///
|
||||
/// Every defect is accumulated rather than bailing on the first, so a user fixing a command
|
||||
/// line sees all of its problems at once. Required-parameter checking deliberately stays
|
||||
/// OUT of here — <c>ValidateCommandParameters</c> owns it, so the message and error type are
|
||||
/// byte-identical across both request forms.
|
||||
///
|
||||
/// Grammar: <c>--key value</c>, <c>--key=value</c>, bare <c>--key</c>. A follower is
|
||||
/// consumed unless it starts with <c>--</c>, which is what makes <c>--path -foo</c> work.
|
||||
/// <c>--</c> ends flag parsing. No short flags, no <c>-abc</c> bundling, no <c>--no-x</c>,
|
||||
/// no repeated-flag-becomes-array.
|
||||
/// </summary>
|
||||
internal static class CommandLineBinder
|
||||
{
|
||||
private static readonly CommandParameterInfo[] k_NoParameters = new CommandParameterInfo[0];
|
||||
|
||||
/// <summary>
|
||||
/// Binds <paramref name="args"/> against <paramref name="command"/>. Returns true only
|
||||
/// when no problems were found; <paramref name="parameters"/> is always populated as far
|
||||
/// as binding got, so a caller may inspect a partial bind.
|
||||
/// </summary>
|
||||
public static bool TryBind(CommandInfo command, IReadOnlyList<string> args,
|
||||
out JObject parameters, out List<ArgProblem> problems)
|
||||
{
|
||||
if (command == null)
|
||||
throw new ArgumentNullException(nameof(command));
|
||||
|
||||
parameters = new JObject();
|
||||
problems = new List<ArgProblem>();
|
||||
|
||||
var declared = command.Parameters ?? (IReadOnlyList<CommandParameterInfo>)k_NoParameters;
|
||||
if (args == null || args.Count == 0)
|
||||
return true;
|
||||
|
||||
// Pass 1 - classify flags. Positionals are collected with a flag recording whether
|
||||
// they appeared after "--", which exempts them from the -foo and key=value
|
||||
// heuristics below.
|
||||
var flagFilled = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
// Which parameters were set by a BARE flag. Tracked rather than inferred from the
|
||||
// stored value being boolean: an untyped parameter given the literal token `true`
|
||||
// also stores a boolean, and conflating the two reported a phantom missing value.
|
||||
// The token as typed, per parameter, so a type error can quote the user verbatim even
|
||||
// when the stored value was literal-parsed into a non-string.
|
||||
var rawTokens = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var bareFlagged = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var positionals = new List<string>();
|
||||
var positionalExempt = new List<bool>();
|
||||
var endOfFlags = false;
|
||||
|
||||
for (var i = 0; i < args.Count; i++)
|
||||
{
|
||||
var arg = args[i] ?? string.Empty;
|
||||
|
||||
if (!endOfFlags && arg == "--")
|
||||
{
|
||||
endOfFlags = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (endOfFlags || !arg.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
positionals.Add(arg);
|
||||
positionalExempt.Add(endOfFlags);
|
||||
continue;
|
||||
}
|
||||
|
||||
var body = arg.Substring(2);
|
||||
var equals = body.IndexOf('=');
|
||||
var name = equals >= 0 ? body.Substring(0, equals) : body;
|
||||
var inlineValue = equals >= 0 ? body.Substring(equals + 1) : null;
|
||||
|
||||
if (name.Length == 0)
|
||||
{
|
||||
problems.Add(new ArgProblem { Kind = ArgProblemKind.EmptyName, Token = arg });
|
||||
continue;
|
||||
}
|
||||
|
||||
var parameter = FindParameter(declared, name);
|
||||
if (parameter == null)
|
||||
{
|
||||
problems.Add(new ArgProblem
|
||||
{
|
||||
Kind = ArgProblemKind.UnknownName,
|
||||
Name = name,
|
||||
Suggestion = SuggestParameterName(name, declared),
|
||||
});
|
||||
// Swallow what was clearly meant as this flag's value, so it does not
|
||||
// resurface as a spurious excess positional.
|
||||
if (inlineValue == null && HasConsumableFollower(args, i))
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Checked before the empty-value bail below: a second `--key=` is BOTH a
|
||||
// duplicate and an empty value, and reporting only the second would let a user
|
||||
// fix what they were told about and still be duplicating the flag.
|
||||
if (flagFilled.Contains(parameter.Name))
|
||||
problems.Add(new ArgProblem { Kind = ArgProblemKind.Duplicate, Name = parameter.Name });
|
||||
|
||||
if (inlineValue != null && inlineValue.Length == 0)
|
||||
{
|
||||
problems.Add(new ArgProblem { Kind = ArgProblemKind.EmptyValue, Name = parameter.Name });
|
||||
continue;
|
||||
}
|
||||
|
||||
JToken value;
|
||||
if (inlineValue != null)
|
||||
{
|
||||
value = inlineValue;
|
||||
}
|
||||
else if (HasConsumableFollower(args, i))
|
||||
{
|
||||
// Absence and emptiness are different: `--message ""` is the empty string,
|
||||
// not the boolean true.
|
||||
value = args[++i] ?? string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
value = new JValue(true);
|
||||
bareFlagged.Add(parameter.Name);
|
||||
}
|
||||
|
||||
if (value.Type == JTokenType.String)
|
||||
{
|
||||
var text = value.Value<string>();
|
||||
parameters[parameter.Name] = StoreValue(parameter, text);
|
||||
rawTokens[parameter.Name] = text;
|
||||
}
|
||||
else
|
||||
{
|
||||
parameters[parameter.Name] = value;
|
||||
}
|
||||
flagFilled.Add(parameter.Name);
|
||||
}
|
||||
|
||||
// Pass 2 - positional slots: required parameters in declaration order, then
|
||||
// optional in declaration order. A slot already filled by a flag is SKIPPED, not
|
||||
// overwritten, so the explicit flag always wins its own slot.
|
||||
var slots = new List<CommandParameterInfo>(declared.Count);
|
||||
for (var i = 0; i < declared.Count; i++)
|
||||
if (declared[i].Required)
|
||||
slots.Add(declared[i]);
|
||||
for (var i = 0; i < declared.Count; i++)
|
||||
if (!declared[i].Required)
|
||||
slots.Add(declared[i]);
|
||||
|
||||
var free = new List<CommandParameterInfo>(slots.Count);
|
||||
for (var i = 0; i < slots.Count; i++)
|
||||
if (!flagFilled.Contains(slots[i].Name))
|
||||
free.Add(slots[i]);
|
||||
|
||||
var next = 0;
|
||||
var firstOverflow = -1;
|
||||
|
||||
for (var i = 0; i < positionals.Count; i++)
|
||||
{
|
||||
var token = positionals[i];
|
||||
|
||||
if (!positionalExempt[i])
|
||||
{
|
||||
// Both of these are reported but STILL consume a slot: the token was
|
||||
// clearly meant as a positional, and pretending otherwise would cascade
|
||||
// into misleading downstream problems.
|
||||
if (IsSingleDashFlag(token))
|
||||
{
|
||||
problems.Add(new ArgProblem { Kind = ArgProblemKind.SingleDash, Token = token });
|
||||
}
|
||||
else if (next < free.Count && IsBareAssignmentNamingItsOwnSlot(token, free[next]))
|
||||
{
|
||||
problems.Add(new ArgProblem { Kind = ArgProblemKind.BareAssignment, Token = token });
|
||||
}
|
||||
}
|
||||
|
||||
if (next < free.Count)
|
||||
{
|
||||
var slot = free[next++];
|
||||
parameters[slot.Name] = StoreValue(slot, token);
|
||||
rawTokens[slot.Name] = token;
|
||||
}
|
||||
else if (firstOverflow < 0)
|
||||
{
|
||||
firstOverflow = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3 - coerce, as a DRY RUN only. We never rewrite what we stored: the executor
|
||||
// re-converts from the same string, so binding and execution cannot diverge.
|
||||
for (var i = 0; i < declared.Count; i++)
|
||||
{
|
||||
var parameter = declared[i];
|
||||
var value = parameters[parameter.Name];
|
||||
if (value == null)
|
||||
continue;
|
||||
|
||||
rawTokens.TryGetValue(parameter.Name, out var rawToken);
|
||||
var problem = CheckCoercible(
|
||||
value, parameter, bareFlagged.Contains(parameter.Name), rawToken);
|
||||
if (problem != null)
|
||||
problems.Add(problem);
|
||||
}
|
||||
|
||||
if (firstOverflow >= 0)
|
||||
{
|
||||
// Which of the two overflow kinds this is depends on the slot the token WOULD
|
||||
// have taken, not on whether the request used a flag anywhere. Positionals fill
|
||||
// `free` in order, so the token at index n would have taken slots[n]: if that
|
||||
// slot exists and a flag claimed it, the token collided with that flag and the
|
||||
// parameter can be named. Otherwise the command simply received more values than
|
||||
// it declares parameters.
|
||||
var collided = firstOverflow < slots.Count && flagFilled.Contains(slots[firstOverflow].Name)
|
||||
? slots[firstOverflow]
|
||||
: null;
|
||||
|
||||
problems.Add(collided != null
|
||||
? new ArgProblem
|
||||
{
|
||||
Kind = ArgProblemKind.PositionalConflict,
|
||||
Name = collided.Name,
|
||||
Token = positionals[firstOverflow],
|
||||
}
|
||||
: new ArgProblem
|
||||
{
|
||||
Kind = ArgProblemKind.ExcessPositional,
|
||||
Token = positionals[firstOverflow],
|
||||
// Capacity and given must be commensurable, because they are rendered as
|
||||
// one sentence: slots still FREE after the flags took theirs, against
|
||||
// positionals supplied. Pairing the declared total with the positional
|
||||
// count compares two different things and reads as nonsense whenever a
|
||||
// flag is involved ("takes 2 but got 2").
|
||||
Capacity = free.Count,
|
||||
Given = positionals.Count,
|
||||
});
|
||||
}
|
||||
|
||||
return problems.Count == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decides what JSON type a token is stored as.
|
||||
///
|
||||
/// <para>For a parameter with a DECLARED type the answer is always a string: the type
|
||||
/// itself disambiguates, and <c>ConvertParameterToken</c> widens it downstream. That is
|
||||
/// also the fix over the CLI's old client-side guessing, which turned <c>007</c> into the
|
||||
/// number 7 even for a declared <c>string</c>.</para>
|
||||
///
|
||||
/// <para>An UNTYPED parameter (<c>JToken</c>/<c>JObject</c>/<c>JArray</c>/<c>object</c> —
|
||||
/// 14 of them across 12 shipped commands, e.g. <c>set_serialized_field.value</c>) has
|
||||
/// nothing to coerce toward, so the token's shape is the only signal there is. Those keep
|
||||
/// the literal rules the CLI used, or a working <c>--value 5</c> would silently start
|
||||
/// sending the string "5".</para>
|
||||
/// </summary>
|
||||
private static JToken StoreValue(CommandParameterInfo parameter, string token)
|
||||
{
|
||||
return IsUntypedTarget(parameter.ParameterType) && TryParseLiteral(token, out var literal)
|
||||
? literal
|
||||
: token;
|
||||
}
|
||||
|
||||
/// <summary>A declared type that can hold any JSON value, so it constrains nothing.</summary>
|
||||
private static bool IsUntypedTarget(Type type)
|
||||
{
|
||||
return type == typeof(object) || typeof(JToken).IsAssignableFrom(type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a bare flag's implied <c>true</c> is a value this parameter could hold.
|
||||
/// <c>JObject</c> and <c>JArray</c> are untyped but still cannot, so this is narrower
|
||||
/// than <see cref="IsUntypedTarget"/>.
|
||||
/// </summary>
|
||||
private static bool CanHoldBoolean(Type type)
|
||||
{
|
||||
var underlying = Nullable.GetUnderlyingType(type) ?? type;
|
||||
return underlying == typeof(bool)
|
||||
|| underlying == typeof(object)
|
||||
|| underlying == typeof(JToken)
|
||||
|| underlying == typeof(JValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The CLI's own literal rules, reproduced exactly: <c>true</c>/<c>false</c>
|
||||
/// case-insensitively, an all-digit integer with an optional sign (so <c>07</c> is 7 — no
|
||||
/// radix handling), and a single-dot decimal. Everything else stays a string, which is why
|
||||
/// <c>1.2.3</c>, a bare <c>-</c> and asset paths pass through untouched.
|
||||
/// </summary>
|
||||
private static bool TryParseLiteral(string token, out JToken value)
|
||||
{
|
||||
value = null;
|
||||
if (string.IsNullOrEmpty(token))
|
||||
return false;
|
||||
|
||||
var lowered = token.ToLowerInvariant();
|
||||
if (lowered == "true") { value = new JValue(true); return true; }
|
||||
if (lowered == "false") { value = new JValue(false); return true; }
|
||||
|
||||
var body = token[0] == '-' ? token.Substring(1) : token;
|
||||
if (body.Length == 0)
|
||||
return false;
|
||||
|
||||
var dot = body.IndexOf('.');
|
||||
if (dot < 0)
|
||||
{
|
||||
if (!AllAsciiDigits(body))
|
||||
return false;
|
||||
if (long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var whole))
|
||||
{
|
||||
value = new JValue(whole);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exactly one dot, digits on both sides.
|
||||
if (dot == 0 || dot == body.Length - 1
|
||||
|| body.IndexOf('.', dot + 1) >= 0
|
||||
|| !AllAsciiDigits(body.Substring(0, dot))
|
||||
|| !AllAsciiDigits(body.Substring(dot + 1)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out var real))
|
||||
{
|
||||
value = new JValue(real);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool AllAsciiDigits(string s)
|
||||
{
|
||||
for (var i = 0; i < s.Length; i++)
|
||||
if (s[i] < '0' || s[i] > '9')
|
||||
return false;
|
||||
return s.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dry-runs the executor's own converter against a bound value and reports the defect,
|
||||
/// or null when the value is acceptable.
|
||||
///
|
||||
/// Three distinct failures fold into one report: the converter throwing; the converter
|
||||
/// returning NULL for a non-empty token, which would otherwise make the parameter vanish
|
||||
/// and let the command run with a default; and a bare flag on a non-bool parameter, which
|
||||
/// <c>JValue(true).ToObject<string>()</c> would happily turn into the word "True".
|
||||
/// </summary>
|
||||
private static ArgProblem CheckCoercible(
|
||||
JToken value, CommandParameterInfo parameter, bool wasBareFlag, string rawToken)
|
||||
{
|
||||
var targetType = parameter.ParameterType;
|
||||
|
||||
// A bare flag implied `true`. On a parameter that cannot hold a boolean at all the
|
||||
// user simply omitted the value, which reads better as EmptyValue than as a type
|
||||
// error. Untyped is not the same as bool-capable: `object`, `JToken` and `JValue`
|
||||
// hold `true` happily, while `JObject` and `JArray` cannot, and letting the implied
|
||||
// `true` reach the converter reported those as a type error instead.
|
||||
if (wasBareFlag && !CanHoldBoolean(targetType))
|
||||
{
|
||||
return new ArgProblem { Kind = ArgProblemKind.EmptyValue, Name = parameter.Name };
|
||||
}
|
||||
|
||||
// The token as the user typed it. An untyped target stores a literal-parsed JValue,
|
||||
// so the stored value is not always a string and its text is not always the token
|
||||
// (`007` parses to 7) — reading it back from the value dropped or altered the quote
|
||||
// the diagnostic exists to show.
|
||||
var token = rawToken ?? (value.Type == JTokenType.String ? value.Value<string>() : null);
|
||||
|
||||
// Enums are NOT pre-matched against Enum.GetNames. The converter accepts a [Flags]
|
||||
// comma combination and a numeric value; a hand-rolled name match rejects both, which
|
||||
// would make the raw form refuse what the structured form binds. TypeMismatch already
|
||||
// unwraps Nullable<T> for the type name and the prose appends the valid values, so
|
||||
// deferring costs the message nothing.
|
||||
try
|
||||
{
|
||||
var converted = BasePipelineServer.ConvertParameterToken(value, targetType);
|
||||
if (converted == null && !string.IsNullOrEmpty(token))
|
||||
return TypeMismatch(parameter, token, targetType);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return TypeMismatch(parameter, token, targetType);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ArgProblem TypeMismatch(CommandParameterInfo parameter, string token, Type targetType)
|
||||
{
|
||||
return new ArgProblem
|
||||
{
|
||||
Kind = ArgProblemKind.TypeMismatch,
|
||||
Name = parameter.Name,
|
||||
Token = token,
|
||||
ExpectedType = (Nullable.GetUnderlyingType(targetType) ?? targetType).Name,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the token after <paramref name="index"/> should be taken as the current
|
||||
/// flag's value. Anything starting with <c>--</c> is the next flag, not a value; a
|
||||
/// single-dash token IS consumable, which is the escape hatch for values like
|
||||
/// <c>--message -foo</c>.
|
||||
/// </summary>
|
||||
private static bool HasConsumableFollower(IReadOnlyList<string> args, int index)
|
||||
{
|
||||
if (index + 1 >= args.Count)
|
||||
return false;
|
||||
var follower = args[index + 1];
|
||||
return follower == null || !follower.StartsWith("--", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static CommandParameterInfo FindParameter(IReadOnlyList<CommandParameterInfo> declared, string name)
|
||||
{
|
||||
for (var i = 0; i < declared.Count; i++)
|
||||
if (string.Equals(declared[i].Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
return declared[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <c>-x</c> token in positional position: a single dash followed by a LETTER.
|
||||
///
|
||||
/// Letter-anchored, deliberately matching the `unity` CLI's own rule byte for byte.
|
||||
/// The CLI keeps this check client-side so a typo costs nothing (no Editor boot, no CI
|
||||
/// license seat), which only stays safe while the local check is a strict SUBSET of this
|
||||
/// one — any divergence would surface as the server rejecting what the CLI let through.
|
||||
/// A looser test such as "not parseable as a number" would do exactly that for tokens
|
||||
/// like <c>-5x</c>. Negative numbers and a bare <c>-</c> remain valid positionals.
|
||||
/// </summary>
|
||||
private static bool IsSingleDashFlag(string token)
|
||||
{
|
||||
return token != null && token.Length >= 2 && token[0] == '-' && IsAsciiLetter(token[1]);
|
||||
}
|
||||
|
||||
private static bool IsAsciiLetter(char c)
|
||||
{
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bare <c>key=value</c> positional whose left side names <paramref name="slot"/> — the
|
||||
/// very parameter the token is about to fill, which is what makes it a misremembered
|
||||
/// <c>--key value</c> rather than a value.
|
||||
///
|
||||
/// <para>Testing the token against its OWN slot, rather than against every declared name,
|
||||
/// is what keeps legal code clean: <c>eval "timeout=5;"</c> lands on <c>code</c>, and
|
||||
/// <c>timeout</c> is a different parameter, so the token is a statement. Matching any
|
||||
/// declared name rejected it while the equivalent structured request succeeded.</para>
|
||||
///
|
||||
/// <para>A token that assigns its own slot (<c>eval "code=1;"</c>) stays ambiguous by
|
||||
/// nature and is still reported; <c>--</c> is the documented way through.</para>
|
||||
/// </summary>
|
||||
private static bool IsBareAssignmentNamingItsOwnSlot(string token, CommandParameterInfo slot)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token) || token[0] == '-')
|
||||
return false;
|
||||
var equals = token.IndexOf('=');
|
||||
if (equals <= 0)
|
||||
return false;
|
||||
return string.Equals(slot.Name, token.Substring(0, equals), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closest declared parameter name within an edit-distance budget that scales with
|
||||
/// length, or null. Budget is <c>min(3, max(1, longest/3))</c>: tight enough that an
|
||||
/// unrelated name gets no suggestion at all, loose enough to catch real typos.
|
||||
/// </summary>
|
||||
private static string SuggestParameterName(string name, IReadOnlyList<CommandParameterInfo> declared)
|
||||
{
|
||||
string best = null;
|
||||
var bestDistance = int.MaxValue;
|
||||
|
||||
for (var i = 0; i < declared.Count; i++)
|
||||
{
|
||||
var candidate = declared[i].Name;
|
||||
var budget = Math.Min(3, Math.Max(1, Math.Max(name.Length, candidate.Length) / 3));
|
||||
var distance = EditDistance(name, candidate);
|
||||
if (distance <= budget && distance < bestDistance)
|
||||
{
|
||||
bestDistance = distance;
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>Case-insensitive Levenshtein distance.</summary>
|
||||
private static int EditDistance(string a, string b)
|
||||
{
|
||||
a = a.ToLowerInvariant();
|
||||
b = b.ToLowerInvariant();
|
||||
|
||||
var previous = new int[b.Length + 1];
|
||||
var current = new int[b.Length + 1];
|
||||
|
||||
for (var j = 0; j <= b.Length; j++)
|
||||
previous[j] = j;
|
||||
|
||||
for (var i = 1; i <= a.Length; i++)
|
||||
{
|
||||
current[0] = i;
|
||||
for (var j = 1; j <= b.Length; j++)
|
||||
{
|
||||
var substitution = previous[j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
|
||||
var deletion = previous[j] + 1;
|
||||
var insertion = current[j - 1] + 1;
|
||||
current[j] = Math.Min(substitution, Math.Min(deletion, insertion));
|
||||
}
|
||||
|
||||
var swap = previous;
|
||||
previous = current;
|
||||
current = swap;
|
||||
}
|
||||
|
||||
return previous[b.Length];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eb75a7888547abb419286040fb9a0a58
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Unity.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Splits a raw command line into argv tokens for <c>/api/exec</c>'s <c>commandLine</c> form.
|
||||
///
|
||||
/// The dialect is POSIX-ish and is the <b>pipeline's own</b> — not the caller's shell, and
|
||||
/// identical on every OS, so the same string tokenizes the same way from cmd.exe, bash, an
|
||||
/// MCP client or a chat box. It is documented as a versioned contract in
|
||||
/// <c>Documentation~/connectivity.md</c>; <c>CommandLineTokenizerTests</c> pins it.
|
||||
///
|
||||
/// Rules: whitespace separates tokens; <c>"…"</c> groups with <c>\</c> escaping the next
|
||||
/// character; <c>'…'</c> groups with <b>no</b> escapes at all; <c>\</c> escapes outside
|
||||
/// quotes; adjacent spans concatenate (<c>a"b c"d</c> → <c>ab cd</c>); <c>""</c> yields one
|
||||
/// empty token. There is <b>no expansion of any kind</b> — no <c>$VAR</c>, globbing, <c>~</c>,
|
||||
/// comments or operators; <c>&&</c> and <c>|</c> are ordinary characters.
|
||||
///
|
||||
/// POSIX-ish rather than Windows <c>CommandLineToArgvW</c> because single quotes are
|
||||
/// load-bearing here — <c>eval 'obj.name = "x";'</c> and <c>batch --operations '[{…}]'</c>
|
||||
/// need a form that passes double quotes through untouched, and Windows semantics have no
|
||||
/// single-quote concept. The usual argument for Windows rules (<c>\</c> is the path
|
||||
/// separator) does not apply: every path this API accepts is a forward-slashed Unity asset
|
||||
/// path.
|
||||
/// </summary>
|
||||
internal static class CommandLineTokenizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Tokenizes <paramref name="commandLine"/>. Returns false and sets
|
||||
/// <paramref name="error"/> on an unbalanced quote or a dangling backslash.
|
||||
/// A null, empty or all-whitespace input succeeds with zero tokens — a lexer has no
|
||||
/// opinion about needing a command name, so the caller reports that (keeping the message
|
||||
/// identical to the structured path).
|
||||
/// </summary>
|
||||
public static bool TryTokenize(string commandLine, out List<string> tokens, out string error)
|
||||
{
|
||||
tokens = new List<string>();
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrEmpty(commandLine))
|
||||
return true;
|
||||
|
||||
var current = new StringBuilder();
|
||||
// Distinguishes `""` (one empty token) from whitespace (no token at all).
|
||||
var hasToken = false;
|
||||
var inSingle = false;
|
||||
var inDouble = false;
|
||||
|
||||
for (var i = 0; i < commandLine.Length; i++)
|
||||
{
|
||||
var c = commandLine[i];
|
||||
|
||||
if (inSingle)
|
||||
{
|
||||
if (c == '\'')
|
||||
inSingle = false;
|
||||
else
|
||||
current.Append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inDouble)
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
inDouble = false;
|
||||
}
|
||||
else if (c == '\\')
|
||||
{
|
||||
if (i + 1 >= commandLine.Length)
|
||||
{
|
||||
error = "Dangling backslash at end of command line";
|
||||
return false;
|
||||
}
|
||||
current.Append(commandLine[++i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
current.Append(c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '\'')
|
||||
{
|
||||
inSingle = true;
|
||||
hasToken = true;
|
||||
}
|
||||
else if (c == '"')
|
||||
{
|
||||
inDouble = true;
|
||||
hasToken = true;
|
||||
}
|
||||
else if (c == '\\')
|
||||
{
|
||||
if (i + 1 >= commandLine.Length)
|
||||
{
|
||||
error = "Dangling backslash at end of command line";
|
||||
return false;
|
||||
}
|
||||
current.Append(commandLine[++i]);
|
||||
hasToken = true;
|
||||
}
|
||||
else if (c == ' ' || c == '\t' || c == '\n' || c == '\r')
|
||||
{
|
||||
if (hasToken)
|
||||
{
|
||||
tokens.Add(current.ToString());
|
||||
current.Length = 0;
|
||||
hasToken = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
current.Append(c);
|
||||
hasToken = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (inSingle)
|
||||
{
|
||||
error = "Unbalanced single quote in command line";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (inDouble)
|
||||
{
|
||||
error = "Unbalanced double quote in command line";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasToken)
|
||||
tokens.Add(current.ToString());
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 04dfc14e12dcc7f4dbd172a1e84380b2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,400 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using UnityEngine;
|
||||
#if UNITY_6000_3_OR_NEWER
|
||||
using UnityEngine.Assemblies;
|
||||
#endif
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
using Unity.Scripting.LifecycleManagement;
|
||||
#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>
|
||||
/// <remarks>
|
||||
/// [NoAutoStaticsCleanup] on purpose: m_DiscoveryLock and the injected discovery mechanism are
|
||||
/// set up once per domain and must not be reset under the running server. The dynamic-command
|
||||
/// table is the one static here with a shorter useful life, and PlayModeSessionEnded resets the
|
||||
/// part of it that a Play session owns.
|
||||
/// </remarks>
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
[NoAutoStaticsCleanup]
|
||||
#endif
|
||||
static class CommandRegistry
|
||||
{
|
||||
private static IReadOnlyList<CommandInfo> m_CachedCommands;
|
||||
private static IReadOnlyList<CommandInfo> m_CachedAllCommands;
|
||||
private static readonly Dictionary<string, CommandInfo> m_DynamicCommands =
|
||||
new Dictionary<string, CommandInfo>(StringComparer.Ordinal);
|
||||
// Names in m_DynamicCommands that predate the Play session currently running; null outside
|
||||
// one. Lets PlayModeSessionEnded tell a registration made during the session apart from one
|
||||
// that was already there. See PlayModeSessionStarted.
|
||||
private static HashSet<string> m_PrePlayModeCommands;
|
||||
private static ICommandDiscovery m_Discovery;
|
||||
private static readonly object m_DiscoveryLock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Set the command discovery mechanism.
|
||||
/// Editor assembly provides TypeCache-based discovery, Runtime uses reflection fallback.
|
||||
/// </summary>
|
||||
/// <param name="discovery">The discovery mechanism to use.</param>
|
||||
public static void SetDiscovery(ICommandDiscovery discovery)
|
||||
{
|
||||
m_Discovery = discovery;
|
||||
ClearCache(); // Re-discover with new mechanism
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discover every available command: the methods marked with [CliCommand], plus any
|
||||
/// registered through <see cref="RegisterCommand"/>.
|
||||
/// Attribute discovery uses the injected mechanism (TypeCache in Editor, reflection in
|
||||
/// Runtime) and is cached until domain reload; the combined result is re-cached whenever
|
||||
/// a dynamic command is registered or unregistered.
|
||||
/// </summary>
|
||||
/// <returns>All discovered commands.</returns>
|
||||
public static IEnumerable<CommandInfo> DiscoverCommands()
|
||||
{
|
||||
var cached = m_CachedAllCommands;
|
||||
if (cached != null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
lock (m_DiscoveryLock)
|
||||
{
|
||||
if (m_CachedCommands == null)
|
||||
{
|
||||
m_CachedCommands = DiscoverCommandsInternal().ToList();
|
||||
}
|
||||
|
||||
if (m_CachedAllCommands == null)
|
||||
{
|
||||
m_CachedAllCommands = m_DynamicCommands.Count == 0
|
||||
? m_CachedCommands
|
||||
: m_CachedCommands.Concat(m_DynamicCommands.Values).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
return m_CachedAllCommands;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a command at runtime, without a [CliCommand] attribute. The resulting command
|
||||
/// is indistinguishable from an attribute-declared one: it is listed by <c>/api/commands</c>
|
||||
/// and executable through <c>/api/exec</c> under <paramref name="name"/>.
|
||||
///
|
||||
/// Parameter metadata is read from the handler's own parameters exactly as it is for an
|
||||
/// attribute-declared command — a [CliArg] on a parameter supplies its CLI name,
|
||||
/// description, and required flag; a parameter without one falls back to its declared name
|
||||
/// and default value. Note that a lambda cannot carry [CliArg], so registering a named
|
||||
/// method is what yields full metadata.
|
||||
///
|
||||
/// Registrations live until they are unregistered or the domain reloads; they survive
|
||||
/// <see cref="ClearCache"/> and <see cref="SetDiscovery"/>. One made while the Editor is in
|
||||
/// Play Mode is additionally dropped when Play Mode exits — see
|
||||
/// <see cref="PlayModeSessionEnded"/>.
|
||||
/// </summary>
|
||||
/// <param name="name">Unique command name for CLI execution.</param>
|
||||
/// <param name="description">Human-readable description, shown in command listings.</param>
|
||||
/// <param name="handler">
|
||||
/// Method to invoke. An instance-method delegate keeps its receiver, so the command runs
|
||||
/// against that instance; a static one runs with no receiver.
|
||||
/// </param>
|
||||
/// <param name="mainThreadRequired">Whether the command must run on the Unity main thread.</param>
|
||||
/// <param name="runtimeOnly">Whether the command belongs to the Player command surface only.</param>
|
||||
/// <param name="tags">Optional path-style tags used to group and browse the command.</param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// A command called <paramref name="name"/> is already registered. Attribute-declared
|
||||
/// commands can never be replaced; a dynamic one must be unregistered first.
|
||||
/// </exception>
|
||||
public static void RegisterCommand(string name, string description, Delegate handler,
|
||||
bool mainThreadRequired = true, bool runtimeOnly = false, string[] tags = null)
|
||||
{
|
||||
if (name == null)
|
||||
throw new ArgumentNullException(nameof(name));
|
||||
if (description == null)
|
||||
throw new ArgumentNullException(nameof(description));
|
||||
if (handler == null)
|
||||
throw new ArgumentNullException(nameof(handler));
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("Command name must not be empty or whitespace.", nameof(name));
|
||||
|
||||
var method = handler.Method;
|
||||
var command = new CommandInfo(
|
||||
name,
|
||||
description,
|
||||
mainThreadRequired,
|
||||
method,
|
||||
DiscoverParameters(method).ToList(),
|
||||
runtimeOnly,
|
||||
tags,
|
||||
method.DeclaringType?.Assembly.GetName().Name,
|
||||
handler.Target);
|
||||
|
||||
lock (m_DiscoveryLock)
|
||||
{
|
||||
if (m_DynamicCommands.ContainsKey(name))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A command named '{name}' is already registered dynamically. " +
|
||||
"Unregister it before registering it again.");
|
||||
}
|
||||
|
||||
// Anything left matching here is attribute-declared: the dynamic table was just checked.
|
||||
if (DiscoverCommands().Any(c => c.Name == name))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A command named '{name}' is already declared with a [CliCommand] attribute " +
|
||||
"and cannot be replaced.");
|
||||
}
|
||||
|
||||
m_DynamicCommands[name] = command;
|
||||
m_CachedAllCommands = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a command previously registered with <see cref="RegisterCommand"/>.
|
||||
/// Attribute-declared commands are part of the code and are never removable.
|
||||
/// </summary>
|
||||
/// <param name="name">Name the command was registered under.</param>
|
||||
/// <returns>
|
||||
/// True if a dynamic command was removed; false if no command is registered under that
|
||||
/// name dynamically, including when the name belongs to an attribute-declared command.
|
||||
/// </returns>
|
||||
public static bool UnregisterCommand(string name)
|
||||
{
|
||||
if (name == null)
|
||||
throw new ArgumentNullException(nameof(name));
|
||||
|
||||
lock (m_DiscoveryLock)
|
||||
{
|
||||
if (!m_DynamicCommands.Remove(name))
|
||||
return false;
|
||||
|
||||
m_CachedAllCommands = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Note that a Play session is starting, so registrations made from here on can be told
|
||||
/// apart from the ones that predate it. Called by the Editor on Play Mode entry, before any
|
||||
/// user code runs.
|
||||
/// </summary>
|
||||
internal static void PlayModeSessionStarted()
|
||||
{
|
||||
lock (m_DiscoveryLock)
|
||||
{
|
||||
m_PrePlayModeCommands = new HashSet<string>(m_DynamicCommands.Keys, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop every dynamic registration made during the Play session that just ended, the way a
|
||||
/// domain reload would have. Registrations that predate the session are left alone: nothing
|
||||
/// re-runs to restore them (an [InitializeOnLoadMethod] runs once per domain).
|
||||
///
|
||||
/// A script registering from Awake or OnEnable re-registers on every Play Mode entry, so
|
||||
/// without this the second entry would throw "already registered dynamically" whenever no
|
||||
/// domain reload happened in between — and any surviving entry would still hold a delegate
|
||||
/// bound to the previous session's destroyed object. That covers Enter Play Mode with domain
|
||||
/// reload disabled on any Editor version, and on 6000.5+ the class-wide
|
||||
/// [NoAutoStaticsCleanup] means AutoStaticsCleanup does not step in either.
|
||||
/// </summary>
|
||||
internal static void PlayModeSessionEnded()
|
||||
{
|
||||
lock (m_DiscoveryLock)
|
||||
{
|
||||
// No recorded session start (e.g. a domain reload wiped it on the way in, having
|
||||
// already cleared the registry): nothing is known to belong to the session, so
|
||||
// removing anything would be guesswork.
|
||||
if (m_PrePlayModeCommands == null)
|
||||
return;
|
||||
|
||||
foreach (var name in m_DynamicCommands.Keys.Where(n => !m_PrePlayModeCommands.Contains(n)).ToList())
|
||||
m_DynamicCommands.Remove(name);
|
||||
|
||||
m_PrePlayModeCommands = null;
|
||||
m_CachedAllCommands = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear the discovered-command cache, so the next discovery re-scans for [CliCommand]
|
||||
/// methods. Called automatically on domain reload, and whenever the discovery mechanism
|
||||
/// changes. Commands registered through <see cref="RegisterCommand"/> are not affected.
|
||||
/// </summary>
|
||||
public static void ClearCache()
|
||||
{
|
||||
lock (m_DiscoveryLock)
|
||||
{
|
||||
m_CachedCommands = null;
|
||||
m_CachedAllCommands = 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,
|
||||
commandAttr.Tags,
|
||||
method.DeclaringType?.Assembly.GetName().Name
|
||||
);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c53b0ba553311e14c833aadafdb44999
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Unity.Pipeline
|
||||
{
|
||||
/// <summary>Snapshot of one dialog shown/dismissed event, as relayed by EditorDialogEvents.</summary>
|
||||
internal readonly struct DialogInfo
|
||||
{
|
||||
public readonly int Id;
|
||||
public readonly string Source;
|
||||
public readonly string Title;
|
||||
public readonly string Message;
|
||||
public readonly string[] Buttons;
|
||||
public readonly string Level;
|
||||
public readonly DateTime OpenedAtUtc;
|
||||
public readonly DateTime? DismissedAtUtc;
|
||||
|
||||
public DialogInfo(int id, string source, string title, string message, string[] buttons, string level, DateTime openedAtUtc, DateTime? dismissedAtUtc)
|
||||
{
|
||||
Id = id;
|
||||
Source = source;
|
||||
Title = title;
|
||||
Message = message;
|
||||
Buttons = buttons;
|
||||
Level = level;
|
||||
OpenedAtUtc = openedAtUtc;
|
||||
DismissedAtUtc = dismissedAtUtc;
|
||||
}
|
||||
|
||||
public DialogInfo WithDismissed(DateTime dismissedAtUtc) =>
|
||||
new DialogInfo(Id, Source, Title, Message, Buttons, Level, OpenedAtUtc, dismissedAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-server modal-dialog state (the same one-instance-per-<see cref="BasePipelineServer"/>
|
||||
/// ownership as <c>CliProgressState</c>/<c>Progress</c>, so a test server's dialog state never
|
||||
/// crosses into the live server's). Unlike progress, dialogs are not scoped to a currently
|
||||
/// executing command — they can be raised by anything (a human, a background job, an unrelated
|
||||
/// command) at any time, so there is no owner-id concept here.
|
||||
/// </summary>
|
||||
internal sealed class DialogStateTracker
|
||||
{
|
||||
const int MaxRecentEvents = 50;
|
||||
|
||||
readonly object m_Gate = new object();
|
||||
readonly Dictionary<int, DialogInfo> m_Open = new Dictionary<int, DialogInfo>();
|
||||
readonly LinkedList<DialogInfo> m_Recent = new LinkedList<DialogInfo>();
|
||||
|
||||
internal bool IsDialogOpen
|
||||
{
|
||||
get { lock (m_Gate) return m_Open.Count > 0; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ordered oldest-opened-first: callers (the dialog busy-gate, /api/status) treat index 0
|
||||
/// as "the first open dialog", and Dictionary<,>'s enumeration order isn't guaranteed
|
||||
/// to match insertion order once entries have been removed and re-added.
|
||||
/// </summary>
|
||||
internal List<DialogInfo> CurrentlyOpen
|
||||
{
|
||||
get { lock (m_Gate) return m_Open.Values.OrderBy(e => e.OpenedAtUtc).ToList(); }
|
||||
}
|
||||
|
||||
internal void OnShown(int id, string source, string title, string message, string[] buttons, string level, DateTime openedAtUtc)
|
||||
{
|
||||
var info = new DialogInfo(id, source, title, message, buttons, level, openedAtUtc, null);
|
||||
lock (m_Gate)
|
||||
{
|
||||
m_Open[id] = info;
|
||||
}
|
||||
}
|
||||
|
||||
internal void OnDismissed(int id, DateTime dismissedAtUtc)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
if (!m_Open.TryGetValue(id, out var info))
|
||||
return;
|
||||
|
||||
m_Open.Remove(id);
|
||||
m_Recent.AddLast(info.WithDismissed(dismissedAtUtc));
|
||||
while (m_Recent.Count > MaxRecentEvents)
|
||||
m_Recent.RemoveFirst();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dialogs opened at or after <paramref name="utc"/> — still-open ones and recently
|
||||
/// dismissed ones. Used to attach dialogsDuringExecution to a command's exec response.
|
||||
/// </summary>
|
||||
internal List<DialogInfo> EventsSince(DateTime utc)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var result = m_Open.Values.Where(e => e.OpenedAtUtc >= utc).ToList();
|
||||
result.AddRange(m_Recent.Where(e => e.OpenedAtUtc >= utc));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9700dd7e04984bd7a48f4d5f0f82d71c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetVersion: 1
|
||||
@@ -0,0 +1,299 @@
|
||||
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 RuntimePipelineDriver.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;
|
||||
|
||||
/// <summary>True once <see cref="Initialize"/> has run and this dispatcher is pumping its queue.</summary>
|
||||
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>
|
||||
/// <typeparam name="T">The function's return type.</typeparam>
|
||||
/// <param name="function">Function to run on the main thread.</param>
|
||||
/// <param name="timeoutMs">Time to wait before throwing <see cref="TimeoutException"/>.</param>
|
||||
/// <returns>The function's result.</returns>
|
||||
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)
|
||||
{
|
||||
// No-op if execution already started on the main thread — only prevents a
|
||||
// still-queued item from running after we've reported the timeout.
|
||||
workItem.TryCancel();
|
||||
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>
|
||||
/// <param name="action">Action to run on the main thread.</param>
|
||||
/// <param name="timeoutMs">Time to wait before throwing <see cref="TimeoutException"/>.</param>
|
||||
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>
|
||||
/// <typeparam name="T">The function's return type.</typeparam>
|
||||
/// <param name="function">Function to run on the main thread.</param>
|
||||
/// <param name="timeoutMs">Time to wait before throwing <see cref="TimeoutException"/>.</param>
|
||||
/// <returns>The function's result.</returns>
|
||||
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>
|
||||
/// <param name="action">Action to run on the main thread.</param>
|
||||
/// <param name="timeoutMs">Time to wait before throwing <see cref="TimeoutException"/>.</param>
|
||||
/// <returns>A task that completes once the action has run on the main thread.</returns>
|
||||
public async Task InvokeAsync(Action action, int timeoutMs = 60000)
|
||||
{
|
||||
await Task.Run(() => Invoke(action, timeoutMs));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue an action to run on the main thread and return immediately.
|
||||
///
|
||||
/// For work with no caller left to return to — reporting, logging, telemetry, anything
|
||||
/// whose result nobody reads. <see cref="Invoke(Action, int)"/> is wrong for that: it parks
|
||||
/// the calling thread until the next pump, and on a request thread that delay is paid by
|
||||
/// whoever is waiting on the response. InvokeAsync only moves the same wait onto a
|
||||
/// threadpool thread.
|
||||
///
|
||||
/// The action always runs on a later pump, never inline, even when called from the main
|
||||
/// thread, so posted work runs in the order it was posted. It has nowhere to report a
|
||||
/// failure, so an exception is logged and swallowed rather than propagated. Work still
|
||||
/// queued when the dispatcher shuts down is dropped, and posting to a dispatcher that is
|
||||
/// not running is a no-op for the same reason — losing a report is always preferable to
|
||||
/// throwing at a caller that was only trying to record something.
|
||||
/// </summary>
|
||||
/// <param name="action">Action to run on the main thread on a later pump.</param>
|
||||
public void Post(Action action)
|
||||
{
|
||||
if (action == null)
|
||||
throw new ArgumentNullException(nameof(action));
|
||||
|
||||
if (!m_IsInitialized)
|
||||
return;
|
||||
|
||||
m_WorkQueue.Enqueue(new PostedWorkItem(action));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if we're currently on Unity's main thread.
|
||||
/// </summary>
|
||||
/// <returns>True if the calling thread is the main thread captured by <see cref="Initialize"/>.</returns>
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Process queued work items, up to a default limit of 10 per call.</summary>
|
||||
public void ProcessWorkQueue()
|
||||
{
|
||||
ProcessWorkQueue(10);
|
||||
}
|
||||
|
||||
private abstract class WorkItem
|
||||
{
|
||||
private const int Pending = 0;
|
||||
private const int Started = 1;
|
||||
private const int Canceled = 2;
|
||||
private int m_State;
|
||||
|
||||
/// <summary>Marks the item canceled if it hasn't started executing yet. Returns false
|
||||
/// (no-op) once <see cref="Execute"/> has already claimed it.</summary>
|
||||
public bool TryCancel() => Interlocked.CompareExchange(ref m_State, Canceled, Pending) == Pending;
|
||||
|
||||
public void Execute()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref m_State, Started, Pending) != Pending)
|
||||
return; // Canceled before it could start.
|
||||
|
||||
ExecuteCore();
|
||||
}
|
||||
|
||||
protected abstract void ExecuteCore();
|
||||
public abstract void SetException(Exception exception);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="Post(Action)"/> work item. Unlike <see cref="WorkItem{T}"/> it carries no
|
||||
/// TaskCompletionSource: nobody is waiting on the result, so a fault has nowhere to go but
|
||||
/// the log — and faulting a TCS no one awaits would surface later as an unobserved task
|
||||
/// exception. A shutdown cancellation simply drops the work.
|
||||
/// </summary>
|
||||
private class PostedWorkItem : WorkItem
|
||||
{
|
||||
private readonly Action m_Action;
|
||||
|
||||
public PostedWorkItem(Action action)
|
||||
{
|
||||
m_Action = action;
|
||||
}
|
||||
|
||||
protected override void ExecuteCore()
|
||||
{
|
||||
try
|
||||
{
|
||||
m_Action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"Dispatcher posted work failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public override void SetException(Exception exception)
|
||||
{
|
||||
// Nobody is waiting on this item, so there is nothing to hand the exception to.
|
||||
}
|
||||
}
|
||||
|
||||
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>();
|
||||
}
|
||||
|
||||
protected override void ExecuteCore()
|
||||
{
|
||||
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,141 @@
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using Unity.Pipeline.Models;
|
||||
|
||||
namespace Unity.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Single source of truth for how <c>/api/exec</c> replies are serialized (AUTHAPI-21). Centralized
|
||||
/// here so the server and the envelope byte-size regression test share one contract.
|
||||
///
|
||||
/// Every mode serializes with <see cref="Formatting.None"/> (compact): agents consume the JSON, and
|
||||
/// an MCP client can pretty-print for a human if ever needed — the wire form never needs indentation.
|
||||
///
|
||||
/// Two independent request flags select the mode:
|
||||
///
|
||||
/// * <c>verbose</c> — envelope metadata. Off (default): the always-on metadata (executedAt,
|
||||
/// command, executionTimeMs) is stripped from every <see cref="BaseResponse"/>-derived object
|
||||
/// in the graph (the outer envelope AND responses nested as <c>result</c>, e.g. eval's), and
|
||||
/// the envelope's own null keys (message/error/errorDetails when null, result on failure — all
|
||||
/// redundant given <c>success</c>) are omitted, so a minimal success is just
|
||||
/// <c>{"success":true,"result":...}</c>. On: full fidelity for debugging/correlation.
|
||||
/// * <c>omitNulls</c> — payload null fidelity. Off (default): the command's own <c>result</c>
|
||||
/// payload keeps explicit nulls — an agent must be able to distinguish "this field is null"
|
||||
/// from "this field doesn't exist / I got the name wrong". On: null keys are dropped
|
||||
/// everywhere, for callers that already know the result schema and want the bytes back
|
||||
/// (e.g. bulk list reads where a null column repeats hundreds of times).
|
||||
///
|
||||
/// One invariant holds in EVERY mode: a successful reply always carries <c>result</c>, even
|
||||
/// when its value is null — a null result on success is the command's actual value (e.g. a
|
||||
/// format="value" read of a genuinely-null field), never a droppable envelope null.
|
||||
///
|
||||
/// The two axes are deliberately separate booleans: envelope metadata and payload null fidelity
|
||||
/// are orthogonal concerns, and each combination is meaningful.
|
||||
///
|
||||
/// The gating lives in a contract resolver scoped to THIS serializer, not in ShouldSerialize*
|
||||
/// hooks on the models: serializing these types anywhere else (other endpoints, logs, tests) is
|
||||
/// unaffected, and Serialize never mutates the response object.
|
||||
/// </summary>
|
||||
internal static class ExecResponseSerializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Lean-envelope gating for every <see cref="BaseResponse"/>-derived object in the graph
|
||||
/// (nested results included): strips the always-on metadata and omits the envelope's own
|
||||
/// null keys — WITHOUT touching null handling on non-envelope payload objects, which keep
|
||||
/// the serializer-level default. One shared instance per settings object:
|
||||
/// DefaultContractResolver caches resolved contracts per resolver instance (thread-safe),
|
||||
/// so the reflection cost is paid once per type, not per request.
|
||||
/// </summary>
|
||||
private class LeanEnvelopeContractResolver : DefaultContractResolver
|
||||
{
|
||||
protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
|
||||
{
|
||||
var property = base.CreateProperty(member, memberSerialization);
|
||||
|
||||
// DeclaringType guards against unrelated same-named members (e.g.
|
||||
// CommandExecutionRequest.Command must keep serializing).
|
||||
if (member.DeclaringType != null && typeof(BaseResponse).IsAssignableFrom(member.DeclaringType))
|
||||
{
|
||||
switch (member.Name)
|
||||
{
|
||||
case nameof(BaseResponse.ExecutedAt):
|
||||
case nameof(CommandExecutionResponse.Command):
|
||||
case nameof(CommandExecutionResponse.ExecutionTimeMs):
|
||||
property.ShouldSerialize = _ => false;
|
||||
break;
|
||||
|
||||
// The ONLY keys lean mode may null-drop: genuinely redundant given
|
||||
// `success` (error/errorDetails are null exactly on success, message on
|
||||
// both paths when there is nothing to say). Everything else declared on
|
||||
// a BaseResponse-DERIVED type is payload (TestExecutionResponse's
|
||||
// summary/results/filterApplied, EvalResponse.Output, …) and keeps
|
||||
// explicit nulls — a dropped payload key is indistinguishable from a
|
||||
// nonexistent or misspelled one; e.g. an async run_tests poller could
|
||||
// not tell "not ready yet" from "not a field". Enumerated per key
|
||||
// instead of type-wide for exactly that reason.
|
||||
case nameof(BaseResponse.Message):
|
||||
case nameof(BaseResponse.Error):
|
||||
case nameof(BaseResponse.ErrorDetails):
|
||||
property.NullValueHandling = NullValueHandling.Ignore;
|
||||
break;
|
||||
|
||||
case nameof(CommandExecutionResponse.Result):
|
||||
// A null result on SUCCESS is not a redundant envelope null — it is
|
||||
// the command's actual value (e.g. get_serialized_fields
|
||||
// format="value" reading a genuinely-null field), so a successful
|
||||
// reply always carries "result", explicitly null when null (Include
|
||||
// also defeats the omitNulls serializer settings). On failure the
|
||||
// result is structurally meaningless and stays omitted.
|
||||
property.NullValueHandling = NullValueHandling.Include;
|
||||
property.ShouldSerialize = instance =>
|
||||
!(instance is CommandExecutionResponse response) || response.Success;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return property;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerSettings m_LeanSettings = new JsonSerializerSettings
|
||||
{
|
||||
Formatting = Formatting.None,
|
||||
NullValueHandling = NullValueHandling.Include,
|
||||
ContractResolver = new LeanEnvelopeContractResolver(),
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerSettings m_LeanOmitNullsSettings = new JsonSerializerSettings
|
||||
{
|
||||
Formatting = Formatting.None,
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
ContractResolver = new LeanEnvelopeContractResolver(),
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerSettings m_VerboseSettings = new JsonSerializerSettings
|
||||
{
|
||||
Formatting = Formatting.None,
|
||||
NullValueHandling = NullValueHandling.Include,
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerSettings m_VerboseOmitNullsSettings = new JsonSerializerSettings
|
||||
{
|
||||
Formatting = Formatting.None,
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Serialize an exec response body with the contract selected by the request's flags (see
|
||||
/// the class doc for the two axes). Pure: the body is never mutated — the mode is carried
|
||||
/// entirely by the serializer settings, so serializing the same instance repeatedly (in
|
||||
/// any order) always yields the same JSON per mode.
|
||||
/// </summary>
|
||||
public static string Serialize(BaseResponse body, bool verbose, bool omitNulls = false)
|
||||
{
|
||||
var settings = verbose
|
||||
? (omitNulls ? m_VerboseOmitNullsSettings : m_VerboseSettings)
|
||||
: (omitNulls ? m_LeanOmitNullsSettings : m_LeanSettings);
|
||||
return JsonConvert.SerializeObject(body, settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 56ce3a87dbdb3c84bac7bccdf5c8c0fb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,110 @@
|
||||
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>
|
||||
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>
|
||||
/// <param name="path">The file to restrict.</param>
|
||||
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,20 @@
|
||||
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>
|
||||
interface ICommandDiscovery
|
||||
{
|
||||
/// <summary>
|
||||
/// Find all methods marked with CliCommand attribute.
|
||||
/// Implementation uses TypeCache in Editor or reflection fallback in Runtime.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The attribute type to search for.</typeparam>
|
||||
/// <returns>The matching methods.</returns>
|
||||
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,326 @@
|
||||
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>
|
||||
static class JsonSchemaGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate JSON Schema for a command.
|
||||
/// Returns standard JSON Schema format for CLI validation.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to generate a schema for.</param>
|
||||
/// <returns>The generated JSON Schema document.</returns>
|
||||
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,62 @@
|
||||
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>
|
||||
/// <param name="name">Name of the parameter for CLI arguments.</param>
|
||||
/// <param name="description">Human-readable description of the parameter.</param>
|
||||
/// <param name="required">Whether this parameter is required for command execution.</param>
|
||||
/// <param name="parameterType">Type of the parameter for validation and conversion.</param>
|
||||
/// <param name="defaultValue">Default value for optional parameters.</param>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>A short diagnostic summary of the parameter.</summary>
|
||||
/// <returns>The summary string.</returns>
|
||||
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,270 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
|
||||
namespace Unity.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Lifecycle states of a detached command job (CLI-335).
|
||||
/// Serialized to the wire as lower-case strings (see BasePipelineServer).
|
||||
/// </summary>
|
||||
internal enum PipelineJobState
|
||||
{
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Canceled
|
||||
}
|
||||
|
||||
/// <summary>One detached command execution tracked by <see cref="PipelineJobRegistry"/>.</summary>
|
||||
internal sealed class PipelineJobRecord
|
||||
{
|
||||
public string Id;
|
||||
public string Command;
|
||||
public PipelineJobState State;
|
||||
public object Result;
|
||||
public string Error;
|
||||
public string ErrorDetails;
|
||||
public DateTime EnqueuedAt;
|
||||
public DateTime? StartedAt;
|
||||
public DateTime? CompletedAt;
|
||||
public volatile bool CancellationRequested;
|
||||
|
||||
/// <summary>Guards State/Result/Error/timestamps; reads and writes cross threads.</summary>
|
||||
public readonly object Gate = new object();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry of detached command jobs (CLI-335): a client can submit a command as a job
|
||||
/// (`/api/exec` with `"job": true`), get a job id back immediately, and later poll
|
||||
/// `/api/job?id=…` to reattach and collect the result — surviving the client's own HTTP
|
||||
/// timeout. Results are retained (bounded, see below) so a client that timed out or
|
||||
/// disconnected can still fetch the outcome.
|
||||
///
|
||||
/// Owned one-per-<see cref="BasePipelineServer"/> (the same pattern as <c>Dispatcher</c>), so
|
||||
/// a test server's jobs can never cross-attribute state with the live server's.
|
||||
///
|
||||
/// In-memory by design: a domain reload (script recompilation) discards pending jobs the
|
||||
/// same way it discards every other C# static. That limitation is inherent to in-Editor
|
||||
/// state and is tracked separately.
|
||||
/// </summary>
|
||||
internal sealed class PipelineJobRegistry
|
||||
{
|
||||
/// <summary>Completed/failed/canceled jobs retained beyond this count are pruned oldest-first.</summary>
|
||||
private const int MaxRetainedTerminalJobs = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Jobs allowed queued or running at once. Jobs execute strictly serially through the
|
||||
/// exec gate, so a deep backlog beyond this is pure queued work with no throughput
|
||||
/// benefit — an authorized client loop (or a retrying script) submitting faster than jobs
|
||||
/// drain would otherwise grow this without bound.
|
||||
/// </summary>
|
||||
private const int MaxQueuedJobs = 100;
|
||||
|
||||
/// <summary>Terminal jobs older than this are pruned regardless of count.</summary>
|
||||
private static readonly TimeSpan m_RetentionTtl = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly ConcurrentDictionary<string, PipelineJobRecord> m_Jobs =
|
||||
new ConcurrentDictionary<string, PipelineJobRecord>();
|
||||
|
||||
/// <summary>Guards the queued-job cap check against a concurrent create racing past it.</summary>
|
||||
private readonly object m_CreateGate = new object();
|
||||
|
||||
/// <summary>
|
||||
/// The record currently executing. At most one job runs at a time (jobs share the
|
||||
/// server's exec gate), so a single reference suffices; read by
|
||||
/// <see cref="PipelineCancellation"/> from whatever thread the command runs on.
|
||||
/// </summary>
|
||||
private volatile PipelineJobRecord m_CurrentRunning;
|
||||
|
||||
internal PipelineJobRecord CurrentRunning => m_CurrentRunning;
|
||||
|
||||
/// <summary>
|
||||
/// Create a queued job for <paramref name="command"/>, unless <see cref="MaxQueuedJobs"/>
|
||||
/// queued-or-running jobs already exist (returns false; the caller should reject the
|
||||
/// submission rather than grow an unbounded backlog).
|
||||
/// </summary>
|
||||
internal bool TryCreate(string command, out PipelineJobRecord record)
|
||||
{
|
||||
Prune();
|
||||
|
||||
lock (m_CreateGate)
|
||||
{
|
||||
var nonTerminal = m_Jobs.Values.Count(job =>
|
||||
{
|
||||
lock (job.Gate)
|
||||
{
|
||||
return job.State == PipelineJobState.Queued || job.State == PipelineJobState.Running;
|
||||
}
|
||||
});
|
||||
if (nonTerminal >= MaxQueuedJobs)
|
||||
{
|
||||
record = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
record = new PipelineJobRecord
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Command = command,
|
||||
State = PipelineJobState.Queued,
|
||||
EnqueuedAt = DateTime.UtcNow
|
||||
};
|
||||
m_Jobs[record.Id] = record;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool TryGet(string id, out PipelineJobRecord record)
|
||||
{
|
||||
Prune();
|
||||
return m_Jobs.TryGetValue(id ?? string.Empty, out record);
|
||||
}
|
||||
|
||||
internal void MarkRunning(PipelineJobRecord record)
|
||||
{
|
||||
lock (record.Gate)
|
||||
{
|
||||
record.State = PipelineJobState.Running;
|
||||
record.StartedAt = DateTime.UtcNow;
|
||||
}
|
||||
m_CurrentRunning = record;
|
||||
}
|
||||
|
||||
internal void MarkCompleted(PipelineJobRecord record, object result)
|
||||
{
|
||||
lock (record.Gate)
|
||||
{
|
||||
record.State = PipelineJobState.Completed;
|
||||
record.Result = result;
|
||||
record.CompletedAt = DateTime.UtcNow;
|
||||
}
|
||||
ClearCurrent(record);
|
||||
}
|
||||
|
||||
internal void MarkFailed(PipelineJobRecord record, string error, string details)
|
||||
{
|
||||
lock (record.Gate)
|
||||
{
|
||||
record.State = PipelineJobState.Failed;
|
||||
record.Error = error;
|
||||
record.ErrorDetails = details;
|
||||
record.CompletedAt = DateTime.UtcNow;
|
||||
}
|
||||
ClearCurrent(record);
|
||||
}
|
||||
|
||||
internal void MarkCanceled(PipelineJobRecord record)
|
||||
{
|
||||
lock (record.Gate)
|
||||
{
|
||||
record.State = PipelineJobState.Canceled;
|
||||
record.CompletedAt = DateTime.UtcNow;
|
||||
}
|
||||
ClearCurrent(record);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request cancellation. A queued job is canceled before it ever starts (the runner
|
||||
/// checks the flag under the exec gate); a running job gets a cooperative flag that
|
||||
/// command/eval code can observe via <see cref="PipelineCancellation"/> — arbitrary
|
||||
/// synchronous main-thread code cannot be aborted safely, so cancellation of a running
|
||||
/// job takes effect only when the code cooperates (mirroring how test cancellation
|
||||
/// requires the runner's cooperation).
|
||||
/// </summary>
|
||||
internal bool RequestCancel(string id, out PipelineJobRecord record)
|
||||
{
|
||||
if (!TryGet(id, out record))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
record.CancellationRequested = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ClearCurrent(PipelineJobRecord record)
|
||||
{
|
||||
if (ReferenceEquals(m_CurrentRunning, record))
|
||||
{
|
||||
m_CurrentRunning = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Prune()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var terminal = m_Jobs.Values
|
||||
.Where(job =>
|
||||
{
|
||||
lock (job.Gate)
|
||||
{
|
||||
return job.State == PipelineJobState.Completed
|
||||
|| job.State == PipelineJobState.Failed
|
||||
|| job.State == PipelineJobState.Canceled;
|
||||
}
|
||||
})
|
||||
.OrderBy(job => job.CompletedAt ?? job.EnqueuedAt)
|
||||
.ToList();
|
||||
|
||||
// Oldest-first: drop everything beyond the retained count, plus anything expired.
|
||||
var excess = terminal.Count - MaxRetainedTerminalJobs;
|
||||
for (var i = 0; i < terminal.Count; i++)
|
||||
{
|
||||
var job = terminal[i];
|
||||
var expired = now - (job.CompletedAt ?? job.EnqueuedAt) > m_RetentionTtl;
|
||||
if (i < excess || expired)
|
||||
{
|
||||
m_Jobs.TryRemove(job.Id, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Test hook: forget every job.</summary>
|
||||
internal void Reset()
|
||||
{
|
||||
m_Jobs.Clear();
|
||||
m_CurrentRunning = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Cooperative cancellation for detached jobs (CLI-335). Long-running command or
|
||||
/// <c>eval</c> code checks this periodically; when a client has requested cancellation
|
||||
/// (<c>POST /api/job/cancel</c>), the code can wind down early:</para>
|
||||
/// <code>
|
||||
/// for (int i = 0; i < n; i++)
|
||||
/// {
|
||||
/// Unity.Pipeline.PipelineCancellation.ThrowIfCancellationRequested();
|
||||
/// // … chunk of work …
|
||||
/// }
|
||||
/// </code>
|
||||
/// <para>Arbitrary synchronous code cannot be aborted safely from outside, so a running job
|
||||
/// that never checks this flag runs to completion (its state still reports canceled).</para>
|
||||
/// <para>This is a thin static façade over the executing server's own
|
||||
/// <see cref="PipelineJobRegistry"/> (resolved via <see cref="BasePipelineServer.CurrentServer"/>)
|
||||
/// so command code — which has no reference to "which server is running me" — can keep
|
||||
/// calling this the same way regardless of which server dispatched it.</para>
|
||||
/// </summary>
|
||||
public static class PipelineCancellation
|
||||
{
|
||||
/// <summary>True when the currently executing job has been asked to cancel.</summary>
|
||||
public static bool IsCancellationRequested
|
||||
{
|
||||
get
|
||||
{
|
||||
var current = BasePipelineServer.CurrentServer?.JobRegistry.CurrentRunning;
|
||||
return current != null && current.CancellationRequested;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Throw <see cref="OperationCanceledException"/> when cancellation was requested.</summary>
|
||||
public static void ThrowIfCancellationRequested()
|
||||
{
|
||||
if (IsCancellationRequested)
|
||||
{
|
||||
throw new OperationCanceledException("Pipeline job cancellation was requested");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 34693b29c0c343d9804a82d9b234ea3f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetVersion: 1
|
||||
@@ -0,0 +1,231 @@
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
#if UNITY_6000_3_OR_NEWER
|
||||
using UnityEngine.Assemblies;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Version-stable handle to a Unity object id. In 6000.4 Unity replaced the int instance id with
|
||||
/// <see cref="EntityId"/> (a ulong-backed handle), made <c>Object.GetInstanceID()</c> /
|
||||
/// <c>EditorUtility.InstanceIDToObject(int)</c> obsolete-as-error, and is removing the
|
||||
/// <c>EntityId</c><-><c>int</c> conversions. This struct stores the concrete id type for the
|
||||
/// running editor — <see cref="EntityId"/> on 6000.4+, <c>int</c> below — and converts implicitly to
|
||||
/// it so it can be passed straight to Unity APIs. All version branching for object ids is confined
|
||||
/// here. It (de)serializes as a single JSON integer (the raw ulong on 6000.4+, the int below).
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(ObjectIdConverter))]
|
||||
public readonly struct ObjectId : System.IEquatable<ObjectId>
|
||||
{
|
||||
#if UNITY_6000_4_OR_NEWER
|
||||
readonly EntityId m_Value;
|
||||
/// <summary>Wrap an <see cref="EntityId"/>.</summary>
|
||||
/// <param name="value">The entity id to wrap.</param>
|
||||
public ObjectId(EntityId value) { m_Value = value; }
|
||||
/// <summary>Unwrap to the underlying <see cref="EntityId"/>.</summary>
|
||||
/// <param name="id">The id to unwrap.</param>
|
||||
/// <returns>The underlying entity id.</returns>
|
||||
public static implicit operator EntityId(ObjectId id) => id.m_Value;
|
||||
/// <summary>Wrap an <see cref="EntityId"/>.</summary>
|
||||
/// <param name="value">The entity id to wrap.</param>
|
||||
/// <returns>The wrapped id.</returns>
|
||||
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);
|
||||
/// <summary>Reconstruct an <see cref="ObjectId"/> from its raw wire value.</summary>
|
||||
/// <param name="raw">The raw wire value.</param>
|
||||
/// <returns>The reconstructed id.</returns>
|
||||
public static ObjectId FromRaw(ulong raw) => new ObjectId(EntityId.FromULong(raw));
|
||||
/// <summary>Value-equality on the underlying id.</summary>
|
||||
/// <param name="other">The id to compare against.</param>
|
||||
/// <returns>True if the underlying ids are equal.</returns>
|
||||
public bool Equals(ObjectId other) => m_Value.Equals(other.m_Value);
|
||||
/// <summary>Hash of the underlying id.</summary>
|
||||
/// <returns>The hash code.</returns>
|
||||
public override int GetHashCode() => m_Value.GetHashCode();
|
||||
#else
|
||||
readonly int m_Value;
|
||||
/// <summary>Wrap a raw instance id.</summary>
|
||||
/// <param name="value">The instance id to wrap.</param>
|
||||
public ObjectId(int value) { m_Value = value; }
|
||||
/// <summary>Unwrap to the underlying instance id.</summary>
|
||||
/// <param name="id">The id to unwrap.</param>
|
||||
/// <returns>The underlying instance id.</returns>
|
||||
public static implicit operator int(ObjectId id) => id.m_Value;
|
||||
/// <summary>Wrap a raw instance id.</summary>
|
||||
/// <param name="value">The instance id to wrap.</param>
|
||||
/// <returns>The wrapped id.</returns>
|
||||
public static implicit operator ObjectId(int value) => new ObjectId(value);
|
||||
/// <summary>Raw id — the canonical wire / serialization form.</summary>
|
||||
public long RawValue => m_Value;
|
||||
/// <summary>Reconstruct an <see cref="ObjectId"/> from its raw wire value.</summary>
|
||||
/// <param name="raw">The raw wire value.</param>
|
||||
/// <returns>The reconstructed id.</returns>
|
||||
public static ObjectId FromRaw(long raw) => new ObjectId((int)raw);
|
||||
/// <summary>Value-equality on the underlying id.</summary>
|
||||
/// <param name="other">The id to compare against.</param>
|
||||
/// <returns>True if the underlying ids are equal.</returns>
|
||||
public bool Equals(ObjectId other) => m_Value == other.m_Value;
|
||||
/// <summary>Hash of the underlying id.</summary>
|
||||
/// <returns>The hash code.</returns>
|
||||
public override int GetHashCode() => m_Value;
|
||||
#endif
|
||||
/// <summary>Value-equality on the underlying id.</summary>
|
||||
/// <param name="obj">The object to compare against.</param>
|
||||
/// <returns>True if <paramref name="obj"/> is an equal <see cref="ObjectId"/>.</returns>
|
||||
public override bool Equals(object obj) => obj is ObjectId other && Equals(other);
|
||||
/// <summary>The canonical numeric wire form (see <see cref="RawValue"/>).</summary>
|
||||
/// <returns>The numeric string form.</returns>
|
||||
public override string ToString() => RawValue.ToString();
|
||||
|
||||
/// <summary>Parse the canonical numeric form produced by <see cref="ToString"/>.</summary>
|
||||
/// <param name="s">The numeric string to parse.</param>
|
||||
/// <returns>The parsed id.</returns>
|
||||
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>
|
||||
/// <param name="s">The numeric string to parse.</param>
|
||||
/// <param name="id">The parsed id, if successful.</param>
|
||||
/// <returns>True if <paramref name="s"/> was a valid numeric form.</returns>
|
||||
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>
|
||||
sealed class ObjectIdConverter : JsonConverter<ObjectId>
|
||||
{
|
||||
/// <summary>Write an <see cref="ObjectId"/> as its raw numeric wire value.</summary>
|
||||
/// <param name="writer">The JSON writer.</param>
|
||||
/// <param name="value">The id to write.</param>
|
||||
/// <param name="serializer">The active serializer.</param>
|
||||
public override void WriteJson(JsonWriter writer, ObjectId value, JsonSerializer serializer)
|
||||
{
|
||||
writer.WriteValue(value.RawValue);
|
||||
}
|
||||
|
||||
/// <summary>Read an <see cref="ObjectId"/> from either its numeric or string wire form.</summary>
|
||||
/// <param name="reader">The JSON reader.</param>
|
||||
/// <param name="objectType">The declared property type.</param>
|
||||
/// <param name="existingValue">The existing value, if any.</param>
|
||||
/// <param name="hasExistingValue">Whether <paramref name="existingValue"/> is valid.</param>
|
||||
/// <param name="serializer">The active serializer.</param>
|
||||
/// <returns>The parsed id, or <c>default</c> for an unrecognized token type.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Small cross-Unity-version compatibility shims used across the package.</summary>
|
||||
public static class PipelineUtils
|
||||
{
|
||||
/// <summary>The on-disk path Unity loaded an assembly from.</summary>
|
||||
/// <param name="a">The assembly to query.</param>
|
||||
/// <returns>The assembly's loaded path.</returns>
|
||||
public static string GetLoadedAssemblyPath(System.Reflection.Assembly a)
|
||||
{
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
return a.GetLoadedAssemblyPath();
|
||||
#else
|
||||
return a.Location;
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>Load a compiled assembly from raw bytes (used by the code-reload/eval compilers).</summary>
|
||||
/// <param name="bytes">The assembly image.</param>
|
||||
/// <param name="pdb">The matching portable PDB, if any.</param>
|
||||
/// <returns>The loaded assembly.</returns>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>Find every loaded object of type <typeparamref name="T"/>, including inactive ones.</summary>
|
||||
/// <typeparam name="T">The object type to search for.</typeparam>
|
||||
/// <returns>All matching objects.</returns>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>All assemblies currently loaded in the domain.</summary>
|
||||
/// <returns>The loaded assemblies.</returns>
|
||||
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>
|
||||
/// <param name="obj">The object to get an id for.</param>
|
||||
/// <returns>The object's version-stable id.</returns>
|
||||
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,31 @@
|
||||
using System.IO;
|
||||
|
||||
namespace Unity.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// The one piece of "rotate a log file" that <c>PipelineTransactionLog</c> (a whole-file JSON
|
||||
/// array, rotated once per Unity session) and <c>EvalUsageTelemetry</c> (true JSONL, rotated on
|
||||
/// a byte-size cap) actually share verbatim: swapping the active file for a backup. Everything
|
||||
/// else — the append shape, the lock, and what triggers a rotation — differs enough between the
|
||||
/// two that forcing them into one bigger "rotating append log" abstraction would cost more
|
||||
/// clarity than the duplication it removes, so only this file-swap step is factored out.
|
||||
/// </summary>
|
||||
static class RotatingFileBackup
|
||||
{
|
||||
/// <summary>
|
||||
/// Move <paramref name="activePath"/> to <paramref name="backupPath"/>, replacing any prior
|
||||
/// backup, so the active path is free for a fresh file. No-op when there is no active file.
|
||||
/// Callers are responsible for their own locking around this — it performs no synchronization
|
||||
/// itself.
|
||||
/// </summary>
|
||||
public static void RotateToBackup(string activePath, string backupPath)
|
||||
{
|
||||
if (!File.Exists(activePath))
|
||||
return;
|
||||
|
||||
if (File.Exists(backupPath))
|
||||
File.Delete(backupPath);
|
||||
File.Move(activePath, backupPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 24f2295b1c6944c89c0615c9be0ed96a
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 257ad47737473da48b7569484405904e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,578 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
using Unity.Pipeline.Models;
|
||||
using Unity.Pipeline.Threading;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Compilation
|
||||
{
|
||||
/// <summary>
|
||||
/// Compiles transformed code reload source using the shared RoslynCompilationService.
|
||||
/// Creates versioned DLLs and manages code reload assembly lifecycle.
|
||||
/// </summary>
|
||||
static class CodeReloadCompiler
|
||||
{
|
||||
/// <summary>
|
||||
/// Interpreter-targeted overrides (in-editor backend and push-to-player) carry an embedded
|
||||
/// PDB and #line mapping, so runtime errors report the user's source file and line instead
|
||||
/// of an IL offset. Costs a few KB per pushed override and compiles it unoptimized
|
||||
/// (irrelevant to interpreted execution). Always on; this knob exists only so tests can
|
||||
/// exercise the no-line-info error path — nothing in production writes it, and a domain
|
||||
/// reload resets it to true.
|
||||
/// </summary>
|
||||
internal static bool EmitSourceLineInfo = true;
|
||||
|
||||
#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG)
|
||||
|
||||
private static readonly Dictionary<string, int> _versionTracker = new();
|
||||
private const string CodeReloadTempDir = "Temp/CodeReload";
|
||||
|
||||
/// <summary>
|
||||
/// Compile source code on main thread synchronously to avoid deadlocks.
|
||||
/// </summary>
|
||||
/// <param name="sourceCode">Code 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>
|
||||
/// <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>
|
||||
/// <param name="interpreterMethods">When set, the compiled bytes register with the interpreter backend instead of Assembly.Load.</param>
|
||||
/// <returns>The compilation result.</returns>
|
||||
internal static CodeReloadCompileResult CompileSourceCodeOnMainThread(string sourceCode, string baseFileName, string assemblyDir = null, bool emitPdb = false, string documentPath = null, InterpreterOverrideSet interpreterMethods = null)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var useInterpreter = interpreterMethods != null;
|
||||
|
||||
try
|
||||
{
|
||||
Debug.Log($"CodeReload: Starting synchronous source code compilation for {baseFileName}");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sourceCode))
|
||||
{
|
||||
return CodeReloadCompileResult.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(CodeReloadTempDir);
|
||||
|
||||
// The interpreter path needs only the bytes (no Assembly.Load), with the PDB embedded
|
||||
// in them: the interpreter reads sequence points only from an embedded PDB and uses
|
||||
// them for source lines in runtime error messages.
|
||||
var compilationResult = CompileCodeReloadAssembly(sourceCode, assemblyName,
|
||||
emitPdb: emitPdb && !useInterpreter, documentPath, loadAssembly: !useInterpreter,
|
||||
embedPdb: useInterpreter && EmitSourceLineInfo);
|
||||
|
||||
if (!compilationResult.Success)
|
||||
{
|
||||
var dumpPath = DumpFailedSource(assemblyName, sourceCode);
|
||||
return CodeReloadCompileResult.Failure(
|
||||
"Compilation Failed",
|
||||
"Code reload source code compilation failed" +
|
||||
(dumpPath != null ? $" (generated source: {dumpPath})" : ""),
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
compilationResult.Diagnostics.Select(FormatDiagnostic).ToList());
|
||||
}
|
||||
|
||||
List<string> registeredMethods;
|
||||
List<string> skippedOverrides;
|
||||
if (useInterpreter)
|
||||
{
|
||||
registeredMethods = InterpreterCodeReloadExecutor.Register(
|
||||
compilationResult.AssemblyBytes, interpreterMethods.TypeName, interpreterMethods.MethodNames,
|
||||
out skippedOverrides, out var bindingWarnings);
|
||||
// The reload response carries one flat Diagnostics list; append binding warnings
|
||||
// (unbound host members that throw only if reached) so the client sees the gap
|
||||
// in the same reply that reports the reload as applied.
|
||||
skippedOverrides.AddRange(bindingWarnings);
|
||||
}
|
||||
else
|
||||
{
|
||||
registeredMethods = RegisterCodeReloadMethods(compilationResult.Assembly, assemblyName, out 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($"CodeReload: 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($"CodeReload: Symbols saved to disk: {pdbPath}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
actualAssemblyPath = Path.Combine(CodeReloadTempDir, $"{assemblyName}.dll"); // For compatibility (in-memory)
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
Debug.Log($"CodeReload: Successfully compiled source code -> {assemblyName}.dll with {registeredMethods.Count} methods in {stopwatch.ElapsedMilliseconds}ms");
|
||||
|
||||
var sourceCompileResult = CodeReloadCompileResult.Success(
|
||||
assemblyName,
|
||||
actualAssemblyPath,
|
||||
registeredMethods,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
sourceCompileResult.Diagnostics = skippedOverrides;
|
||||
return sourceCompileResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"CodeReload: Synchronous source code compilation failed for {baseFileName}: {ex.Message}");
|
||||
return CodeReloadCompileResult.Failure(
|
||||
"Exception",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clean up old code reload DLL versions, keeping only the latest for each base filename.
|
||||
/// </summary>
|
||||
/// <param name="assemblyDir">Directory containing code-reload assemblies, or null for the default temp directory.</param>
|
||||
/// <returns>Which files were deleted, and whether cleanup succeeded.</returns>
|
||||
public static CodeReloadCleanupResult CleanupCodeReloadDlls(string assemblyDir = null)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var deletedFiles = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// Use specified assemblyDir or default to CodeReloadTempDir
|
||||
var targetDir = !string.IsNullOrWhiteSpace(assemblyDir) ? assemblyDir : CodeReloadTempDir;
|
||||
|
||||
if (!Directory.Exists(targetDir))
|
||||
{
|
||||
return new CodeReloadCleanupResult
|
||||
{
|
||||
Success = true,
|
||||
DeletedFiles = deletedFiles,
|
||||
Message = $"No code 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 code 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($"CodeReload: Deleted old version {fileToDelete.FileName}.dll");
|
||||
}
|
||||
}
|
||||
|
||||
// Clear registry and reset version tracker
|
||||
CodeReloadRegistry.ClearAllOverrides();
|
||||
_versionTracker.Clear();
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
Debug.Log($"CodeReload: Cleanup completed - deleted {deletedFiles.Count} files in {stopwatch.ElapsedMilliseconds}ms");
|
||||
|
||||
return new CodeReloadCleanupResult
|
||||
{
|
||||
Success = true,
|
||||
DeletedFiles = deletedFiles,
|
||||
Message = $"Deleted {deletedFiles.Count} old DLL versions",
|
||||
ExecutionTimeMs = stopwatch.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"CodeReload: Cleanup failed: {ex.Message}");
|
||||
|
||||
return new CodeReloadCleanupResult
|
||||
{
|
||||
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>
|
||||
/// Compile transformed override source to raw assembly bytes without loading or registering
|
||||
/// anything, for the push-to-player path (the device runs the bytes through the interpreter).
|
||||
/// The PDB is embedded so the interpreter can report source lines instead of IL offsets;
|
||||
/// <paramref name="documentPath"/> is recorded for source outside the transform's #line
|
||||
/// directives.
|
||||
/// </summary>
|
||||
internal static bool TryCompileOverrideBytes(string transformedCode, string baseFileName, out byte[] ilBytes, out List<string> diagnostics, string documentPath = null, string[] preprocessorSymbols = null)
|
||||
{
|
||||
ilBytes = null;
|
||||
diagnostics = new List<string>();
|
||||
|
||||
var assemblyName = $"{baseFileName}_{GetNextVersion(baseFileName):D3}";
|
||||
var result = CompileCodeReloadAssembly(transformedCode, assemblyName, emitPdb: false, documentPath: documentPath, loadAssembly: false, embedPdb: EmitSourceLineInfo, preprocessorSymbols: preprocessorSymbols);
|
||||
if (!result.Success)
|
||||
{
|
||||
diagnostics = result.Diagnostics.Select(FormatDiagnostic).ToList();
|
||||
var dumpPath = DumpFailedSource(assemblyName, transformedCode);
|
||||
if (dumpPath != null)
|
||||
diagnostics.Add($"Generated source written to {dumpPath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
ilBytes = result.AssemblyBytes;
|
||||
return ilBytes != null && ilBytes.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One diagnostic as the user should read it: the line of THEIR file it maps to (via the
|
||||
/// transform's #line directives), the compiler message, and the generated line that
|
||||
/// actually failed. The generated source differs from what the user wrote (bodies are
|
||||
/// lifted into a static class, members re-qualified), so a bare compiler message such as
|
||||
/// "the type name 'var' does not exist in the type 'Foo'" is not debuggable on its own.
|
||||
/// </summary>
|
||||
internal static string FormatDiagnostic(DiagnosticInfo d)
|
||||
{
|
||||
var where = d.File != null
|
||||
? $"{Path.GetFileName(d.File)}({d.FileLine})"
|
||||
: $"generated line {d.Line + 1}";
|
||||
var text = $"{where}: {d.Severity} {d.Id}: {d.Message}";
|
||||
if (!string.IsNullOrEmpty(d.Source))
|
||||
text += $"\n generated line {d.Line + 1}: {d.Source}";
|
||||
return text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write the generated override source next to the versioned DLLs so a failed compile can
|
||||
/// be inspected whole. Returns the path, or null when the write failed (never fatal).
|
||||
/// </summary>
|
||||
private static string DumpFailedSource(string assemblyName, string sourceCode)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(CodeReloadTempDir);
|
||||
var path = Path.Combine(CodeReloadTempDir, $"{assemblyName}.failed.cs");
|
||||
File.WriteAllText(path, sourceCode);
|
||||
return path;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compile code reload source code using shared RoslynCompilationService.
|
||||
/// Synchronous compilation for main thread use.
|
||||
/// </summary>
|
||||
private static CompilationResult CompileCodeReloadAssembly(string sourceCode, string assemblyName, bool emitPdb = false, string documentPath = null, bool loadAssembly = true, bool embedPdb = false, string[] preprocessorSymbols = null)
|
||||
{
|
||||
var request = new CompilationRequest
|
||||
{
|
||||
// Null = the project's define set; the push path passes the player's defines.
|
||||
PreprocessorSymbols = preprocessorSymbols,
|
||||
SourceCode = sourceCode,
|
||||
AssemblyName = assemblyName,
|
||||
// Ensure Unity.Pipeline assemblies are included (contains CodeReloadOverrideMethodAttribute)
|
||||
// Also include test assemblies for testing scenarios
|
||||
AdditionalAssemblyPrefixes = new[] { "Unity.Pipeline", "Unity.Pipeline.Tests" },
|
||||
EmitDebugInformation = emitPdb,
|
||||
EmbedDebugInformation = embedPdb,
|
||||
DocumentPath = documentPath,
|
||||
// The interpreter path runs the bytes directly; skip Assembly.Load (IL2CPP-unsafe).
|
||||
SkipLoad = !loadAssembly,
|
||||
// Interpreter-bound bytes only: the interpreter reaches non-public members via
|
||||
// reflection. Loaded assemblies keep standard access checks — Mono JIT-enforces
|
||||
// accessibility on Assembly.Load'd IL, so relaxing them would only trade a compile
|
||||
// error for a throw at first dispatch (see AccessibilityValidator).
|
||||
AllowNonPublicMemberAccess = !loadAssembly
|
||||
};
|
||||
|
||||
return RoslynCompilationService.Compile(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register the [CodeReloadOverrideMethod] methods of a compiled assembly with the registry.
|
||||
/// Only overrides that actually bind are returned; overrides that were skipped (e.g. the
|
||||
/// target is not a registered [CodeReload] method, or a signature mismatch) are reported via
|
||||
/// <paramref name="skipped"/> with a user-facing reason.
|
||||
/// </summary>
|
||||
private static List<string> RegisterCodeReloadMethods(Assembly assembly, string assemblyId, out List<string> skipped)
|
||||
{
|
||||
var registeredMethods = new List<string>();
|
||||
skipped = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// Register assembly types for discovery
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
CodeReloadRegistry.RegisterCodeReloadType(type, assemblyId);
|
||||
|
||||
// Find methods with [CodeReloadOverrideMethod] attribute for overrides
|
||||
var staticMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Static);
|
||||
|
||||
foreach (var method in staticMethods)
|
||||
{
|
||||
// Try multiple ways to find the attribute
|
||||
var codeReloadAttr = method.GetCustomAttribute<CodeReloadOverrideMethodAttribute>();
|
||||
if (codeReloadAttr == null)
|
||||
{
|
||||
// Try by name in case of type loading issues
|
||||
var attrByName = method.GetCustomAttributes(true)
|
||||
.FirstOrDefault(a => a.GetType().Name == "CodeReloadOverrideMethodAttribute");
|
||||
if (attrByName != null)
|
||||
{
|
||||
// Cast to the attribute type
|
||||
codeReloadAttr = attrByName as CodeReloadOverrideMethodAttribute;
|
||||
}
|
||||
}
|
||||
|
||||
if (codeReloadAttr != null)
|
||||
{
|
||||
if (CodeReloadRegistry.RegisterMethodOverride(method, codeReloadAttr, type, out var skipReason))
|
||||
{
|
||||
registeredMethods.Add(codeReloadAttr.TargetMethodId);
|
||||
}
|
||||
else
|
||||
{
|
||||
skipped.Add($"{method.Name} -> {codeReloadAttr.TargetMethodId}: {skipReason}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"CodeReload: Registered {registeredMethods.Count} override methods: {string.Join(", ", registeredMethods)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"CodeReload: Error registering methods from assembly {assemblyId}: {ex.Message}");
|
||||
Debug.LogError($"CodeReload: 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
|
||||
// Code 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 surface used by callers (CodeReloadCommands, InPlaceReloadProcessor)
|
||||
// so the project still builds, and returning a clear "not supported" failure at runtime.
|
||||
const string NotSupportedMessage =
|
||||
"Code reload compilation is only supported on Desktop development builds (Windows/Mac/Linux).";
|
||||
|
||||
/// <summary>
|
||||
/// Code reload source compilation (in-place editing) not supported on this build.
|
||||
/// </summary>
|
||||
/// <param name="sourceCode">Unused on this build.</param>
|
||||
/// <param name="baseFileName">Unused on this build.</param>
|
||||
/// <param name="assemblyDir">Unused on this build.</param>
|
||||
/// <param name="emitPdb">Unused on this build.</param>
|
||||
/// <param name="documentPath">Unused on this build.</param>
|
||||
/// <param name="interpreterMethods">Unused on this build.</param>
|
||||
/// <returns>A "Platform Not Supported" failure.</returns>
|
||||
internal static CodeReloadCompileResult CompileSourceCodeOnMainThread(string sourceCode, string baseFileName, string assemblyDir = null, bool emitPdb = false, string documentPath = null, InterpreterOverrideSet interpreterMethods = null)
|
||||
{
|
||||
return CodeReloadCompileResult.Failure("Platform Not Supported", NotSupportedMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override compilation to bytes (push path) not supported on this build (no Roslyn).
|
||||
/// </summary>
|
||||
/// <param name="transformedCode">Unused on this build.</param>
|
||||
/// <param name="baseFileName">Unused on this build.</param>
|
||||
/// <param name="ilBytes">Always null on this build.</param>
|
||||
/// <param name="diagnostics">Always contains the "not supported" message on this build.</param>
|
||||
/// <param name="documentPath">Unused on this build.</param>
|
||||
/// <param name="preprocessorSymbols">Unused on this build.</param>
|
||||
/// <returns>Always false on this build.</returns>
|
||||
internal static bool TryCompileOverrideBytes(string transformedCode, string baseFileName, out byte[] ilBytes, out List<string> diagnostics, string documentPath = null, string[] preprocessorSymbols = null)
|
||||
{
|
||||
ilBytes = null;
|
||||
diagnostics = new List<string> { NotSupportedMessage };
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Code reload cleanup not supported on this build.
|
||||
/// </summary>
|
||||
/// <param name="assemblyDir">Unused on this build.</param>
|
||||
/// <returns>A failure result explaining code reload isn't supported on this build.</returns>
|
||||
public static CodeReloadCleanupResult CleanupCodeReloadDlls(string assemblyDir = null)
|
||||
{
|
||||
return new CodeReloadCleanupResult
|
||||
{
|
||||
Success = false,
|
||||
Message = NotSupportedMessage,
|
||||
ExecutionTimeMs = 0
|
||||
};
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The override set an interpreter-backed compile registers instead of Assembly.Load: the
|
||||
/// reloaded type plus its surviving method names. Passing one to
|
||||
/// <see cref="CodeReloadCompiler.CompileSourceCodeOnMainThread"/> selects the interpreter path.
|
||||
/// </summary>
|
||||
internal class InterpreterOverrideSet
|
||||
{
|
||||
public string TypeName { get; set; }
|
||||
public List<string> MethodNames { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from code reload compilation operation.
|
||||
/// </summary>
|
||||
class CodeReloadCompileResult
|
||||
{
|
||||
/// <summary>Whether compilation succeeded.</summary>
|
||||
public bool IsSuccess { get; set; }
|
||||
/// <summary>Name of the compiled code-reload assembly.</summary>
|
||||
public string AssemblyName { get; set; }
|
||||
/// <summary>Path the assembly was written to, if saved to disk.</summary>
|
||||
public string OutputPath { get; set; }
|
||||
/// <summary>Method ids registered as overrides.</summary>
|
||||
public List<string> RegisteredMethods { get; set; } = new List<string>();
|
||||
/// <summary>How long compilation took, in milliseconds.</summary>
|
||||
public long ExecutionTimeMs { get; set; }
|
||||
/// <summary>Error message, if compilation failed.</summary>
|
||||
public string Error { get; set; }
|
||||
/// <summary>Additional error details for debugging.</summary>
|
||||
public string ErrorDetails { get; set; }
|
||||
/// <summary>Compiler diagnostics (errors/warnings), if any.</summary>
|
||||
public List<string> Diagnostics { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Create a successful compile result.</summary>
|
||||
/// <param name="assemblyName">Name of the compiled assembly.</param>
|
||||
/// <param name="outputPath">Path the assembly was written to, if saved to disk.</param>
|
||||
/// <param name="registeredMethods">Method ids registered as overrides.</param>
|
||||
/// <param name="executionTimeMs">How long compilation took, in milliseconds.</param>
|
||||
/// <returns>A successful result.</returns>
|
||||
public static CodeReloadCompileResult Success(string assemblyName, string outputPath, List<string> registeredMethods, long executionTimeMs)
|
||||
{
|
||||
return new CodeReloadCompileResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
AssemblyName = assemblyName,
|
||||
OutputPath = outputPath,
|
||||
RegisteredMethods = registeredMethods,
|
||||
ExecutionTimeMs = executionTimeMs
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Create a failed compile result.</summary>
|
||||
/// <param name="error">Error message.</param>
|
||||
/// <param name="errorDetails">Additional error details for debugging.</param>
|
||||
/// <param name="executionTimeMs">How long compilation took, in milliseconds.</param>
|
||||
/// <param name="diagnostics">Compiler diagnostics (errors/warnings), if any.</param>
|
||||
/// <returns>A failed result.</returns>
|
||||
public static CodeReloadCompileResult Failure(string error, string errorDetails, long executionTimeMs = 0, List<string> diagnostics = null)
|
||||
{
|
||||
return new CodeReloadCompileResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Error = error,
|
||||
ErrorDetails = errorDetails,
|
||||
ExecutionTimeMs = executionTimeMs,
|
||||
Diagnostics = diagnostics ?? new List<string>()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from code reload cleanup operation.
|
||||
/// </summary>
|
||||
class CodeReloadCleanupResult
|
||||
{
|
||||
/// <summary>Whether cleanup succeeded.</summary>
|
||||
public bool Success { get; set; }
|
||||
/// <summary>Paths of the deleted files.</summary>
|
||||
public List<string> DeletedFiles { get; set; } = new List<string>();
|
||||
/// <summary>Human-readable summary or error message.</summary>
|
||||
public string Message { get; set; }
|
||||
/// <summary>How long cleanup took, in milliseconds.</summary>
|
||||
public long ExecutionTimeMs { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 29ede4b57fcc6c64b84e5f6ee75df373
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,335 @@
|
||||
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>
|
||||
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>
|
||||
/// <param name="code">The C# expression/statement(s) to evaluate.</param>
|
||||
/// <param name="timeoutMs">Timeout for compilation, in milliseconds.</param>
|
||||
/// <param name="stopwatch">Running stopwatch used to report execution time in the response.</param>
|
||||
/// <returns>The evaluation result.</returns>
|
||||
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>
|
||||
/// <param name="code">Unused on this build.</param>
|
||||
/// <param name="timeoutMs">Unused on this build.</param>
|
||||
/// <param name="stopwatch">Unused on this build.</param>
|
||||
/// <returns>A failure result explaining runtime compilation isn't supported on this build.</returns>
|
||||
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:
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Compilation
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared IlInterpreter <see cref="IlInterpreter.Interpreter.HostBinding"/> surface used by the
|
||||
/// interpreter-backed code-reload path. Mirrors IlInterpreter's
|
||||
/// <c>UnityHostBinding.Prototyping()</c> minus the Input System block, Audio, and physics —
|
||||
/// anything outside UnityEngine's core module binds on demand via <see cref="ResolveAutoBindType"/>,
|
||||
/// so the package forces no engine-module dependency. Reference types use <c>AllowType<T>()</c>
|
||||
/// and blittable structs <c>AllowTypeStruct<T>()</c> for AOT/IL2CPP safety; static classes
|
||||
/// and enums use <c>AllowType(typeof(...))</c>.
|
||||
/// </summary>
|
||||
static class IlInterpreterHostBindings
|
||||
{
|
||||
// Used by the interpreter-backed code-reload sink; available in dev players (incl. IL2CPP).
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE
|
||||
/// <summary>
|
||||
/// Single source of truth for the interpreter's host surface: BCL + the standard UnityEngine
|
||||
/// types + <c>Debug</c>. Every consumer — eval binding, code-reload binding, and the build-time
|
||||
/// <c>link.xml</c> preservation set — is a projection of this one builder, so they cannot
|
||||
/// drift. Callers overlay only what is site-specific: eval overlays capturing <c>Debug.Log</c>
|
||||
/// variants; code reload overlays <c>AllowType(targetType)</c>; link.xml reads
|
||||
/// <see cref="IlInterpreter.Interpreter.HostBinding.RegisteredTypes"/> off the result.
|
||||
/// </summary>
|
||||
public static IlInterpreter.Interpreter.HostBinding CreateStandard()
|
||||
{
|
||||
// Route IlInterpreter registration warnings to the Unity console (the default sink is
|
||||
// stderr, which Unity players never surface).
|
||||
IlInterpreter.Interpreter.HostBinding.Warn = msg => Debug.LogWarning(msg);
|
||||
|
||||
var binding = AddStandardUnity(new IlInterpreter.Interpreter.HostBinding().AllowBcl())
|
||||
.AllowType(typeof(Debug)); // logging from eval/reloaded bodies (eval overlays capture on top)
|
||||
// Demand-time auto-bind: the interpreter resolves every TypeRef a loaded script carries
|
||||
// through this policy, so engine/game types outside the curated surface bind on first use
|
||||
// instead of throwing "not registered". The curated list above stays the fast-path core
|
||||
// (typed delegates, flat structs) and the link.xml baseline — a floor, not a ceiling.
|
||||
// Types the policy declines (see ResolveAutoBindType) are not bindable — the
|
||||
// project-extension seam that could promote them was deferred to a future release.
|
||||
binding.AutoBindResolver = ResolveAutoBindType;
|
||||
return binding;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-bind policy for <see cref="CreateStandard"/>: resolve a CLR full name from the loaded
|
||||
/// assemblies, skipping (a) the BCL — the hand-rolled <c>AllowBcl</c> shims collapse doubles
|
||||
/// into the script's float number space and must stay authoritative — and (b) this package's
|
||||
/// own assemblies. On IL2CPP dev players this self-limits to types the stripper kept (see the
|
||||
/// dev-build link.xml widening in <c>CodeReloadLinkXmlGenerator</c>).
|
||||
/// </summary>
|
||||
static Type ResolveAutoBindType(string fullName)
|
||||
{
|
||||
foreach (var asm in PipelineUtils.GetLoadedAssemblies())
|
||||
{
|
||||
if (IsAutoBindSkippedAssembly(asm.GetName().Name ?? string.Empty)) continue;
|
||||
Type t = null;
|
||||
try { t = asm.GetType(fullName, throwOnError: false); } catch { }
|
||||
if (t != null) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Internal for tests: the classification itself is the contract (a misclassified game
|
||||
// assembly silently loses auto-bind), and no test can load a real assembly by these names.
|
||||
internal static bool IsAutoBindSkippedAssembly(string name) =>
|
||||
name == "System" ||
|
||||
name.StartsWith("System.", StringComparison.Ordinal) ||
|
||||
name.StartsWith("Microsoft.", StringComparison.Ordinal) ||
|
||||
name.StartsWith("Mono.", StringComparison.Ordinal) ||
|
||||
name == "mscorlib" ||
|
||||
name == "netstandard" ||
|
||||
name.StartsWith("nunit.", StringComparison.Ordinal) ||
|
||||
name.StartsWith("Unity.Pipeline", StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Register the standard UnityEngine type surface onto <paramref name="binding"/> (fluent).
|
||||
/// Private: <see cref="CreateStandard"/> is the only supported way to build the surface,
|
||||
/// closing the compose-it-yourself seam where per-site drift used to enter.
|
||||
/// </summary>
|
||||
static IlInterpreter.Interpreter.HostBinding AddStandardUnity(IlInterpreter.Interpreter.HostBinding binding)
|
||||
{
|
||||
return binding
|
||||
.AllowType(typeof(Resources))
|
||||
.AllowType(typeof(PrimitiveType))
|
||||
// The base Object surface carries the fake-null operators (op_Equality,
|
||||
// op_Implicit -> bool) every `target == null` / `if (component)` guard compiles
|
||||
// against, plus Destroy/Instantiate/name. Explicit, not auto-bound: the guards
|
||||
// must work on IL2CPP without relying on the on-demand resolver.
|
||||
.AllowType<UnityEngine.Object>()
|
||||
.AllowType<GameObject>()
|
||||
.AllowType<Transform>()
|
||||
.AllowType<Camera>()
|
||||
.AllowType<Renderer>()
|
||||
.AllowType<MeshRenderer>()
|
||||
.AllowType<SpriteRenderer>()
|
||||
.AllowType<LineRenderer>()
|
||||
.AllowType<TrailRenderer>()
|
||||
.AllowType<Material>()
|
||||
.AllowType<TextMesh>()
|
||||
.AllowType<Sprite>()
|
||||
.AllowType(typeof(Shader)) // Shader.Find for material creation
|
||||
.AllowType(typeof(CameraClearFlags)) // cam.clearFlags
|
||||
.AllowType(typeof(TextAnchor)) // TextMesh.anchor
|
||||
.AllowType(typeof(TextAlignment)) // TextMesh.alignment
|
||||
.AllowType<Vector2>()
|
||||
.AllowTypeStruct<Vector3>()
|
||||
.AllowType<Quaternion>()
|
||||
.AllowType<Color>()
|
||||
.AllowType(typeof(Mathf))
|
||||
.AllowType(typeof(Time))
|
||||
// 3D physics (Rigidbody, Collider, Physics, ForceMode, RaycastHit, ...) is NOT
|
||||
// registered here: those types live in UnityEngine.PhysicsModule, and a compile-time
|
||||
// reference would force a physics module dependency on every consumer. They bind on
|
||||
// demand through AutoBindResolver instead — reflection-tier, and present in a player
|
||||
// only when the game's own code kept the module alive.
|
||||
.AllowType(typeof(Space))
|
||||
.AllowType<Ray>()
|
||||
.AllowType(typeof(LayerMask))
|
||||
.AllowType(typeof(Cursor))
|
||||
.AllowType(typeof(CursorLockMode))
|
||||
.AllowType(typeof(Screen))
|
||||
.AllowType(typeof(Rect))
|
||||
.AllowType(typeof(ScreenOrientation))
|
||||
.AllowType(typeof(Application))
|
||||
.AllowType(typeof(PlayerPrefs))
|
||||
.AllowType(typeof(Color32))
|
||||
.AllowType(typeof(System.Collections.IEnumerator))
|
||||
.AllowType(typeof(UnityEngine.Random))
|
||||
// UITK value-changed callbacks read evt.newValue/previousValue on ChangeEvent<T>.
|
||||
// Those are members on an OPEN generic type, which auto-bind can't resolve at Load
|
||||
// (there's no closed instantiation to invoke against) — so reflect on the receiver's
|
||||
// concrete type at call time. Covers Slider/Toggle/TextField/DropdownField/... the
|
||||
// whole RegisterValueChangedCallback surface, without a compile-time UITK dependency.
|
||||
.Allow("ChangeEvent`1", "get_newValue",
|
||||
(recv, _) => recv?.GetType().GetProperty("newValue")?.GetValue(recv), 0)
|
||||
.Allow("ChangeEvent`1", "get_previousValue",
|
||||
(recv, _) => recv?.GetType().GetProperty("previousValue")?.GetValue(recv), 0)
|
||||
// AllowType registers a type's methods/fields/props but not its constructors, so the
|
||||
// common ctors are wired explicitly (Vector3's comes from AllowTypeStruct above).
|
||||
.AllowConstructor("Vector2",
|
||||
(_, args) => (object)new Vector2(Convert.ToSingle(args[0]), Convert.ToSingle(args[1])), 2)
|
||||
.AllowConstructor("Color",
|
||||
(_, args) => (object)new Color(Convert.ToSingle(args[0]), Convert.ToSingle(args[1]), Convert.ToSingle(args[2]), Convert.ToSingle(args[3])), 4)
|
||||
.AllowConstructor("GameObject", (_, args) => (object)new GameObject(), 0)
|
||||
.AllowConstructor("GameObject", (_, args) => (object)new GameObject((string)args[0]), 1)
|
||||
.AllowConstructor("Material", (_, args) => (object)new Material((Shader)args[0]), 1);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fb251aef26c42704ca3d81d67fd13fc5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
// The package bundles Roslyn renamed with a "UnityPipeline." prefix (Runtime/Plugins/CodeAnalysis);
|
||||
// the dotnet test projects reference the same DLLs (IlInterpreter/BundledRoslyn.props).
|
||||
using UnityPipeline.Microsoft.CodeAnalysis;
|
||||
using UnityPipeline.Microsoft.CodeAnalysis.CSharp;
|
||||
using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
|
||||
namespace Unity.Pipeline.Compilation
|
||||
{
|
||||
/// <summary>
|
||||
/// Rewrites C# string interpolation (<c>$"..."</c>) into <c>string.Format(fmt, args…)</c> at the
|
||||
/// source level: the interpreter can't run the compiler's normal interpolation lowering
|
||||
/// (<c>DefaultInterpolatedStringHandler</c>, a ref struct), but it can call <c>String.Format</c>
|
||||
/// (bound in <see cref="IlInterpreter.Interpreter.HostBinding"/>). Applied by
|
||||
/// <see cref="SourceCodeTransformer"/> before an in-place reload body is compiled.
|
||||
///
|
||||
/// Purely syntactic — no semantic model. Format specifiers, alignment, escaped braces, verbatim
|
||||
/// strings and nested interpolation are handled. The one unserved case is an interpolation whose
|
||||
/// target type is <c>FormattableString</c>/<c>IFormattable</c>: rewriting yields a <c>string</c>,
|
||||
/// so that rare usage becomes a compile error rather than a silent wrong result.
|
||||
/// </summary>
|
||||
sealed class InterpolationToFormatRewriter : CSharpSyntaxRewriter
|
||||
{
|
||||
public override SyntaxNode VisitInterpolatedStringExpression(InterpolatedStringExpressionSyntax node)
|
||||
{
|
||||
var fmt = new StringBuilder();
|
||||
var args = new List<ArgumentSyntax>();
|
||||
int index = 0;
|
||||
|
||||
foreach (var content in node.Contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case InterpolatedStringTextSyntax text:
|
||||
// ValueText decodes escapes (\t etc.) but leaves literal braces doubled
|
||||
// ({{ / }}) — exactly the escaping a Format string wants, so append as-is.
|
||||
fmt.Append(text.TextToken.ValueText);
|
||||
break;
|
||||
|
||||
case InterpolationSyntax interp:
|
||||
fmt.Append('{').Append(index++);
|
||||
if (interp.AlignmentClause != null)
|
||||
fmt.Append(',').Append(interp.AlignmentClause.Value.ToString().Trim());
|
||||
if (interp.FormatClause != null)
|
||||
fmt.Append(':').Append(interp.FormatClause.FormatStringToken.ValueText);
|
||||
fmt.Append('}');
|
||||
// Visit the hole so nested interpolations rewrite bottom-up and later
|
||||
// member-qualification passes see it.
|
||||
args.Add(SyntaxFactory.Argument((ExpressionSyntax)Visit(interp.Expression)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// No holes: emit a plain string literal (no Format, no brace doubling), matching how
|
||||
// Roslyn treats a hole-less interpolated string.
|
||||
if (index == 0)
|
||||
{
|
||||
var raw = new StringBuilder();
|
||||
foreach (var content in node.Contents)
|
||||
if (content is InterpolatedStringTextSyntax t)
|
||||
// A plain literal wants single braces, so undo the {{ / }} escaping ValueText keeps.
|
||||
raw.Append(t.TextToken.ValueText.Replace("{{", "{").Replace("}}", "}"));
|
||||
return SyntaxFactory.LiteralExpression(
|
||||
SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal(raw.ToString()))
|
||||
.WithTriviaFrom(node);
|
||||
}
|
||||
|
||||
var allArgs = new List<ArgumentSyntax>(args.Count + 1)
|
||||
{
|
||||
SyntaxFactory.Argument(SyntaxFactory.LiteralExpression(
|
||||
SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal(fmt.ToString()))),
|
||||
};
|
||||
allArgs.AddRange(args);
|
||||
|
||||
// Passing holes as loose args lets Roslyn pick the Format overload: fixed arity for ≤3,
|
||||
// params object[] for ≥4 — both are bound on the interpreter side.
|
||||
return SyntaxFactory.InvocationExpression(
|
||||
SyntaxFactory.ParseExpression("string.Format"),
|
||||
SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(allArgs)))
|
||||
.WithTriviaFrom(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d10aa4b9388057a4f96c43b0c41c35d1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user