using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Threading.Tasks; using UnityPipeline.Microsoft.CodeAnalysis; using UnityPipeline.Microsoft.CodeAnalysis.CSharp; using UnityPipeline.Microsoft.CodeAnalysis.Emit; using UnityPipeline.Microsoft.CodeAnalysis.Text; using Unity.Pipeline.Models; using UnityEngine; namespace Unity.Pipeline.Compilation { /// /// Shared Roslyn compilation service for both code evaluation and code reload systems. /// Eliminates code duplication by providing common compilation infrastructure. /// Thread-safe and can be called from any thread. /// static class RoslynCompilationService { #if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG) /// /// Compile source code to assembly with customizable options. /// Thread-safe compilation that can run on any thread. /// /// The source and compilation options. /// The compilation result. public static CompilationResult Compile(CompilationRequest request) { try { // Parse syntax tree. When emitting debug info, the tree needs a document path and // UTF-8 encoding so the portable PDB can reference a source document. Explicit // symbols (e.g. run_script's `defines`) win; otherwise the project's define set // applies so the source's #if regions match the running assemblies. var emitDebug = request.EmitDebugInformation || request.EmbedDebugInformation; var parseOptions = ProjectParseOptions(request.PreprocessorSymbols); var syntaxTree = emitDebug ? CSharpSyntaxTree.ParseText( SourceText.From(request.SourceCode, Encoding.UTF8), parseOptions, path: request.DocumentPath ?? request.AssemblyName + ".cs") : CSharpSyntaxTree.ParseText(request.SourceCode, parseOptions); // Get metadata references with optional additional prefixes var references = GetMetadataReferences(request.AdditionalAssemblyPrefixes); // Create compilation. Disable optimizations when emitting debug info so the JIT // keeps full sequence points (otherwise breakpoints can't bind). var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary); if (emitDebug) options = options.WithOptimizationLevel(OptimizationLevel.Debug); // Non-public member access needs two pieces: MetadataImportOptions.All (the default, // Public, doesn't import non-public members of referenced assemblies at all — CS1061) // and BinderFlags.IgnoreAccessibility (the compiler does not honor // [assembly: IgnoresAccessChecksTo] — CS0122 otherwise). Interpreter-bound compiles // only (see the property doc): the interpreter executes the result by reflection, // which reaches non-public members. Unity's Mono JIT-enforces accessibility on // Assembly.Load'd IL and ignores IgnoresAccessChecksTo too (verified on Mono 6.13) — // see CodeReloadInPlaceTests.ReloadFileInPlace_PrivateFieldAccess_FailsValidationUpFront. if (request.AllowNonPublicMemberAccess) options = WithIgnoreAccessibility(options.WithMetadataImportOptions(MetadataImportOptions.All)); var compilation = CSharpCompilation.Create( request.AssemblyName, new[] { syntaxTree }, references, options); // Get diagnostics var diagnostics = compilation.GetDiagnostics(); var diagnosticInfos = ConvertDiagnostics(diagnostics, request.LineNumberOffset); // Check for compilation errors var errors = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); if (errors.Any()) { return new CompilationResult { Success = false, Diagnostics = diagnosticInfos }; } // Compile to memory using (var peStream = new MemoryStream()) { byte[] pdbBytes = null; EmitResult emitResult; if (request.EmbedDebugInformation) { // Embed the portable PDB in the PE debug directory so the single byte[] // pushed to a player carries its own sequence points; the IlInterpreter // interpreter reads line info only from an embedded PDB. emitResult = compilation.Emit( peStream, options: new EmitOptions(debugInformationFormat: DebugInformationFormat.Embedded)); } else if (request.EmitDebugInformation) { using (var pdbStream = new MemoryStream()) { emitResult = compilation.Emit( peStream, pdbStream, options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb)); if (emitResult.Success) pdbBytes = pdbStream.ToArray(); } } else { emitResult = compilation.Emit(peStream); } if (!emitResult.Success) { var emitDiagnostics = ConvertDiagnostics(emitResult.Diagnostics, request.LineNumberOffset); return new CompilationResult { Success = false, Diagnostics = diagnosticInfos.Concat(emitDiagnostics).ToList() }; } // Load assembly from memory. When symbols are present, load them alongside the // assembly so an attached debugger can bind breakpoints into the emitted code. // SkipLoad = emit-only (run_script's dry_run, and the interpreter path — see // the property doc). var assemblyBytes = peStream.ToArray(); Assembly assembly = null; if (!request.SkipLoad) { assembly = pdbBytes != null ? PipelineUtils.LoadFromBytes(assemblyBytes, pdbBytes) : PipelineUtils.LoadFromBytes(assemblyBytes); } return new CompilationResult { Success = true, Assembly = assembly, AssemblyBytes = assemblyBytes, PdbBytes = pdbBytes, Diagnostics = diagnosticInfos }; } } catch (Exception ex) { return new CompilationResult { Success = false, Diagnostics = new List { new DiagnosticInfo { Severity = "error", Message = $"Compilation exception: {ex.Message}", Line = 0, Column = 0, Id = "ROSLYN001" } } }; } } /// /// Async wrapper for compilation that runs on background thread. /// Use when you need to avoid blocking the calling thread. /// /// The source and compilation options. /// The compilation result. public static Task CompileAsync(CompilationRequest request) { return Task.Run(() => Compile(request)); } // MetadataReference cache keyed by assembly path. Reusing the same instances, both within one // reload and across successive reloads, lets Roslyn cache each assembly's parsed metadata // instead of re-reading all ~190 DLLs per compilation (~830 ms each). Static: cleared by // domain reload, which is when the loaded-assembly set can change. static readonly System.Collections.Concurrent.ConcurrentDictionary s_ReferenceCache = new System.Collections.Concurrent.ConcurrentDictionary(); static MetadataReference GetCachedReference(string path) => s_ReferenceCache.GetOrAdd(path, p => MetadataReference.CreateFromFile(p)); // The internal BinderFlags.IgnoreAccessibility is the only way to disable Roslyn's // compile-time access checks (no public API; [IgnoresAccessChecksTo] is ignored, see Compile). // Resolve the internal CSharpCompilationOptions.WithTopLevelBinderFlags(BinderFlags) once. static bool s_BinderFlagResolved; static MethodInfo s_WithTopLevelBinderFlags; static object s_IgnoreAccessibilityFlag; static CSharpCompilationOptions WithIgnoreAccessibility(CSharpCompilationOptions options) { if (!s_BinderFlagResolved) { s_BinderFlagResolved = true; try { var binderFlags = typeof(CSharpCompilationOptions).Assembly .GetType(typeof(CSharpCompilationOptions).Namespace + ".BinderFlags"); if (binderFlags != null) { s_IgnoreAccessibilityFlag = Enum.Parse(binderFlags, "IgnoreAccessibility"); s_WithTopLevelBinderFlags = typeof(CSharpCompilationOptions).GetMethod( "WithTopLevelBinderFlags", BindingFlags.NonPublic | BindingFlags.Instance); } } catch (Exception ex) { Debug.LogWarning($"Pipeline: could not resolve IgnoreAccessibility binder flag; code-reload " + $"overrides touching non-public members may fail to compile. {ex.Message}"); } } if (s_WithTopLevelBinderFlags != null && s_IgnoreAccessibilityFlag != null) return (CSharpCompilationOptions)s_WithTopLevelBinderFlags.Invoke(options, new[] { s_IgnoreAccessibilityFlag }); return options; } /// /// Get metadata references for compilation with optional additional assembly prefixes. /// Handles both Editor and Runtime scenarios with appropriate filtering. Reference instances /// are cached by path (see ) so repeated compilations are cheap. /// /// Extra assembly name prefixes to include as references. /// The metadata references to compile against. public static List GetMetadataReferences(string[] additionalPrefixes = null) { var references = new List(); var assemblies = PipelineUtils.GetLoadedAssemblies() .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(PipelineUtils.GetLoadedAssemblyPath(a))); if (Application.isEditor) { // Editor: Include all loaded assemblies for maximum compatibility foreach (var assembly in assemblies) { try { references.Add(GetCachedReference(PipelineUtils.GetLoadedAssemblyPath(assembly))); } catch { // Skip problematic assemblies } } } else { // Runtime: Use curated filtering for Unity + user assemblies var allowedPrefixes = new List { "UnityEngine", "Assembly-CSharp", "netstandard", "mscorlib", "System.", "Unity.Pipeline" }; // Add any additional prefixes (e.g., for code reload: "UnityPipeline.Microsoft.CodeAnalysis") if (additionalPrefixes != null) { allowedPrefixes.AddRange(additionalPrefixes); } foreach (var assembly in assemblies) { try { var assemblyName = assembly.GetName().Name; if (allowedPrefixes.Any(prefix => assemblyName.StartsWith(prefix))) { references.Add(GetCachedReference(PipelineUtils.GetLoadedAssemblyPath(assembly))); } } catch { // Skip problematic assemblies } } } return references; } /// /// Convert Roslyn diagnostics to DiagnosticInfo list with optional line number adjustment. /// Handles line number offset for wrapped code scenarios. /// private static List ConvertDiagnostics(IEnumerable diagnostics, int lineOffset = 0) { var result = new List(); foreach (var diagnostic in diagnostics.Where(d => d.Severity >= DiagnosticSeverity.Warning)) { var location = diagnostic.Location.GetLineSpan(); var line = Math.Max(0, location.StartLinePosition.Line - lineOffset); var column = location.StartLinePosition.Character; var info = new DiagnosticInfo { Severity = diagnostic.Severity.ToString().ToLower(), Message = diagnostic.GetMessage(), Line = line, Column = column, Id = diagnostic.Id }; // #line directives (code reload maps rewritten bodies back to the user's file) give // the user a location they can open; the raw line text shows what was compiled. var mapped = diagnostic.Location.GetMappedLineSpan(); if (mapped.HasMappedPath) { info.File = mapped.Path; info.FileLine = mapped.StartLinePosition.Line + 1; } var text = diagnostic.Location.SourceTree?.GetText(); var rawLine = location.StartLinePosition.Line; if (text != null && rawLine >= 0 && rawLine < text.Lines.Count) info.Source = text.Lines[rawLine].ToString().Trim(); result.Add(info); } return result; } #else /// /// Runtime compilation not supported on this platform. /// Desktop development builds only (Windows/Mac/Linux). /// /// Unused on this build. /// A failure result explaining runtime compilation isn't supported on this build. public static CompilationResult Compile(CompilationRequest request) { return new CompilationResult { Success = false, Diagnostics = new List { new DiagnosticInfo { Severity = "error", Message = "Runtime code compilation only supported on Desktop development builds", Line = 0, Column = 0, Id = "PLATFORM001" } } }; } /// /// Runtime compilation not supported on this platform. /// /// Unused on this build. /// A completed task carrying a failure result. public static Task CompileAsync(CompilationRequest request) { return Task.FromResult(Compile(request)); } /// /// Runtime compilation not supported on this platform. /// /// Unused on this build. /// An empty list. public static List GetMetadataReferences(string[] additionalPrefixes = null) { return new List(); } #endif // Preprocessor symbols the surrounding project was compiled with. Reload/eval sources must // parse under the same defines as the assemblies they patch, or every #if UNITY_EDITOR / // ENABLE_INPUT_SYSTEM / UNITY_STANDALONE region resolves to the wrong branch. The editor // snapshots its live define set; a Mono dev player has no runtime API for it, so callers // that care (the editor-side push path) pass explicit symbols. // // Kept outside the desktop-dev #if region: the parse sites that consume this // (InPlaceReloadProcessor, SourceCodeTransformer) also compile on // release standalone, where the region above is compiled out. static string[] s_LastProjectDefines = Array.Empty(); static string[] ProjectDefines() { #if UNITY_EDITOR // Editor API — main thread only. Off-thread callers reuse the last snapshot. try { s_LastProjectDefines = UnityEditor.EditorUserBuildSettings.activeScriptCompilationDefines; } catch { } #endif return s_LastProjectDefines; } /// /// Seed the define snapshot from the main thread. Called at editor load /// (PipelineServerStartup's [InitializeOnLoad] ctor) so a background compile that runs /// before any main-thread parse doesn't see an EMPTY define set — its #if UNITY_EDITOR / /// UNITY_STANDALONE regions would resolve to the wrong branch. /// internal static void SnapshotProjectDefines() => ProjectDefines(); /// Parse options carrying , or the project's /// define set (see ) when null. Every parse of user source in the /// reload/eval pipeline should use this so #if regions match the running assemblies. internal static CSharpParseOptions ProjectParseOptions(string[] preprocessorSymbols = null) => CSharpParseOptions.Default.WithPreprocessorSymbols(preprocessorSymbols ?? ProjectDefines()); } /// /// Request for Roslyn compilation with customizable options. /// public class CompilationRequest { /// /// Source code to compile. /// public string SourceCode { get; set; } /// /// Name for the generated assembly (should be unique). /// public string AssemblyName { get; set; } /// /// Additional assembly prefixes to include in metadata references. /// Useful for code reload scenarios that need "UnityPipeline.Microsoft.CodeAnalysis" etc. /// public string[] AdditionalAssemblyPrefixes { get; set; } /// /// Preprocessor symbols to parse with. Null = the project's define set /// (), so #if regions in the source /// resolve the same way they did when the running assemblies were compiled. The push-to-player /// path passes the target player's defines explicitly, and run_script's /// defines parameter arrives here too. /// internal string[] PreprocessorSymbols { get; set; } /// /// Line number offset to subtract from diagnostic line numbers. /// Used when source code has wrapper code that should be hidden from diagnostics. /// public int LineNumberOffset { get; set; } /// /// When true, emit a portable PDB and load the assembly with its symbols so an attached /// managed debugger can bind breakpoints. Compiles unoptimized. Default false (no symbols). /// public bool EmitDebugInformation { get; set; } /// /// When true, emit the portable PDB inside the assembly itself (PE debug directory) instead /// of as separate . Compiles unoptimized, like /// . Use for the interpreter push path, which ships a single /// byte[] to the player — the IlInterpreter interpreter resolves error line numbers only from an /// embedded PDB. /// internal bool EmbedDebugInformation { get; set; } /// /// Document path recorded in the syntax tree when or /// is set. /// Source that is not covered by explicit #line directives maps to this path. /// public string DocumentPath { get; set; } /// /// When true, compile and collect diagnostics but do NOT load the emitted assembly into /// the domain: stays null; /// is still returned. Two callers need this: /// compile-only paths such as run_script's dry_run (in-memory assemblies are /// not unloadable under Mono, so loading would leak one per compile), and the IL2CPP-safe /// interpreter path, which runs the raw bytes directly — Assembly.Load is /// unavailable under IL2CPP. Default false — existing callers keep the load behavior. /// public bool SkipLoad { get; set; } /// /// When true, referenced assemblies' non-public members are imported and access checks against them /// are relaxed (). Interpreter-backend compiles only: the /// interpreter executes the emitted IL via reflection, which reaches non-public members. Never set /// this for IL that will be Assembly.Load'd — Mono JIT-enforces accessibility on loaded IL, /// so the access would compile and then throw at first dispatch. Off by default. /// internal bool AllowNonPublicMemberAccess { get; set; } } /// /// Result from Roslyn compilation. /// public class CompilationResult { /// /// Whether compilation was successful. /// public bool Success { get; set; } /// /// Compiled assembly (only available if Success = true and /// was not set). /// public Assembly Assembly { get; set; } /// /// Raw assembly bytes (for potential disk saving or caching). /// public byte[] AssemblyBytes { get; set; } /// /// Portable PDB bytes when was set; /// null otherwise. /// public byte[] PdbBytes { get; set; } /// /// Compilation diagnostics (errors, warnings, info). /// public List Diagnostics { get; set; } = new List(); } }