param( [string]$PackageRoot = 'C:\HyperTwist\packaged\desktop', [string]$MapUrl = '', [int]$SmokeSeconds = 30, [int]$ResX = 1600, [int]$ResY = 900, [string]$ReportPath = '', [switch]$KeepRunning, [switch]$UseNullRHI, [switch]$NoSound, [switch]$RequireStartupDiagnostics, [string]$ExpectedStartupDiagnosticsResult = '' ) $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 Resolve-StartupDiagnosticsLogPath { param( [Parameter(Mandatory = $true)] [string]$ResolvedPackageRoot, [Parameter(Mandatory = $true)] [DateTime]$NotBeforeUtc, [int]$TimeoutSeconds = 8, [int]$PollMilliseconds = 250 ) $CandidateDirectories = @( (Join-Path $ResolvedPackageRoot 'Saved\Logs'), (Join-Path $ResolvedPackageRoot 'Windows\Saved\Logs'), (Join-Path $ResolvedPackageRoot 'Windows\UnrealHyperTwist\Saved\Logs'), (Join-Path $env:LOCALAPPDATA 'UnrealHyperTwist\Saved\Logs') ) | Select-Object -Unique $CandidatePaths = @( (Join-Path $ResolvedPackageRoot 'Saved\Logs\HyperTwistFirstRunLaunch-latest.log'), (Join-Path $ResolvedPackageRoot 'Windows\Saved\Logs\HyperTwistFirstRunLaunch-latest.log'), (Join-Path $ResolvedPackageRoot 'Windows\UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log'), (Join-Path $env:LOCALAPPDATA 'UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log') ) | Select-Object -Unique $DeadlineUtc = [DateTime]::UtcNow.AddSeconds([Math]::Max(0, $TimeoutSeconds)) do { foreach ($CandidatePath in $CandidatePaths) { if ((Test-Path -LiteralPath $CandidatePath) ` -and (Get-Item -LiteralPath $CandidatePath).LastWriteTimeUtc -ge $NotBeforeUtc.AddSeconds(-2)) { return $CandidatePath } } foreach ($CandidateDirectory in $CandidateDirectories) { if (-not (Test-Path -LiteralPath $CandidateDirectory)) { continue } $NewestDiagnosticsLog = Get-ChildItem -LiteralPath $CandidateDirectory -Filter 'HyperTwistFirstRunLaunch*.log' -File -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTimeUtc -ge $NotBeforeUtc.AddSeconds(-2) } | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 if ($null -ne $NewestDiagnosticsLog) { return $NewestDiagnosticsLog.FullName } } if ([DateTime]::UtcNow -ge $DeadlineUtc) { break } Start-Sleep -Milliseconds ([Math]::Max(50, $PollMilliseconds)) } while ($true) return $null } 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 -ErrorAction SilentlyContinue | Where-Object { ([string]$_.Name) -like 'UnrealHyperTwist*.exe' ` -and -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) } 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 }) } $CandidateExecutablePaths = @( (Join-Path $PackageRoot 'Windows\UnrealHyperTwist.exe'), (Join-Path $PackageRoot 'WindowsNoEditor\UnrealHyperTwist.exe'), (Join-Path $PackageRoot 'UnrealHyperTwist.exe') ) $ExecutablePath = $CandidateExecutablePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 if ($null -eq $ExecutablePath) { throw "No packaged UnrealHyperTwist executable was found beneath '$PackageRoot'." } $LogDirectory = Join-Path $PackageRoot 'validation\logs' New-Item -ItemType Directory -Force -Path $LogDirectory | Out-Null $RuntimeLogPath = Join-Path $LogDirectory 'desktop-runtime.log' $RuntimeDiagnosticsPath = Join-Path $LogDirectory 'desktop-runtime.hyperdiagnostics.log' Remove-Item -LiteralPath $RuntimeLogPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $RuntimeDiagnosticsPath -Force -ErrorAction SilentlyContinue $ArgumentList = @( "-ResX=$ResX", "-ResY=$ResY", '-windowed', '-log', '-FORCELOGFLUSH', "-abslog=$RuntimeLogPath", "-HyperTwistDiagnosticsLog=`"$RuntimeDiagnosticsPath`"" ) if ($UseNullRHI) { $ArgumentList += '-NullRHI' } if ($NoSound) { $ArgumentList += '-nosound' } if (-not [string]::IsNullOrWhiteSpace($MapUrl)) { $ArgumentList = @($MapUrl) + $ArgumentList } Write-Host "Launching packaged HyperTwist desktop experience from '$ExecutablePath'..." $ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path $ExistingPackageProcessIds = @( Get-OwnedPackageProcessIds -ResolvedPackageRoot $ResolvedPackageRoot ) $GeneratedAtUtc = [DateTime]::UtcNow.ToString('o') $Process = $null $Report = [ordered]@{ reportVersion = 'ht-desktop-package-launch-surface/v2' generatedAtUtc = $GeneratedAtUtc packageRoot = $ResolvedPackageRoot executablePath = $ExecutablePath mapUrl = $MapUrl runtimeLogPath = $RuntimeLogPath runtimeLogExists = $false runtimeDiagnosticsPath = $RuntimeDiagnosticsPath runtimeDiagnosticsExists = $false runtimeDiagnosticsInitialized = $false detectedFatalLogLines = @() smokeSeconds = $SmokeSeconds resolution = [ordered]@{ width = $ResX height = $ResY } keepRunning = [bool]$KeepRunning useNullRhi = [bool]$UseNullRHI noSound = [bool]$NoSound requireStartupDiagnostics = [bool]$RequireStartupDiagnostics expectedStartupDiagnosticsResult = $ExpectedStartupDiagnosticsResult startupDiagnosticsLogPath = $null startupDiagnosticsLogExists = $false startupDiagnosticsTail = @() startupDiagnosticsResult = $null result = 'failed' processId = $null processIds = @() processCommandLines = @() ownedListeningTcpEndpoints = @() traceControlListenerLines = @() traceControlListeningEndpoints = @() processStopped = $false stoppedProcessIds = @() exitCode = $null error = $null } try { $LaunchStartedAtUtc = [DateTime]::UtcNow $Process = Start-Process -FilePath $ExecutablePath -ArgumentList $ArgumentList -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) ) $CurrentRuntimeLogLines = @( if ($Report.runtimeLogExists) { Get-Content -LiteralPath $RuntimeLogPath } ) $CurrentRuntimeDiagnosticsLines = @( if ($Report.runtimeDiagnosticsExists) { Get-Content -LiteralPath $RuntimeDiagnosticsPath } ) $CurrentRuntimeEvidenceLines = @( $CurrentRuntimeLogLines + $CurrentRuntimeDiagnosticsLines ) $Report.runtimeDiagnosticsInitialized = @( $CurrentRuntimeDiagnosticsLines | Where-Object { $_.ToString().IndexOf( '[Process] HyperTwist runtime module initialized.', [System.StringComparison]::OrdinalIgnoreCase ) -ge 0 } ).Count -gt 0 $Report.traceControlListenerLines = @( $CurrentRuntimeEvidenceLines | Where-Object { $_.ToString().IndexOf( 'Control listening on port', [System.StringComparison]::OrdinalIgnoreCase ) -ge 0 } | ForEach-Object { $_.ToString() } ) $StartupDiagnosticsLogPath = Resolve-StartupDiagnosticsLogPath ` -ResolvedPackageRoot $ResolvedPackageRoot ` -NotBeforeUtc $LaunchStartedAtUtc $Report.startupDiagnosticsLogPath = $StartupDiagnosticsLogPath $Report.startupDiagnosticsLogExists = $null -ne $StartupDiagnosticsLogPath if ($Report.startupDiagnosticsLogExists) { $Report.startupDiagnosticsTail = @( Get-Content -LiteralPath $StartupDiagnosticsLogPath -Tail 40 | ForEach-Object { $_.ToString() } ) $Report.startupDiagnosticsResult = [string]( $Report.startupDiagnosticsTail | Where-Object { $_ -like 'result=*' } | Select-Object -Last 1 ) } if (-not $Report.runtimeDiagnosticsExists) { throw 'Packaged desktop launch did not write its direct HyperTwist diagnostics log.' } if (-not $Report.runtimeDiagnosticsInitialized) { throw 'Packaged desktop launch did not record direct HyperTwist runtime initialization.' } if ($Report.detectedFatalLogLines.Count -gt 0) { throw ( "Packaged desktop runtime diagnostics recorded fatal startup evidence: " + ($Report.detectedFatalLogLines -join ' | ') ) } if ($Report.traceControlListenerLines.Count -gt 0 ` -or $Report.traceControlListeningEndpoints.Count -gt 0) { throw 'Packaged desktop runtime opened an Unreal trace-control listener.' } if ($RequireStartupDiagnostics -and -not $Report.startupDiagnosticsLogExists) { throw "Packaged desktop launch did not write HyperTwistFirstRunLaunch-latest.log." } if (-not [string]::IsNullOrWhiteSpace($ExpectedStartupDiagnosticsResult) ` -and $Report.startupDiagnosticsResult -ne "result=$ExpectedStartupDiagnosticsResult") { throw ( "Expected startup diagnostics result '$ExpectedStartupDiagnosticsResult' but observed " + "'$($Report.startupDiagnosticsResult)'." ) } if ($Report.processIds.Count -eq 0) { if ($Process.HasExited) { $Report.exitCode = $Process.ExitCode } throw 'Packaged desktop 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 desktop 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 desktop smoke process tree after successful launch validation.' } if (-not [string]::IsNullOrWhiteSpace($ReportPath)) { Write-Utf8JsonFile -Path $ReportPath -Value $Report }