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 }