param( [string]$PackageRoot = 'C:\HyperTwist_worktrees\phase10validate_packaged_alpha_release19_shipping_20260723', [string]$OutputDirectory = 'C:\HyperTwist_worktrees\installer-release19-20260726', [string]$InstallerFileName = 'HyperTwist-Alpha-Test-20260723-R19-Setup.exe', [string]$ProductVersion = '0.19.0.0', [string]$DisplayVersion = 'Alpha R19', [string]$ReleaseId = '20260723-R19', [string]$MakensisPath = 'C:\Program Files (x86)\NSIS\makensis.exe', [string]$VcRedistPath = '', [string]$ReportPath = '', [switch]$KeepBuildContext ) $ErrorActionPreference = 'Stop' $ExpectedSolverTableSizeBytes = 676207080 $ExpectedSolverTableSha256 = 'dc6de3d909a37afe2ba7f1f978543a13eae215bd20d954d66813524a8ca20034' function Write-Utf8JsonFile { param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [object]$Value ) $ParentPath = Split-Path -Parent $Path if (-not [string]::IsNullOrWhiteSpace($ParentPath)) { New-Item -ItemType Directory -Force -Path $ParentPath | Out-Null } $Json = $Value | ConvertTo-Json -Depth 12 $Utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom) } function Resolve-WindowsPayloadRoot { param( [Parameter(Mandatory = $true)] [string]$RootPath ) $ResolvedRootPath = (Resolve-Path -LiteralPath $RootPath).Path $WindowsCandidate = Join-Path $ResolvedRootPath 'Windows' if (Test-Path -LiteralPath (Join-Path $WindowsCandidate 'UnrealHyperTwist.exe') -PathType Leaf) { return $WindowsCandidate } if (Test-Path -LiteralPath (Join-Path $ResolvedRootPath 'UnrealHyperTwist.exe') -PathType Leaf) { return $ResolvedRootPath } throw "No packaged Windows payload was found beneath '$RootPath'." } function Get-NormalizedRelativePath { param( [Parameter(Mandatory = $true)] [string]$RootPath, [Parameter(Mandatory = $true)] [string]$FilePath ) $RootPrefix = $RootPath.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar $ResolvedFilePath = [System.IO.Path]::GetFullPath($FilePath) if (-not $ResolvedFilePath.StartsWith( $RootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "File '$ResolvedFilePath' is outside payload root '$RootPath'." } return $ResolvedFilePath.Substring($RootPrefix.Length).Replace('\', '/') } function Assert-RequiredPayloadFile { param( [Parameter(Mandatory = $true)] [string]$PayloadRoot, [Parameter(Mandatory = $true)] [string]$RelativePath, [Int64]$ExpectedSizeBytes = -1, [string]$ExpectedSha256 = '' ) $RequiredPath = Join-Path $PayloadRoot $RelativePath if (-not (Test-Path -LiteralPath $RequiredPath -PathType Leaf)) { throw "Required payload file '$RelativePath' was not found." } $RequiredItem = Get-Item -LiteralPath $RequiredPath if ($RequiredItem.Length -le 0) { throw "Required payload file '$RelativePath' is empty." } if ($ExpectedSizeBytes -ge 0 -and $RequiredItem.Length -ne $ExpectedSizeBytes) { throw "Required payload file '$RelativePath' has an unexpected size." } $ActualSha256 = (Get-FileHash -LiteralPath $RequiredPath -Algorithm SHA256).Hash.ToLowerInvariant() if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256) ` -and $ActualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Required payload file '$RelativePath' has an unexpected SHA-256." } } function Resolve-VcRedistPath { param( [string]$RequestedPath ) if (-not [string]::IsNullOrWhiteSpace($RequestedPath)) { if (-not (Test-Path -LiteralPath $RequestedPath -PathType Leaf)) { throw "Requested VC++ redistributable '$RequestedPath' was not found." } return (Resolve-Path -LiteralPath $RequestedPath).Path } $CandidateRoots = @( 'C:\Program Files\Epic Games\UE_5.7\Engine\Extras\Redist', 'C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Redist', 'C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Redist' ) $Candidates = @( foreach ($CandidateRoot in $CandidateRoots) { if (Test-Path -LiteralPath $CandidateRoot) { Get-ChildItem -LiteralPath $CandidateRoot ` -Filter 'vc_redist.x64.exe' ` -File ` -Recurse ` -ErrorAction SilentlyContinue } } ) $Candidate = $Candidates | Sort-Object { [version]$_.VersionInfo.FileVersion } -Descending | Select-Object -First 1 if ($null -eq $Candidate) { throw 'No Microsoft Visual C++ 2015-2022 x64 redistributable was found.' } return $Candidate.FullName } if (-not (Test-Path -LiteralPath $PackageRoot -PathType Container)) { throw "Package root '$PackageRoot' was not found." } if (-not (Test-Path -LiteralPath $MakensisPath -PathType Leaf)) { throw "NSIS compiler '$MakensisPath' was not found." } try { $ParsedProductVersion = [version]$ProductVersion } catch { throw "ProductVersion '$ProductVersion' is not a valid numeric Windows file version." } if ($ParsedProductVersion.Build -lt 0 -or $ParsedProductVersion.Revision -lt 0) { throw 'ProductVersion must contain exactly four numeric components.' } if ($InstallerFileName -ne [System.IO.Path]::GetFileName($InstallerFileName) ` -or [System.IO.Path]::GetExtension($InstallerFileName) -ne '.exe') { throw "InstallerFileName '$InstallerFileName' must be a leaf .exe filename." } foreach ($MetadataValue in @($DisplayVersion, $ReleaseId)) { if ([string]::IsNullOrWhiteSpace($MetadataValue) ` -or $MetadataValue -notmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$') { throw "Installer metadata value '$MetadataValue' contains unsupported characters or length." } } $ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path $ResolvedPayloadRoot = Resolve-WindowsPayloadRoot -RootPath $ResolvedPackageRoot $ResolvedOutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory) $ResolvedInstallerPath = Join-Path $ResolvedOutputDirectory $InstallerFileName $PayloadRootPrefix = $ResolvedPayloadRoot.TrimEnd('\') + '\' if ($ResolvedInstallerPath.StartsWith( $PayloadRootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw 'Installer output must not be written inside its source payload.' } $RepositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path $NsiScriptPath = Join-Path $RepositoryRoot 'packaging\windows\HyperTwistInstaller.nsi' $ReadinessScriptPath = Join-Path $RepositoryRoot 'scripts\Test-HyperTwistWindowsReadiness.ps1' if (-not (Test-Path -LiteralPath $NsiScriptPath -PathType Leaf)) { throw "Installer definition '$NsiScriptPath' was not found." } if (-not (Test-Path -LiteralPath $ReadinessScriptPath -PathType Leaf)) { throw "Readiness script '$ReadinessScriptPath' was not found." } $ResolvedVcRedistPath = Resolve-VcRedistPath -RequestedPath $VcRedistPath $VcRedistSignature = Get-AuthenticodeSignature -LiteralPath $ResolvedVcRedistPath if ($VcRedistSignature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { throw "VC++ redistributable '$ResolvedVcRedistPath' does not have a valid Authenticode signature." } $RequiredRelativePaths = @( 'UnrealHyperTwist.exe', 'UnrealHyperTwist\Binaries\Win64\UnrealHyperTwist-Win64-Shipping.exe', 'UnrealHyperTwist\Binaries\Win64\tbb12.dll', 'UnrealHyperTwist\Binaries\Win64\tbbmalloc.dll', 'UnrealHyperTwist\Content\Paks\UnrealHyperTwist-Windows.pak', 'UnrealHyperTwist\Content\Paks\UnrealHyperTwist-Windows.utoc', 'UnrealHyperTwist\Content\Paks\UnrealHyperTwist-Windows.ucas', 'Content\Browser\index.html', 'Content\Browser\src\browser-runtime-bootstrap.js', 'Content\Browser\src\browser-spatial-runtime-fallback.js', 'Content\Browser\dist\browser-spatial-runtime.js' ) foreach ($RequiredRelativePath in $RequiredRelativePaths) { Assert-RequiredPayloadFile ` -PayloadRoot $ResolvedPayloadRoot ` -RelativePath $RequiredRelativePath } Assert-RequiredPayloadFile ` -PayloadRoot $ResolvedPayloadRoot ` -RelativePath 'UnrealHyperTwist\Saved\twophase-ht.tbl' ` -ExpectedSizeBytes $ExpectedSolverTableSizeBytes ` -ExpectedSha256 $ExpectedSolverTableSha256 $ReparsePoints = @( Get-ChildItem -LiteralPath $ResolvedPayloadRoot -Force -Recurse | Where-Object { $_.Attributes -band [System.IO.FileAttributes]::ReparsePoint } ) if ($ReparsePoints.Count -gt 0) { throw ( "The payload contains reparse points, which are not permitted in an installer source: " + (($ReparsePoints | Select-Object -ExpandProperty FullName) -join ', ') ) } New-Item -ItemType Directory -Force -Path $ResolvedOutputDirectory | Out-Null $ValidationDirectory = Join-Path $ResolvedOutputDirectory 'validation' $BuildContextDirectory = Join-Path $ResolvedOutputDirectory 'build-context' New-Item -ItemType Directory -Force -Path $ValidationDirectory | Out-Null if (Test-Path -LiteralPath $BuildContextDirectory) { Remove-Item -LiteralPath $BuildContextDirectory -Recurse -Force } New-Item -ItemType Directory -Force -Path $BuildContextDirectory | Out-Null if ([string]::IsNullOrWhiteSpace($ReportPath)) { $ReportPath = Join-Path $ValidationDirectory 'windows-installer-build-report.json' } $PayloadManifestPath = Join-Path $ValidationDirectory 'windows-installer-payload-manifest.json' $SourceReadinessReportPath = Join-Path $ValidationDirectory 'windows-installer-source-readiness-report.json' $MakensisLogPath = Join-Path $ValidationDirectory 'makensis.log' $InstallerIconPath = Join-Path $BuildContextDirectory 'HyperTwist.ico' $BuildReport = [ordered]@{ reportVersion = 'ht-windows-installer-build/v1' generatedAtUtc = [DateTime]::UtcNow.ToString('o') result = 'failed' releaseId = $ReleaseId displayVersion = $DisplayVersion productVersion = $ProductVersion packageRoot = $ResolvedPackageRoot payloadRoot = $ResolvedPayloadRoot installerPath = $ResolvedInstallerPath payloadManifestPath = $PayloadManifestPath sourceReadinessReportPath = $SourceReadinessReportPath makensisPath = (Resolve-Path -LiteralPath $MakensisPath).Path makensisLogPath = $MakensisLogPath vcRedist = $null excludedPatterns = @('*.pdb', 'Manifest_DebugFiles_Win64.txt') payloadFileCount = 0 payloadSizeBytes = 0 installerSizeBytes = 0 installerSha256 = $null installerSignatureStatus = $null incompleteInstallerRemoved = $false error = $null } try { $VcRedistItem = Get-Item -LiteralPath $ResolvedVcRedistPath $BuildReport.vcRedist = [pscustomobject][ordered]@{ path = $ResolvedVcRedistPath version = [string]$VcRedistItem.VersionInfo.FileVersion sizeBytes = [Int64]$VcRedistItem.Length sha256 = (Get-FileHash -LiteralPath $ResolvedVcRedistPath -Algorithm SHA256).Hash.ToLowerInvariant() signatureStatus = $VcRedistSignature.Status.ToString() signer = [string]$VcRedistSignature.SignerCertificate.Subject } $PayloadFiles = @( Get-ChildItem -LiteralPath $ResolvedPayloadRoot -Force -File -Recurse | Where-Object { $_.Extension -ne '.pdb' ` -and $_.Name -ne 'Manifest_DebugFiles_Win64.txt' } | Sort-Object FullName ) $PayloadManifestEntries = @( foreach ($PayloadFile in $PayloadFiles) { [pscustomobject][ordered]@{ relativePath = Get-NormalizedRelativePath ` -RootPath $ResolvedPayloadRoot ` -FilePath $PayloadFile.FullName sizeBytes = [Int64]$PayloadFile.Length sha256 = (Get-FileHash -LiteralPath $PayloadFile.FullName -Algorithm SHA256).Hash.ToLowerInvariant() } } ) $PayloadSizeBytes = [Int64](($PayloadFiles | Measure-Object Length -Sum).Sum) $PayloadManifest = [pscustomobject][ordered]@{ manifestVersion = 'ht-windows-installer-payload/v1' generatedAtUtc = [DateTime]::UtcNow.ToString('o') releaseId = $ReleaseId sourcePayloadRoot = $ResolvedPayloadRoot excludedPatterns = @('*.pdb', 'Manifest_DebugFiles_Win64.txt') fileCount = $PayloadManifestEntries.Count totalSizeBytes = $PayloadSizeBytes files = $PayloadManifestEntries } Write-Utf8JsonFile -Path $PayloadManifestPath -Value $PayloadManifest $BuildReport.payloadFileCount = $PayloadManifestEntries.Count $BuildReport.payloadSizeBytes = $PayloadSizeBytes $ReadinessReport = & $ReadinessScriptPath ` -PayloadRoot $ResolvedPayloadRoot ` -TargetInstallRoot (Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'HyperTwist') ` -ExpectedPayloadManifestPath $PayloadManifestPath ` -ReportPath $SourceReadinessReportPath ` -VerifyPayloadHashes ` -PassThru if ($ReadinessReport.result -eq 'blocked') { throw 'Source payload or Windows host readiness is blocked.' } Add-Type -AssemblyName System.Drawing $PayloadExecutablePath = Join-Path $ResolvedPayloadRoot 'UnrealHyperTwist.exe' $InstallerIcon = [System.Drawing.Icon]::ExtractAssociatedIcon($PayloadExecutablePath) if ($null -eq $InstallerIcon) { throw "No application icon could be extracted from '$PayloadExecutablePath'." } $IconStream = [System.IO.File]::Open( $InstallerIconPath, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None ) try { $InstallerIcon.Save($IconStream) } finally { $IconStream.Dispose() $InstallerIcon.Dispose() } if (Test-Path -LiteralPath $ResolvedInstallerPath) { Remove-Item -LiteralPath $ResolvedInstallerPath -Force } $PayloadSizeKilobytes = [Math]::Ceiling($PayloadSizeBytes / 1KB) $MakensisArguments = @( '/V4', "/DPRODUCT_VERSION=$ProductVersion", "/DDISPLAY_VERSION=$DisplayVersion", "/DRELEASE_ID=$ReleaseId", "/DOUTPUT_FILE=$ResolvedInstallerPath", "/DPAYLOAD_ROOT=$ResolvedPayloadRoot", "/DPAYLOAD_MANIFEST=$PayloadManifestPath", "/DREADINESS_SCRIPT=$ReadinessScriptPath", "/DVC_REDIST_PATH=$ResolvedVcRedistPath", "/DINSTALLER_ICON=$InstallerIconPath", "/DPAYLOAD_SIZE_KB=$PayloadSizeKilobytes", $NsiScriptPath ) $MakensisOutput = @( & $MakensisPath @MakensisArguments 2>&1 | ForEach-Object { $Line = $_.ToString() [Console]::Out.WriteLine($Line) $Line } ) $MakensisExitCode = $LASTEXITCODE [System.IO.File]::WriteAllLines( $MakensisLogPath, $MakensisOutput, (New-Object System.Text.UTF8Encoding($false)) ) if ($MakensisExitCode -ne 0) { throw "NSIS compilation failed with exit code $MakensisExitCode." } if (-not (Test-Path -LiteralPath $ResolvedInstallerPath -PathType Leaf)) { throw "NSIS did not create '$ResolvedInstallerPath'." } $InstallerItem = Get-Item -LiteralPath $ResolvedInstallerPath if ($InstallerItem.Length -le 0) { throw 'The generated installer is empty.' } $InstallerSignature = Get-AuthenticodeSignature -LiteralPath $ResolvedInstallerPath $BuildReport.installerSizeBytes = [Int64]$InstallerItem.Length $BuildReport.installerSha256 = (Get-FileHash -LiteralPath $ResolvedInstallerPath -Algorithm SHA256).Hash.ToLowerInvariant() $BuildReport.installerSignatureStatus = $InstallerSignature.Status.ToString() $BuildReport.result = 'passed' } catch { $BuildReport.error = $_.Exception.Message throw } finally { if ($BuildReport.result -ne 'passed' ` -and (Test-Path -LiteralPath $ResolvedInstallerPath -PathType Leaf)) { Remove-Item -LiteralPath $ResolvedInstallerPath -Force $BuildReport.incompleteInstallerRemoved = $true } Write-Utf8JsonFile -Path $ReportPath -Value $BuildReport if (-not $KeepBuildContext -and (Test-Path -LiteralPath $BuildContextDirectory)) { Remove-Item -LiteralPath $BuildContextDirectory -Recurse -Force } } [Console]::Out.WriteLine('HyperTwist Windows installer built successfully.') [Console]::Out.WriteLine("Installer: $ResolvedInstallerPath") [Console]::Out.WriteLine("SHA-256: $($BuildReport.installerSha256)") [Console]::Out.WriteLine("Report: $([System.IO.Path]::GetFullPath($ReportPath))")