hypertwist/scripts/Export-HyperTwistPackagedBuildZip.ps1
2026-07-23 17:23:24 +00:00

452 lines
15 KiB
PowerShell

param(
[string]$PackageRoot = 'C:\HyperTwist\packaged\desktop',
[string]$DestinationZipPath = '',
[string]$ReportPath = '',
[Int64]$ExpectedSolverTableSizeBytes = 676207080,
[string]$ExpectedSolverTableSha256 = 'dc6de3d909a37afe2ba7f1f978543a13eae215bd20d954d66813524a8ca20034'
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$ZipCreationRetryCount = 15
$ZipCreationRetryDelayMilliseconds = 1000
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 10
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
}
function Test-IsTransientFileLockMessage {
param(
[string]$Message
)
if ([string]::IsNullOrWhiteSpace($Message))
{
return $false
}
return $Message -like '*being used by another process*' `
-or $Message -like '*cannot access the file*'
}
function Remove-ItemWithRetry {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
for ($AttemptIndex = 1; $AttemptIndex -le $ZipCreationRetryCount; $AttemptIndex++)
{
try
{
if (Test-Path -LiteralPath $Path)
{
Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
}
return
}
catch
{
if ($AttemptIndex -ge $ZipCreationRetryCount `
-or -not (Test-IsTransientFileLockMessage -Message $_.Exception.Message))
{
throw
}
Start-Sleep -Milliseconds $ZipCreationRetryDelayMilliseconds
}
}
}
function Resolve-PackagedExecutablePath {
param(
[Parameter(Mandatory = $true)]
[string]$RootPath
)
$CandidateExecutablePaths = @(
(Join-Path $RootPath 'Windows\UnrealHyperTwist.exe'),
(Join-Path $RootPath 'WindowsNoEditor\UnrealHyperTwist.exe'),
(Join-Path $RootPath 'UnrealHyperTwist.exe')
)
return $CandidateExecutablePaths | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
}
function Resolve-PackagedGameExecutablePath {
param(
[Parameter(Mandatory = $true)]
[string]$PackagedProjectRoot
)
$GameExecutableDirectory = Join-Path $PackagedProjectRoot 'Binaries\Win64'
$CandidateExecutablePaths = @(
(Join-Path $GameExecutableDirectory 'UnrealHyperTwist.exe'),
(Join-Path $GameExecutableDirectory 'UnrealHyperTwist-Win64-Shipping.exe'),
(Join-Path $GameExecutableDirectory 'UnrealHyperTwist-Win64-Development.exe')
)
return $CandidateExecutablePaths |
Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } |
Select-Object -First 1
}
function Invoke-ZipCreationWithRetry {
param(
[Parameter(Mandatory = $true)]
[string]$SourceDirectory,
[Parameter(Mandatory = $true)]
[string]$DestinationZipPath
)
$LastException = $null
for ($AttemptIndex = 1; $AttemptIndex -le $ZipCreationRetryCount; $AttemptIndex++)
{
try
{
if (Test-Path -LiteralPath $DestinationZipPath)
{
Remove-ItemWithRetry -Path $DestinationZipPath
}
[System.IO.Compression.ZipFile]::CreateFromDirectory(
$SourceDirectory,
$DestinationZipPath,
[System.IO.Compression.CompressionLevel]::Optimal,
$false
)
return
}
catch
{
$LastException = $_.Exception
if ($AttemptIndex -ge $ZipCreationRetryCount `
-or -not (($_.Exception -is [System.IO.IOException]) `
-or (Test-IsTransientFileLockMessage -Message $_.Exception.Message)))
{
break
}
Start-Sleep -Milliseconds $ZipCreationRetryDelayMilliseconds
}
}
if ($null -ne $LastException)
{
throw $LastException
}
throw "Packaged zip export failed for an unknown reason."
}
function Test-ZipEntryMatchesExecutablePath {
param(
[Parameter(Mandatory = $true)]
[string]$EntryFullName
)
$NormalizedEntryPath = $EntryFullName.Replace('\', '/')
return $NormalizedEntryPath.EndsWith('UnrealHyperTwist.exe', [System.StringComparison]::OrdinalIgnoreCase)
}
function Get-NormalizedRelativePath {
param(
[Parameter(Mandatory = $true)]
[string]$RootPath,
[Parameter(Mandatory = $true)]
[string]$FilePath
)
$RootWithSeparator = $RootPath.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
$FullFilePath = [System.IO.Path]::GetFullPath($FilePath)
if (-not $FullFilePath.StartsWith($RootWithSeparator, [System.StringComparison]::OrdinalIgnoreCase))
{
throw "Required package file '$FullFilePath' is not beneath package root '$RootPath'."
}
return $FullFilePath.Substring($RootWithSeparator.Length).Replace('\', '/')
}
function Get-RequiredPackageFileEvidence {
param(
[Parameter(Mandatory = $true)]
[string]$RootPath,
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$Kind,
[Int64]$ExpectedSizeBytes = -1,
[string]$ExpectedSha256 = ''
)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf))
{
throw "Required packaged $Kind file was not found at '$Path'."
}
$Item = Get-Item -LiteralPath $Path
if ($Item.Length -le 0)
{
throw "Required packaged $Kind file '$Path' was empty."
}
if ($ExpectedSizeBytes -ge 0 -and $Item.Length -ne $ExpectedSizeBytes)
{
throw "Required packaged $Kind file '$Path' was $($Item.Length) bytes; expected exactly $ExpectedSizeBytes bytes."
}
$ActualSha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256) `
-and $ActualSha256 -ne $ExpectedSha256.Trim().ToLowerInvariant())
{
throw "Required packaged $Kind file '$Path' did not match the expected SHA-256."
}
return [pscustomobject][ordered]@{
kind = $Kind
relativePath = Get-NormalizedRelativePath -RootPath $RootPath -FilePath $Item.FullName
sizeBytes = [Int64]$Item.Length
sha256 = $ActualSha256
}
}
function Get-ZipEntrySha256 {
param(
[Parameter(Mandatory = $true)]
[System.IO.Compression.ZipArchiveEntry]$Entry
)
$Sha256 = [System.Security.Cryptography.SHA256]::Create()
$EntryStream = $Entry.Open()
try
{
$Digest = $Sha256.ComputeHash($EntryStream)
return ([System.BitConverter]::ToString($Digest)).Replace('-', '').ToLowerInvariant()
}
finally
{
$EntryStream.Dispose()
$Sha256.Dispose()
}
}
if (-not (Test-Path -LiteralPath $PackageRoot))
{
throw "Package root '$PackageRoot' was not found."
}
$ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path
$PackagedExecutablePath = Resolve-PackagedExecutablePath -RootPath $ResolvedPackageRoot
if ($null -eq $PackagedExecutablePath)
{
throw "No packaged UnrealHyperTwist executable was found beneath '$ResolvedPackageRoot'."
}
$PackagedExecutableDirectory = Split-Path -Parent $PackagedExecutablePath
$PackagedProjectRoot = Join-Path $PackagedExecutableDirectory 'UnrealHyperTwist'
$PackagedGameExecutablePath = Resolve-PackagedGameExecutablePath `
-PackagedProjectRoot $PackagedProjectRoot
if ($null -eq $PackagedGameExecutablePath)
{
throw "No packaged inner UnrealHyperTwist game executable was found beneath '$PackagedProjectRoot'."
}
$RequiredPackageFiles = @(
Get-RequiredPackageFileEvidence `
-RootPath $ResolvedPackageRoot `
-Path $PackagedExecutablePath `
-Kind 'launcher-executable'
Get-RequiredPackageFileEvidence `
-RootPath $ResolvedPackageRoot `
-Path $PackagedGameExecutablePath `
-Kind 'game-executable'
Get-RequiredPackageFileEvidence `
-RootPath $ResolvedPackageRoot `
-Path (Join-Path $PackagedProjectRoot 'Binaries\Win64\tbbmalloc.dll') `
-Kind 'runtime-dependency-tbbmalloc'
Get-RequiredPackageFileEvidence `
-RootPath $ResolvedPackageRoot `
-Path (Join-Path $PackagedProjectRoot 'Content\Paks\UnrealHyperTwist-Windows.pak') `
-Kind 'cooked-pak'
Get-RequiredPackageFileEvidence `
-RootPath $ResolvedPackageRoot `
-Path (Join-Path $PackagedProjectRoot 'Content\Paks\UnrealHyperTwist-Windows.utoc') `
-Kind 'cooked-utoc'
Get-RequiredPackageFileEvidence `
-RootPath $ResolvedPackageRoot `
-Path (Join-Path $PackagedProjectRoot 'Content\Paks\UnrealHyperTwist-Windows.ucas') `
-Kind 'cooked-ucas'
Get-RequiredPackageFileEvidence `
-RootPath $ResolvedPackageRoot `
-Path (Join-Path $PackagedProjectRoot 'Saved\twophase-ht.tbl') `
-Kind 'solver-table' `
-ExpectedSizeBytes $ExpectedSolverTableSizeBytes `
-ExpectedSha256 $ExpectedSolverTableSha256
Get-RequiredPackageFileEvidence `
-RootPath $ResolvedPackageRoot `
-Path (Join-Path $PackagedExecutableDirectory 'Content\Browser\index.html') `
-Kind 'browser-shell-index'
Get-RequiredPackageFileEvidence `
-RootPath $ResolvedPackageRoot `
-Path (Join-Path $PackagedExecutableDirectory 'Content\Browser\src\browser-runtime-bootstrap.js') `
-Kind 'browser-shell-bootstrap'
Get-RequiredPackageFileEvidence `
-RootPath $ResolvedPackageRoot `
-Path (Join-Path $PackagedExecutableDirectory 'Content\Browser\src\browser-spatial-runtime-fallback.js') `
-Kind 'browser-shell-fallback'
Get-RequiredPackageFileEvidence `
-RootPath $ResolvedPackageRoot `
-Path (Join-Path $PackagedExecutableDirectory 'Content\Browser\dist\browser-spatial-runtime.js') `
-Kind 'browser-shell-runtime'
)
if ([string]::IsNullOrWhiteSpace($DestinationZipPath))
{
$PackageLeafName = Split-Path -Leaf $ResolvedPackageRoot
$DestinationZipPath = Join-Path (
Split-Path -Parent $ResolvedPackageRoot
) ("{0}-{1}.zip" -f $PackageLeafName, [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ'))
}
if ([string]::IsNullOrWhiteSpace($ReportPath))
{
$ReportPath = Join-Path $ResolvedPackageRoot 'validation\zip-export-report.json'
}
$ResolvedDestinationZipPath = [System.IO.Path]::GetFullPath($DestinationZipPath)
$ResolvedPackageRootWithSeparator = $ResolvedPackageRoot.TrimEnd('\') + '\'
if ($ResolvedDestinationZipPath.StartsWith($ResolvedPackageRootWithSeparator, [System.StringComparison]::OrdinalIgnoreCase))
{
throw "Destination zip path '$ResolvedDestinationZipPath' must live outside the package root '$ResolvedPackageRoot'."
}
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $ResolvedDestinationZipPath) | Out-Null
if (Test-Path -LiteralPath $ResolvedDestinationZipPath)
{
Remove-ItemWithRetry -Path $ResolvedDestinationZipPath
}
$ZipReport = [ordered]@{
reportVersion = 'ht-packaged-build-zip-export/v2'
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
packageRoot = $ResolvedPackageRoot
packagedExecutablePath = $PackagedExecutablePath
destinationZipPath = $ResolvedDestinationZipPath
result = 'failed'
entryCount = 0
packagedExecutableEntry = $null
requiredEntries = @($RequiredPackageFiles)
archivedRequiredEntries = @()
zipSizeBytes = 0
zipSha256 = $null
error = $null
}
try
{
Invoke-ZipCreationWithRetry `
-SourceDirectory $ResolvedPackageRoot `
-DestinationZipPath $ResolvedDestinationZipPath
$ZipArchive = [System.IO.Compression.ZipFile]::OpenRead($ResolvedDestinationZipPath)
try
{
$Entries = @($ZipArchive.Entries)
$ZipReport.entryCount = $Entries.Count
$ExecutableEntry = $Entries | Where-Object {
Test-ZipEntryMatchesExecutablePath -EntryFullName $_.FullName
} | Select-Object -First 1
if ($null -eq $ExecutableEntry)
{
throw "The exported zip '$ResolvedDestinationZipPath' did not contain a packaged UnrealHyperTwist executable entry."
}
$ZipReport.packagedExecutableEntry = $ExecutableEntry.FullName
if ($ZipReport.entryCount -le 0)
{
throw "The exported zip '$ResolvedDestinationZipPath' contained zero entries."
}
foreach ($RequiredFile in $RequiredPackageFiles)
{
$MatchingRequiredEntries = @($Entries | Where-Object {
$_.FullName.Replace('\', '/').Equals(
$RequiredFile.relativePath,
[System.StringComparison]::OrdinalIgnoreCase)
})
if ($MatchingRequiredEntries.Count -ne 1)
{
throw (
"The exported zip '$ResolvedDestinationZipPath' contained " +
"$($MatchingRequiredEntries.Count) copies of required $($RequiredFile.kind) " +
"entry '$($RequiredFile.relativePath)'; expected exactly one."
)
}
$RequiredEntry = $MatchingRequiredEntries[0]
if ([Int64]$RequiredEntry.Length -ne [Int64]$RequiredFile.sizeBytes)
{
throw "Required zip entry '$($RequiredFile.relativePath)' had an unexpected uncompressed size."
}
$ArchivedSha256 = Get-ZipEntrySha256 -Entry $RequiredEntry
if ($ArchivedSha256 -ne $RequiredFile.sha256)
{
throw (
"Required zip entry '$($RequiredFile.relativePath)' did not preserve " +
"the source SHA-256 '$($RequiredFile.sha256)'."
)
}
$ZipReport.archivedRequiredEntries += @(
[pscustomobject][ordered]@{
kind = $RequiredFile.kind
relativePath = $RequiredFile.relativePath
sizeBytes = [Int64]$RequiredEntry.Length
sha256 = $ArchivedSha256
}
)
}
}
finally
{
if ($null -ne $ZipArchive)
{
$ZipArchive.Dispose()
}
}
$ZipItem = Get-Item -LiteralPath $ResolvedDestinationZipPath
$ZipReport.zipSizeBytes = $ZipItem.Length
$ZipReport.zipSha256 = (Get-FileHash -LiteralPath $ResolvedDestinationZipPath -Algorithm SHA256).Hash.ToLowerInvariant()
$ZipReport.result = 'passed'
}
catch
{
$ZipReport.error = $_.Exception.Message
Write-Utf8JsonFile -Path $ReportPath -Value $ZipReport
throw
}
Write-Utf8JsonFile -Path $ReportPath -Value $ZipReport
Write-Host "Exported verified packaged zip to '$ResolvedDestinationZipPath'."