Files
UnrealEngine/Engine/Plugins/Marketplace/PCGExt/Scripts/generate-uplugin.js
Brandyn / Techy fcc1b09210 init
2026-04-04 15:40:51 -05:00

471 lines
14 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Copyright 2025 Timothé Lapetite and contributors
// Released under the MIT license https://opensource.org/license/MIT/
// NOTE : This code was generated by Claude AI.
const fs = require('fs');
const path = require('path');
const PLUGIN_NAME = 'PCGExtendedToolkit';
const MODULE_PREFIX = 'PCGEx';
const EDITOR_SUFFIX = 'Editor';
const PLATFORMS = {
runtime: ['Win64', 'Mac', 'IOS', 'Android', 'Linux', 'LinuxArm64'],
editor: ['Win64', 'Mac', 'Linux']
};
// Resolve paths relative to script location (assumes script is in plugin root or Config/)
const scriptDir = __dirname;
const pluginRoot = fs.existsSync(path.join(scriptDir, `${PLUGIN_NAME}.uplugin`))
? scriptDir
: path.dirname(scriptDir);
// Project root is typically two levels up from plugin (Plugins/PCGExtendedToolkit/)
const projectRoot = path.dirname(path.dirname(pluginRoot));
const PATHS = {
uplugin: path.join(pluginRoot, `${PLUGIN_NAME}.uplugin`),
modulesDir: path.join(pluginRoot, 'Source'),
binaries: path.join(pluginRoot, 'Binaries'),
intermediate: path.join(pluginRoot, 'Intermediate'),
// Project-level config takes priority, falls back to plugin config
projectSubmodulesConfig: path.join(projectRoot, 'Config', 'PCGExSubModulesConfig.ini'),
pluginSubmodulesConfig: path.join(pluginRoot, 'Config', 'PCGExSubModulesConfig.ini'),
pluginsDeps: path.join(pluginRoot, 'Config', 'PluginsDeps.ini')
};
// =============================================================================
// Fuzzy Matching
// =============================================================================
function levenshteinDistance(a, b) {
const matrix = [];
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
);
}
}
}
return matrix[b.length][a.length];
}
function findClosestMatch(name, candidates, maxDistance = 3) {
let best = null;
let bestDistance = Infinity;
for (const candidate of candidates) {
const distance = levenshteinDistance(name.toLowerCase(), candidate.toLowerCase());
if (distance < bestDistance && distance <= maxDistance) {
bestDistance = distance;
best = candidate;
}
}
return best;
}
// =============================================================================
// INI Parsing
// =============================================================================
function resolveSubmodulesConfigPath() {
// Check project-level config first
if (fs.existsSync(PATHS.projectSubmodulesConfig)) {
return { path: PATHS.projectSubmodulesConfig, isProjectLevel: true };
}
// Fall back to plugin config
if (fs.existsSync(PATHS.pluginSubmodulesConfig)) {
return { path: PATHS.pluginSubmodulesConfig, isProjectLevel: false };
}
return null;
}
function parseSubModulesConfig(filePath) {
const modules = [];
const content = fs.readFileSync(filePath, 'utf8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith(';')) continue;
// Support both "ModuleName=1" and plain "ModuleName" formats
const parts = trimmed.split('=');
const moduleName = parts[0].trim();
if (parts.length === 1 || parts[1].trim() === '1') {
if (moduleName.startsWith(MODULE_PREFIX)) {
modules.push(moduleName);
}
}
}
return modules;
}
function parsePluginsDeps(filePath) {
const deps = new Map(); // moduleName -> [pluginNames]
if (!fs.existsSync(filePath)) {
console.warn(`PluginsDeps.ini not found at: ${filePath} - skipping plugin dependencies`);
return deps;
}
const content = fs.readFileSync(filePath, 'utf8');
let currentModule = null;
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith(';')) continue;
// Section header: [ModuleName]
const sectionMatch = trimmed.match(/^\[(.+)\]$/);
if (sectionMatch) {
currentModule = sectionMatch[1];
if (!deps.has(currentModule)) {
deps.set(currentModule, []);
}
continue;
}
// Plugin name under current section
if (currentModule && trimmed) {
deps.get(currentModule).push(trimmed);
}
}
return deps;
}
// =============================================================================
// Module Discovery
// =============================================================================
function getAvailableModules() {
if (!fs.existsSync(PATHS.modulesDir)) {
return [];
}
return fs.readdirSync(PATHS.modulesDir).filter(name => {
const fullPath = path.join(PATHS.modulesDir, name);
return fs.statSync(fullPath).isDirectory() && name.startsWith(MODULE_PREFIX);
});
}
function moduleExists(moduleName) {
const modulePath = path.join(PATHS.modulesDir, moduleName);
return fs.existsSync(modulePath) && fs.statSync(modulePath).isDirectory();
}
function getEditorCompanion(moduleName) {
if (moduleName.endsWith(EDITOR_SUFFIX)) return null;
const editorName = moduleName + EDITOR_SUFFIX;
return moduleExists(editorName) ? editorName : null;
}
function scanBuildCsDependencies(moduleName) {
const buildFile = path.join(PATHS.modulesDir, moduleName, `${moduleName}.Build.cs`);
if (!fs.existsSync(buildFile)) return [];
const content = fs.readFileSync(buildFile, 'utf8');
const deps = [];
// Match: PublicDependencyModuleNames.AddRange(new[] { ... }) or similar
const blockPattern = /(?:Public|Private)DependencyModuleNames\s*\.\s*AddRange\s*\(\s*new\s*(?:string)?\s*\[\s*\]\s*\{([^}]*)\}/g;
const namePattern = /"(PCGEx\w+)"/g;
let blockMatch;
while ((blockMatch = blockPattern.exec(content)) !== null) {
const arrayContent = blockMatch[1];
let nameMatch;
while ((nameMatch = namePattern.exec(arrayContent)) !== null) {
const dep = nameMatch[1];
if (dep !== moduleName && !deps.includes(dep)) {
deps.push(dep);
}
}
}
return deps;
}
// =============================================================================
// Module Resolution
// =============================================================================
function validateRequestedModules(requestedModules, availableModules) {
const valid = [];
const invalid = [];
for (const moduleName of requestedModules) {
if (moduleExists(moduleName)) {
valid.push(moduleName);
} else {
const suggestion = findClosestMatch(moduleName, availableModules);
invalid.push({ name: moduleName, suggestion });
}
}
return { valid, invalid };
}
function resolveAllModules(requestedModules) {
const resolved = new Set();
const queue = [...requestedModules];
while (queue.length > 0) {
const moduleName = queue.shift();
if (resolved.has(moduleName)) continue;
if (!moduleExists(moduleName)) {
continue; // Already warned during validation
}
resolved.add(moduleName);
// Add editor companion if exists
const editor = getEditorCompanion(moduleName);
if (editor && !resolved.has(editor)) {
queue.push(editor);
}
// Add PCGEx dependencies
const deps = scanBuildCsDependencies(moduleName);
for (const dep of deps) {
if (!resolved.has(dep)) {
queue.push(dep);
}
}
}
return resolved;
}
// =============================================================================
// Uplugin Generation
// =============================================================================
function loadExistingUplugin() {
if (!fs.existsSync(PATHS.uplugin)) {
console.error(`.uplugin not found at: ${PATHS.uplugin}`);
process.exit(1);
}
return fs.readFileSync(PATHS.uplugin, 'utf8');
}
function buildModuleEntry(name) {
const isEditor = name.endsWith(EDITOR_SUFFIX);
return {
Name: name,
Type: isEditor ? 'Editor' : 'Runtime',
LoadingPhase: 'Default',
PlatformAllowList: isEditor ? [...PLATFORMS.editor] : [...PLATFORMS.runtime]
};
}
function buildPluginEntry(name) {
return {
Name: name,
Enabled: true
};
}
function resolveRequiredPlugins(modules, pluginsDeps) {
const plugins = new Set(['PCG']); // PCG is always required
for (const moduleName of modules) {
const required = pluginsDeps.get(moduleName);
if (required) {
for (const plugin of required) {
plugins.add(plugin);
}
}
}
return plugins;
}
function generateUplugin(existingUplugin, modules, plugins) {
// Sort modules: umbrella first, then alphabetically
const sortedModules = Array.from(modules).sort((a, b) => {
const aIsUmbrella = a === PLUGIN_NAME || a === PLUGIN_NAME + EDITOR_SUFFIX;
const bIsUmbrella = b === PLUGIN_NAME || b === PLUGIN_NAME + EDITOR_SUFFIX;
if (aIsUmbrella && !bIsUmbrella) return -1;
if (!aIsUmbrella && bIsUmbrella) return 1;
if (aIsUmbrella && bIsUmbrella) {
// Main umbrella before editor umbrella
return a === PLUGIN_NAME ? -1 : 1;
}
return a.localeCompare(b);
});
// Build new uplugin preserving all existing metadata
const newUplugin = { ...existingUplugin };
// Always include umbrella modules
const allModules = new Set(sortedModules);
allModules.add(PLUGIN_NAME);
allModules.add(PLUGIN_NAME + EDITOR_SUFFIX);
newUplugin.Modules = Array.from(allModules)
.sort((a, b) => {
const aIsUmbrella = a === PLUGIN_NAME || a === PLUGIN_NAME + EDITOR_SUFFIX;
const bIsUmbrella = b === PLUGIN_NAME || b === PLUGIN_NAME + EDITOR_SUFFIX;
if (aIsUmbrella && !bIsUmbrella) return -1;
if (!aIsUmbrella && bIsUmbrella) return 1;
if (aIsUmbrella && bIsUmbrella) return a === PLUGIN_NAME ? -1 : 1;
return a.localeCompare(b);
})
.map(buildModuleEntry);
newUplugin.Plugins = Array.from(plugins)
.sort((a, b) => {
// PCG first, then alphabetically
if (a === 'PCG') return -1;
if (b === 'PCG') return 1;
return a.localeCompare(b);
})
.map(buildPluginEntry);
return newUplugin;
}
// =============================================================================
// Build Artifact Cleanup
// =============================================================================
function deleteFolderRecursive(folderPath) {
if (!fs.existsSync(folderPath)) {
return false;
}
fs.rmSync(folderPath, { recursive: true, force: true });
return true;
}
function cleanBuildArtifacts() {
console.log('\n[CLEANUP] .uplugin changed - invalidating build artifacts...');
let cleaned = false;
if (deleteFolderRecursive(PATHS.binaries)) {
console.log(` Deleted: ${PATHS.binaries}`);
cleaned = true;
}
if (deleteFolderRecursive(PATHS.intermediate)) {
console.log(` Deleted: ${PATHS.intermediate}`);
cleaned = true;
}
if (!cleaned) {
console.log(' No build artifacts to clean.');
}
}
// =============================================================================
// Main
// =============================================================================
function main() {
console.log(`Generating ${PLUGIN_NAME}.uplugin...`);
console.log(`Plugin root: ${pluginRoot}`);
console.log(`Project root: ${projectRoot}`);
// Resolve config path (project-level or plugin-level)
const configInfo = resolveSubmodulesConfigPath();
if (!configInfo) {
console.error(`PCGExSubModulesConfig.ini not found.`);
console.error(`Searched:`);
console.error(` - ${PATHS.projectSubmodulesConfig}`);
console.error(` - ${PATHS.pluginSubmodulesConfig}`);
process.exit(1);
}
const configSource = configInfo.isProjectLevel ? 'project' : 'plugin';
console.log(`Using ${configSource}-level config: ${configInfo.path}`);
// Get available modules for validation and suggestions
const availableModules = getAvailableModules();
console.log(`Available modules on disk: ${availableModules.length}`);
// Parse configs
const requestedModules = parseSubModulesConfig(configInfo.path);
console.log(`Requested modules: ${requestedModules.length}`);
// Validate requested modules
const { valid, invalid } = validateRequestedModules(requestedModules, availableModules);
if (invalid.length > 0) {
console.warn(`\n[WARNING] Invalid module names in PCGExSubModulesConfig.ini:`);
for (const { name, suggestion } of invalid) {
if (suggestion) {
console.warn(` - '${name}' not found. Did you mean '${suggestion}'?`);
} else {
console.warn(` - '${name}' not found.`);
}
}
console.warn('');
}
const pluginsDeps = parsePluginsDeps(PATHS.pluginsDeps);
console.log(`Plugin dependencies defined for: ${pluginsDeps.size} modules`);
// Resolve all modules (with deps and editor companions)
const allModules = resolveAllModules(valid);
console.log(`Resolved modules (with dependencies): ${allModules.size}`);
// Resolve required plugins
const requiredPlugins = resolveRequiredPlugins(allModules, pluginsDeps);
console.log(`Required plugins: ${Array.from(requiredPlugins).join(', ')}`);
// Load existing uplugin content (as string for comparison)
const existingContent = loadExistingUplugin();
const existingUplugin = JSON.parse(existingContent);
// Generate new uplugin
const newUplugin = generateUplugin(existingUplugin, allModules, requiredPlugins);
const newContent = JSON.stringify(newUplugin, null, ' ');
// Check if content changed
const hasChanged = existingContent !== newContent;
if (hasChanged) {
// Clean build artifacts before writing new uplugin
// cleanBuildArtifacts();
// Write new uplugin
fs.writeFileSync(PATHS.uplugin, newContent, 'utf8');
console.log(`\nGenerated ${PATHS.uplugin}`);
} else {
console.log(`\n.uplugin unchanged - skipping write.`);
}
console.log(` Modules: ${newUplugin.Modules.length}`);
console.log(` Plugins: ${newUplugin.Plugins.length}`);
// Exit with warning code if there were invalid modules
if (invalid.length > 0) {
process.exit(0); // Still succeeds, but you could use exit(1) to fail the build
}
}
main();