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'."