Created
March 12, 2026 04:38
-
-
Save MarcusBuer/1c3c7d209027b72c730ae5bc036315f6 to your computer and use it in GitHub Desktop.
Script for building UE plugins
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| Builds Unreal plugins for one or more engine versions, syncs outputs to test projects, | |
| and optionally packages zip archives for distribution. | |
| Expected project structure (relative to this script): | |
| .\ | |
| ├─ BuildPlugin.ps1 | |
| ├─ config.ini | |
| ├─ Project\ | |
| │ └─ Plugins\ | |
| │ └─ <PluginName>\ | |
| │ └─ <PluginName>.uplugin | |
| ├─ Build\ | |
| │ └─ <PluginName>\ | |
| │ └─ <PluginName_5.x> (generated build output) | |
| ├─ Archive\ | |
| │ └─ <PluginName>\ | |
| │ └─ yyyy.MM.dd HH.mm\ (generated archive output/logs) | |
| └─ PluginTestProjects\ | |
| └─ UE_5.x\ | |
| └─ Plugins\ | |
| └─ <PluginName_5.x> (synced from Build output) | |
| Engine install expectation: | |
| <EngineBasePath>\UE_5.x\Engine\Build\BatchFiles\RunUAT.bat | |
| Examples: | |
| # Interactive Setup | |
| .\BuildPlugin.ps1 | |
| # Default run (build + sync + archive) using EngineVersions from config.ini | |
| .\BuildPlugin.ps1 -PluginName "MyPlugin" | |
| # Build specific versions | |
| .\BuildPlugin.ps1 -PluginName "MyPlugin" -EngineVersions @("UE_5.5","UE_5.6") | |
| # Development mode (skip archive) | |
| .\BuildPlugin.ps1 -PluginName "MyPlugin" -EnableArchive:$false | |
| # Voice only for end notifications | |
| .\BuildPlugin.ps1 -PluginName "MyPlugin" -EnableVoiceNotifications:$true -SpeakEndNotificationsOnly:$true | |
| # Silent run (no voice) | |
| .\BuildPlugin.ps1 -PluginName "MyPlugin" -EnableVoiceNotifications:$false | |
| # Clean source plugin Binaries/Intermediate before each build | |
| .\BuildPlugin.ps1 -PluginName "MyPlugin" -CleanSourceBuild | |
| # Use a different config file | |
| .\BuildPlugin.ps1 -PluginName "MyPlugin" -ConfigFile ".\config.local.ini" | |
| #> | |
| [CmdletBinding()] | |
| param ( | |
| # --- User Parameters --- | |
| [string] $PluginName, | |
| [string[]] $EngineVersions, | |
| [switch] $UsePassword = $false, # If set, generates a random password and saves it to password.txt in the archive. | |
| [switch] $VerboseRun = $false, # Set to $true if you want to see the UAT and 7Zip outputs | |
| [bool] $EnableArchive = $false, # If false, skips zip packaging and archive output. | |
| [bool] $EnableVoiceNotifications = $true, # If true, speaks user notifications asynchronously. | |
| [bool] $SpeakEndNotificationsOnly = $true, # If true, only end-of-run notifications are spoken. | |
| [switch] $CleanSourceBuild, # If set, deletes Binaries/Intermediate from source plugin dir before build. | |
| [switch] $CleanBuildDirOnSuccess, # If set to true will delete the Build files after packaging into Zip files. | |
| # --- Config --- | |
| [string] $ConfigFile = "config.ini", | |
| # --- Absolute Paths (optional override; config.ini or fallback default is used when omitted) --- | |
| [string] $EngineBasePath, | |
| [string] $SevenZipPath, | |
| # --- Relative Paths (optional override; config.ini or fallback default is used when omitted) --- | |
| [string] $ProjectDir, | |
| [string] $PluginBuildDir, | |
| [string] $ArchiveDir, | |
| [string] $TestProjectsBaseDir | |
| ) | |
| # ------------------------------------------------------------ | |
| # GLOBAL SETTINGS | |
| # ------------------------------------------------------------ | |
| $ErrorActionPreference = "Stop" | |
| $Stopwatch = [System.Diagnostics.Stopwatch]::StartNew() | |
| $Timestamp = Get-Date -Format "yyyy.MM.dd HH.mm" | |
| # Fallback for $PSScriptRoot if not set (e.g., running in ISE) | |
| if (-not $PSScriptRoot) | |
| { | |
| Write-Info "PSScriptRoot not found, using current directory as base path." | |
| $ScriptBasePath = (Get-Location).Path | |
| } else | |
| { | |
| $ScriptBasePath = $PSScriptRoot | |
| } | |
| $DefaultEngineBasePath = "F:\UE" | |
| $DefaultSevenZipPath = "C:\Program Files\7-Zip\7z.exe" | |
| $DefaultProjectDir = "Project" | |
| $DefaultPluginBuildDir = "Build" | |
| $DefaultArchiveDir = "Archive" | |
| $DefaultTestProjectsBaseDir = "PluginTestProjects" | |
| $ConfigPath = if ([System.IO.Path]::IsPathRooted($ConfigFile)) | |
| { $ConfigFile | |
| } else | |
| { Join-Path $ScriptBasePath $ConfigFile | |
| } | |
| $ConfigValues = @{} | |
| if (Test-Path $ConfigPath) | |
| { | |
| foreach ($RawLine in (Get-Content $ConfigPath)) | |
| { | |
| $Line = $RawLine.Trim() | |
| if (-not $Line -or $Line.StartsWith(';') -or $Line.StartsWith('#')) | |
| { | |
| continue | |
| } | |
| if ($Line.StartsWith('[') -and $Line.EndsWith(']')) | |
| { | |
| continue | |
| } | |
| $SeparatorIndex = $Line.IndexOf('=') | |
| if ($SeparatorIndex -lt 1) | |
| { | |
| continue | |
| } | |
| $Key = $Line.Substring(0, $SeparatorIndex).Trim() | |
| $Value = $Line.Substring($SeparatorIndex + 1).Trim().Trim('"') | |
| if (-not [string]::IsNullOrWhiteSpace($Key)) | |
| { | |
| $ConfigValues[$Key] = $Value | |
| } | |
| } | |
| } | |
| $EngineBasePath = if ($PSBoundParameters.ContainsKey('EngineBasePath')) | |
| { $EngineBasePath | |
| } elseif ($ConfigValues.ContainsKey('EngineBasePath')) | |
| { $ConfigValues['EngineBasePath'] | |
| } else | |
| { $DefaultEngineBasePath | |
| } | |
| $SevenZipPath = if ($PSBoundParameters.ContainsKey('SevenZipPath')) | |
| { $SevenZipPath | |
| } elseif ($ConfigValues.ContainsKey('SevenZipPath')) | |
| { $ConfigValues['SevenZipPath'] | |
| } else | |
| { $DefaultSevenZipPath | |
| } | |
| $ProjectDir = if ($PSBoundParameters.ContainsKey('ProjectDir')) | |
| { $ProjectDir | |
| } elseif ($ConfigValues.ContainsKey('ProjectDir')) | |
| { $ConfigValues['ProjectDir'] | |
| } else | |
| { $DefaultProjectDir | |
| } | |
| $PluginBuildDir = if ($PSBoundParameters.ContainsKey('PluginBuildDir')) | |
| { $PluginBuildDir | |
| } elseif ($ConfigValues.ContainsKey('PluginBuildDir')) | |
| { $ConfigValues['PluginBuildDir'] | |
| } else | |
| { $DefaultPluginBuildDir | |
| } | |
| $ArchiveDir = if ($PSBoundParameters.ContainsKey('ArchiveDir')) | |
| { $ArchiveDir | |
| } elseif ($ConfigValues.ContainsKey('ArchiveDir')) | |
| { $ConfigValues['ArchiveDir'] | |
| } else | |
| { $DefaultArchiveDir | |
| } | |
| $TestProjectsBaseDir = if ($PSBoundParameters.ContainsKey('TestProjectsBaseDir')) | |
| { $TestProjectsBaseDir | |
| } elseif ($ConfigValues.ContainsKey('TestProjectsBaseDir')) | |
| { $ConfigValues['TestProjectsBaseDir'] | |
| } else | |
| { $DefaultTestProjectsBaseDir | |
| } | |
| if ($PSBoundParameters.ContainsKey('EngineVersions')) | |
| { | |
| $EngineVersions = @($EngineVersions | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) | |
| } elseif ($ConfigValues.ContainsKey('EngineVersions')) | |
| { | |
| $EngineVersions = @( | |
| ($ConfigValues['EngineVersions'] -split '[,;]') | |
| | ForEach-Object { $_.Trim() } | |
| | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | |
| ) | |
| } | |
| $InitialPluginNames = @() | |
| if (-not [string]::IsNullOrWhiteSpace($PluginName)) | |
| { | |
| $InitialPluginNames = @( | |
| ($PluginName -split '[,;]') | |
| | ForEach-Object { $_.Trim() } | |
| | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | |
| ) | |
| } | |
| $SelectedPluginNames = @() | |
| $PluginDir = $null | |
| $PluginOutputBase = $null | |
| $ArchiveBase = $null | |
| $ArchiveDirWithTimestamp = $null | |
| $PluginSourceFile = $null | |
| $LogFile = $null | |
| $LogFileUri = $null | |
| $FailedBuilds = @() | |
| $script:TaskHadWarning = $false | |
| # Create a new SpVoice object | |
| $voice = $null | |
| $SVSFlagsAsync = 1 | |
| if ($EnableVoiceNotifications) | |
| { | |
| try | |
| { | |
| $voice = New-Object -ComObject Sapi.spvoice | |
| $voice.rate = 4 | |
| } catch | |
| { | |
| $voice = $null | |
| } | |
| } | |
| # ------------------------------------------------------------ | |
| # UI UTILITIES | |
| # ------------------------------------------------------------ | |
| function Write-Log([string]$Text) | |
| { | |
| if ([string]::IsNullOrWhiteSpace($script:LogFile)) | |
| { | |
| return | |
| } | |
| $logDir = Split-Path -Path $script:LogFile -Parent | |
| if (-not [string]::IsNullOrWhiteSpace($logDir) -and (Test-Path $logDir)) | |
| { | |
| Add-Content -Path $script:LogFile -Value $Text -Encoding utf8 | |
| } | |
| } | |
| function Get-UiCanvasMetrics | |
| { | |
| $designWidth = 88 | |
| $windowWidth = 100 | |
| try | |
| { | |
| if ($Host -and $Host.UI -and $Host.UI.RawUI) | |
| { | |
| $windowWidth = [Math]::Max(40, $Host.UI.RawUI.WindowSize.Width) | |
| } | |
| } catch | |
| { | |
| $windowWidth = 100 | |
| } | |
| $canvasWidth = [Math]::Min($designWidth, $windowWidth) | |
| $leftPad = [Math]::Max(0, [int](($windowWidth - $canvasWidth) / 2)) | |
| return [pscustomobject]@{ Width = $canvasWidth; LeftPad = $leftPad } | |
| } | |
| function Write-CanvasLine | |
| { | |
| param ( | |
| [string]$Text, | |
| [string]$Color = 'Gray', | |
| [switch]$CenterInCanvas | |
| ) | |
| $canvas = Get-UiCanvasMetrics | |
| $innerPad = 0 | |
| if ($CenterInCanvas) | |
| { | |
| $innerPad = [Math]::Max(0, [int](($canvas.Width - $Text.Length) / 2)) | |
| } | |
| $prefix = (' ' * $canvas.LeftPad) + (' ' * $innerPad) | |
| Write-Host ($prefix + $Text) -ForegroundColor $Color | |
| } | |
| function Write-CenteredHost | |
| { | |
| param ( | |
| [string]$Text, | |
| [string]$Color = 'Gray' | |
| ) | |
| Write-CanvasLine -Text $Text -Color $Color -CenterInCanvas | |
| } | |
| function Write-NeonBanner( | |
| [string]$Subtitle = "PLUGIN BUILD MATRIX" | |
| ) | |
| { | |
| $line = "▓" * 88 | |
| Write-CenteredHost -Text $line -Color DarkMagenta | |
| Write-CenteredHost -Text "" -Color Cyan | |
| Write-CenteredHost -Text "██╗ ██╗ ██╗███████╗███████╗██████╗ █████╗" -Color Green | |
| Write-CenteredHost -Text "██║ ██║ ██║╚══███╔╝██╔════╝██╔══██╗██╔══██╗" -Color Green | |
| Write-CenteredHost -Text "██║ ██║ ██║ ███╔╝ █████╗ ██████╔╝███████║" -Color Green | |
| Write-CenteredHost -Text "██║ ██║ ██║ ███╔╝ ██╔══╝ ██╔══██╗██╔══██║" -Color Green | |
| Write-CenteredHost -Text "███████╗╚██████╔╝███████╗███████╗██║ ██║██║ ██║" -Color Green | |
| Write-CenteredHost -Text "╚══════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝" -Color Green | |
| Write-CenteredHost -Text "⚡ PLUGIN BUILDER :: $Subtitle ⚡" -Color Yellow | |
| Write-CenteredHost -Text $line -Color DarkMagenta | |
| } | |
| function Write-Section( | |
| [string]$Text, | |
| [switch]$IsEndNotification | |
| ) | |
| { | |
| $line = "═" * 88 | |
| Write-CanvasLine -Text "" -Color Gray | |
| Write-CanvasLine -Text $line -Color DarkMagenta | |
| Write-CanvasLine -Text ("🌆 🚀 " + $Text) -Color Cyan | |
| Speak-Notification -Text $Text -IsEndNotification:$IsEndNotification | |
| Write-CanvasLine -Text $line -Color DarkMagenta | |
| Write-Log "" | |
| Write-Log $line | |
| Write-Log ">>> $Text" | |
| Write-Log $line | |
| } | |
| function Write-Success( | |
| [string]$Text, | |
| [switch]$IsEndNotification | |
| ) | |
| { | |
| Write-CanvasLine -Text "🟢 $Text" -Color Green | |
| Speak-Notification -Text $Text -IsEndNotification:$IsEndNotification | |
| Write-Log "[OK] $Text" | |
| } | |
| function Write-Failure( | |
| [string]$Text, | |
| [switch]$IsEndNotification, | |
| [string]$SpeechText, | |
| [switch]$NoSpeech | |
| ) | |
| { | |
| Write-CanvasLine -Text "❌ 🔴 $Text" -Color Red | |
| if (-not $NoSpeech) | |
| { | |
| $SpeechPayload = if ([string]::IsNullOrWhiteSpace($SpeechText)) | |
| { $Text | |
| } else | |
| { $SpeechText | |
| } | |
| Speak-Notification -Text $SpeechPayload -IsEndNotification:$IsEndNotification | |
| } | |
| Write-Log "[FAIL] $Text" | |
| } | |
| function Write-Info( | |
| [string]$Text, | |
| [switch]$IsEndNotification | |
| ) | |
| { | |
| Write-CanvasLine -Text "ℹ️ 🟣 $Text" -Color Magenta | |
| Write-Log "[INFO] $Text" | |
| } | |
| function Write-MissionBriefing | |
| { | |
| param ( | |
| [string[]]$Plugins, | |
| [string[]]$Engines, | |
| [bool]$ArchiveEnabled, | |
| [bool]$PasswordEnabled, | |
| [bool]$CleanSource, | |
| [bool]$CleanBuildOnSuccess, | |
| [bool]$VerboseMode, | |
| [string]$VoiceMode | |
| ) | |
| $line = "═" * 88 | |
| $sub = "─" * 88 | |
| $onTag = "✅ ON" | |
| $offTag = "🔴 OFF" | |
| Write-NeonBanner -Subtitle 'MISSION BRIEFING' | |
| Write-CanvasLine -Text $line -Color DarkMagenta | |
| Write-CanvasLine -Text '🎯 BUILD MISSION CONFIGURATION' -Color Yellow | |
| Write-CanvasLine -Text $sub -Color DarkMagenta | |
| Write-CanvasLine -Text ('🧩 Plugins : {0}' -f ($Plugins -join ', ')) -Color Cyan | |
| Write-CanvasLine -Text ('🛠️ Engines : {0}' -f ($Engines -join ', ')) -Color Cyan | |
| Write-CanvasLine -Text $sub -Color DarkMagenta | |
| Write-CanvasLine -Text ('📦 Archive : {0}' -f $(if($ArchiveEnabled) | |
| {$onTag | |
| } else | |
| {$offTag | |
| })) -Color Magenta | |
| Write-CanvasLine -Text ('🔐 Password : {0}' -f $(if($PasswordEnabled) | |
| {$onTag | |
| } else | |
| {$offTag | |
| })) -Color Magenta | |
| Write-CanvasLine -Text ('🧹 Clean src : {0}' -f $(if($CleanSource) | |
| {$onTag | |
| } else | |
| {$offTag | |
| })) -Color Magenta | |
| Write-CanvasLine -Text ('🧽 Clean build: {0}' -f $(if($CleanBuildOnSuccess) | |
| {$onTag | |
| } else | |
| {$offTag | |
| })) -Color Magenta | |
| Write-CanvasLine -Text ('📡 Verbose : {0}' -f $(if($VerboseMode) | |
| {$onTag | |
| } else | |
| {$offTag | |
| })) -Color Magenta | |
| Write-CanvasLine -Text ('🔊 Voice mode : {0}' -f $VoiceMode) -Color Magenta | |
| Write-CanvasLine -Text $line -Color DarkMagenta | |
| } | |
| function Show-GracefulAbort | |
| { | |
| param ( | |
| [string]$Reason = 'Mission aborted by operator.' | |
| ) | |
| Clear-Host | |
| Write-NeonBanner -Subtitle 'MISSION ABORTED' | |
| Write-CanvasLine -Text '' -Color Gray | |
| Write-CenteredHost -Text ' █████╗ ██████╗ ██████╗ ██████╗ ████████╗███████╗██████╗ ' -Color Yellow | |
| Write-CenteredHost -Text '██╔══██╗██╔══██╗██╔═══██╗██╔══██╗╚══██╔══╝██╔════╝██╔══██╗' -Color Yellow | |
| Write-CenteredHost -Text '███████║██████╔╝██║ ██║██████╔╝ ██║ █████╗ ██║ ██║' -Color Yellow | |
| Write-CenteredHost -Text '██╔══██║██╔══██╗██║ ██║██╔══██╗ ██║ ██╔══╝ ██║ ██║' -Color Yellow | |
| Write-CenteredHost -Text '██║ ██║██████╔╝╚██████╔╝██║ ██║ ██║ ███████╗██████╔╝' -Color Yellow | |
| Write-CenteredHost -Text '╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═════╝ ' -Color Yellow | |
| Write-CanvasLine -Text '' -Color Gray | |
| Write-CenteredHost -Text ('🛑 ' + $Reason) -Color Yellow | |
| Write-CenteredHost -Text '🌙 No build tasks were started. You can relaunch whenever you are ready.' -Color DarkCyan | |
| Write-CanvasLine -Text '' -Color Gray | |
| } | |
| function Read-MissionProceedChoice | |
| { | |
| if (-not (Supports-InteractiveMenu)) | |
| { | |
| if (Read-YesNoChoice -Prompt 'Start build mission now?' -DefaultValue $true) | |
| { | |
| return 'Start' | |
| } | |
| return 'Abort' | |
| } | |
| Write-CanvasLine -Text '🚀 [Enter] Start • [Backspace] Go back • [Esc] Abort' -Color Yellow | |
| while ($true) | |
| { | |
| $key = [Console]::ReadKey($true) | |
| switch ($key.Key) | |
| { | |
| 'Enter' | |
| { return 'Start' | |
| } | |
| 'Backspace' | |
| { return 'Back' | |
| } | |
| 'LeftArrow' | |
| { return 'Back' | |
| } | |
| 'Escape' | |
| { return 'Abort' | |
| } | |
| } | |
| } | |
| } | |
| function Speak-Notification | |
| { | |
| param ( | |
| [string]$Text, | |
| [switch]$IsEndNotification | |
| ) | |
| if ($EnableVoiceNotifications -and $script:voice -and -not [string]::IsNullOrWhiteSpace($Text)) | |
| { | |
| if ($SpeakEndNotificationsOnly -and -not $IsEndNotification) | |
| { | |
| return | |
| } | |
| try | |
| { | |
| # Async mode queues messages in order without blocking build execution. | |
| $script:voice.Speak($Text, $script:SVSFlagsAsync) | Out-Null | |
| } catch | |
| { | |
| # Keep builds running if voice output fails. | |
| } | |
| } | |
| } | |
| # Interactive CLI helpers | |
| function Read-YesNoChoice | |
| { | |
| param ( | |
| [string]$Prompt, | |
| [bool]$DefaultValue = $true | |
| ) | |
| $defaultLabel = if ($DefaultValue) | |
| { "Y/n" | |
| } else | |
| { "y/N" | |
| } | |
| while ($true) | |
| { | |
| $answer = Read-Host "$Prompt [$defaultLabel]" | |
| if ([string]::IsNullOrWhiteSpace($answer)) | |
| { | |
| return $DefaultValue | |
| } | |
| switch ($answer.Trim().ToLowerInvariant()) | |
| { | |
| "y" | |
| { return $true | |
| } | |
| "yes" | |
| { return $true | |
| } | |
| "n" | |
| { return $false | |
| } | |
| "no" | |
| { return $false | |
| } | |
| default | |
| { Write-CanvasLine -Text "Invalid choice. Use y or n." -Color Yellow | |
| } | |
| } | |
| } | |
| } | |
| function Read-MultiSelectByIndex | |
| { | |
| param ( | |
| [string]$Prompt, | |
| [string[]]$Options, | |
| [int[]]$DefaultIndices | |
| ) | |
| if (-not $Options -or $Options.Count -eq 0) | |
| { | |
| return @() | |
| } | |
| while ($true) | |
| { | |
| Write-CanvasLine -Text "" -Color Gray | |
| Write-CanvasLine -Text $Prompt -Color Cyan | |
| for ($i = 0; $i -lt $Options.Count; $i++) | |
| { | |
| Write-CanvasLine -Text ("[{0}] {1}" -f ($i + 1), $Options[$i]) -Color Gray | |
| } | |
| $defaultText = "" | |
| if ($DefaultIndices -and $DefaultIndices.Count -gt 0) | |
| { | |
| $defaultText = " (default: $($DefaultIndices -join ','))" | |
| } | |
| $rawInput = Read-Host "Choose one or more values separated by comma, or 'a' for all$defaultText" | |
| if ([string]::IsNullOrWhiteSpace($rawInput)) | |
| { | |
| if ($DefaultIndices -and $DefaultIndices.Count -gt 0) | |
| { | |
| return @( | |
| $DefaultIndices | |
| | Where-Object { $_ -ge 1 -and $_ -le $Options.Count } | |
| | ForEach-Object { $Options[$_ - 1] } | |
| ) | |
| } | |
| Write-CanvasLine -Text "Select at least one option." -Color Yellow | |
| continue | |
| } | |
| if ($rawInput.Trim().ToLowerInvariant() -eq "a") | |
| { | |
| return @($Options) | |
| } | |
| $indices = @() | |
| $valid = $true | |
| foreach ($token in ($rawInput -split '[,\s;]')) | |
| { | |
| if ([string]::IsNullOrWhiteSpace($token)) | |
| { | |
| continue | |
| } | |
| $number = 0 | |
| if (-not [int]::TryParse($token, [ref]$number)) | |
| { | |
| $valid = $false | |
| break | |
| } | |
| if ($number -lt 1 -or $number -gt $Options.Count) | |
| { | |
| $valid = $false | |
| break | |
| } | |
| $indices += $number | |
| } | |
| if (-not $valid -or $indices.Count -eq 0) | |
| { | |
| Write-CanvasLine -Text "Invalid selection." -Color Yellow | |
| continue | |
| } | |
| return @( | |
| $indices | |
| | Select-Object -Unique | |
| | Sort-Object | |
| | ForEach-Object { $Options[$_ - 1] } | |
| ) | |
| } | |
| } | |
| function Supports-InteractiveMenu | |
| { | |
| try | |
| { | |
| return (-not [Console]::IsInputRedirected) -and (-not [Console]::IsOutputRedirected) | |
| } catch | |
| { | |
| return $false | |
| } | |
| } | |
| function Read-MultiSelectMenu | |
| { | |
| param ( | |
| [string]$Title, | |
| [string[]]$Options, | |
| [int[]]$DefaultIndices, | |
| [switch]$AllowNone, | |
| [switch]$AllowBack, | |
| [switch]$ReturnAction | |
| ) | |
| if (-not $Options -or $Options.Count -eq 0) | |
| { | |
| return @() | |
| } | |
| if (-not (Supports-InteractiveMenu)) | |
| { | |
| $fallbackValues = Read-MultiSelectByIndex -Prompt $Title -Options $Options -DefaultIndices $DefaultIndices | |
| if ($ReturnAction) | |
| { | |
| return [pscustomobject]@{ Action = 'next'; Values = @($fallbackValues) } | |
| } | |
| return @($fallbackValues) | |
| } | |
| $selected = New-Object 'System.Collections.Generic.HashSet[int]' | |
| foreach ($idx in @($DefaultIndices)) | |
| { | |
| if ($idx -ge 1 -and $idx -le $Options.Count) | |
| { | |
| [void]$selected.Add($idx - 1) | |
| } | |
| } | |
| if ($selected.Count -eq 0 -and -not $AllowNone) | |
| { | |
| [void]$selected.Add(0) | |
| } | |
| $cursor = 0 | |
| while ($true) | |
| { | |
| Clear-Host | |
| Write-NeonBanner -Subtitle 'SETUP' | |
| Write-CanvasLine -Text ('🧠 ' + $Title) -Color Cyan | |
| $rule = '─' * (Get-UiCanvasMetrics).Width | |
| $hint = '⬆/⬇ Move • [Space] Toggle • [A] All • [Enter] Confirm • [Esc] Abort' | |
| if ($AllowBack) | |
| { | |
| $hint = '⬆/⬇ Move • [Space] Toggle • [A] All • [Enter] Confirm • [Backspace] Back • [Esc] Abort' | |
| } | |
| Write-CanvasLine -Text $rule -Color DarkMagenta | |
| Write-CanvasLine -Text $hint -Color DarkMagenta | |
| Write-CanvasLine -Text $rule -Color DarkMagenta | |
| Write-CanvasLine -Text '' -Color Gray | |
| for ($i = 0; $i -lt $Options.Count; $i++) | |
| { | |
| $pointer = if ($i -eq $cursor) | |
| { '▶' | |
| } else | |
| { '·' | |
| } | |
| $checked = if ($selected.Contains($i)) | |
| { '■' | |
| } else | |
| { ' ' | |
| } | |
| $color = if ($i -eq $cursor) | |
| { 'Yellow' | |
| } else | |
| { 'DarkCyan' | |
| } | |
| Write-CanvasLine -Text (' {0} [{1}] {2}' -f $pointer, $checked, $Options[$i]) -Color $color | |
| } | |
| $key = [Console]::ReadKey($true) | |
| switch ($key.Key) | |
| { | |
| 'UpArrow' | |
| { $cursor = if ($cursor -le 0) | |
| { $Options.Count - 1 | |
| } else | |
| { $cursor - 1 | |
| }; continue | |
| } | |
| 'DownArrow' | |
| { $cursor = if ($cursor -ge ($Options.Count - 1)) | |
| { 0 | |
| } else | |
| { $cursor + 1 | |
| }; continue | |
| } | |
| 'Spacebar' | |
| { | |
| if ($selected.Contains($cursor)) | |
| { [void]$selected.Remove($cursor) | |
| } else | |
| { [void]$selected.Add($cursor) | |
| } | |
| continue | |
| } | |
| 'A' | |
| { | |
| if ($selected.Count -eq $Options.Count) | |
| { | |
| $selected.Clear() | |
| } else | |
| { | |
| $selected.Clear() | |
| for ($j = 0; $j -lt $Options.Count; $j++) | |
| { [void]$selected.Add($j) | |
| } | |
| } | |
| continue | |
| } | |
| 'Backspace' | |
| { | |
| if ($AllowBack) | |
| { | |
| if ($ReturnAction) | |
| { return [pscustomobject]@{ Action = 'back'; Values = @() } | |
| } | |
| return @() | |
| } | |
| continue | |
| } | |
| 'LeftArrow' | |
| { | |
| if ($AllowBack) | |
| { | |
| if ($ReturnAction) | |
| { return [pscustomobject]@{ Action = 'back'; Values = @() } | |
| } | |
| return @() | |
| } | |
| continue | |
| } | |
| 'Enter' | |
| { | |
| if ($selected.Count -eq 0 -and -not $AllowNone) | |
| { | |
| Write-CanvasLine -Text '' -Color Gray | |
| Write-CanvasLine -Text 'Select at least one option.' -Color Yellow | |
| Start-Sleep -Milliseconds 700 | |
| continue | |
| } | |
| $values = @($selected | Sort-Object | ForEach-Object { $Options[$_] }) | |
| if ($ReturnAction) | |
| { | |
| return [pscustomobject]@{ Action = 'next'; Values = $values } | |
| } | |
| return $values | |
| } | |
| 'Escape' | |
| { | |
| if ($ReturnAction) | |
| { return [pscustomobject]@{ Action = 'cancel'; Values = @() } | |
| } | |
| throw 'Setup canceled by user.' | |
| } | |
| } | |
| } | |
| } | |
| function Read-SingleSelectMenu | |
| { | |
| param ( | |
| [string]$Title, | |
| [string[]]$Options, | |
| [int]$DefaultIndex = 1, | |
| [switch]$AllowBack, | |
| [switch]$ReturnAction | |
| ) | |
| if (-not $Options -or $Options.Count -eq 0) | |
| { | |
| throw 'No options provided for selection menu.' | |
| } | |
| if ($DefaultIndex -lt 1 -or $DefaultIndex -gt $Options.Count) | |
| { | |
| $DefaultIndex = 1 | |
| } | |
| if (-not (Supports-InteractiveMenu)) | |
| { | |
| $value = (Read-MultiSelectByIndex -Prompt $Title -Options $Options -DefaultIndices @($DefaultIndex))[0] | |
| if ($ReturnAction) | |
| { | |
| return [pscustomobject]@{ Action = 'next'; Value = $value } | |
| } | |
| return $value | |
| } | |
| $cursor = $DefaultIndex - 1 | |
| while ($true) | |
| { | |
| Clear-Host | |
| Write-NeonBanner -Subtitle 'SETUP' | |
| Write-CanvasLine -Text ('🧠 ' + $Title) -Color Cyan | |
| $rule = '─' * (Get-UiCanvasMetrics).Width | |
| $hint = '⬆/⬇ move • [Enter] confirm • [Esc] abort' | |
| if ($AllowBack) | |
| { | |
| $hint = '⬆/⬇ move • [Enter] confirm • [Backspace] back • [Esc] abort' | |
| } | |
| Write-CanvasLine -Text $rule -Color DarkMagenta | |
| Write-CanvasLine -Text $hint -Color DarkMagenta | |
| Write-CanvasLine -Text $rule -Color DarkMagenta | |
| Write-CanvasLine -Text '' -Color Gray | |
| for ($i = 0; $i -lt $Options.Count; $i++) | |
| { | |
| $pointer = if ($i -eq $cursor) | |
| { '▶' | |
| } else | |
| { '·' | |
| } | |
| $mark = if ($i -eq $cursor) | |
| { '◉' | |
| } else | |
| { '○' | |
| } | |
| $color = if ($i -eq $cursor) | |
| { 'Yellow' | |
| } else | |
| { 'DarkCyan' | |
| } | |
| Write-CanvasLine -Text (' {0} ({1}) {2}' -f $pointer, $mark, $Options[$i]) -Color $color | |
| } | |
| $key = [Console]::ReadKey($true) | |
| switch ($key.Key) | |
| { | |
| 'UpArrow' | |
| { $cursor = if ($cursor -le 0) | |
| { $Options.Count - 1 | |
| } else | |
| { $cursor - 1 | |
| }; continue | |
| } | |
| 'DownArrow' | |
| { $cursor = if ($cursor -ge ($Options.Count - 1)) | |
| { 0 | |
| } else | |
| { $cursor + 1 | |
| }; continue | |
| } | |
| 'Backspace' | |
| { | |
| if ($AllowBack) | |
| { | |
| if ($ReturnAction) | |
| { return [pscustomobject]@{ Action = 'back'; Value = $null } | |
| } | |
| return $null | |
| } | |
| continue | |
| } | |
| 'LeftArrow' | |
| { | |
| if ($AllowBack) | |
| { | |
| if ($ReturnAction) | |
| { return [pscustomobject]@{ Action = 'back'; Value = $null } | |
| } | |
| return $null | |
| } | |
| continue | |
| } | |
| 'Enter' | |
| { | |
| $value = $Options[$cursor] | |
| if ($ReturnAction) | |
| { | |
| return [pscustomobject]@{ Action = 'next'; Value = $value } | |
| } | |
| return $value | |
| } | |
| 'Escape' | |
| { | |
| if ($ReturnAction) | |
| { return [pscustomobject]@{ Action = 'cancel'; Value = $null } | |
| } | |
| throw 'Setup canceled by user.' | |
| } | |
| } | |
| } | |
| } | |
| function Get-AvailablePluginNames | |
| { | |
| param ( | |
| [string]$PluginsRoot | |
| ) | |
| if (-not (Test-Path $PluginsRoot)) | |
| { | |
| return @() | |
| } | |
| $pluginNames = @() | |
| $pluginDirs = Get-ChildItem -Path $PluginsRoot -Directory | Sort-Object Name | |
| foreach ($pluginDir in $pluginDirs) | |
| { | |
| $upluginPath = Join-Path $pluginDir.FullName ("{0}.uplugin" -f $pluginDir.Name) | |
| if (Test-Path $upluginPath) | |
| { | |
| $pluginNames += $pluginDir.Name | |
| } | |
| } | |
| return @($pluginNames) | |
| } | |
| function Set-PluginContext | |
| { | |
| param ( | |
| [string]$CurrentPluginName | |
| ) | |
| $script:PluginName = $CurrentPluginName | |
| $script:PluginDir = Join-Path $ScriptBasePath "$ProjectDir\Plugins\$CurrentPluginName" | |
| $script:PluginOutputBase = Join-Path $ScriptBasePath "$PluginBuildDir\$CurrentPluginName" | |
| $script:ArchiveBase = Join-Path $ScriptBasePath "$ArchiveDir\$CurrentPluginName" | |
| $script:ArchiveDirWithTimestamp = Join-Path $script:ArchiveBase $Timestamp | |
| $script:PluginSourceFile = Join-Path $script:PluginDir "$CurrentPluginName.uplugin" | |
| $script:LogFile = if ($EnableArchive) | |
| { Join-Path $script:ArchiveDirWithTimestamp "build_log.txt" | |
| } else | |
| { Join-Path $script:PluginOutputBase "build_log.txt" | |
| } | |
| $script:LogFileUri = ([System.Uri]::new($script:LogFile)).AbsoluteUri | |
| } | |
| # ------------------------------------------------------------ | |
| # TOOLING UTILITIES | |
| # ------------------------------------------------------------ | |
| # ------------------------------------------------------------ | |
| # DASHBOARD UTILITIES | |
| # ------------------------------------------------------------ | |
| function Get-PluginLogUri | |
| { | |
| param ( | |
| [string]$PluginNameToResolve | |
| ) | |
| $outputBase = Join-Path $ScriptBasePath "$PluginBuildDir\$PluginNameToResolve" | |
| $archiveBase = Join-Path $ScriptBasePath "$ArchiveDir\$PluginNameToResolve" | |
| $logPath = if ($EnableArchive) | |
| { Join-Path (Join-Path $archiveBase $Timestamp) 'build_log.txt' | |
| } else | |
| { Join-Path $outputBase 'build_log.txt' | |
| } | |
| return ([System.Uri]::new($logPath)).AbsoluteUri | |
| } | |
| function Get-StatusIcon | |
| { | |
| param ( | |
| [string]$Status | |
| ) | |
| switch ($Status) | |
| { | |
| 'queued' | |
| { return '🔵' | |
| } | |
| 'running' | |
| { return '🟣' | |
| } | |
| 'success' | |
| { return '🟢' | |
| } | |
| 'warn' | |
| { return '🟡' | |
| } | |
| 'failed' | |
| { return '🔴' | |
| } | |
| default | |
| { return '⚪' | |
| } | |
| } | |
| } | |
| function New-BuildMatrix | |
| { | |
| param ( | |
| [string[]]$Plugins, | |
| [string[]]$Versions | |
| ) | |
| $matrix = [ordered]@{} | |
| foreach ($plugin in $Plugins) | |
| { | |
| $versionMap = [ordered]@{} | |
| foreach ($version in $Versions) | |
| { | |
| $versionMap[$version] = 'queued' | |
| } | |
| $matrix[$plugin] = [ordered]@{ | |
| LogUri = Get-PluginLogUri -PluginNameToResolve $plugin | |
| Versions = $versionMap | |
| } | |
| } | |
| return $matrix | |
| } | |
| function Set-BuildMatrixStatus | |
| { | |
| param ( | |
| [hashtable]$Matrix, | |
| [string]$Plugin, | |
| [string]$Version, | |
| [string]$Status | |
| ) | |
| if ($Matrix.Contains($Plugin) -and $Matrix[$Plugin].Versions.Contains($Version)) | |
| { | |
| $Matrix[$Plugin].Versions[$Version] = $Status | |
| } | |
| } | |
| function Show-BuildDashboard | |
| { | |
| param ( | |
| [hashtable]$Matrix, | |
| [string[]]$Plugins, | |
| [string[]]$Versions, | |
| [string]$CurrentPlugin, | |
| [string]$CurrentVersion | |
| ) | |
| Clear-Host | |
| Write-NeonBanner -Subtitle 'BUILD PROCESS' | |
| $line = '═' * 88 | |
| Write-CanvasLine -Text $line -Color DarkMagenta | |
| if (-not [string]::IsNullOrWhiteSpace($CurrentPlugin) -and -not [string]::IsNullOrWhiteSpace($CurrentVersion)) | |
| { | |
| Write-CanvasLine -Text ("⚙️ Now building: {0} {1}" -f $CurrentPlugin, $CurrentVersion) -Color Yellow | |
| } | |
| Write-CanvasLine -Text $line -Color DarkMagenta | |
| foreach ($plugin in $Plugins) | |
| { | |
| $statusParts = @() | |
| foreach ($version in $Versions) | |
| { | |
| $status = $Matrix[$plugin].Versions[$version] | |
| $statusParts += ("{0} {1}" -f (Get-StatusIcon -Status $status), $version) | |
| } | |
| Write-CanvasLine -Text ("🚀 {0} {1}" -f $plugin, ($statusParts -join ' ')) -Color White | |
| Write-CanvasLine -Text "🗃️ Build log:" -Color DarkGray | |
| Write-CanvasLine -Text (" {0}" -f $Matrix[$plugin].LogUri) -Color DarkGray | |
| } | |
| } | |
| function Show-FinalReport | |
| { | |
| param ( | |
| [hashtable]$Matrix, | |
| [string[]]$Plugins, | |
| [string[]]$Versions | |
| ) | |
| Clear-Host | |
| Write-NeonBanner -Subtitle 'FINAL REPORT' | |
| Write-CanvasLine -Text '' -Color Gray | |
| Write-CenteredHost -Text '██████╗ ███████╗██████╗ ██████╗ ██████╗ ████████╗' -Color Cyan | |
| Write-CenteredHost -Text '██╔══██╗██╔════╝██╔══██╗██╔═══██╗██╔══██╗╚══██╔══╝' -Color Magenta | |
| Write-CenteredHost -Text '██████╔╝█████╗ ██████╔╝██║ ██║██████╔╝ ██║ ' -Color Cyan | |
| Write-CenteredHost -Text '██╔══██╗██╔══╝ ██╔═══╝ ██║ ██║██╔══██╗ ██║ ' -Color Magenta | |
| Write-CenteredHost -Text '██║ ██║███████╗██║ ╚██████╔╝██║ ██║ ██║ ' -Color Cyan | |
| Write-CenteredHost -Text '╚═╝ ╚═╝╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ' -Color Magenta | |
| Write-CanvasLine -Text '' -Color Gray | |
| $successCount = 0 | |
| $warnCount = 0 | |
| $failedCount = 0 | |
| foreach ($plugin in $Plugins) | |
| { | |
| $statusParts = @() | |
| foreach ($version in $Versions) | |
| { | |
| $status = $Matrix[$plugin].Versions[$version] | |
| switch ($status) | |
| { | |
| 'success' | |
| { $successCount++ | |
| } | |
| 'warn' | |
| { $warnCount++ | |
| } | |
| 'failed' | |
| { $failedCount++ | |
| } | |
| } | |
| $statusParts += ("{0} {1}" -f (Get-StatusIcon -Status $status), $version) | |
| } | |
| Write-CanvasLine -Text ("🚀 {0} {1}" -f $plugin, ($statusParts -join ' ')) -Color White | |
| Write-CanvasLine -Text "🗃️ Build log:" -Color DarkGray | |
| Write-CanvasLine -Text (" {0}" -f $Matrix[$plugin].LogUri) -Color DarkGray | |
| Write-CanvasLine -Text '' -Color Gray | |
| } | |
| $line = '═' * 88 | |
| Write-CanvasLine -Text $line -Color DarkMagenta | |
| Write-CanvasLine -Text ("🟢 Success: {0} 🟡 Warning: {1} 🔴 Failed: {2}" -f $successCount, $warnCount, $failedCount) -Color Yellow | |
| Write-CanvasLine -Text $line -Color DarkMagenta | |
| } | |
| # ------------------------------------------------------------ | |
| # TOOLING UTILITIES | |
| # ------------------------------------------------------------function Get-RandomPassword | |
| { | |
| param ( | |
| [int]$Length = 16 | |
| ) | |
| $CharSet = (48..57) + (65..90) + (97..122) | ForEach-Object { [char]$_ } | |
| $Password = (Get-Random -Count $Length -InputObject $CharSet) -join '' | |
| return $Password | |
| } | |
| function Get-PluginVersionName | |
| { | |
| param ( | |
| [string]$EngineVersion | |
| ) | |
| return ($EngineVersion -replace '^UE', $PluginName) | |
| } | |
| function Get-EngineDisplayVersion | |
| { | |
| param ( | |
| [string]$EngineVersion | |
| ) | |
| return ($EngineVersion -replace '^UE_', '') | |
| } | |
| function Remove-DirectoryQuiet | |
| { | |
| param ( | |
| [string]$Path, | |
| [switch]$Async, | |
| [switch]$RenameBeforeDelete | |
| ) | |
| if (-not (Test-Path $Path)) | |
| { | |
| return | |
| } | |
| $DeleteTargetPath = $Path | |
| if ($RenameBeforeDelete) | |
| { | |
| $ParentDir = Split-Path $Path -Parent | |
| $LeafName = Split-Path $Path -Leaf | |
| $TempLeafName = "${LeafName}_Temp" | |
| $TempPath = Join-Path $ParentDir $TempLeafName | |
| $tempIndex = 1 | |
| while (Test-Path $TempPath) | |
| { | |
| $TempLeafName = "{0}_Temp_{1}" -f $LeafName, $tempIndex | |
| $TempPath = Join-Path $ParentDir $TempLeafName | |
| $tempIndex++ | |
| } | |
| Rename-Item -Path $Path -NewName $TempLeafName -Force | |
| $DeleteTargetPath = $TempPath | |
| } | |
| # Use cmd.exe for quiet deletion to avoid PowerShell progress output. | |
| $deleteArgs = "/c rmdir /s /q ""$DeleteTargetPath""" | |
| $process = Start-Process -FilePath "cmd.exe" -ArgumentList $deleteArgs -WindowStyle Hidden -PassThru | |
| if ($Async) | |
| { | |
| Write-Log "[INFO] Scheduled async deletion: $DeleteTargetPath" | |
| return | |
| } | |
| $process.WaitForExit() | |
| if ($process.ExitCode -ne 0 -and (Test-Path $DeleteTargetPath)) | |
| { | |
| throw "Failed to delete directory: $DeleteTargetPath" | |
| } | |
| } | |
| function Invoke-ExternalTool | |
| { | |
| param ( | |
| [string]$Command, | |
| [string[]]$Arguments, | |
| [switch]$VerboseRun | |
| ) | |
| $quotedArgs = $Arguments | ForEach-Object { if ($_ -match '\s') | |
| { """$_""" | |
| } else | |
| { $_ | |
| } } | |
| $cmdLabel = Split-Path $Command -Leaf | |
| if ($VerboseRun) | |
| { | |
| Write-Info "Running Command: $Command" | |
| Write-Info "With Arguments: $($quotedArgs -join ' ')" | |
| } | |
| # Always capture stdout + stderr so we can write them to the log. | |
| $stdoutTmp = [System.IO.Path]::GetTempFileName() | |
| $stderrTmp = [System.IO.Path]::GetTempFileName() | |
| $processArgs = @{ | |
| FilePath = $Command | |
| ArgumentList = $Arguments | |
| Wait = $true | |
| PassThru = $true | |
| NoNewWindow = $true | |
| RedirectStandardOutput = $stdoutTmp | |
| RedirectStandardError = $stderrTmp | |
| } | |
| $process = Start-Process @processArgs | |
| # Append captured output to the log file. | |
| $rawOut = Get-Content $stdoutTmp -Raw -ErrorAction SilentlyContinue | |
| $rawErr = Get-Content $stderrTmp -Raw -ErrorAction SilentlyContinue | |
| Write-Log "" | |
| Write-Log "--- [$cmdLabel] stdout ---" | |
| if ($rawOut) | |
| { Write-Log $rawOut | |
| } | |
| if ($rawErr) | |
| { Write-Log "--- [$cmdLabel] stderr ---"; Write-Log $rawErr | |
| } | |
| # Echo to console only in verbose mode. | |
| if ($VerboseRun) | |
| { | |
| if ($rawOut) | |
| { Write-Host $rawOut | |
| } | |
| if ($rawErr) | |
| { Write-Host $rawErr -ForegroundColor Yellow | |
| } | |
| } | |
| Remove-Item $stdoutTmp, $stderrTmp -Force -ErrorAction SilentlyContinue | |
| if ($process.ExitCode -eq 0) | |
| { | |
| $hasStderrText = -not [string]::IsNullOrWhiteSpace($rawErr) | |
| $hasWarningText = (-not [string]::IsNullOrWhiteSpace($rawOut)) -and ($rawOut -match '(?im)\bwarning(s)?\b') | |
| if ($hasStderrText -or $hasWarningText) | |
| { | |
| $script:TaskHadWarning = $true | |
| } | |
| } | |
| if ($process.ExitCode -ne 0) | |
| { | |
| throw "External tool failed with exit code $($process.ExitCode): $Command $($quotedArgs -join ' ')" | |
| } | |
| } | |
| function New-Task | |
| { | |
| param ( | |
| [string]$Name, | |
| [scriptblock]$Action | |
| ) | |
| Write-Section $Name | |
| try | |
| { | |
| & $Action | |
| Write-Success "$Name completed successfully." | |
| } catch | |
| { | |
| Write-Failure -Text "$Name failed: $_" -SpeechText "$Name failed." | |
| throw | |
| } | |
| } | |
| # ------------------------------------------------------------ | |
| # MAIN TASKS | |
| # ------------------------------------------------------------ | |
| function Build-Plugin | |
| { | |
| param ( | |
| [string]$EngineVersion | |
| ) | |
| if ($CleanSourceBuild) | |
| { | |
| Write-Info "Cleaning source plugin directory:" | |
| Write-CanvasLine -Text " $PluginDir" -Color DarkGray | |
| $SourceIntermediateDir = Join-Path $PluginDir "Intermediate" | |
| $SourceBinariesDir = Join-Path $PluginDir "Binaries" | |
| if (Test-Path $SourceIntermediateDir) | |
| { | |
| Write-Info "Removing source Intermediate directory..." | |
| Remove-DirectoryQuiet -Path $SourceIntermediateDir | |
| } | |
| if (Test-Path $SourceBinariesDir) | |
| { | |
| Write-Info "Removing source Binaries directory..." | |
| Remove-DirectoryQuiet -Path $SourceBinariesDir | |
| } | |
| } | |
| $EnginePath = Join-Path $EngineBasePath "$EngineVersion" | |
| $UATPath = Join-Path $EnginePath "Engine\Build\BatchFiles\RunUAT.bat" | |
| if (-not (Test-Path $UATPath)) | |
| { | |
| throw "UAT not found for engine version $EngineVersion at $UATPath" | |
| } | |
| $DisplayEngineVersion = Get-EngineDisplayVersion -EngineVersion $EngineVersion | |
| Write-Info "Building plugin for Unreal Engine $DisplayEngineVersion" | |
| $PluginVersionName = Get-PluginVersionName -EngineVersion $EngineVersion | |
| $PluginOutputDir = Join-Path $PluginOutputBase $PluginVersionName | |
| if (Test-Path $PluginOutputDir) | |
| { | |
| Remove-DirectoryQuiet -Path $PluginOutputDir -RenameBeforeDelete -Async | |
| } | |
| New-Item -Path $PluginOutputDir -ItemType Directory -Force | Out-Null | |
| $UATArgs = @( | |
| "BuildPlugin", | |
| "-Plugin=$PluginSourceFile", | |
| "-Package=$PluginOutputDir", | |
| "-Rocket" | |
| ) | |
| Invoke-ExternalTool -Command $UATPath -Arguments $UATArgs -VerboseRun:$VerboseRun | |
| Write-Info "Patching .uplugin for Fab store compliance..." | |
| $PatchedUPlugin = Join-Path $PluginOutputDir "$PluginName.uplugin" | |
| (Get-Content $PatchedUPlugin -Raw) -replace '"MarketplaceURL"', '"FabURL"' -replace '"Installed": true', '"Installed": false' | Set-Content $PatchedUPlugin -Encoding utf8 | |
| } | |
| function Sync-TestProjectPlugin | |
| { | |
| param ( | |
| [string]$EngineVersion | |
| ) | |
| $PluginVersionName = Get-PluginVersionName -EngineVersion $EngineVersion | |
| $SourceDir = Join-Path $PluginOutputBase $PluginVersionName | |
| $TargetDir = Join-Path $ScriptBasePath "$TestProjectsBaseDir\$EngineVersion\Plugins\$PluginVersionName" | |
| $TargetPluginsDir = Split-Path $TargetDir -Parent | |
| if (-not (Test-Path $SourceDir)) | |
| { | |
| throw "Built plugin folder not found at $SourceDir" | |
| } | |
| if (-not (Test-Path $TargetPluginsDir)) | |
| { | |
| New-Item -Path $TargetPluginsDir -ItemType Directory -Force | Out-Null | |
| } | |
| if (Test-Path $TargetDir) | |
| { | |
| Write-Info "Removing existing test project plugin folder:" | |
| Write-CanvasLine -Text " $TargetDir" -Color DarkGray | |
| Remove-DirectoryQuiet -Path $TargetDir | |
| } | |
| Write-Info "Copying built plugin to test project:" | |
| Write-CanvasLine -Text " $TargetDir" -Color DarkGray | |
| Copy-Item -Path $SourceDir -Destination $TargetDir -Recurse -Force | |
| } | |
| function Invoke-Package | |
| { | |
| param ( | |
| [string]$EngineVersion, | |
| [string]$TimeStamp, | |
| [string]$PasswordToUse | |
| ) | |
| $PluginVersionName = Get-PluginVersionName -EngineVersion $EngineVersion | |
| $SourceDir = Join-Path $PluginOutputBase $PluginVersionName | |
| $ArchiveDirWithTimestamp = Join-Path $ArchiveBase $TimeStamp | |
| if (-not (Test-Path $ArchiveDirWithTimestamp)) | |
| { | |
| New-Item -Path $ArchiveDirWithTimestamp -ItemType Directory -Force | Out-Null | |
| } | |
| $ArchivePath = Join-Path $ArchiveDirWithTimestamp "$PluginName-UE$EngineVersion.zip" | |
| $SevenZipArgs = @( | |
| "a", | |
| "-tzip", | |
| """$ArchivePath""", | |
| """$($SourceDir)\.""", | |
| "-x!Intermediate", | |
| "-x!Binaries" | |
| ) | |
| if ($PasswordToUse) | |
| { | |
| $SevenZipArgs += "-p$PasswordToUse" | |
| } | |
| Write-Info "Compacting plugin $PluginName for UE $EngineVersion..." | |
| Write-Info "Path:" | |
| Write-CanvasLine -Text " $ArchivePath" -Color DarkGray | |
| Invoke-ExternalTool -Command $SevenZipPath -Arguments $SevenZipArgs -VerboseRun:$VerboseRun | |
| } | |
| # ------------------------------------------------------------ | |
| # EXECUTION | |
| # ------------------------------------------------------------ | |
| if ($InitialPluginNames.Count -eq 0) | |
| { | |
| $pluginsRoot = Join-Path $ScriptBasePath "$ProjectDir\Plugins" | |
| $detectedPlugins = Get-AvailablePluginNames -PluginsRoot $pluginsRoot | |
| if (-not $detectedPlugins -or $detectedPlugins.Count -eq 0) | |
| { | |
| throw "No plugins found in '$pluginsRoot'. Expected '<PluginName>\<PluginName>.uplugin'." | |
| } | |
| $availableEngineVersions = @() | |
| if ($EngineVersions -and $EngineVersions.Count -gt 0) | |
| { | |
| $availableEngineVersions = @($EngineVersions) | |
| } else | |
| { | |
| if (Test-Path $EngineBasePath) | |
| { | |
| $availableEngineVersions = @( | |
| Get-ChildItem -Path $EngineBasePath -Directory | |
| | Where-Object { $_.Name -match '^UE_5\.\d+$' } | |
| | Sort-Object Name | |
| | Select-Object -ExpandProperty Name | |
| ) | |
| } | |
| } | |
| if (-not $availableEngineVersions -or $availableEngineVersions.Count -eq 0) | |
| { | |
| throw "No engine versions available. Add 'EngineVersions' in config.ini or install engines under '$EngineBasePath'." | |
| } | |
| if (-not $EngineVersions -or $EngineVersions.Count -eq 0) | |
| { | |
| $EngineVersions = @($availableEngineVersions) | |
| } | |
| $toggleOptions = @( | |
| 'Enable archive packaging (.zip)', | |
| 'Protect archive with generated password', | |
| 'Clean source Binaries/Intermediate before build', | |
| 'Clean Build/<PluginName> after successful build', | |
| 'Verbose external tools output' | |
| ) | |
| $voiceModeOptions = @('Voice: Off', 'Voice: All notifications', 'Voice: End notifications only') | |
| $setupPage = 1 | |
| $setupComplete = $false | |
| while (-not $setupComplete) | |
| { | |
| switch ($setupPage) | |
| { | |
| 1 | |
| { | |
| $pluginDefaultIndices = @() | |
| if ($SelectedPluginNames -and $SelectedPluginNames.Count -gt 0) | |
| { | |
| $pluginDefaultIndices = @( | |
| $SelectedPluginNames | |
| | ForEach-Object { [array]::IndexOf($detectedPlugins, $_) + 1 } | |
| | Where-Object { $_ -gt 0 } | |
| ) | |
| } | |
| if (-not $pluginDefaultIndices -or $pluginDefaultIndices.Count -eq 0) | |
| { | |
| $pluginDefaultIndices = @(1) | |
| } | |
| $pluginStep = Read-MultiSelectMenu -Title 'Select plugin(s) to build' -Options $detectedPlugins -DefaultIndices $pluginDefaultIndices -ReturnAction | |
| if ($pluginStep.Action -eq 'cancel') | |
| { | |
| Show-GracefulAbort -Reason 'Setup canceled before build launch.' | |
| return | |
| } | |
| $SelectedPluginNames = @($pluginStep.Values) | |
| if (-not $SelectedPluginNames -or $SelectedPluginNames.Count -eq 0) | |
| { | |
| continue | |
| } | |
| $setupPage = 2 | |
| continue | |
| } | |
| 2 | |
| { | |
| $engineDefaultIndices = @() | |
| if ($EngineVersions -and $EngineVersions.Count -gt 0) | |
| { | |
| $engineDefaultIndices = @( | |
| $EngineVersions | |
| | ForEach-Object { [array]::IndexOf($availableEngineVersions, $_) + 1 } | |
| | Where-Object { $_ -gt 0 } | |
| ) | |
| } | |
| if (-not $engineDefaultIndices -or $engineDefaultIndices.Count -eq 0) | |
| { | |
| $engineDefaultIndices = @(1..$availableEngineVersions.Count) | |
| } | |
| $engineStep = Read-MultiSelectMenu -Title 'Select engine version(s)' -Options $availableEngineVersions -DefaultIndices $engineDefaultIndices -AllowBack -ReturnAction | |
| if ($engineStep.Action -eq 'cancel') | |
| { | |
| Show-GracefulAbort -Reason 'Setup canceled before build launch.' | |
| return | |
| } | |
| if ($engineStep.Action -eq 'back') | |
| { | |
| $setupPage = 1 | |
| continue | |
| } | |
| $EngineVersions = @($engineStep.Values) | |
| if (-not $EngineVersions -or $EngineVersions.Count -eq 0) | |
| { | |
| continue | |
| } | |
| $setupPage = 3 | |
| continue | |
| } | |
| 3 | |
| { | |
| $toggleDefaults = @() | |
| if ($EnableArchive) | |
| { $toggleDefaults += 1 | |
| } | |
| if ([bool]$UsePassword.IsPresent -or [bool]$UsePassword) | |
| { $toggleDefaults += 2 | |
| } | |
| if ([bool]$CleanSourceBuild.IsPresent -or [bool]$CleanSourceBuild) | |
| { $toggleDefaults += 3 | |
| } | |
| if ([bool]$CleanBuildDirOnSuccess.IsPresent -or [bool]$CleanBuildDirOnSuccess) | |
| { $toggleDefaults += 4 | |
| } | |
| if ([bool]$VerboseRun.IsPresent -or [bool]$VerboseRun) | |
| { $toggleDefaults += 5 | |
| } | |
| $optionsStep = Read-MultiSelectMenu -Title 'Select build options' -Options $toggleOptions -DefaultIndices $toggleDefaults -AllowNone -AllowBack -ReturnAction | |
| if ($optionsStep.Action -eq 'cancel') | |
| { | |
| Show-GracefulAbort -Reason 'Setup canceled before build launch.' | |
| return | |
| } | |
| if ($optionsStep.Action -eq 'back') | |
| { | |
| $setupPage = 2 | |
| continue | |
| } | |
| $selectedToggles = @($optionsStep.Values) | |
| $EnableArchive = $selectedToggles -contains $toggleOptions[0] | |
| $UsePassword = $EnableArchive -and ($selectedToggles -contains $toggleOptions[1]) | |
| $CleanSourceBuild = ($selectedToggles -contains $toggleOptions[2]) | |
| $CleanBuildDirOnSuccess = ($selectedToggles -contains $toggleOptions[3]) | |
| $VerboseRun = ($selectedToggles -contains $toggleOptions[4]) | |
| $setupPage = 4 | |
| continue | |
| } | |
| 4 | |
| { | |
| $defaultVoiceIndex = if (-not $EnableVoiceNotifications) | |
| { 1 | |
| } elseif ($SpeakEndNotificationsOnly) | |
| { 3 | |
| } else | |
| { 2 | |
| } | |
| $voiceStep = Read-SingleSelectMenu -Title 'Select voice mode' -Options $voiceModeOptions -DefaultIndex $defaultVoiceIndex -AllowBack -ReturnAction | |
| if ($voiceStep.Action -eq 'cancel') | |
| { | |
| Show-GracefulAbort -Reason 'Setup canceled before build launch.' | |
| return | |
| } | |
| if ($voiceStep.Action -eq 'back') | |
| { | |
| $setupPage = 3 | |
| continue | |
| } | |
| switch ($voiceStep.Value) | |
| { | |
| 'Voice: Off' | |
| { | |
| $EnableVoiceNotifications = $false | |
| $SpeakEndNotificationsOnly = $false | |
| } | |
| 'Voice: All notifications' | |
| { | |
| $EnableVoiceNotifications = $true | |
| $SpeakEndNotificationsOnly = $false | |
| } | |
| 'Voice: End notifications only' | |
| { | |
| $EnableVoiceNotifications = $true | |
| $SpeakEndNotificationsOnly = $true | |
| } | |
| } | |
| $setupPage = 5 | |
| continue | |
| } | |
| 5 | |
| { | |
| $voiceModeLabel = if (-not $EnableVoiceNotifications) | |
| { | |
| 'Off' | |
| } elseif ($SpeakEndNotificationsOnly) | |
| { | |
| 'End notifications only' | |
| } else | |
| { | |
| 'All notifications' | |
| } | |
| Clear-Host | |
| Write-MissionBriefing ` | |
| -Plugins $SelectedPluginNames ` | |
| -Engines $EngineVersions ` | |
| -ArchiveEnabled $EnableArchive ` | |
| -PasswordEnabled ([bool]$UsePassword) ` | |
| -CleanSource ([bool]$CleanSourceBuild) ` | |
| -CleanBuildOnSuccess ([bool]$CleanBuildDirOnSuccess) ` | |
| -VerboseMode ([bool]$VerboseRun) ` | |
| -VoiceMode $voiceModeLabel | |
| $missionAction = Read-MissionProceedChoice | |
| switch ($missionAction) | |
| { | |
| 'Start' | |
| { | |
| $setupComplete = $true | |
| break | |
| } | |
| 'Back' | |
| { | |
| $setupPage = 4 | |
| continue | |
| } | |
| 'Abort' | |
| { | |
| Show-GracefulAbort -Reason 'Setup canceled before build launch.' | |
| return | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } else | |
| { | |
| $SelectedPluginNames = @($InitialPluginNames | Select-Object -Unique) | |
| } | |
| if (-not $EngineVersions -or $EngineVersions.Count -eq 0) | |
| { | |
| throw "No engine versions provided. Pass '-EngineVersions' or set 'EngineVersions' in config.ini." | |
| } | |
| if (-not $SelectedPluginNames -or $SelectedPluginNames.Count -eq 0) | |
| { | |
| throw "No plugins selected for build." | |
| } | |
| $BuildMatrix = New-BuildMatrix -Plugins $SelectedPluginNames -Versions $EngineVersions | |
| Show-BuildDashboard -Matrix $BuildMatrix -Plugins $SelectedPluginNames -Versions $EngineVersions -CurrentPlugin $null -CurrentVersion $null | |
| if ($EnableArchive) | |
| { | |
| if (-not (Test-Path $SevenZipPath)) | |
| { | |
| throw "7-Zip executable not found at '$SevenZipPath'" | |
| } | |
| } | |
| $GlobalPassword = $null | |
| if ($UsePassword -and $EnableArchive) | |
| { | |
| $GlobalPassword = Get-RandomPassword -Length 16 | |
| } elseif ($UsePassword -and -not $EnableArchive) | |
| { | |
| Write-Info "UsePassword was enabled, but archiving is disabled. Skipping password generation." | |
| } | |
| foreach ($CurrentPluginName in $SelectedPluginNames) | |
| { | |
| Set-PluginContext -CurrentPluginName $CurrentPluginName | |
| $BuildMatrix[$CurrentPluginName].LogUri = $LogFileUri | |
| if (-not (Test-Path $PluginSourceFile)) | |
| { | |
| foreach ($Version in $EngineVersions) | |
| { | |
| Set-BuildMatrixStatus -Matrix $BuildMatrix -Plugin $CurrentPluginName -Version $Version -Status 'failed' | |
| } | |
| $FailedBuilds += "$CurrentPluginName (all selected engines)" | |
| Show-BuildDashboard -Matrix $BuildMatrix -Plugins $SelectedPluginNames -Versions $EngineVersions -CurrentPlugin $CurrentPluginName -CurrentVersion $null | |
| Write-Failure -Text "Plugin source not found." -NoSpeech | |
| Write-CanvasLine -Text " $PluginSourceFile" -Color DarkGray | |
| continue | |
| } | |
| if (Test-Path $PluginOutputBase) | |
| { | |
| Remove-DirectoryQuiet -Path $PluginOutputBase -RenameBeforeDelete -Async | |
| } | |
| New-Item -Path $PluginOutputBase -ItemType Directory -Force | Out-Null | |
| if ($EnableArchive) | |
| { | |
| New-Item -Path $ArchiveDirWithTimestamp -ItemType Directory -Force | Out-Null | |
| } | |
| # Initialize per-plugin log file. | |
| Set-Content -Path $LogFile -Value "[Build Log - $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')]" -Encoding utf8 | |
| if ($GlobalPassword) | |
| { | |
| $PasswordFilePath = Join-Path $ArchiveDirWithTimestamp "password.txt" | |
| Set-Content -Path $PasswordFilePath -Value $GlobalPassword -Encoding utf8 | |
| Write-Info "Generated single random password for all archives:" | |
| Write-CanvasLine -Text " $PasswordFilePath" -Color DarkGray | |
| } | |
| $PluginFailedBuilds = @() | |
| foreach ($Version in $EngineVersions) | |
| { | |
| Set-BuildMatrixStatus -Matrix $BuildMatrix -Plugin $CurrentPluginName -Version $Version -Status 'running' | |
| Show-BuildDashboard -Matrix $BuildMatrix -Plugins $SelectedPluginNames -Versions $EngineVersions -CurrentPlugin $CurrentPluginName -CurrentVersion $Version | |
| $script:TaskHadWarning = $false | |
| try | |
| { | |
| $DisplayEngineVersion = Get-EngineDisplayVersion -EngineVersion $Version | |
| New-Task "Building plugin for Unreal Engine $DisplayEngineVersion" { Build-Plugin $Version } | |
| New-Task "Syncing plugin to test project for Unreal Engine $DisplayEngineVersion" { Sync-TestProjectPlugin $Version } | |
| if ($EnableArchive) | |
| { | |
| New-Task "Packaging plugin for Unreal Engine $DisplayEngineVersion" { Invoke-Package $Version $Timestamp $GlobalPassword } | |
| } | |
| $finalStatus = if ($script:TaskHadWarning) | |
| { 'warn' | |
| } else | |
| { 'success' | |
| } | |
| Set-BuildMatrixStatus -Matrix $BuildMatrix -Plugin $CurrentPluginName -Version $Version -Status $finalStatus | |
| } catch | |
| { | |
| $DisplayEngineVersion = Get-EngineDisplayVersion -EngineVersion $Version | |
| $PluginFailedBuilds += $Version | |
| $FailedBuilds += "$CurrentPluginName ($Version)" | |
| Set-BuildMatrixStatus -Matrix $BuildMatrix -Plugin $CurrentPluginName -Version $Version -Status 'failed' | |
| Write-Failure -Text "Build for plugin $CurrentPluginName on Unreal Engine $DisplayEngineVersion failed." -NoSpeech | |
| } | |
| Show-BuildDashboard -Matrix $BuildMatrix -Plugins $SelectedPluginNames -Versions $EngineVersions -CurrentPlugin $CurrentPluginName -CurrentVersion $null | |
| } | |
| if ($CleanBuildDirOnSuccess -and $PluginFailedBuilds.Count -eq 0) | |
| { | |
| Remove-DirectoryQuiet -Path $PluginOutputBase -RenameBeforeDelete -Async | |
| Write-Info -Text "Cleaned build directory as requested." -IsEndNotification | |
| } | |
| } | |
| Show-FinalReport -Matrix $BuildMatrix -Plugins $SelectedPluginNames -Versions $EngineVersions | |
| if ($FailedBuilds.Count -eq 0) | |
| { | |
| Write-Success -Text "All builds completed successfully!" -IsEndNotification | |
| } else | |
| { | |
| Write-Failure -Text "Failed builds: $($FailedBuilds -join ', ')" -IsEndNotification | |
| Write-Info "Most recent build log:" | |
| Write-CanvasLine -Text " $LogFileUri" -Color DarkGray | |
| } | |
| $Stopwatch.Stop() | |
| Write-CanvasLine -Text "🕒 Total time: $([math]::Round($Stopwatch.Elapsed.TotalMinutes,2)) minutes" -Color Magenta | |
| Speak-Notification -Text "Total time: $([math]::Round($Stopwatch.Elapsed.TotalMinutes,2)) minutes" -IsEndNotification | |
| Write-CanvasLine -Text "" -Color Gray # Blank Line |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ; BuildPlugin.ps1 configuration | |
| ; Values here are used when the same parameter is not passed in the command line. | |
| [Paths] | |
| EngineBasePath=F:\UE | |
| SevenZipPath=C:\Program Files\7-Zip\7z.exe | |
| ProjectDir=Project | |
| PluginBuildDir=Build | |
| ArchiveDir=Archive | |
| TestProjectsBaseDir=PluginTestProjects | |
| [Build] | |
| EngineVersions=UE_5.2,UE_5.3,UE_5.4,UE_5.5,UE_5.6,UE_5.7 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment