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();
}
}