// Copyright Epic Games, Inc. All Rights Reserved. using EpicGames.Core; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Text.Json; using Microsoft.Build.Shared; using EpicGames.MsBuild; using Microsoft.Extensions.Logging; namespace UnrealBuildBase { public static class CompileScriptModule { class Hook : CsProjBuildHook { private ILogger Logger; private Dictionary WriteTimes = new Dictionary(); public Hook(ILogger InLogger) { Logger = InLogger; } public DateTime GetLastWriteTime(DirectoryReference BasePath, string RelativeFilePath) { return GetLastWriteTime(BasePath.FullName, RelativeFilePath); } public DateTime GetLastWriteTime(string BasePath, string RelativeFilePath) { string NormalizedPath = Path.GetFullPath(RelativeFilePath, BasePath); if (!WriteTimes.TryGetValue(NormalizedPath, out DateTime WriteTime)) { WriteTimes.Add(NormalizedPath, WriteTime = File.GetLastWriteTime(NormalizedPath)); } return WriteTime; } public void ValidateRecursively( Dictionary ValidBuildRecords, Dictionary InvalidBuildRecords, Dictionary BuildRecords, FileReference ProjectPath) { CompileScriptModule.ValidateBuildRecordRecursively(ValidBuildRecords, InvalidBuildRecords, BuildRecords, ProjectPath, this, Logger); } public bool HasWildcards(string FileSpec) { return FileMatcher.HasWildcards(FileSpec); } DirectoryReference CsProjBuildHook.EngineDirectory => Unreal.EngineDirectory; DirectoryReference CsProjBuildHook.DotnetDirectory => Unreal.DotnetDirectory; FileReference CsProjBuildHook.DotnetPath => Unreal.DotnetPath; } /// /// Return the target paths from the collection of build records /// /// Input build records /// Set of target files public static HashSet GetTargetPaths(IReadOnlyDictionary BuildRecords) { return new HashSet(BuildRecords.Select(x => GetTargetPath(x))); } /// /// Return the target path for the given build record /// /// Build record /// File reference for the target public static FileReference GetTargetPath(KeyValuePair BuildRecord) { return FileReference.Combine(BuildRecord.Key.Directory, BuildRecord.Value.BuildRecord.TargetPath!); } /// /// Locates script modules, builds them if necessary, returns set of .dll files /// /// /// /// /// /// /// /// /// Action to invoke when projects get built /// Collection of all the projects. They will have been compiled. public static HashSet InitializeScriptModules(Rules.RulesFileType RulesFileType, string? ScriptsForProjectFileName, List? AdditionalScriptsFolders, bool bForceCompile, bool bNoCompile, bool bUseBuildRecords, out bool bBuildSuccess, Action OnBuildingProjects, ILogger Logger) { List GameDirectories = GetGameDirectories(ScriptsForProjectFileName, Logger); List AdditionalDirectories = GetAdditionalDirectories(AdditionalScriptsFolders); List GameBuildDirectories = GetAdditionalBuildDirectories(GameDirectories); // List of directories used to locate Intermediate/ScriptModules dirs for writing build records List BaseDirectories = new List(1 + GameDirectories.Count + AdditionalDirectories.Count); BaseDirectories.Add(Unreal.EngineDirectory); BaseDirectories.AddRange(GameDirectories); BaseDirectories.AddRange(AdditionalDirectories); HashSet FoundProjects = new HashSet( Rules.FindAllRulesSourceFiles(RulesFileType, // Project scripts require source engine builds GameFolders: Unreal.IsEngineInstalled() ? GameDirectories : new List(), ForeignPlugins: null, AdditionalSearchPaths: AdditionalDirectories.Concat(GameBuildDirectories).ToList())); return GetTargetPaths(Build(RulesFileType, FoundProjects, BaseDirectories, bForceCompile, bNoCompile, bUseBuildRecords, out bBuildSuccess, OnBuildingProjects, Logger)); } /// /// Test to see if all the given projects are up-to-date /// /// Collection of projects to test /// Base directories of the projects /// True if all of the projects are up to date public static bool AreScriptModulesUpToDate(HashSet FoundProjects, List BaseDirectories, ILogger Logger) { CsProjBuildHook Hook = new Hook(Logger); // Load existing build records, validating them only if (re)compiling script projects is an option Dictionary ExistingBuildRecords = LoadExistingBuildRecords(BaseDirectories, Logger); Dictionary ValidBuildRecords = new Dictionary(ExistingBuildRecords.Count); Dictionary InvalidBuildRecords = new Dictionary(ExistingBuildRecords.Count); foreach (FileReference Project in FoundProjects) { ValidateBuildRecordRecursively(ValidBuildRecords, InvalidBuildRecords, ExistingBuildRecords, Project, Hook, Logger); } // If all found records are valid, we can return their targets directly return FoundProjects.All(x => ValidBuildRecords.ContainsKey(x)); } /// /// Locates script modules, builds them if necessary, returns set of .dll files /// /// /// Projects to be compiled /// Base directories for all the projects /// /// /// /// /// Action to invoke when projects get built /// /// Collection of all the projects. They will have been compiled. public static Dictionary Build(Rules.RulesFileType RulesFileType, HashSet FoundProjects, List BaseDirectories, bool bForceCompile, bool bNoCompile, bool bUseBuildRecords, out bool bBuildSuccess, Action OnBuildingProjects, ILogger Logger) { CsProjBuildHook Hook = new Hook(Logger); bool bUseBuildRecordsOnlyForProjectDiscovery = bNoCompile || Unreal.IsEngineInstalled(); // Load existing build records, validating them only if (re)compiling script projects is an option Dictionary ExistingBuildRecords = LoadExistingBuildRecords(BaseDirectories, Logger); Dictionary ValidBuildRecords = new Dictionary(ExistingBuildRecords.Count); Dictionary InvalidBuildRecords = new Dictionary(ExistingBuildRecords.Count); if (bUseBuildRecords) { foreach (FileReference Project in FoundProjects) { ValidateBuildRecordRecursively(ValidBuildRecords, InvalidBuildRecords, ExistingBuildRecords, Project, Hook, Logger); } } if (bUseBuildRecordsOnlyForProjectDiscovery) { string FilterExtension = String.Empty; switch (RulesFileType) { case Rules.RulesFileType.AutomationModule: FilterExtension = ".Automation.json"; break; case Rules.RulesFileType.UbtPlugin: FilterExtension = ".ubtplugin.json"; break; default: throw new Exception("Unsupported rules file type"); } Dictionary OutRecords = new Dictionary(ExistingBuildRecords.Count); foreach (KeyValuePair Record in ExistingBuildRecords.Where(x => x.Value.BuildRecordPath.HasExtension(FilterExtension))) { FileReference TargetPath = FileReference.Combine(Record.Key.Directory, Record.Value.BuildRecord.TargetPath!); if (FileReference.Exists(TargetPath)) { OutRecords.Add(Record.Key, Record.Value); } else { if (bNoCompile) { // when -NoCompile is on the command line, try to run with whatever is available Logger.LogWarning("Script module \"{TargetPath}\" not found for record \"{BuildRecordPath}\"", TargetPath, Record.Value.BuildRecordPath); } else { // when the engine is installed, expect to find a built target assembly for every record that was found throw new Exception($"Script module \"{TargetPath}\" not found for record \"{Record.Value.BuildRecordPath}\""); } } } bBuildSuccess = true; return OutRecords; } else { // when the engine is not installed, delete any build record .json file that is not valid foreach ((CsProjBuildRecord _, FileReference BuildRecordPath) in InvalidBuildRecords.Values) { if (BuildRecordPath != null) { Logger.LogDebug("Deleting invalid build record \"{BuildRecordPath}\"", BuildRecordPath); FileReference.Delete(BuildRecordPath); } } } if (!bForceCompile && bUseBuildRecords) { // If all found records are valid, we can return their targets directly if (FoundProjects.All(x => ValidBuildRecords.ContainsKey(x))) { bBuildSuccess = true; return new Dictionary(ValidBuildRecords.Where(x => FoundProjects.Contains(x.Key))); } } // Fall back to the slower approach: use msbuild to load csproj files & build as necessary return Build(FoundProjects, bForceCompile || !bUseBuildRecords, out bBuildSuccess, Hook, BaseDirectories, OnBuildingProjects, Logger); } /// /// This method exists purely to prevent EpicGames.MsBuild from being loaded until the absolute last moment. /// If it is placed in the caller directly, then when the caller is invoked, the assembly will be loaded resulting /// in the possible Microsoft.Build.Framework load issue later on is this method isn't invoked. /// static Dictionary Build(HashSet FoundProjects, bool bForceCompile, out bool bBuildSuccess, CsProjBuildHook Hook, List BaseDirectories, Action OnBuildingProjects, ILogger Logger) { return CsProjBuilder.Build(FoundProjects, bForceCompile, out bBuildSuccess, Hook, BaseDirectories, OnBuildingProjects, Logger); } /// /// Find and load existing build record .json files from any Intermediate/ScriptModules found in the provided lists /// /// /// static Dictionary LoadExistingBuildRecords(List BaseDirectories, ILogger Logger) { Dictionary LoadedBuildRecords = new Dictionary(); foreach (DirectoryReference Directory in BaseDirectories) { DirectoryReference IntermediateDirectory = DirectoryReference.Combine(Directory, "Intermediate", "ScriptModules"); if (!DirectoryReference.Exists(IntermediateDirectory)) { continue; } foreach (FileReference JsonFile in DirectoryReference.EnumerateFiles(IntermediateDirectory, "*.json")) { CsProjBuildRecord? BuildRecord = default; // filesystem errors or json parsing might result in an exception. If that happens, we fall back to the // slower path - if compiling, buildrecord files will be re-generated; other filesystem errors may persist try { BuildRecord = JsonSerializer.Deserialize(FileReference.ReadAllText(JsonFile)); Logger.LogDebug("Loaded script module build record {JsonFile}", JsonFile); } catch(Exception Ex) { Logger.LogWarning("[{JsonFile}] Failed to load build record: {Message}", JsonFile, Ex.Message); } if (BuildRecord != null && BuildRecord.ProjectPath != null) { LoadedBuildRecords.Add(FileReference.FromString(Path.GetFullPath(BuildRecord.ProjectPath, JsonFile.Directory.FullName)), (BuildRecord, JsonFile)); } else { // Delete the invalid build record Logger.LogWarning("Deleting invalid build record {JsonFile}", JsonFile); } } } return LoadedBuildRecords; } private static bool ValidateGlobbedFiles(DirectoryReference ProjectDirectory, List Globs, HashSet GlobbedDependencies, out string ValidationFailureMessage) { // First, evaluate globs // Files are grouped by ItemType (e.g. Compile, EmbeddedResource) to ensure that Exclude and // Remove act as expected. Dictionary> Files = new Dictionary>(); foreach (CsProjBuildRecord.Glob Glob in Globs) { HashSet? TypedFiles; if (!Files.TryGetValue(Glob.ItemType!, out TypedFiles)) { TypedFiles = new HashSet(); Files.Add(Glob.ItemType!, TypedFiles); } foreach (string IncludePath in Glob.Include!) { TypedFiles.UnionWith(FileMatcher.Default.GetFiles(ProjectDirectory.FullName, IncludePath, Glob.Exclude)); } foreach (string Remove in Glob.Remove!) { // FileMatcher.IsMatch() doesn't handle inconsistent path separators correctly - which is why globs // are normalized when they are added to CsProjBuildRecord TypedFiles.RemoveWhere(F => FileMatcher.IsMatch(F, Remove)); } } // Then, validation that our evaluation matches what we're comparing against bool bValid = true; StringBuilder ValidationFailureText = new StringBuilder(); // Look for extra files that were found foreach (HashSet TypedFiles in Files.Values) { foreach (string File in TypedFiles) { if (!GlobbedDependencies.Contains(File)) { ValidationFailureText.AppendLine($"Found additional file {File}"); bValid = false; } } } // Look for files that are missing foreach (string File in GlobbedDependencies) { bool bFound = false; foreach (HashSet TypedFiles in Files.Values) { if (TypedFiles.Contains(File)) { bFound = true; break; } } if (!bFound) { ValidationFailureText.AppendLine($"Did not find {File}"); bValid = false; } } ValidationFailureMessage = ValidationFailureText.ToString(); return bValid; } private static bool ValidateBuildRecord(CsProjBuildRecord BuildRecord, DirectoryReference ProjectDirectory, out string ValidationFailureMessage, CsProjBuildHook Hook) { string TargetRelativePath = Path.GetRelativePath(Unreal.EngineDirectory.FullName, BuildRecord.TargetPath!); if (BuildRecord.Version != CsProjBuildRecord.CurrentVersion) { ValidationFailureMessage = $"version does not match: build record has version {BuildRecord.Version}; current version is {CsProjBuildRecord.CurrentVersion}"; return false; } DateTime TargetWriteTime = Hook.GetLastWriteTime(ProjectDirectory, BuildRecord.TargetPath!); if (BuildRecord.TargetBuildTime != TargetWriteTime) { ValidationFailureMessage = $"recorded target build time ({BuildRecord.TargetBuildTime}) does not match {TargetRelativePath} ({TargetWriteTime})"; return false; } foreach (string Dependency in BuildRecord.Dependencies) { if (Hook.GetLastWriteTime(ProjectDirectory, Dependency) > TargetWriteTime) { ValidationFailureMessage = $"{Dependency} is newer than {TargetRelativePath}"; return false; } } if (!ValidateGlobbedFiles(ProjectDirectory, BuildRecord.Globs, BuildRecord.GlobbedDependencies, out ValidationFailureMessage)) { return false; } foreach (string Dependency in BuildRecord.GlobbedDependencies) { if (Hook.GetLastWriteTime(ProjectDirectory, Dependency) > TargetWriteTime) { ValidationFailureMessage = $"{Dependency} is newer than {TargetRelativePath}"; return false; } } return true; } static void ValidateBuildRecordRecursively( Dictionary ValidBuildRecords, Dictionary InvalidBuildRecords, Dictionary BuildRecords, FileReference ProjectPath, CsProjBuildHook Hook, ILogger Logger) { if (ValidBuildRecords.ContainsKey(ProjectPath) || InvalidBuildRecords.ContainsKey(ProjectPath)) { // Project validity has already been determined return; } // Was a build record loaded for this project path? (relevant when considering referenced projects) if (!BuildRecords.TryGetValue(ProjectPath, out (CsProjBuildRecord BuildRecord, FileReference BuildRecordPath) Entry)) { Logger.LogDebug("Found project {ProjectPath} with no existing build record", ProjectPath); return; } // Is this particular build record valid? if (!ValidateBuildRecord(Entry.BuildRecord, ProjectPath.Directory, out string ValidationFailureMessage, Hook)) { string ProjectRelativePath = Path.GetRelativePath(Unreal.EngineDirectory.FullName, ProjectPath.FullName); Logger.LogDebug("[{ProjectRelativePath}] {ValidationFailureMessage}", ProjectRelativePath, ValidationFailureMessage); InvalidBuildRecords.Add(ProjectPath, Entry); return; } // Are all referenced build records valid? foreach (string ReferencedProjectPath in Entry.BuildRecord.ProjectReferences) { FileReference FullProjectPath = FileReference.FromString(Path.GetFullPath(ReferencedProjectPath, ProjectPath.Directory.FullName)); ValidateBuildRecordRecursively(ValidBuildRecords, InvalidBuildRecords, BuildRecords, FullProjectPath, Hook, Logger); if (!ValidBuildRecords.ContainsKey(FullProjectPath)) { string ProjectRelativePath = Path.GetRelativePath(Unreal.EngineDirectory.FullName, ProjectPath.FullName); string DependencyRelativePath = Path.GetRelativePath(Unreal.EngineDirectory.FullName, FullProjectPath.FullName); Logger.LogDebug("[{ProjectRelativePath}] Existing output is not valid because dependency {DependencyRelativePath} is not valid", ProjectRelativePath, DependencyRelativePath); InvalidBuildRecords.Add(ProjectPath, Entry); return; } // Ensure that the dependency was not built more recently than the project if (Entry.BuildRecord.TargetBuildTime < ValidBuildRecords[FullProjectPath].BuildRecord.TargetBuildTime) { string ProjectRelativePath = Path.GetRelativePath(Unreal.EngineDirectory.FullName, ProjectPath.FullName); string DependencyRelativePath = Path.GetRelativePath(Unreal.EngineDirectory.FullName, FullProjectPath.FullName); Logger.LogDebug("[{ProjectRelativePath}] Existing output is not valid because dependency {DependencyRelativePath} is newer", ProjectRelativePath, DependencyRelativePath); InvalidBuildRecords.Add(ProjectPath, Entry); return; } } ValidBuildRecords.Add(ProjectPath, Entry); } static List GetGameDirectories(string? ScriptsForProjectFileName, ILogger Logger) { List GameDirectories = new List(); if (String.IsNullOrEmpty(ScriptsForProjectFileName)) { GameDirectories = NativeProjectsBase.EnumerateProjectFiles(Logger).Select(x => x.Directory).ToList(); } else { DirectoryReference ScriptsDir = new DirectoryReference(Path.GetDirectoryName(ScriptsForProjectFileName)!); ScriptsDir = DirectoryReference.FindCorrectCase(ScriptsDir); GameDirectories.Add(ScriptsDir); } return GameDirectories; } static List GetAdditionalDirectories(List? AdditionalScriptsFolders) => AdditionalScriptsFolders == null ? new List() : AdditionalScriptsFolders.Select(x => DirectoryReference.FindCorrectCase(new DirectoryReference(x))).ToList(); static List GetAdditionalBuildDirectories(List GameDirectories) => GameDirectories.Select(x => DirectoryReference.Combine(x, "Build")).Where(x => DirectoryReference.Exists(x)).ToList(); } }