// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text.RegularExpressions; using System.Xml.Linq; using EpicGames.Core; using Microsoft.Extensions.Logging; using UnrealBuildBase; namespace UnrealBuildTool { /// /// Base class for VC appx manifest generation /// public abstract class AppXManifestGeneratorBase { /// config section for platform-specific target settings protected virtual string IniSection_PlatformTargetSettings => String.Format("/Script/{0}PlatformEditor.{0}TargetSettings", Platform.ToString()); /// config section for platform-specific general target settings (i.e. settings with are unrelated to manifest generation) protected virtual string? IniSection_GeneralPlatformSettings => null; /// config section for general target settings protected virtual string IniSection_GeneralProjectSettings => "/Script/EngineSettings.GeneralProjectSettings"; /// default subdirectory for build resources protected const string BuildResourceSubPath = "Resources"; /// default subdirectory for engine resources protected const string EngineResourceSubPath = "DefaultImages"; /// Manifest compliance values protected const int MaxResourceEntries = 200; /// cached engine ini protected ConfigHierarchy? EngineIni; /// cached game ini protected ConfigHierarchy? GameIni; /// AppX package resource generator protected UEAppXResources? AppXResources; /// the default culture to use protected string? DefaultAppXCultureId; /// lookup table for UE's CultureToStage StageId to AppX CultureId protected Dictionary UEStageIdToAppXCultureId = new Dictionary(); /// the platform to generate the manifest for protected UnrealTargetPlatform Platform; /// project file to use protected FileReference? ProjectFile; /// target name to use protected string? TargetName; /// Logger for output protected readonly ILogger Logger; /// Whether we have logged the deprecation warning for PerCultureResources CultureId being replaced by StageIdOverrides protected static bool bHasWarnedAboutDeprecatedCultureId = false; /// CustomConfig to use when reading the ini files public string CustomConfig = ""; /// /// Create a manifest generator for the given platform variant. /// protected AppXManifestGeneratorBase(UnrealTargetPlatform InPlatform, ILogger InLogger) { Platform = InPlatform; Logger = InLogger; } /// /// Returns a valid version of the given package version string /// protected string? ValidatePackageVersion(string InVersionNumber) { string WorkingVersionNumber = Regex.Replace(InVersionNumber, "[^.0-9]", ""); string CompletedVersionString = ""; if (WorkingVersionNumber != null) { string[] SplitVersionString = WorkingVersionNumber.Split(new char[] { '.' }); int NumVersionElements = Math.Min(4, SplitVersionString.Length); for (int VersionElement = 0; VersionElement < NumVersionElements; VersionElement++) { string QuadElement = SplitVersionString[VersionElement]; int QuadValue = 0; if (QuadElement.Length == 0 || !Int32.TryParse(QuadElement, out QuadValue)) { CompletedVersionString += "0"; } else { if (QuadValue < 0) { QuadValue = 0; } if (QuadValue > 65535) { QuadValue = 65535; } CompletedVersionString += QuadValue; } if (VersionElement < 3) { CompletedVersionString += "."; } } for (int VersionElement = NumVersionElements; VersionElement < 4; VersionElement++) { CompletedVersionString += "0"; if (VersionElement < 3) { CompletedVersionString += "."; } } } if (CompletedVersionString == null || CompletedVersionString.Length <= 0) { Logger.LogError("Invalid package version {Ver}. Package versions must be in the format #.#.#.# where # is a number 0-65535.", InVersionNumber); Logger.LogError("Consider setting [{IniSection}]:PackageVersion to provide a specific value.", IniSection_PlatformTargetSettings); } return CompletedVersionString; } /// /// Returns a valid version of the given application id /// protected string? ValidateProjectBaseName(string InApplicationId) { string ReturnVal = Regex.Replace(InApplicationId, "[^A-Za-z0-9]", ""); if (ReturnVal != null) { // Remove any leading numbers (must start with a letter) ReturnVal = Regex.Replace(ReturnVal, "^[0-9]*", ""); } if (ReturnVal == null || ReturnVal.Length <= 0) { Logger.LogError("Invalid application ID {AppId}. Application IDs must only contain letters and numbers. And they must begin with a letter.", InApplicationId); } return ReturnVal; } /// /// Reads an integer from the cached ini files /// [return: NotNullIfNotNull("DefaultValue")] protected string? ReadIniString(string? Key, string Section, string? DefaultValue = null) { if (Key == null) { return DefaultValue; } string Value; if (GameIni!.GetString(Section, Key, out Value) && !String.IsNullOrWhiteSpace(Value)) { return Value; } if (EngineIni!.GetString(Section, Key, out Value) && !String.IsNullOrWhiteSpace(Value)) { return Value; } return DefaultValue; } /// /// Reads a string from the cached ini files /// [return: NotNullIfNotNull("DefaultValue")] protected string? GetConfigString(string PlatformKey, string? GenericKey, string? DefaultValue = null) { string? GeneralPlatformValue = (IniSection_GeneralPlatformSettings != null) ? ReadIniString(PlatformKey, IniSection_GeneralPlatformSettings) : null; string? GenericValue = ReadIniString(GenericKey, IniSection_GeneralProjectSettings, DefaultValue); return GeneralPlatformValue ?? ReadIniString(PlatformKey, IniSection_PlatformTargetSettings, GenericValue); } /// /// Reads a bool from the cached ini files /// protected bool GetConfigBool(string PlatformKey, string? GenericKey, bool DefaultValue = false) { string? GeneralPlatformValue = (IniSection_GeneralPlatformSettings != null) ? ReadIniString(PlatformKey, IniSection_GeneralPlatformSettings) : null; string? GenericValue = ReadIniString(GenericKey, IniSection_GeneralProjectSettings, null); string? ResultStr = GeneralPlatformValue ?? ReadIniString(PlatformKey, IniSection_PlatformTargetSettings, GenericValue); if (ResultStr == null) { return DefaultValue; } ResultStr = ResultStr.Trim().ToLower(); return ResultStr == "true" || ResultStr == "1" || ResultStr == "yes"; } /// /// Reads a color from the cached ini files /// protected string GetConfigColor(string PlatformConfigKey, string DefaultValue) { string? ConfigValue = GetConfigString(PlatformConfigKey, null, null); if (ConfigValue == null) { return DefaultValue; } Dictionary? Pairs; int R, G, B; if (ConfigHierarchy.TryParse(ConfigValue, out Pairs) && Int32.TryParse(Pairs["R"], out R) && Int32.TryParse(Pairs["G"], out G) && Int32.TryParse(Pairs["B"], out B)) { return "#" + R.ToString("X2") + G.ToString("X2") + B.ToString("X2"); } Logger.LogWarning("Failed to parse color config value. Using default."); return DefaultValue; } /// /// Create all the localization data. Returns whether there is any per-culture data set up /// protected virtual bool BuildLocalizationData() { bool bHasPerCultureResources = false; // reset per-culture strings and make sure the default culture entry exists AppXResources!.ClearStrings(); // add all default strings if (EngineIni!.GetString(IniSection_PlatformTargetSettings, "CultureStringResources", out string DefaultCultureScratchValue) && ConfigHierarchy.TryParse(DefaultCultureScratchValue, out Dictionary? DefaultStrings)) { AppXResources!.AddDefaultStrings(DefaultStrings); } // read StageId overrides bool bHasStageIdOverrides = false; if (EngineIni!.GetString(IniSection_PlatformTargetSettings, "StageIdOverrides", out string? StageIdOverridesString) && ConfigHierarchy.TryParseAsMap(StageIdOverridesString, out Dictionary? StageIdOverrides)) { bHasStageIdOverrides = true; UEStageIdToAppXCultureId = StageIdOverrides; AppXResources!.AddCultures(UEStageIdToAppXCultureId.Values); } // add per culture strings if (EngineIni.GetArray(IniSection_PlatformTargetSettings, "PerCultureResources", out List? PerCultureResources)) { bHasPerCultureResources = true; foreach (string CultureResources in PerCultureResources) { if (!ConfigHierarchy.TryParse(CultureResources, out Dictionary? CultureProperties) || !CultureProperties.ContainsKey("CultureStringResources") || !CultureProperties.ContainsKey("StageId")) { Logger.LogWarning("Invalid per-culture resource value: {Culture}", CultureResources); continue; } string StageId = CultureProperties["StageId"]; if (String.IsNullOrEmpty(StageId)) { Logger.LogWarning("Missing StageId value: {Culture}", CultureResources); continue; } string CultureId = ""; if (bHasStageIdOverrides) { CultureId = UEStageIdToAppXCultureId.ContainsKey(StageId) ? UEStageIdToAppXCultureId[StageId] : StageId; } else if (!CultureProperties.ContainsKey("CultureId")) { Logger.LogWarning("Invalid per-culture resource value: {Culture}", CultureResources); } else { CultureId = CultureProperties["CultureId"]; if (String.IsNullOrEmpty(CultureId)) { Logger.LogWarning("Missing CultureId value: {Culture}", CultureResources); continue; } // PerCultureResources CultureId is deprecated in favor of new StageIdOverrides property // if the CultureId field contains an overridden property, warn they need to update the data if (CultureId != StageId && !bHasWarnedAboutDeprecatedCultureId) { Logger.LogWarning("PerCultureResources is out of date - please re-save this project's {Platform}Engine.ini in the editor to update StageIdOverrides. This must be done before UE5.5 to avoid losing data to deprecation", Platform); bHasWarnedAboutDeprecatedCultureId = true; } UEStageIdToAppXCultureId[StageId] = CultureId; } AppXResources.AddCulture(CultureId); // read culture strings if (!ConfigHierarchy.TryParse(CultureProperties["CultureStringResources"], out Dictionary? CultureStringResources)) { Logger.LogError("Invalid culture string resources: \"{Culture}\". Unable to add resource entry.", CultureResources); continue; } AppXResources!.AddCultureStrings(CultureId, CultureStringResources); } } return bHasPerCultureResources; } /// /// Register the locations where resource binary files can be found /// protected virtual void PrepareResourceBinaryPaths() { if (ProjectFile != null) { AppXResources!.ProjectBinaryResourceDirectories.Add(DirectoryReference.Combine(ProjectFile.Directory, "Build", Platform.ToString(), BuildResourceSubPath)); AppXResources!.ProjectBinaryResourceDirectories.Add(DirectoryReference.Combine(ProjectFile.Directory, "Platforms", Platform.ToString(), "Build", BuildResourceSubPath)); } AppXResources!.EngineFallbackBinaryResourceDirectories.Add(DirectoryReference.Combine(Unreal.EngineDirectory, "Build", Platform.ToString(), EngineResourceSubPath)); AppXResources!.EngineFallbackBinaryResourceDirectories.Add(DirectoryReference.Combine(Unreal.EngineDirectory, "Platforms", Platform.ToString(), "Build", EngineResourceSubPath)); } /// /// Get the resources element /// protected XElement GetResources() { List ResourceCulturesList = AppXResources!.GetAllCultureIds().ToList(); // Move the default culture to the front of the list ResourceCulturesList.Remove(DefaultAppXCultureId!); ResourceCulturesList.Insert(0, DefaultAppXCultureId!); // Check that we have a valid number of cultures if (ResourceCulturesList!.Count < 1 || ResourceCulturesList.Count >= MaxResourceEntries) { Logger.LogWarning("Incorrect number of cultures to stage. There must be between 1 and {MaxCultures} cultures selected.", MaxResourceEntries); } // Create the culture list. This list is unordered except that the default language must be first which we already took care of above. IEnumerable CultureElements = ResourceCulturesList.Select(c => new XElement("Resource", new XAttribute("Language", c))); return new XElement("Resources", CultureElements); } /// /// Get the package identity name string /// protected virtual string GetIdentityPackageName() { // Read the PackageName from config string DefaultName = (ProjectFile != null) ? ProjectFile.GetFileNameWithoutAnyExtensions() : (TargetName ?? "DefaultUEProject"); string PackageName = Regex.Replace(GetConfigString("PackageName", "ProjectName", DefaultName), "[^-.A-Za-z0-9]", ""); if (String.IsNullOrWhiteSpace(PackageName)) { Logger.LogError("Invalid package name {Name}. Package names must only contain letters, numbers, dash, and period and must be at least one character long.", PackageName); Logger.LogError("Consider using the setting [{IniSection}]:PackageName to provide a specific value.", IniSection_PlatformTargetSettings); } // If specified in the project settings append the users machine name onto the package name to allow sharing of devkits without stomping of deploys bool bPackageNameUseMachineName; if (EngineIni!.GetBool(IniSection_PlatformTargetSettings, "bPackageNameUseMachineName", out bPackageNameUseMachineName) && bPackageNameUseMachineName) { string MachineName = Regex.Replace(Unreal.MachineName, "[^-.A-Za-z0-9]", ""); PackageName = PackageName + ".NOT.SHIPPABLE." + MachineName; } return PackageName; } /// /// Get the publisher name string /// protected virtual string GetIdentityPublisherName() { string PublisherName = GetConfigString("PublisherName", "CompanyDistinguishedName", "CN=NoPublisher"); return PublisherName; } /// /// Get the package version string /// protected virtual string? GetIdentityVersionNumber() { string? VersionNumber = GetConfigString("PackageVersion", "ProjectVersion", "1.0.0.0"); VersionNumber = ValidatePackageVersion(VersionNumber); // If specified in the project settings attempt to retrieve the current build number and increment the version number by that amount, accounting for overflows bool bIncludeEngineVersionInPackageVersion; if (EngineIni!.GetBool(IniSection_PlatformTargetSettings, "bIncludeEngineVersionInPackageVersion", out bIncludeEngineVersionInPackageVersion) && bIncludeEngineVersionInPackageVersion) { VersionNumber = IncludeBuildVersionInPackageVersion(VersionNumber); } return VersionNumber; } /// /// Get the package identity element /// protected XElement GetIdentity(out string IdentityName) { string PackageName = GetIdentityPackageName(); string PublisherName = GetIdentityPublisherName(); string? VersionNumber = GetIdentityVersionNumber(); IdentityName = PackageName; return new XElement("Identity", new XAttribute("Name", PackageName), new XAttribute("Publisher", PublisherName), new XAttribute("Version", VersionNumber!)); } /// /// Updates the given package version to include the engine build version, if requested /// protected virtual string? IncludeBuildVersionInPackageVersion(string? VersionNumber) { BuildVersion? BuildVersionForPackage; if (VersionNumber != null && BuildVersion.TryRead(BuildVersion.GetDefaultFileName(), out BuildVersionForPackage) && BuildVersionForPackage.Changelist != 0) { // Break apart the version number into individual elements string[] SplitVersionString = VersionNumber.Split('.'); VersionNumber = String.Format("{0}.{1}.{2}.{3}", SplitVersionString[0], SplitVersionString[1], BuildVersionForPackage.Changelist / 10000, BuildVersionForPackage.Changelist % 10000); } return VersionNumber; } /// /// Get the path to the makepri.exe tool /// protected virtual FileReference GetMakePriBinaryPath() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { throw new BuildException("unsupported platform"); } if (!MicrosoftPlatformSDK.TryGetWindowsSdkDir(null, Logger, out VersionNumber? SdkVersion, out DirectoryReference? SdkDir)) { throw new BuildException("Cannot get default Windows Sdk directory"); } FileReference MakePriPath = FileReference.Combine(SdkDir, "bin", SdkVersion.ToString(), "x64", "makepri.exe"); if (!FileReference.Exists(MakePriPath)) { throw new BuildException($"{MakePriPath} - file not found"); } return MakePriPath; } /// /// Get any additional platform-specific parameters for makepri.exe /// protected virtual string GetMakePriExtraCommandLine() { return ""; } /// /// Return the entire manifest element /// protected abstract XElement GetManifest(Dictionary InExecutablePairs, out string IdentityName); /// /// Perform any platform-specific processing on the manifest before it is saved /// protected virtual void ProcessManifest(Dictionary InExecutablePairs, string ManifestName, string ManifestTargetPath, string ManifestIntermediatePath) { } /// /// Perform any additional initialization once all parameters and configuration are ready /// protected virtual void PostConfigurationInit() { } /// /// Create a manifest and return the list of modified files /// public List? CreateManifest(string InManifestName, DirectoryReference OutputDirectory, string? InTargetName, FileReference? InProjectFile, Dictionary InExecutablePairs) { FileUtils.CreateDirectoryTree(OutputDirectory); DirectoryReference ProjectRoot = (InProjectFile != null) ? InProjectFile.Directory : Unreal.EngineDirectory; DirectoryReference IntermediateDirectory = DirectoryReference.Combine(ProjectRoot, "Intermediate", "Manifest", Platform.ToString()); FileUtils.ForceDeleteDirectory(IntermediateDirectory); FileUtils.CreateDirectoryTree(IntermediateDirectory); TargetName = InTargetName; ProjectFile = InProjectFile; AppXResources = new(Logger, GetMakePriBinaryPath()); PrepareResourceBinaryPaths(); // Load up INI settings. We'll use engine settings to retrieve the manifest configuration, but these may reference // values in either game or engine settings, so we'll keep both. GameIni = ConfigCache.ReadHierarchy(ConfigHierarchyType.Game, DirectoryReference.FromFile(InProjectFile), Platform, CustomConfig); EngineIni = ConfigCache.ReadHierarchy(ConfigHierarchyType.Engine, DirectoryReference.FromFile(InProjectFile), Platform, CustomConfig); PostConfigurationInit(); // Load and verify/clean culture list List? SelectedUECultureIds; string DefaultUECultureId; GameIni.GetArray("/Script/UnrealEd.ProjectPackagingSettings", "CulturesToStage", out SelectedUECultureIds); GameIni.GetString("/Script/UnrealEd.ProjectPackagingSettings", "DefaultCulture", out DefaultUECultureId); if (SelectedUECultureIds == null || SelectedUECultureIds.Count < 1) { Logger.LogError("At least one culture must be selected to stage."); return null; } SelectedUECultureIds = SelectedUECultureIds.Distinct().ToList(); if (DefaultUECultureId == null || DefaultUECultureId.Length < 1) { DefaultUECultureId = SelectedUECultureIds[0]; Logger.LogWarning("A default culture must be selected to stage. Using {DefaultCulture}.", DefaultUECultureId); } if (!SelectedUECultureIds.Contains(DefaultUECultureId)) { DefaultUECultureId = SelectedUECultureIds[0]; Logger.LogWarning("The default culture must be one of the staged cultures. Using {DefaultCulture}.", DefaultUECultureId); } BuildLocalizationData(); // generate the list of AppX cultures to stage foreach (string UEStageId in SelectedUECultureIds) { if (!UEStageIdToAppXCultureId.TryGetValue(UEStageId, out string? AppXCultureId) || String.IsNullOrEmpty(AppXCultureId)) { // use the culture directly - no remapping required AppXCultureId = UEStageId; UEStageIdToAppXCultureId[UEStageId] = AppXCultureId; AppXResources.AddCulture(UEStageId); } } // look up the default AppX culture if (!UEStageIdToAppXCultureId.TryGetValue(DefaultUECultureId, out DefaultAppXCultureId) || String.IsNullOrEmpty(DefaultAppXCultureId)) { // use the default culture directly - no remapping required DefaultAppXCultureId = DefaultUECultureId; UEStageIdToAppXCultureId[DefaultUECultureId] = DefaultAppXCultureId; } // Create the manifest document string? IdentityName = null; XDocument ManifestXmlDocument = new XDocument(GetManifest(InExecutablePairs, out IdentityName)); // Export manifest to the intermediate directory and add it to the manifest resources files for copying FileReference ManifestIntermediateFile = FileReference.Combine(IntermediateDirectory, InManifestName); FileReference ManifestTargetFile = FileReference.Combine(OutputDirectory, InManifestName); ManifestXmlDocument.Save(ManifestIntermediateFile.FullName); AppXResources.AddFileReference(ManifestIntermediateFile, InManifestName); ProcessManifest(InExecutablePairs, InManifestName, ManifestTargetFile.FullName, ManifestIntermediateFile.FullName); // Generate the package resource index and copy all resource files to the output FileReference ManifestTargetPath = FileReference.Combine(OutputDirectory, InManifestName); List UpdatedFiles = AppXResources.GenerateAppXResources(OutputDirectory, IntermediateDirectory, ManifestTargetFile, DefaultAppXCultureId, IdentityName); // Clean up and reutrn the list of updated files FileUtils.ForceDeleteDirectory(IntermediateDirectory); return UpdatedFiles.ConvertAll(X => X.FullName); } } }