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 { /// /// Main orchestrator for in-place code reload processing. /// Handles the complete pipeline: source parsing -> transformation -> compilation. /// static class InPlaceReloadProcessor { /// /// 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. /// /// Path to source file containing [CodeReload] methods /// Optional directory to save compiled assembly /// Emit debug symbols mapped to the original source so breakpoints bind. /// Compilation result with success/failure and diagnostic information public static Task 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)); } /// /// Process source file on main thread synchronously to avoid deadlocks. /// /// Path to source file containing [CodeReload] methods. /// Optional directory to save compiled assembly. /// Emit debug symbols mapped to the original source so breakpoints bind. /// Route the override through the interpreter backend instead of Assembly.Load. /// Compilation result with success/failure and diagnostic information. 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; } } /// /// What 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. false means a hard failure with /// set. /// private sealed class OverridePreparation { public bool Success; public string Error; public string TypeName; public List MethodNames; public List UpToDateMethods = new List(); public string TransformedCode; public List Diagnostics = new List(); } /// /// Shared prefix for in-place reload and push-to-player: read the file, extract [CodeReload] /// methods, drop the ones still matching the captured (they /// run correct AND faster compiled — /// reports them, and keeps named ones in anyway), reject /// non-public member access when the compiled backend will run the override /// (), and transform bodies into a static override /// class. When every method is up to date, succeeds with an empty /// and a null /// — nothing to compile. /// private static OverridePreparation PrepareOverride( string sourceFilePath, bool pdb, bool enforcePublicAccess, string[] preprocessorSymbols = null, ICollection 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(); 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; } /// /// 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). false /// bypasses the filter — the caller can't vouch that the /// target player was built from the baselined sources. /// internal static PushCompileResult CompileOverrideForPush(string sourceFilePath, string[] preprocessorSymbols = null, ICollection 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; } } /// /// Check if a source file contains [CodeReload] methods. /// Simple synchronous check to avoid async deadlocks in tests. /// /// Path to source file to check /// True if file contains [CodeReload] methods public static Task 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); } } /// /// Read source file content synchronously (for main thread). /// 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; } } /// /// Extract [CodeReload] methods from source code. /// 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() .FirstOrDefault(c => c.DescendantNodes().OfType().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().Where(HasCodeReloadAttribute)) { var owner = method.Ancestors().OfType().FirstOrDefault(); if (owner == null || owner == classDeclaration) continue; var ownerChain = string.Join(".", method.Ancestors() .OfType().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() .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; } } /// /// 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 hashes exactly the methods extraction picks. /// internal static bool HasCodeReloadAttribute(MethodDeclarationSyntax method) { return method.AttributeLists .SelectMany(al => al.Attributes) .Any(a => UnqualifiedAttributeName(a) == "CodeReload"); } /// /// Attribute name as the source wrote it, minus any namespace qualifier and the optional /// "Attribute" suffix — [Foo.BarAttribute] and [Bar] both yield "Bar". /// 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; } /// /// 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. /// 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; } /// /// Extract method body content (excluding braces). /// null when the method has no block body at all (expression-bodied, abstract). /// 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; } /// /// Extract method signature information. /// 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; } /// /// Compile transformed code synchronously (for main thread). /// 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 { ex.ToString() }); } } /// /// Result of extracting [CodeReload] methods from source code. /// private class CodeReloadableExtractionResult { /// /// Name of the class containing [CodeReload] methods. /// public string TypeName { get; set; } /// /// Dictionary of method names to their extracted body code. /// public Dictionary Methods { get; set; } = new Dictionary(); /// /// Dictionary of method names to their signature information. /// public Dictionary MethodSignatures { get; set; } = new Dictionary(); /// /// Whether any [CodeReload] methods were found. /// public bool HasMethods => Methods.Count > 0; /// /// 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. /// public List SkippedMethods { get; } = new List(); } } /// /// Result of in-place code reload processing. /// class InPlaceReloadResult { /// /// Path to the source file that was processed. /// public string SourceFilePath { get; set; } /// /// Whether the processing was successful. /// public bool Success { get; set; } /// /// Name of the original type containing [CodeReload] methods. /// public string OriginalTypeName { get; set; } /// /// List of method names that were extracted and processed. /// public List ExtractedMethods { get; set; } = new List(); /// /// Generated transformed code for code reload assembly. /// public string TransformedCode { get; set; } /// /// Name of the compiled assembly (if successful). /// public string AssemblyName { get; set; } /// /// List of registered method IDs (if successful). /// public List RegisteredMethods { get; set; } = new List(); /// /// [CodeReload] method names skipped because they still match the compiled baseline — /// their compiled bodies keep running (no interpreter cost). /// internal List UpToDateMethods { get; set; } = new List(); /// /// Method ids whose stale override was removed because the source matches the compiled /// baseline again (the edit was reverted). /// internal List RevertedMethods { get; set; } = new List(); /// Every extracted method matched the baseline; nothing was compiled or registered. internal bool AllUpToDate => Success && ExtractedMethods.Count == 0 && UpToDateMethods.Count > 0; /// /// Error message if processing failed. /// public string ErrorMessage { get; set; } /// /// Compilation diagnostics (warnings, errors). /// public List CompilationDiagnostics { get; set; } = new List(); /// /// Execution time in milliseconds. /// public long ExecutionTimeMs { get; set; } } /// /// Result of compiling an override for pushing to a player (no local registration). /// internal class PushCompileResult { public bool Success { get; set; } public string TypeName { get; set; } public List MethodNames { get; set; } = new List(); public byte[] IlBytes { get; set; } public string Error { get; set; } public List Diagnostics { get; set; } = new List(); /// [CodeReload] method names skipped because they still match the compiled baseline. public List UpToDateMethods { get; set; } = new List(); /// Every extracted method matched the baseline; there is nothing to push. public bool AllUpToDate => Success && MethodNames.Count == 0 && UpToDateMethods.Count > 0; } }