param( [string]$ProjectRoot = 'C:\HyperTwist', [string]$ArchiveDirectory = 'C:\HyperTwist\packaged\higher-dimensional', [ValidateSet('Development', 'Shipping')] [string]$Configuration = 'Development', [string]$CookMap = '/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining', [string[]]$AdditionalCookMaps = @('/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining'), [string[]]$SmokeMaps = @(), [string[]]$AdditionalCookerOptions = @( '-DisablePlugins=MovieRenderPipeline', '-SkipCookingEditorContent' ), [string]$AuthoringManifestPath = '', [string]$ValidationReportPath = '', [switch]$CleanArchive, [switch]$SkipBuild, [switch]$PrePackageEditorBuildPerformed, [ValidateSet('not-run', 'passed')] [string]$PrePackageEditorBuildResult = 'not-run', [switch]$SkipLaunch ) $ErrorActionPreference = 'Stop' $RunUatPath = 'C:\Program Files\Epic Games\UE_5.7\Engine\Build\BatchFiles\RunUAT.bat' $UProjectPath = Join-Path $ProjectRoot 'UnrealHyperTwist\UnrealHyperTwist.uproject' $LaunchScriptPath = Join-Path $ProjectRoot 'scripts\Launch-HyperTwistHigherDimensionalPackage.ps1' $GameTargetReceiptPath = Join-Path $ProjectRoot 'UnrealHyperTwist\Binaries\Win64\UnrealHyperTwist.target' $UnrealBuildToolSavedPath = Join-Path $ProjectRoot 'UnrealHyperTwist\Saved\UnrealBuildTool' $CookMaps = @($CookMap) + $AdditionalCookMaps | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique $ResolvedSmokeMaps = @( if ($SmokeMaps.Count -gt 0) { $SmokeMaps } else { $CookMaps } ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique $ResolvedAdditionalCookerOptions = @($AdditionalCookerOptions) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique $ValidationRootPath = Join-Path $ArchiveDirectory 'validation' $SmokeReportDirectory = Join-Path $ValidationRootPath 'smoke' 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 Convert-PathToken { param( [string]$Value ) if ([string]::IsNullOrWhiteSpace($Value)) { return 'unknown' } return ($Value -replace '[\\/:*?"<>| ]', '_') } function Read-JsonFile { param( [Parameter(Mandatory = $true)] [string]$Path ) return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json } function Resolve-PackagedExecutablePath { param( [string]$PackageRoot ) $CandidateExecutablePaths = @( (Join-Path $PackageRoot 'Windows\UnrealHyperTwist.exe'), (Join-Path $PackageRoot 'WindowsNoEditor\UnrealHyperTwist.exe'), (Join-Path $PackageRoot 'UnrealHyperTwist.exe') ) return $CandidateExecutablePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 } function Convert-GameMapPathToContentPath { param( [string]$RootPath, [string]$GameMapPath ) if (-not $GameMapPath.StartsWith('/Game/')) { throw "Cook map '$GameMapPath' is not a supported /Game asset path." } $RelativeMapPath = $GameMapPath.Substring('/Game/'.Length).Replace('/', '\') return Join-Path $RootPath ("UnrealHyperTwist\Content\{0}.umap" -f $RelativeMapPath) } function Assert-CookMapExists { param( [string]$RootPath, [string]$GameMapPath ) $ExpectedMapPath = Convert-GameMapPathToContentPath -RootPath $RootPath -GameMapPath $GameMapPath if (-not (Test-Path $ExpectedMapPath)) { throw "Expected higher-dimensional cook map '$GameMapPath' was not found at '$ExpectedMapPath'. Run 'scripts\Invoke-HyperTwistHigherDimensionalMapAuthoring.ps1' first." } } function Resolve-ExistingPath { param( [string[]]$CandidatePaths ) return $CandidatePaths | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 } function Get-MostRecentFileItem { param( [string[]]$Paths ) $MostRecentItem = $null foreach ($Path in $Paths) { if (-not (Test-Path -LiteralPath $Path)) { continue } $PathItem = Get-Item -LiteralPath $Path $CandidateItem = $null if ($PathItem.PSIsContainer) { $CandidateItem = Get-ChildItem -LiteralPath $Path -Recurse -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 } else { $CandidateItem = $PathItem } if ($null -eq $CandidateItem) { continue } if ($null -eq $MostRecentItem -or $CandidateItem.LastWriteTimeUtc -gt $MostRecentItem.LastWriteTimeUtc) { $MostRecentItem = $CandidateItem } } return $MostRecentItem } function Assert-SkipBuildInputFreshness { param( [string]$RootPath, [string]$Configuration, [string]$GameTargetReceiptPath ) if (-not (Test-Path -LiteralPath $GameTargetReceiptPath)) { throw "SkipBuild was requested, but the packaged-game target receipt was not found at '$GameTargetReceiptPath'. Re-run without -SkipBuild so RunUAT can build the Win64 game target." } $GameBinaryPath = Resolve-ExistingPath -CandidatePaths @( (Join-Path $RootPath 'UnrealHyperTwist\Binaries\Win64\UnrealHyperTwist.exe'), (Join-Path $RootPath ("UnrealHyperTwist\Binaries\Win64\UnrealHyperTwist-Win64-{0}.exe" -f $Configuration)) ) if ($null -eq $GameBinaryPath) { throw "SkipBuild was requested, but no local packaged-game binary was found beneath '$(Join-Path $RootPath 'UnrealHyperTwist\Binaries\Win64')'. Re-run without -SkipBuild so RunUAT can build the Win64 game target." } $NewestBuildInputItem = Get-MostRecentFileItem -Paths @( (Join-Path $RootPath 'UnrealHyperTwist\UnrealHyperTwist.uproject'), (Join-Path $RootPath 'UnrealHyperTwist\Source'), (Join-Path $RootPath 'UnrealHyperTwist\Config'), (Join-Path $RootPath 'UnrealHyperTwist\Plugins') ) if ($null -eq $NewestBuildInputItem) { return } $ReceiptItem = Get-Item -LiteralPath $GameTargetReceiptPath $GameBinaryItem = Get-Item -LiteralPath $GameBinaryPath if ($NewestBuildInputItem.LastWriteTimeUtc -gt $ReceiptItem.LastWriteTimeUtc ` -or $NewestBuildInputItem.LastWriteTimeUtc -gt $GameBinaryItem.LastWriteTimeUtc) { throw ( "SkipBuild was requested, but the packaged-game build artifacts are stale. " + "Newest build input '{0}' ({1:o}) is newer than receipt '{2}' ({3:o}) or game binary '{4}' ({5:o}). " + "Re-run without -SkipBuild so RunUAT can rebuild the Win64 game target; a passed editor build alone is not sufficient packaged-runtime proof." ) -f ` $NewestBuildInputItem.FullName, $NewestBuildInputItem.LastWriteTimeUtc, $ReceiptItem.FullName, $ReceiptItem.LastWriteTimeUtc, $GameBinaryItem.FullName, $GameBinaryItem.LastWriteTimeUtc } } if ([string]::IsNullOrWhiteSpace($AuthoringManifestPath)) { $AuthoringManifestPath = Join-Path $ProjectRoot 'docs\generated\higher_dimensional_training_maps\phase6c_dedicated_family_map_manifest.json' } if (-not (Test-Path $RunUatPath)) { throw "RunUAT was not found at '$RunUatPath'." } if (-not (Test-Path $UProjectPath)) { throw "UnrealHyperTwist project file was not found at '$UProjectPath'." } if (-not (Test-Path $AuthoringManifestPath)) { throw "Higher-dimensional authoring manifest was not found at '$AuthoringManifestPath'. Run 'scripts\Invoke-HyperTwistHigherDimensionalMapAuthoring.ps1' first." } $AuthoringManifest = Read-JsonFile -Path $AuthoringManifestPath if ($null -eq $AuthoringManifest) { throw "Higher-dimensional authoring manifest '$AuthoringManifestPath' could not be parsed." } if ($null -eq $AuthoringManifest.entries -or $AuthoringManifest.entries.Count -lt 2) { throw "Higher-dimensional authoring manifest '$AuthoringManifestPath' does not contain the expected dedicated-family entries." } $ManifestEntriesByMapPath = @{} foreach ($ManifestEntry in $AuthoringManifest.entries) { if ([string]::IsNullOrWhiteSpace($ManifestEntry.mapAssetPath)) { throw "Higher-dimensional authoring manifest '$AuthoringManifestPath' contains an entry without mapAssetPath." } $ManifestEntriesByMapPath[$ManifestEntry.mapAssetPath] = $ManifestEntry } $ValidatedManifestEntries = @() foreach ($TargetMap in ($CookMaps + $ResolvedSmokeMaps | Select-Object -Unique)) { Assert-CookMapExists -RootPath $ProjectRoot -GameMapPath $TargetMap if (-not $ManifestEntriesByMapPath.ContainsKey($TargetMap)) { throw "Higher-dimensional cook map '$TargetMap' is not present in '$AuthoringManifestPath'. Keep this helper scoped to the manifest-backed dedicated-family maps." } $ManifestEntry = $ManifestEntriesByMapPath[$TargetMap] $ExpectedMapPath = Convert-GameMapPathToContentPath -RootPath $ProjectRoot -GameMapPath $TargetMap $ManifestRelativeMapPath = Join-Path $ProjectRoot ($ManifestEntry.mapFileRelativePath -replace '/', '\') if ($ExpectedMapPath -ne $ManifestRelativeMapPath) { throw "Higher-dimensional manifest entry '$TargetMap' points at '$ManifestRelativeMapPath', but the expected content path resolved to '$ExpectedMapPath'. Re-run dedicated-family map authoring before packaging." } if (-not (Test-Path $ManifestRelativeMapPath)) { throw "Higher-dimensional manifest-backed map file '$ManifestRelativeMapPath' was not found. Run 'scripts\Invoke-HyperTwistHigherDimensionalMapAuthoring.ps1' first." } $CurrentHash = (Get-FileHash $ManifestRelativeMapPath -Algorithm MD5).Hash.ToLowerInvariant() $ExpectedHash = [string]$ManifestEntry.mapHashMd5 if ([string]::IsNullOrWhiteSpace($ExpectedHash)) { throw "Higher-dimensional manifest entry '$TargetMap' does not record mapHashMd5." } if ($CurrentHash -ne $ExpectedHash.ToLowerInvariant()) { throw "Higher-dimensional map '$TargetMap' hash drifted from manifest '$AuthoringManifestPath'. Expected '$ExpectedHash', found '$CurrentHash'. Re-run 'scripts\Invoke-HyperTwistHigherDimensionalMapAuthoring.ps1' before packaging." } $ValidatedManifestEntries += [ordered]@{ mapKind = [string]$ManifestEntry.mapKind familyKey = [string]$ManifestEntry.familyKey mapAssetPath = [string]$ManifestEntry.mapAssetPath mapFileRelativePath = [string]$ManifestEntry.mapFileRelativePath mapHashMd5 = $CurrentHash activationProfileId = [string]$ManifestEntry.activationProfileId runtimeModeId = [string]$ManifestEntry.runtimeModeId projectionProfileId = [string]$ManifestEntry.projectionProfileId primaryPersistenceBoundaryId = [string]$ManifestEntry.primaryPersistenceBoundaryId } } if ($SkipBuild) { Assert-SkipBuildInputFreshness ` -RootPath $ProjectRoot ` -Configuration $Configuration ` -GameTargetReceiptPath $GameTargetReceiptPath } if ($PrePackageEditorBuildPerformed -and $PrePackageEditorBuildResult -ne 'passed') { throw "PrePackageEditorBuildPerformed was set, but PrePackageEditorBuildResult was '$PrePackageEditorBuildResult' instead of 'passed'." } if (-not $PrePackageEditorBuildPerformed -and $PrePackageEditorBuildResult -eq 'passed') { throw "PrePackageEditorBuildResult was 'passed', but PrePackageEditorBuildPerformed was not set." } if ($CleanArchive -and (Test-Path $ArchiveDirectory)) { Remove-Item -LiteralPath $ArchiveDirectory -Recurse -Force } New-Item -ItemType Directory -Force -Path $UnrealBuildToolSavedPath | Out-Null New-Item -ItemType Directory -Force -Path $ArchiveDirectory | Out-Null New-Item -ItemType Directory -Force -Path $ValidationRootPath | Out-Null New-Item -ItemType Directory -Force -Path $SmokeReportDirectory | Out-Null if ([string]::IsNullOrWhiteSpace($ValidationReportPath)) { $ValidationReportPath = Join-Path $ValidationRootPath 'higher-dimensional-package-validation-report.json' } $RunUatArguments = @( 'BuildCookRun', "-project=$UProjectPath", '-noP4', '-platform=Win64', "-clientconfig=$Configuration", '-cook', '-stage', '-package', '-pak', '-archive', "-archivedirectory=$ArchiveDirectory", "-map=$($CookMaps -join '+')", '-unattended', '-utf8output' ) if (-not $SkipBuild) { $RunUatArguments += '-build' } if ($ResolvedAdditionalCookerOptions.Count -gt 0) { # Keep the dedicated-family runtime lane insulated from editor-only plugin/content bleed. $RunUatArguments += "-AdditionalCookerOptions=$($ResolvedAdditionalCookerOptions -join ' ')" } $ValidationReport = [ordered]@{ reportVersion = 'ht-higher-dimensional-package-validation/v1' generatedAtUtc = [DateTime]::UtcNow.ToString('o') projectRoot = $ProjectRoot archiveDirectory = $ArchiveDirectory configuration = $Configuration cookMaps = @($CookMaps) smokeMaps = @($ResolvedSmokeMaps) additionalCookerOptions = @($ResolvedAdditionalCookerOptions) cleanArchive = [bool]$CleanArchive skipBuild = [bool]$SkipBuild prePackageEditorBuild = [ordered]@{ lane = 'Windows Unreal editor build' performed = [bool]$PrePackageEditorBuildPerformed result = $PrePackageEditorBuildResult } skipLaunch = [bool]$SkipLaunch authoringManifest = [ordered]@{ path = $AuthoringManifestPath manifestId = [string]$AuthoringManifest.manifestId manifestVersion = [string]$AuthoringManifest.manifestVersion authoredThroughGameModeClassPath = [string]$AuthoringManifest.authoredThroughGameModeClassPath validatedEntries = @($ValidatedManifestEntries) } result = 'failed' packagedExecutablePath = $null smokeReports = @() error = $null } try { Write-Host "Packaging HyperTwist higher-dimensional validation lane to '$ArchiveDirectory'..." & $RunUatPath @RunUatArguments if ($LASTEXITCODE -ne 0) { throw "RunUAT packaging failed with exit code $LASTEXITCODE." } $PackagedExecutablePath = Resolve-PackagedExecutablePath -PackageRoot $ArchiveDirectory if ($null -eq $PackagedExecutablePath) { throw "No packaged UnrealHyperTwist executable was found beneath '$ArchiveDirectory' after packaging." } $ValidationReport.packagedExecutablePath = $PackagedExecutablePath if (-not $SkipLaunch) { if (-not (Test-Path $LaunchScriptPath)) { throw "Launch script was not found at '$LaunchScriptPath'." } foreach ($SmokeMap in $ResolvedSmokeMaps) { $SmokeReportPath = Join-Path $SmokeReportDirectory ( '{0}.json' -f (Convert-PathToken -Value $SmokeMap) ) Write-Host "Smoke validating packaged higher-dimensional map '$SmokeMap'..." & $LaunchScriptPath -PackageRoot $ArchiveDirectory -MapUrl $SmokeMap -ReportPath $SmokeReportPath if ($LASTEXITCODE -ne 0) { throw "Packaged higher-dimensional smoke launch failed for '$SmokeMap' with exit code $LASTEXITCODE." } if (-not (Test-Path $SmokeReportPath)) { throw "Packaged higher-dimensional smoke launch for '$SmokeMap' completed without writing the expected report '$SmokeReportPath'." } $SmokeReport = Read-JsonFile -Path $SmokeReportPath if ($null -eq $SmokeReport) { throw "Packaged higher-dimensional smoke report '$SmokeReportPath' could not be parsed." } if ($SmokeReport.result -ne 'passed') { throw "Packaged higher-dimensional smoke report '$SmokeReportPath' did not record a passed result." } if ($SmokeReport.mapUrl -ne $SmokeMap) { throw "Packaged higher-dimensional smoke report '$SmokeReportPath' targeted '$($SmokeReport.mapUrl)' instead of '$SmokeMap'." } $ValidationReport.smokeReports += @($SmokeReport) } } $ValidationReport.result = 'passed' } catch { $ValidationReport.error = $_.Exception.Message Write-Utf8JsonFile -Path $ValidationReportPath -Value $ValidationReport throw } Write-Utf8JsonFile -Path $ValidationReportPath -Value $ValidationReport