hypertwist/scripts/Launch-HyperTwistClassicCubePackage.ps1
2026-07-23 17:23:24 +00:00

645 lines
21 KiB
PowerShell

param(
[string]$PackageRoot = 'C:\HyperTwist\packaged\classic-cube',
[string]$MapUrl = '/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining',
[int]$SmokeSeconds = 30,
[int]$ResX = 1600,
[int]$ResY = 900,
[string]$ReportPath = '',
[switch]$KeepRunning,
[switch]$UseNullRHI,
[switch]$NoSound
)
$ErrorActionPreference = 'Stop'
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 8
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
}
function Get-FatalRuntimeLogMatches {
param(
[Parameter(Mandatory = $true)]
[string]$RuntimeLogPath
)
if (-not (Test-Path -LiteralPath $RuntimeLogPath))
{
return @()
}
$FatalPatterns = @(
'Fatal error:',
'LowLevelFatalError',
'Assertion failed:',
'Critical error:',
'appError called:',
'DXGI_ERROR_NOT_CURRENTLY_AVAILABLE',
'CreateSwapChainResult failed',
'Unhandled Exception:',
'StaticShutdownAfterError'
)
return @(Get-Content -LiteralPath $RuntimeLogPath | Where-Object {
$Line = $_
foreach ($Pattern in $FatalPatterns)
{
if ($Line -like "*$Pattern*")
{
return $true
}
}
return $false
})
}
function Get-OwnedPackageProcessIds {
param(
[Parameter(Mandatory = $true)]
[string]$ResolvedPackageRoot,
[int[]]$ExcludedProcessIds = @()
)
return @(
Get-OwnedPackageProcessRecords `
-ResolvedPackageRoot $ResolvedPackageRoot `
-ExcludedProcessIds $ExcludedProcessIds |
Select-Object -ExpandProperty ProcessId
)
}
function Get-OwnedPackageProcessRecords {
param(
[Parameter(Mandatory = $true)]
[string]$ResolvedPackageRoot,
[int[]]$ExcludedProcessIds = @()
)
$RootPrefix = $ResolvedPackageRoot.TrimEnd(
[System.IO.Path]::DirectorySeparatorChar,
[System.IO.Path]::AltDirectorySeparatorChar
) + [System.IO.Path]::DirectorySeparatorChar
return @(
Get-CimInstance Win32_Process -Filter "Name = 'UnrealHyperTwist.exe'" -ErrorAction SilentlyContinue |
Where-Object {
-not [string]::IsNullOrWhiteSpace([string]$_.ExecutablePath) `
-and ([string]$_.ExecutablePath).StartsWith(
$RootPrefix,
[System.StringComparison]::OrdinalIgnoreCase
) `
-and $ExcludedProcessIds -notcontains [int]$_.ProcessId
}
)
}
function Get-OwnedListeningTcpEndpoints {
param(
[int[]]$ProcessIds
)
if ($ProcessIds.Count -eq 0)
{
return @()
}
return @(
Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
Where-Object { $ProcessIds -contains [int]$_.OwningProcess } |
Sort-Object OwningProcess, LocalPort |
ForEach-Object {
[pscustomobject][ordered]@{
processId = [int]$_.OwningProcess
localAddress = [string]$_.LocalAddress
localPort = [int]$_.LocalPort
}
}
)
}
function Stop-OwnedPackageProcesses {
param(
[Parameter(Mandatory = $true)]
[string]$ResolvedPackageRoot,
[int[]]$ExcludedProcessIds = @()
)
$StoppedProcessIds = New-Object 'System.Collections.Generic.HashSet[int]'
for ($Attempt = 0; $Attempt -lt 5; $Attempt += 1)
{
$OwnedProcessIds = @(
Get-OwnedPackageProcessIds `
-ResolvedPackageRoot $ResolvedPackageRoot `
-ExcludedProcessIds $ExcludedProcessIds
)
if ($OwnedProcessIds.Count -eq 0)
{
break
}
foreach ($OwnedProcessId in $OwnedProcessIds)
{
Stop-Process -Id $OwnedProcessId -Force -ErrorAction SilentlyContinue
[void]$StoppedProcessIds.Add([int]$OwnedProcessId)
}
Start-Sleep -Milliseconds 200
}
return @($StoppedProcessIds)
}
$CandidateExecutablePaths = @(
(Join-Path $PackageRoot 'Windows\UnrealHyperTwist.exe'),
(Join-Path $PackageRoot 'WindowsNoEditor\UnrealHyperTwist.exe'),
(Join-Path $PackageRoot 'UnrealHyperTwist.exe')
)
$LauncherExecutablePath = $CandidateExecutablePaths |
Where-Object { Test-Path $_ } |
Select-Object -First 1
if ($null -eq $LauncherExecutablePath)
{
throw "No packaged UnrealHyperTwist executable was found beneath '$PackageRoot'."
}
$PackagedExecutableDirectory = Split-Path -Parent $LauncherExecutablePath
$ClassicDefaultMap = '/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining'
$StartupRouteByMap = @{
$ClassicDefaultMap = ''
'/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining' = 'follow-along-training'
'/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube4D_2x2x2x2Training' = 'magic-cube-4d-2x2x2x2'
'/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube4D_3x3x3x3Training' = 'magic-cube-4d-3x3x3x3'
'/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube4D_4x4x4x4Training' = 'magic-cube-4d-4x4x4x4'
'/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube4D_5x5x5x5Training' = 'magic-cube-4d-5x5x5x5'
'/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube4D_6x6x6x6Training' = 'magic-cube-4d-6x6x6x6'
'/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining' = 'magic-120-cell-training'
'/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining' = 'magic-cube-5d-training'
}
$FourDimensionalOrderByMap = @{
'/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube4D_2x2x2x2Training' = 2
'/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube4D_3x3x3x3Training' = 3
'/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube4D_4x4x4x4Training' = 4
'/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube4D_5x5x5x5Training' = 5
'/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube4D_6x6x6x6Training' = 6
}
if (-not $StartupRouteByMap.ContainsKey($MapUrl))
{
throw "No first-party packaged startup route is registered for smoke map '$MapUrl'."
}
$StartupRouteId = [string]$StartupRouteByMap[$MapUrl]
$LaunchMode = if ($MapUrl -eq $ClassicDefaultMap)
{
'bootstrap-default-map'
}
else
{
'first-party-startup-route'
}
$ExecutablePath = $LauncherExecutablePath
$LogDirectory = Join-Path $PackageRoot 'validation\logs'
New-Item -ItemType Directory -Force -Path $LogDirectory | Out-Null
$LogToken = if ([string]::IsNullOrWhiteSpace($MapUrl))
{
'classic-cube-runtime'
}
else
{
($MapUrl -replace '[\\/:*?"<>| ]', '_')
}
$RuntimeLogPath = Join-Path $LogDirectory ("{0}.log" -f $LogToken)
$RuntimeDiagnosticsPath = Join-Path $LogDirectory ("{0}.hyperdiagnostics.log" -f $LogToken)
Remove-Item -LiteralPath $RuntimeLogPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $RuntimeDiagnosticsPath -Force -ErrorAction SilentlyContinue
$ArgumentList = @()
if (-not [string]::IsNullOrWhiteSpace($StartupRouteId))
{
# Nondefault smokes use the same registered product route as the launch
# menu. Generic startup URLs are overridden by the packaged first-run
# game-mode authority and therefore are not valid route evidence.
$ArgumentList += "-HyperTwistStartupRoute=$StartupRouteId"
}
$ArgumentList += @(
"-ResX=$ResX",
"-ResY=$ResY",
'-windowed',
'-log',
'-FORCELOGFLUSH',
"-abslog=$RuntimeLogPath",
"-HyperTwistDiagnosticsLog=`"$RuntimeDiagnosticsPath`""
)
if ($UseNullRHI)
{
$ArgumentList += '-NullRHI'
}
if ($NoSound)
{
$ArgumentList += '-nosound'
}
Write-Host "Launching packaged classic-cube validation lane from '$ExecutablePath'..."
$ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path
$ExistingPackageProcessIds = @(
Get-OwnedPackageProcessIds -ResolvedPackageRoot $ResolvedPackageRoot
)
$GeneratedAtUtc = [DateTime]::UtcNow.ToString('o')
$Process = $null
$RequiresClassicPresentation = $MapUrl -match '/L_HyperTwist_(Classic|FollowAlong)Training$'
$HigherDimensionalFamily = if ($MapUrl -match '/L_HyperTwist_Magic120CellTraining$')
{
'magic120cell'
}
elseif ($MapUrl -match '/L_HyperTwist_MagicCube5DTraining$')
{
'magiccube5d'
}
else
{
''
}
$ExpectedHigherDimensionalElementCount = if ($HigherDimensionalFamily -eq 'magic120cell')
{
120
}
elseif ($HigherDimensionalFamily -eq 'magiccube5d')
{
242
}
else
{
0
}
$FourDimensionalOrder = if ($FourDimensionalOrderByMap.ContainsKey($MapUrl))
{
[int]$FourDimensionalOrderByMap[$MapUrl]
}
else
{
0
}
$ExpectedFourDimensionalStatePieceCount = if ($FourDimensionalOrder -gt 0)
{
[int][Math]::Pow($FourDimensionalOrder, 4)
}
else
{
0
}
$ExpectedFourDimensionalVisiblePieceViewCount = if ($FourDimensionalOrder -eq 2)
{
64
}
elseif ($FourDimensionalOrder -gt 0)
{
[int][Math]::Pow($FourDimensionalOrder, 3)
}
else
{
0
}
$Report = [ordered]@{
reportVersion = 'ht-desktop-map-package-smoke/v8'
generatedAtUtc = $GeneratedAtUtc
packageRoot = $ResolvedPackageRoot
launchMode = $LaunchMode
startupRouteId = $StartupRouteId
launcherExecutablePath = $LauncherExecutablePath
executablePath = $ExecutablePath
mapUrl = $MapUrl
runtimeLogPath = $RuntimeLogPath
runtimeLogExists = $false
runtimeDiagnosticsPath = $RuntimeDiagnosticsPath
runtimeDiagnosticsExists = $false
detectedFatalLogLines = @()
mapLoadLogLines = @()
requiresClassicPresentation = $RequiresClassicPresentation
classicPresentationLogLines = @()
classicScrambleSettlementLogLines = @()
higherDimensionalFamily = $HigherDimensionalFamily
expectedHigherDimensionalElementCount = $ExpectedHigherDimensionalElementCount
higherDimensionalPresentationLogLines = @()
higherDimensionalPaletteLogLines = @()
fourDimensionalOrder = $FourDimensionalOrder
expectedFourDimensionalStatePieceCount = $ExpectedFourDimensionalStatePieceCount
expectedFourDimensionalVisiblePieceViewCount = $ExpectedFourDimensionalVisiblePieceViewCount
fourDimensionalPresentationLogLines = @()
smokeSeconds = $SmokeSeconds
resolution = [ordered]@{
width = $ResX
height = $ResY
}
keepRunning = [bool]$KeepRunning
useNullRhi = [bool]$UseNullRHI
noSound = [bool]$NoSound
result = 'failed'
processId = $null
processIds = @()
processCommandLines = @()
ownedListeningTcpEndpoints = @()
traceControlListenerLines = @()
traceControlListeningEndpoints = @()
startupRouteCommandLineEvidence = @()
startupRouteEvidenceLines = @()
processStopped = $false
stoppedProcessIds = @()
exitCode = $null
error = $null
}
try
{
$Process = Start-Process `
-FilePath $ExecutablePath `
-ArgumentList $ArgumentList `
-WorkingDirectory (Split-Path -Parent $ExecutablePath) `
-PassThru
Start-Sleep -Seconds $SmokeSeconds
$Process.Refresh()
$Report.processId = $Process.Id
$OwnedProcessRecords = @(
Get-OwnedPackageProcessRecords `
-ResolvedPackageRoot $ResolvedPackageRoot `
-ExcludedProcessIds $ExistingPackageProcessIds
)
$Report.processIds = @($OwnedProcessRecords | Select-Object -ExpandProperty ProcessId)
$Report.processCommandLines = @(
$OwnedProcessRecords |
ForEach-Object { [string]$_.CommandLine } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
$Report.ownedListeningTcpEndpoints = @(
Get-OwnedListeningTcpEndpoints -ProcessIds $Report.processIds
)
$Report.traceControlListeningEndpoints = @(
$Report.ownedListeningTcpEndpoints |
Where-Object { [int]$_.localPort -eq 1985 }
)
Start-Sleep -Milliseconds 500
$Report.runtimeLogExists = Test-Path -LiteralPath $RuntimeLogPath
$Report.runtimeDiagnosticsExists = Test-Path -LiteralPath $RuntimeDiagnosticsPath
$Report.detectedFatalLogLines = @(
@(Get-FatalRuntimeLogMatches -RuntimeLogPath $RuntimeLogPath) +
@(Get-FatalRuntimeLogMatches -RuntimeLogPath $RuntimeDiagnosticsPath)
)
if (-not $Report.runtimeDiagnosticsExists)
{
throw 'Packaged launch did not write its direct HyperTwist diagnostics log.'
}
if ($Report.detectedFatalLogLines.Count -gt 0)
{
throw (
"Packaged classic-cube runtime log recorded fatal startup evidence: " +
($Report.detectedFatalLogLines -join ' | ')
)
}
$CurrentRuntimeLogLines = @(
if ($Report.runtimeLogExists)
{
Get-Content -LiteralPath $RuntimeLogPath
}
)
$CurrentRuntimeDiagnosticsLines = @(
if ($Report.runtimeDiagnosticsExists)
{
Get-Content -LiteralPath $RuntimeDiagnosticsPath
}
)
$CurrentEvidenceLines = @($CurrentRuntimeLogLines + $CurrentRuntimeDiagnosticsLines)
$Report.traceControlListenerLines = @(
$CurrentEvidenceLines |
Where-Object {
$_.ToString().IndexOf(
'Control listening on port',
[System.StringComparison]::OrdinalIgnoreCase
) -ge 0
} |
ForEach-Object { $_.ToString() }
)
if ($Report.traceControlListenerLines.Count -gt 0 `
-or $Report.traceControlListeningEndpoints.Count -gt 0)
{
throw 'Packaged runtime opened an Unreal trace-control listener.'
}
if (-not [string]::IsNullOrWhiteSpace($StartupRouteId))
{
$ExpectedStartupRouteArgument = "-HyperTwistStartupRoute=$StartupRouteId"
$Report.startupRouteCommandLineEvidence = @(
$Report.processCommandLines |
Where-Object {
$_.IndexOf(
$ExpectedStartupRouteArgument,
[System.StringComparison]::OrdinalIgnoreCase
) -ge 0
}
)
if ($Report.startupRouteCommandLineEvidence.Count -eq 0)
{
throw (
"Packaged bootstrap did not propagate first-party startup route argument " +
"'$ExpectedStartupRouteArgument'."
)
}
$ExpectedStartupRouteMarker = "Accepted packaged startup route '$StartupRouteId'"
$Report.startupRouteEvidenceLines = @(
$CurrentEvidenceLines |
Where-Object { $_ -like "*$ExpectedStartupRouteMarker*" } |
ForEach-Object { $_.ToString() }
)
if ($Report.startupRouteEvidenceLines.Count -eq 0)
{
throw (
"Packaged runtime did not accept first-party startup route '$StartupRouteId' " +
'through the launch controller.'
)
}
}
$ExpectedMapLeaf = ($MapUrl -split '/')[-1]
$Report.mapLoadLogLines = @(
$CurrentEvidenceLines |
Where-Object {
$_ -like "*LogLoad: LoadMap: $MapUrl*" `
-or $_ -like "*Bringing World $MapUrl*up for play*" `
-or $_ -like "*Runtime map ready:*$ExpectedMapLeaf*"
} |
ForEach-Object { $_.ToString() }
)
if ($Report.mapLoadLogLines.Count -eq 0)
{
throw "Packaged launch stayed alive but did not load target map '$MapUrl' before the smoke interval ended."
}
if ($FourDimensionalOrder -gt 0)
{
$ExpectedFourDimensionalMarker = (
'Four-dimensional presentation initialized for order {0} with {1} exact state pieces ' +
'and {2} visible projected piece views; state valid, projection valid.'
) -f (
$FourDimensionalOrder,
$ExpectedFourDimensionalStatePieceCount,
$ExpectedFourDimensionalVisiblePieceViewCount
)
$Report.fourDimensionalPresentationLogLines = @(
$CurrentEvidenceLines |
Where-Object {
$_.ToString().IndexOf(
'[FourDimensionalPresentation]',
[System.StringComparison]::Ordinal
) -ge 0
} |
ForEach-Object { $_.ToString() }
)
$MatchingFourDimensionalPresentationLines = @(
$Report.fourDimensionalPresentationLogLines |
Where-Object { $_ -like "*$ExpectedFourDimensionalMarker*" }
)
if ($MatchingFourDimensionalPresentationLines.Count -eq 0)
{
throw (
"Packaged map '$MapUrl' loaded, but the runtime did not confirm order " +
"$FourDimensionalOrder with $ExpectedFourDimensionalStatePieceCount exact state pieces " +
"and $ExpectedFourDimensionalVisiblePieceViewCount visible projected piece views."
)
}
}
$Report.classicPresentationLogLines = @(
$CurrentEvidenceLines |
Where-Object {
$_ -like '*Classic cube presentation initialized with 26 renderable pieces*'
} |
ForEach-Object { $_.ToString() }
)
if ($RequiresClassicPresentation -and $Report.classicPresentationLogLines.Count -eq 0)
{
throw (
"Packaged map '$MapUrl' loaded, but the runtime did not confirm its complete 26-cubie " +
'Classic cube presentation.'
)
}
$Report.classicScrambleSettlementLogLines = @(
$CurrentEvidenceLines |
Where-Object {
$_ -match 'Classic cube scramble settled with \d+ completed quarter turns, 0 queued rotations, and 26 renderable pieces; renderable state valid\.'
} |
ForEach-Object { $_.ToString() }
)
if ($RequiresClassicPresentation -and $Report.classicScrambleSettlementLogLines.Count -eq 0)
{
throw (
"Packaged map '$MapUrl' loaded its Classic presentation, but the initial scramble " +
'did not settle into a coherent 26-cubie state before the smoke interval ended.'
)
}
if (-not [string]::IsNullOrWhiteSpace($HigherDimensionalFamily))
{
$ExpectedPresentationMarker = (
'Higher-dimensional presentation initialized with {0} renderable elements ' +
'(canonical={0}) for family {1}.'
) -f $ExpectedHigherDimensionalElementCount, $HigherDimensionalFamily
$Report.higherDimensionalPresentationLogLines = @(
$CurrentEvidenceLines |
Where-Object { $_ -like "*$ExpectedPresentationMarker*" } |
ForEach-Object { $_.ToString() }
)
if ($Report.higherDimensionalPresentationLogLines.Count -eq 0)
{
throw (
"Packaged map '$MapUrl' loaded, but the runtime did not confirm its " +
"$ExpectedHigherDimensionalElementCount-element $HigherDimensionalFamily presentation."
)
}
$Report.higherDimensionalPaletteLogLines = @(
$CurrentEvidenceLines |
Where-Object {
$_ -like '*Higher-dimensional projection palette initialized with 6 distinct layer materials.*'
} |
ForEach-Object { $_.ToString() }
)
if ($Report.higherDimensionalPaletteLogLines.Count -eq 0)
{
throw (
"Packaged map '$MapUrl' loaded its projection, but the runtime did not confirm " +
'six distinct first-party layer materials.'
)
}
}
if ($Report.processIds.Count -eq 0)
{
if ($Process.HasExited)
{
$Report.exitCode = $Process.ExitCode
}
throw "Packaged classic-cube executable exited before the smoke interval completed."
}
$Report.result = 'passed'
}
catch
{
$Report.error = $_.Exception.Message
if ($null -ne $Process -and $Process.HasExited)
{
$Report.exitCode = $Process.ExitCode
}
if (-not $KeepRunning)
{
$Report.stoppedProcessIds = @(
Stop-OwnedPackageProcesses `
-ResolvedPackageRoot $ResolvedPackageRoot `
-ExcludedProcessIds $ExistingPackageProcessIds
)
$Report.processStopped = $Report.stoppedProcessIds.Count -gt 0
}
if (-not [string]::IsNullOrWhiteSpace($ReportPath))
{
Write-Utf8JsonFile -Path $ReportPath -Value $Report
}
throw
}
Write-Host "Packaged classic-cube smoke launch succeeded (PID $($Process.Id))."
if (-not $KeepRunning)
{
$Report.stoppedProcessIds = @(
Stop-OwnedPackageProcesses `
-ResolvedPackageRoot $ResolvedPackageRoot `
-ExcludedProcessIds $ExistingPackageProcessIds
)
$Report.processStopped = $Report.stoppedProcessIds.Count -gt 0
Write-Host 'Stopped the complete packaged classic-cube smoke process tree after successful launch validation.'
}
if (-not [string]::IsNullOrWhiteSpace($ReportPath))
{
Write-Utf8JsonFile -Path $ReportPath -Value $Report
}