// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using EpicGames.Core; using EpicGames.Perforce; using Microsoft.Extensions.Logging; namespace UnrealGameSync { public class TargetReceipt { public string? Configuration { get; set; } public string? Launch { get; set; } public string? LaunchCmd { get; set; } public static bool TryRead(FileReference location, DirectoryReference? engineDir, DirectoryReference? projectDir, [NotNullWhen(true)] out TargetReceipt? receipt) { if (Utility.TryLoadJson(location, out receipt)) { receipt.Launch = ExpandReceiptVariables(receipt.Launch, engineDir, projectDir); receipt.LaunchCmd = ExpandReceiptVariables(receipt.LaunchCmd, engineDir, projectDir); return true; } return false; } [return: NotNullIfNotNull("line")] private static string? ExpandReceiptVariables(string? line, DirectoryReference? engineDir, DirectoryReference? projectDir) { string? expandedLine = line; if (expandedLine != null) { if (engineDir != null) { expandedLine = expandedLine.Replace("$(EngineDir)", engineDir.FullName, StringComparison.OrdinalIgnoreCase); } if (projectDir != null) { expandedLine = expandedLine.Replace("$(ProjectDir)", projectDir.FullName, StringComparison.OrdinalIgnoreCase); } } return expandedLine; } } public static class ConfigUtils { public static string HostPlatform { get; } = GetHostPlatform(); public static string HostArchitectureSuffix { get; } = String.Empty; static string GetHostPlatform() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { return "Win64"; } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { return "Mac"; } else { return "Linux"; } } public static Task ReadProjectConfigFileAsync(IPerforceConnection perforce, ProjectInfo projectInfo, ILogger logger, CancellationToken cancellationToken) { return ReadProjectConfigFileAsync(perforce, projectInfo, new List>(), logger, cancellationToken); } public static Task ReadProjectConfigFileAsync(IPerforceConnection perforce, ProjectInfo projectInfo, List> localConfigFiles, ILogger logger, CancellationToken cancellationToken) { return ReadProjectConfigFileAsync(perforce, projectInfo.ClientRootPath, projectInfo.ClientFileName, projectInfo.CacheFolder, localConfigFiles, logger, cancellationToken); } public static async Task ReadProjectConfigFileAsync(IPerforceConnection perforce, string branchClientPath, string selectedClientFileName, DirectoryReference cacheFolder, List> localConfigFiles, ILogger logger, CancellationToken cancellationToken) { List configFilePaths = Utility.GetDepotConfigPaths(branchClientPath + "/Engine", selectedClientFileName); ConfigFile projectConfig = new ConfigFile(); List> responses = await perforce.TryFStatAsync(FStatOptions.IncludeFileSizes, configFilePaths, cancellationToken).ToListAsync(cancellationToken); foreach (PerforceResponse response in responses) { if (response.Succeeded) { string[]? lines = null; // Skip file records which are still in the workspace, but were synced from a different branch. For these files, the action seems to be empty, so filter against that. FStatRecord fileRecord = response.Data; if (fileRecord.HeadAction == FileAction.None) { continue; } // If this file is open for edit, read the local version string? localFileName = fileRecord.ClientFile; if (localFileName != null && File.Exists(localFileName) && (File.GetAttributes(localFileName) & FileAttributes.ReadOnly) == 0) { try { DateTime lastModifiedTime = File.GetLastWriteTimeUtc(localFileName); localConfigFiles.Add(new KeyValuePair(new FileReference(localFileName), lastModifiedTime)); lines = await File.ReadAllLinesAsync(localFileName, cancellationToken); } catch (Exception ex) { logger.LogInformation(ex, "Failed to read local config file for {Path}", localFileName); } } // Otherwise try to get it from perforce if (lines == null && fileRecord.DepotFile != null) { lines = await Utility.TryPrintFileUsingCacheAsync(perforce, fileRecord.DepotFile, cacheFolder, fileRecord.Digest, logger, cancellationToken); } // Merge the text with the config file if (lines != null) { try { projectConfig.Parse(lines.ToArray()); logger.LogDebug("Read config file from {DepotFile}", fileRecord.DepotFile); } catch (Exception ex) { logger.LogInformation(ex, "Failed to read config file from {DepotFile}", fileRecord.DepotFile); } } } } return projectConfig; } public static async Task> ReadConfigFiles(IPerforceConnection perforce, IEnumerable depotPaths, List> localFiles, DirectoryReference cacheFolder, ILogger logger, CancellationToken cancellationToken) { List contents = new List(); List> responses = await perforce.TryFStatAsync(FStatOptions.IncludeFileSizes, depotPaths.ToArray(), cancellationToken).ToListAsync(cancellationToken); foreach (PerforceResponse response in responses) { if (response.Succeeded) { string[]? lines = null; // Skip file records which are still in the workspace, but were synced from a different branch. For these files, the action seems to be empty, so filter against that. FStatRecord fileRecord = response.Data; if (fileRecord.HeadAction == FileAction.None) { continue; } // If this file is open for edit, read the local version string? localFileName = fileRecord.ClientFile; if (localFileName != null && File.Exists(localFileName) && (File.GetAttributes(localFileName) & FileAttributes.ReadOnly) == 0) { try { DateTime lastModifiedTime = File.GetLastWriteTimeUtc(localFileName); localFiles.Add(new KeyValuePair(new FileReference(localFileName), lastModifiedTime)); lines = await File.ReadAllLinesAsync(localFileName, cancellationToken); } catch (Exception ex) { logger.LogInformation(ex, "Failed to read local config file for {Path}", localFileName); } } // Otherwise try to get it from perforce if (lines == null && fileRecord.DepotFile != null) { lines = await Utility.TryPrintFileUsingCacheAsync(perforce, fileRecord.DepotFile, cacheFolder, fileRecord.Digest, logger, cancellationToken); } // Merge the text with the config file if (lines != null) { contents.Add(lines); } } } return contents; } public static FileReference GetEditorTargetFile(ProjectInfo projectInfo, ConfigFile projectConfig) { if (projectInfo.ProjectPath.EndsWith(".uproject", StringComparison.OrdinalIgnoreCase)) { List targetFiles = FindTargets(projectInfo.LocalFileName.Directory); FileReference? targetFile = targetFiles.OrderBy(x => x.FullName, StringComparer.OrdinalIgnoreCase).FirstOrDefault(x => x.FullName.EndsWith("Editor.target.cs", StringComparison.OrdinalIgnoreCase)); if (targetFile != null) { return targetFile; } } string defaultEditorTargetName = GetDefaultEditorTargetName(projectInfo, projectConfig); return FileReference.Combine(projectInfo.LocalRootPath, "Engine", "Source", $"{defaultEditorTargetName}.Target.cs"); } public static FileReference GetEditorReceiptFile(ProjectInfo projectInfo, ConfigFile projectConfig, BuildConfig config) { FileReference targetFile = GetEditorTargetFile(projectInfo, projectConfig); return GetReceiptFile(projectInfo, projectConfig, targetFile, config.ToString()); } private static List FindTargets(DirectoryReference engineOrProjectDir) { List targets = new List(); DirectoryReference sourceDir = DirectoryReference.Combine(engineOrProjectDir, "Source"); if (DirectoryReference.Exists(sourceDir)) { foreach (FileReference targetFile in DirectoryReference.EnumerateFiles(sourceDir)) { const string extension = ".target.cs"; if (targetFile.FullName.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) { targets.Add(targetFile); } } } return targets; } public static string GetDefaultEditorTargetName(ProjectInfo projectInfo, ConfigFile projectConfigFile) { string? editorTarget; if (!TryGetProjectSetting(projectConfigFile, projectInfo.ProjectIdentifier, "EditorTarget", out editorTarget)) { if (projectInfo.IsEnterpriseProject) { editorTarget = "StudioEditor"; } else { editorTarget = "UE4Editor"; } } return editorTarget; } public static bool TryReadEditorReceipt(ProjectInfo projectInfo, FileReference receiptFile, [NotNullWhen(true)] out TargetReceipt? receipt) { DirectoryReference engineDir = DirectoryReference.Combine(projectInfo.LocalRootPath, "Engine"); DirectoryReference projectDir = projectInfo.LocalFileName.Directory; if (receiptFile.IsUnderDirectory(projectDir)) { return TargetReceipt.TryRead(receiptFile, engineDir, projectDir, out receipt); } else { return TargetReceipt.TryRead(receiptFile, engineDir, null, out receipt); } } public static TargetReceipt CreateDefaultEditorReceipt(ProjectInfo projectInfo, ConfigFile projectConfigFile, BuildConfig configuration) { string baseName = GetDefaultEditorTargetName(projectInfo, projectConfigFile); if (configuration != BuildConfig.Development || !String.IsNullOrEmpty(HostArchitectureSuffix)) { if (configuration != BuildConfig.DebugGame || projectConfigFile.GetValue("Options.DebugGameHasSeparateExecutable", false)) { baseName += $"-{HostPlatform}-{configuration}{HostArchitectureSuffix}"; } } string extension = String.Empty; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { extension = ".exe"; } TargetReceipt receipt = new TargetReceipt(); receipt.Configuration = configuration.ToString(); receipt.Launch = FileReference.Combine(projectInfo.LocalRootPath, "Engine", "Binaries", HostPlatform, $"{baseName}{extension}").FullName; receipt.LaunchCmd = FileReference.Combine(projectInfo.LocalRootPath, "Engine", "Binaries", HostPlatform, $"{baseName}-Cmd{extension}").FullName; return receipt; } private static bool UseSharedEditorReceipt(ProjectInfo projectInfo, ConfigFile projectConfig) { string? setting; if (TryGetProjectSetting(projectConfig, projectInfo.ProjectIdentifier, "UseSharedEditor", out setting)) { bool value; if (Boolean.TryParse(setting, out value)) { return value; } } return false; } public static FileReference GetReceiptFile(ProjectInfo projectInfo, ConfigFile projectConfig, FileReference targetFile, string configuration) { string targetName = targetFile.GetFileNameWithoutAnyExtensions(); DirectoryReference? projectDir = projectInfo.ProjectDir; if (projectDir != null && (targetFile.IsUnderDirectory(projectDir) || !UseSharedEditorReceipt(projectInfo, projectConfig))) { return GetReceiptFile(projectDir, targetName, configuration); } else { return GetReceiptFile(projectInfo.EngineDir, targetName, configuration); } } public static FileReference GetReceiptFile(DirectoryReference baseDir, string targetName, string configuration) { return GetReceiptFile(baseDir, targetName, HostPlatform, configuration, HostArchitectureSuffix); } public static FileReference GetReceiptFile(DirectoryReference baseDir, string targetName, string platform, string configuration, string architectureSuffix) { if (String.IsNullOrEmpty(architectureSuffix) && configuration.Equals("Development", StringComparison.OrdinalIgnoreCase)) { return FileReference.Combine(baseDir, "Binaries", platform, $"{targetName}.target"); } else { return FileReference.Combine(baseDir, "Binaries", platform, $"{targetName}-{platform}-{configuration}{architectureSuffix}.target"); } } public static Dictionary GetDefaultBuildStepObjects(ProjectInfo projectInfo, string editorTarget, BuildConfig editorConfig, ConfigFile latestProjectConfigFile, bool shouldSyncPrecompiledEditor) { string projectArgument = ""; if (projectInfo.LocalFileName.HasExtension(".uproject")) { projectArgument = String.Format("\"{0}\"", projectInfo.LocalFileName); } bool useCrashReportClientEditor = latestProjectConfigFile.GetValue("Options.UseCrashReportClientEditor", false); string hostPlatform; if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { hostPlatform = "Mac"; } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { hostPlatform = "Linux"; } else { hostPlatform = "Win64"; } List defaultBuildSteps = new List(); if (latestProjectConfigFile.GetValue("Options.BuildUnrealHeaderTool", true)) { defaultBuildSteps.Add(new BuildStep(new Guid("{01F66060-73FA-4CC8-9CB3-E217FBBA954E}"), 0, "Compile UnrealHeaderTool", "Compiling UnrealHeaderTool...", 1, "UnrealHeaderTool", hostPlatform, "Development", "", !shouldSyncPrecompiledEditor)); } defaultBuildSteps.Add(new BuildStep(new Guid("{F097FF61-C916-4058-8391-35B46C3173D5}"), 1, $"Compile {editorTarget}", $"Compiling {editorTarget}...", 10, editorTarget, hostPlatform, editorConfig.ToString(), projectArgument, !shouldSyncPrecompiledEditor)); defaultBuildSteps.Add(new BuildStep(new Guid("{C6E633A1-956F-4AD3-BC95-6D06D131E7B4}"), 2, "Compile ShaderCompileWorker", "Compiling ShaderCompileWorker...", 1, "ShaderCompileWorker", hostPlatform, "Development", "", !shouldSyncPrecompiledEditor)); defaultBuildSteps.Add(new BuildStep(new Guid("{24FFD88C-7901-4899-9696-AE1066B4B6E8}"), 3, "Compile UnrealLightmass", "Compiling UnrealLightmass...", 1, "UnrealLightmass", hostPlatform, "Development", "", !shouldSyncPrecompiledEditor)); defaultBuildSteps.Add(new BuildStep(new Guid("{FFF20379-06BF-4205-8A3E-C53427736688}"), 4, "Compile CrashReportClient", "Compiling CrashReportClient...", 1, "CrashReportClient", hostPlatform, "Shipping", "", !shouldSyncPrecompiledEditor && !useCrashReportClientEditor)); defaultBuildSteps.Add(new BuildStep(new Guid("{7143D861-58D3-4F83-BADC-BC5DCB2079F6}"), 5, "Compile CrashReportClientEditor", "Compiling CrashReportClientEditor...", 1, "CrashReportClientEditor", hostPlatform, "Shipping", "", !shouldSyncPrecompiledEditor && useCrashReportClientEditor)); return defaultBuildSteps.ToDictionary(x => x.UniqueId, x => x.ToConfigObject()); } public static Dictionary GetWorkspaceVariables(ProjectInfo projectInfo, int changeNumber, int codeChangeNumber, TargetReceipt? editorTarget, ConfigFile? projectConfigFile, IPerforceSettings perforceSettings) { Dictionary variables = new Dictionary(StringComparer.OrdinalIgnoreCase); if (projectInfo.StreamName != null) { variables.Add("Stream", projectInfo.StreamName); } variables.Add("Change", changeNumber.ToString()); variables.Add("CodeChange", codeChangeNumber.ToString()); variables.Add("ClientName", projectInfo.ClientName); variables.Add("BranchDir", projectInfo.LocalRootPath.FullName); variables.Add("ProjectDir", projectInfo.LocalFileName.Directory.FullName); variables.Add("ProjectFile", projectInfo.LocalFileName.FullName); variables.Add("UseIncrementalBuilds", "1"); string editorConfig = editorTarget?.Configuration ?? String.Empty; variables.Add("EditorConfig", editorConfig); string editorLaunch = editorTarget?.Launch ?? String.Empty; variables.Add("EditorExe", editorLaunch); string editorLaunchCmd = editorTarget?.LaunchCmd ?? editorLaunch.Replace(".exe", "-Cmd.exe", StringComparison.OrdinalIgnoreCase); variables.Add("EditorCmdExe", editorLaunchCmd); // Legacy variables.Add("UE4EditorConfig", editorConfig); variables.Add("UE4EditorDebugArg", (editorConfig.Equals("Debug", StringComparison.Ordinal) || editorConfig.Equals("DebugGame", StringComparison.Ordinal)) ? " -debug" : ""); variables.Add("UE4EditorExe", editorLaunch); variables.Add("UE4EditorCmdExe", editorLaunchCmd); if (projectConfigFile != null) { if (TryGetProjectSetting(projectConfigFile, projectInfo.ProjectIdentifier, "SdkInstallerDir", out string? sdkInstallerDir)) { variables.Add("SdkInstallerDir", sdkInstallerDir); } } variables.Add("PerforceServerAndPort", perforceSettings.ServerAndPort); variables.Add("PerforceUserName", perforceSettings.UserName); if (perforceSettings.ClientName != null) { variables.Add("PerforceClientName", perforceSettings.ClientName); } return variables; } public static Dictionary GetWorkspaceVariables(ProjectInfo projectInfo, int changeNumber, int codeChangeNumber, TargetReceipt? editorTarget, ConfigFile? projectConfigFile, IPerforceSettings perforceSettings, IEnumerable> additionalVariables) { Dictionary variables = GetWorkspaceVariables(projectInfo, changeNumber, codeChangeNumber, editorTarget, projectConfigFile, perforceSettings); foreach ((string key, string value) in additionalVariables) { variables[key] = value; } return variables; } public static bool TryGetProjectSetting(ConfigFile projectConfigFile, string selectedProjectIdentifier, string name, [NotNullWhen(true)] out string? value) { string path = selectedProjectIdentifier; for (; ; ) { ConfigSection? projectSection = projectConfigFile.FindSection(path); if (projectSection != null) { string? newValue = projectSection.GetValue(name, null); if (newValue != null) { value = newValue; return true; } } int lastSlash = path.LastIndexOf('/'); if (lastSlash < 2) { break; } path = path.Substring(0, lastSlash); } ConfigSection? defaultSection = projectConfigFile.FindSection("Default"); if (defaultSection != null) { string? newValue = defaultSection.GetValue(name, null); if (newValue != null) { value = newValue; return true; } } value = null; return false; } public static void GetProjectSettings(ConfigFile projectConfigFile, string selectedProjectIdentifier, string name, List values) { string path = selectedProjectIdentifier; for (; ; ) { ConfigSection? projectSection = projectConfigFile.FindSection(path); if (projectSection != null) { values.AddRange(projectSection.GetValues(name, Array.Empty())); } int lastSlash = path.LastIndexOf('/'); if (lastSlash < 2) { break; } path = path.Substring(0, lastSlash); } ConfigSection? defaultSection = projectConfigFile.FindSection("Default"); if (defaultSection != null) { values.AddRange(defaultSection.GetValues(name, Array.Empty())); } } public static Dictionary GetSyncCategories(ConfigFile projectConfigFile) { Dictionary uniqueIdToCategory = new Dictionary(); if (projectConfigFile != null) { string[] categoryLines = projectConfigFile.GetValues("Options.SyncCategory", Array.Empty()); foreach (string categoryLine in categoryLines) { ConfigObject obj = new ConfigObject(categoryLine); Guid uniqueId; if (Guid.TryParse(obj.GetValue("UniqueId", ""), out uniqueId)) { WorkspaceSyncCategory? category; if (!uniqueIdToCategory.TryGetValue(uniqueId, out category)) { category = new WorkspaceSyncCategory(uniqueId); uniqueIdToCategory.Add(uniqueId, category); } if (obj.GetValue("Clear", false)) { category.Paths.Clear(); category.Requires.Clear(); } category.Name = obj.GetValue("Name", category.Name); category.Enable = obj.GetValue("Enable", category.Enable); string[] paths = Enumerable.Concat(category.Paths, obj.GetValue("Paths", "").Split(';').Select(x => x.Trim())).Where(x => x.Length > 0).Distinct().OrderBy(x => x).ToArray(); category.Paths.Clear(); category.Paths.AddRange(paths); category.Hidden = obj.GetValue("Hidden", category.Hidden); Guid[] requires = Enumerable.Concat(category.Requires, ParseGuids(obj.GetValue("Requires", "").Split(';'))).Distinct().OrderBy(x => x).ToArray(); category.Requires.Clear(); category.Requires.AddRange(requires); } } } return uniqueIdToCategory; } public static IDictionary GetPresets(ConfigFile? projectConfigFile, string projectIdentifier) { Dictionary presetsDictionary = new(StringComparer.OrdinalIgnoreCase); if (projectConfigFile == null) { return presetsDictionary; } List presetDefinitions = new List(); presetDefinitions.AddRange(projectConfigFile.GetValues("Presets.Preset", [])); if (TryGetProjectSetting(projectConfigFile, projectIdentifier, "Preset", out string? projectPresets)) { using StringReader sr = new StringReader(projectPresets); while (sr.Peek() != -1) { string? line = sr.ReadLine(); if (String.IsNullOrWhiteSpace(line)) { continue; } presetDefinitions.Add(line); } } foreach (string presetDefinition in presetDefinitions) { ConfigObject obj = new ConfigObject(presetDefinition); Preset? preset = new Preset(); preset.Name = obj.GetValue("Name", String.Empty); // do not allow empty role name if (String.IsNullOrWhiteSpace(preset.Name)) { continue; } IEnumerable categories = obj.GetValue("Categories", String.Empty) .Split(';') .Select(x => x.Trim()) .Where(x => x.Length > 0) .Distinct() .OrderBy(x => x); foreach (string categoryLine in categories) { string[] values = categoryLine .Split(',') .Select(x => x.Trim()) .ToArray(); // all categories shall be completely defined if (values.Length != 2) { continue; } RoleCategory category = new(); if (Guid.TryParse(values[0], out Guid guid)) { category.Id = guid; } if (Boolean.TryParse(values[1], out bool enabled)) { category.Enabled = enabled; } if (category.Id != Guid.Empty) { preset.Categories.TryAdd(category.Id, category); } } IEnumerable views = obj.GetValue("Views", String.Empty) .Split(';') .Select(x => x.Trim()) .Where(x => x.Length > 0) .Distinct() ; foreach (string view in views) { preset.Views.Add(view); } if (presetsDictionary.ContainsKey(preset.Name)) { presetsDictionary[preset.Name].Import(preset); } else { presetsDictionary.TryAdd(preset.Name, preset); } } return presetsDictionary; } static IEnumerable ParseGuids(IEnumerable values) { foreach (string value in values) { Guid guid; if (Guid.TryParse(value, out guid)) { yield return guid; } } } } }