// Copyright 2026 Timothé Lapetite and contributors // Released under the MIT license https://opensource.org/license/MIT/ using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; using EpicGames.Core; using Microsoft.Extensions.Logging; using UnrealBuildTool; public class PCGExtendedToolkit : ModuleRules { private const string PluginName = "PCGExtendedToolkit"; private const string ModulePrefix = "PCGEx"; private const string EditorSuffix = "Editor"; private static readonly string[] BaseDependencies = { "PCGExCore", "PCGExBlending" }; private static readonly string[] BaseEditorDependencies = { "PCGExCoreEditor" }; private readonly Dictionary> _moduleDependencies = new(); public PCGExtendedToolkit(ReadOnlyTargetRules Target) : base(Target) { bool bNoPCH = File.Exists(Path.Combine(ModuleDirectory, "..", "..", "Config", ".noPCH")); PCHUsage = bNoPCH ? PCHUsageMode.NoPCHs : PCHUsageMode.UseExplicitOrSharedPCHs; bUseUnity = true; MinSourceFilesForUnityBuildOverride = 4; PrecompileForTargets = PrecompileTargetsType.Any; ConfigureBaseDependencies(); FileReference upluginFile = new FileReference(Path.Combine(PluginDirectory, $"{PluginName}.uplugin")); ExternalDependencies.Add(upluginFile.FullName); string pluginsDepsPath = Path.Combine(PluginDirectory, "Config", "PluginsDeps.ini"); if (File.Exists(pluginsDepsPath)) { ExternalDependencies.Add(pluginsDepsPath); } PluginDescriptor descriptor = PluginDescriptor.FromFile(upluginFile); var declaredModules = new HashSet(descriptor.Modules.Select(m => m.Name)); var enabledPlugins = new HashSet( descriptor.Plugins .Where(p => p.bEnabled) .Select(p => p.Name) ); var modulePluginRequirements = LoadPluginsDeps(pluginsDepsPath); ValidatePluginRequirements(declaredModules, enabledPlugins, modulePluginRequirements); foreach (ModuleDescriptor module in descriptor.Modules) { RegisterModule(module.Name, declaredModules); } ValidateConfiguration(declaredModules); GenerateSubModulesHeader(); PublicIncludePaths.AddRange(new string[] { Path.Combine(ModuleDirectory, "../../Intermediate/Generated") }); } private Dictionary> LoadPluginsDeps(string filePath) { var deps = new Dictionary>(); if (!File.Exists(filePath)) { Logger.LogInformation("[{Plugin}] PluginsDeps.ini not found - skipping plugin requirement validation", PluginName); return deps; } string currentModule = null; foreach (string line in File.ReadAllLines(filePath)) { string trimmed = line.Trim(); if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith("#") || trimmed.StartsWith(";")) continue; // Section header: [ModuleName] if (trimmed.StartsWith("[") && trimmed.EndsWith("]")) { currentModule = trimmed.Substring(1, trimmed.Length - 2); if (!deps.ContainsKey(currentModule)) { deps[currentModule] = new List(); } continue; } // Plugin name under current section if (currentModule != null && !string.IsNullOrEmpty(trimmed)) { deps[currentModule].Add(trimmed); } } return deps; } private void ValidatePluginRequirements( HashSet declaredModules, HashSet enabledPlugins, Dictionary> modulePluginRequirements) { var errors = new List(); foreach (var (moduleName, requiredPlugins) in modulePluginRequirements) { if (!declaredModules.Contains(moduleName)) continue; foreach (string plugin in requiredPlugins) { if (!enabledPlugins.Contains(plugin)) { errors.Add($"Module '{moduleName}' requires plugin '{plugin}' to be listed and enabled in .uplugin"); } } } if (errors.Count > 0) { string message = $"[{PluginName}] Plugin requirements not met:\n" + string.Join("\n", errors.Select(e => $" - {e}")); throw new BuildException(message); } } private void ConfigureBaseDependencies() { PublicDependencyModuleNames.AddRange(new[] { "Core", "CoreUObject", "Engine", "PCG" }); PublicDependencyModuleNames.AddRange(BaseDependencies); PrivateDependencyModuleNames.Add("DeveloperSettings"); if (Target.bBuildEditor) { PrivateDependencyModuleNames.AddRange(new[] { "UnrealEd", "Settings" }); PrivateDependencyModuleNames.AddRange(BaseEditorDependencies); } } private void RegisterModule(string moduleName, HashSet declaredModules) { if (IsUmbrellaModule(moduleName)) return; if (!IsPCGExModule(moduleName)) return; bool isEditor = moduleName.EndsWith(EditorSuffix); if (isEditor && !Target.bBuildEditor) return; if (isEditor) { PrivateDependencyModuleNames.Add(moduleName); } else { PublicDependencyModuleNames.Add(moduleName); } string buildFile = GetBuildFilePath(moduleName); if (File.Exists(buildFile)) { ExternalDependencies.Add(buildFile); _moduleDependencies[moduleName] = ScanDependencies(buildFile, moduleName); } } private List ScanDependencies(string buildFilePath, string selfName) { var deps = new List(); string content = File.ReadAllText(buildFilePath); var blockPattern = new Regex( @"(?:Public|Private)DependencyModuleNames\s*\.\s*AddRange\s*\(\s*new\s*(?:string)?\s*\[\s*\]\s*\{([^}]*)\}", RegexOptions.Singleline ); foreach (Match block in blockPattern.Matches(content)) { var namePattern = new Regex(@"""(PCGEx\w+)"""); foreach (Match name in namePattern.Matches(block.Groups[1].Value)) { string dep = name.Groups[1].Value; if (dep != selfName && !deps.Contains(dep)) { deps.Add(dep); } } } return deps; } private void ValidateConfiguration(HashSet declaredModules) { var missingDeps = new Dictionary>(); var missingEditors = new List(); foreach (var (module, deps) in _moduleDependencies) { foreach (string dep in deps) { if (!declaredModules.Contains(dep) && !IsBaseDependency(dep)) { if (!missingDeps.ContainsKey(dep)) missingDeps[dep] = new List(); missingDeps[dep].Add(module); } } } foreach (string moduleName in declaredModules) { if (moduleName.EndsWith(EditorSuffix) || IsUmbrellaModule(moduleName)) continue; string editorName = moduleName + EditorSuffix; if (Directory.Exists(Path.Combine(ModuleDirectory, "..", editorName)) && !declaredModules.Contains(editorName)) { missingEditors.Add(editorName); } } foreach (var (dep, referencedBy) in missingDeps.OrderBy(kv => kv.Key)) { Logger.LogWarning( "[{Plugin}] Dependency '{Dep}' required by [{Refs}] is not declared in .uplugin. Add:\n{Entry}", PluginName, dep, string.Join(", ", referencedBy), FormatModuleEntry(dep) ); } foreach (string editor in missingEditors.OrderBy(e => e)) { Logger.LogWarning( "[{Plugin}] Editor module '{Editor}' exists but is not declared in .uplugin. Add:\n{Entry}", PluginName, editor, FormatModuleEntry(editor) ); } } private void GenerateSubModulesHeader() { string headerPath = Path.Combine(ModuleDirectory, "..", "..", "Intermediate", "Generated", "PCGExSubModules.generated.h"); Directory.CreateDirectory(Path.GetDirectoryName(headerPath)!); var modules = _moduleDependencies.Keys.OrderBy(m => m).ToList(); var sb = new StringBuilder(); sb.AppendLine("// Auto-generated by PCGExtendedToolkit.Build.cs - DO NOT EDIT"); sb.AppendLine("#pragma once"); sb.AppendLine("#include \"CoreMinimal.h\""); sb.AppendLine(); sb.AppendLine("namespace PCGExSubModules"); sb.AppendLine("{"); sb.AppendLine("\tinline const TArray& GetEnabledModules()"); sb.AppendLine("\t{"); sb.AppendLine("\t\tstatic TArray Modules = {"); for (int i = 0; i < modules.Count; i++) { sb.AppendLine($"\t\t\tTEXT(\"{modules[i]}\"){(i < modules.Count - 1 ? "," : "")}"); } sb.AppendLine("\t\t};"); sb.AppendLine("\t\treturn Modules;"); sb.AppendLine("\t}"); sb.AppendLine(); sb.AppendLine("\tinline const TMap>& GetModuleDependencies()"); sb.AppendLine("\t{"); sb.AppendLine("\t\tstatic TMap> Dependencies = {"); var sortedDeps = _moduleDependencies.OrderBy(kv => kv.Key).ToList(); for (int i = 0; i < sortedDeps.Count; i++) { var (mod, deps) = sortedDeps[i]; string depsStr = deps.Count > 0 ? string.Join(", ", deps.Select(d => $"TEXT(\"{d}\")")) : ""; sb.AppendLine($"\t\t\t{{ TEXT(\"{mod}\"), {{ {depsStr} }} }}{(i < sortedDeps.Count - 1 ? "," : "")}"); } sb.AppendLine("\t\t};"); sb.AppendLine("\t\treturn Dependencies;"); sb.AppendLine("\t}"); sb.AppendLine("}"); string content = sb.ToString(); if (!File.Exists(headerPath) || File.ReadAllText(headerPath) != content) { File.WriteAllText(headerPath, content); } } private string GetBuildFilePath(string moduleName) => Path.Combine(ModuleDirectory, "..", moduleName, $"{moduleName}.Build.cs"); private string FormatModuleEntry(string name) { bool isEditor = name.EndsWith(EditorSuffix); string platforms = isEditor ? "\"Win64\", \"Mac\", \"Linux\"" : "\"Win64\", \"Mac\", \"IOS\", \"Android\", \"Linux\", \"LinuxArm64\""; return $@" {{ ""Name"": ""{name}"", ""Type"": ""{(isEditor ? "Editor" : "Runtime")}"", ""LoadingPhase"": ""Default"", ""PlatformAllowList"": [ {platforms} ] }}"; } private bool IsBaseDependency(string name) => BaseDependencies.Contains(name) || BaseEditorDependencies.Contains(name); private static bool IsPCGExModule(string name) => name.StartsWith(ModulePrefix); private static bool IsUmbrellaModule(string name) => name == PluginName || name == PluginName + EditorSuffix; }