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 { /// /// Compiles transformed code reload source using the shared RoslynCompilationService. /// Creates versioned DLLs and manages code reload assembly lifecycle. /// static class CodeReloadCompiler { /// /// 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. /// internal static bool EmitSourceLineInfo = true; #if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG) private static readonly Dictionary _versionTracker = new(); private const string CodeReloadTempDir = "Temp/CodeReload"; /// /// Compile source code on main thread synchronously to avoid deadlocks. /// /// Code reload source code to compile. /// Base filename for versioned assembly naming. /// Optional directory to save compiled assembly to disk. /// Emit a portable PDB and load symbols so breakpoints can bind. /// Source document path recorded in the PDB (the original .cs file). /// When set, the compiled bytes register with the interpreter backend instead of Assembly.Load. /// The compilation result. 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 registeredMethods; List 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); } } /// /// Clean up old code reload DLL versions, keeping only the latest for each base filename. /// /// Directory containing code-reload assemblies, or null for the default temp directory. /// Which files were deleted, and whether cleanup succeeded. public static CodeReloadCleanupResult CleanupCodeReloadDlls(string assemblyDir = null) { var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var deletedFiles = new List(); 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 }; } } /// /// Get the next version number for a base filename. /// private static int GetNextVersion(string baseFilename) { if (!_versionTracker.ContainsKey(baseFilename)) { _versionTracker[baseFilename] = 0; } return ++_versionTracker[baseFilename]; } /// /// 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; /// is recorded for source outside the transform's #line /// directives. /// internal static bool TryCompileOverrideBytes(string transformedCode, string baseFileName, out byte[] ilBytes, out List diagnostics, string documentPath = null, string[] preprocessorSymbols = null) { ilBytes = null; diagnostics = new List(); 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; } /// /// 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. /// 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; } /// /// 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). /// 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; } } /// /// Compile code reload source code using shared RoslynCompilationService. /// Synchronous compilation for main thread use. /// 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); } /// /// 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 /// with a user-facing reason. /// private static List RegisterCodeReloadMethods(Assembly assembly, string assemblyId, out List skipped) { var registeredMethods = new List(); skipped = new List(); 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(); 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; } /// /// Extract base filename from versioned filename (e.g., "PlayerMovement_001" -> "PlayerMovement"). /// 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; } /// /// Extract version number from versioned filename (e.g., "PlayerMovement_001" -> 1). /// 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)."; /// /// Code reload source compilation (in-place editing) not supported on this build. /// /// Unused on this build. /// Unused on this build. /// Unused on this build. /// Unused on this build. /// Unused on this build. /// Unused on this build. /// A "Platform Not Supported" failure. 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); } /// /// Override compilation to bytes (push path) not supported on this build (no Roslyn). /// /// Unused on this build. /// Unused on this build. /// Always null on this build. /// Always contains the "not supported" message on this build. /// Unused on this build. /// Unused on this build. /// Always false on this build. internal static bool TryCompileOverrideBytes(string transformedCode, string baseFileName, out byte[] ilBytes, out List diagnostics, string documentPath = null, string[] preprocessorSymbols = null) { ilBytes = null; diagnostics = new List { NotSupportedMessage }; return false; } /// /// Code reload cleanup not supported on this build. /// /// Unused on this build. /// A failure result explaining code reload isn't supported on this build. public static CodeReloadCleanupResult CleanupCodeReloadDlls(string assemblyDir = null) { return new CodeReloadCleanupResult { Success = false, Message = NotSupportedMessage, ExecutionTimeMs = 0 }; } #endif } /// /// The override set an interpreter-backed compile registers instead of Assembly.Load: the /// reloaded type plus its surviving method names. Passing one to /// selects the interpreter path. /// internal class InterpreterOverrideSet { public string TypeName { get; set; } public List MethodNames { get; set; } } /// /// Result from code reload compilation operation. /// class CodeReloadCompileResult { /// Whether compilation succeeded. public bool IsSuccess { get; set; } /// Name of the compiled code-reload assembly. public string AssemblyName { get; set; } /// Path the assembly was written to, if saved to disk. public string OutputPath { get; set; } /// Method ids registered as overrides. public List RegisteredMethods { get; set; } = new List(); /// How long compilation took, in milliseconds. public long ExecutionTimeMs { get; set; } /// Error message, if compilation failed. public string Error { get; set; } /// Additional error details for debugging. public string ErrorDetails { get; set; } /// Compiler diagnostics (errors/warnings), if any. public List Diagnostics { get; set; } = new List(); /// Create a successful compile result. /// Name of the compiled assembly. /// Path the assembly was written to, if saved to disk. /// Method ids registered as overrides. /// How long compilation took, in milliseconds. /// A successful result. public static CodeReloadCompileResult Success(string assemblyName, string outputPath, List registeredMethods, long executionTimeMs) { return new CodeReloadCompileResult { IsSuccess = true, AssemblyName = assemblyName, OutputPath = outputPath, RegisteredMethods = registeredMethods, ExecutionTimeMs = executionTimeMs }; } /// Create a failed compile result. /// Error message. /// Additional error details for debugging. /// How long compilation took, in milliseconds. /// Compiler diagnostics (errors/warnings), if any. /// A failed result. public static CodeReloadCompileResult Failure(string error, string errorDetails, long executionTimeMs = 0, List diagnostics = null) { return new CodeReloadCompileResult { IsSuccess = false, Error = error, ErrorDetails = errorDetails, ExecutionTimeMs = executionTimeMs, Diagnostics = diagnostics ?? new List() }; } } /// /// Result from code reload cleanup operation. /// class CodeReloadCleanupResult { /// Whether cleanup succeeded. public bool Success { get; set; } /// Paths of the deleted files. public List DeletedFiles { get; set; } = new List(); /// Human-readable summary or error message. public string Message { get; set; } /// How long cleanup took, in milliseconds. public long ExecutionTimeMs { get; set; } } }