Skip to content

Instantly share code, notes, and snippets.

@MarcusBuer
Created March 12, 2026 04:38
Show Gist options
  • Select an option

  • Save MarcusBuer/1c3c7d209027b72c730ae5bc036315f6 to your computer and use it in GitHub Desktop.

Select an option

Save MarcusBuer/1c3c7d209027b72c730ae5bc036315f6 to your computer and use it in GitHub Desktop.
Script for building UE plugins
<#
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
; 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