// 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; namespace UnrealBuildBase { public static class CompileScriptModule { class Hook : CsProjBuildHook { private Dictionary WriteTimes = new Dictionary(); 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); } 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) { List GameDirectories = GetGameDirectories(ScriptsForProjectFileName); 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)); } /// /// 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) { CsProjBuildHook Hook = new Hook(); // Load existing build records, validating them only if (re)compiling script projects is an option Dictionary ExistingBuildRecords = LoadExistingBuildRecords(BaseDirectories); Dictionary ValidBuildRecords = new Dictionary(ExistingBuildRecords.Count); Dictionary InvalidBuildRecords = new Dictionary(ExistingBuildRecords.Count); foreach (FileReference Project in FoundProjects) { ValidateBuildRecordRecursively(ValidBuildRecords, InvalidBuildRecords, ExistingBuildRecords, Project, Hook); } // 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) { CsProjBuildHook Hook = new Hook(); bool bUseBuildRecordsOnlyForProjectDiscovery = bNoCompile || Unreal.IsEngineInstalled(); // Load existing build records, validating them only if (re)compiling script projects is an option Dictionary ExistingBuildRecords = LoadExistingBuildRecords(BaseDirectories); 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); } } 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 Log.TraceWarning($"Script module \"{TargetPath}\" not found for record \"{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) { Log.TraceLog($"Deleting invalid build record \"{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); } /// /// 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) { return CsProjBuilder.Build(FoundProjects, bForceCompile, out bBuildSuccess, Hook, BaseDirectories, OnBuildingProjects); } /// /// Find and load existing build record .json files from any Intermediate/ScriptModules found in the provided lists /// /// /// static Dictionary LoadExistingBuildRecords(List BaseDirectories) { 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)); Log.TraceLog($"Loaded script module build record {JsonFile}"); } catch(Exception Ex) { Log.TraceWarning($"[{JsonFile}] Failed to load build record: {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 Log.TraceWarning($"Deleting invalid build record {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) { 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)) { Log.TraceLog($"Found project {ProjectPath} with no existing build record"); 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); Log.TraceLog($"[{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); if (!ValidBuildRecords.ContainsKey(FullProjectPath)) { string ProjectRelativePath = Path.GetRelativePath(Unreal.EngineDirectory.FullName, ProjectPath.FullName); string DependencyRelativePath = Path.GetRelativePath(Unreal.EngineDirectory.FullName, FullProjectPath.FullName); Log.TraceLog($"[{ProjectRelativePath}] Existing output is not valid because dependency {DependencyRelativePath} is not valid"); 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); Log.TraceLog($"[{ProjectRelativePath}] Existing output is not valid because dependency {DependencyRelativePath} is newer"); InvalidBuildRecords.Add(ProjectPath, Entry); return; } } ValidBuildRecords.Add(ProjectPath, Entry); } static List GetGameDirectories(string? ScriptsForProjectFileName) { List GameDirectories = new List(); if (String.IsNullOrEmpty(ScriptsForProjectFileName)) { GameDirectories = NativeProjectsBase.EnumerateProjectFiles().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(); } }