param( [Parameter(Mandatory = $true)] [string]$ExecutablePath, [Parameter(Mandatory = $true)] [string]$OutputDirectory, [string]$TargetMap = '', [string[]]$ExpectedLogSubstring = @(), [ValidateSet('Startup', 'RuntimeReady')] [string]$CaptureTrigger = 'RuntimeReady', [int]$TimeoutSeconds = 45, [int]$SampleStride = 6, [double]$MinimumNonBlackRatio = 0.02, [double]$MinimumLuminanceDeviation = 2.5, [double]$MinimumLuminanceRange = 20.0 ) $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 } [System.IO.File]::WriteAllText( $Path, ($Value | ConvertTo-Json -Depth 12), (New-Object System.Text.UTF8Encoding($false)) ) } function Update-HyperTwistOwnedProcessIds { param( [Parameter(Mandatory = $true)] [System.Collections.Generic.HashSet[int]]$OwnedProcessIds, [Parameter(Mandatory = $true)] [string]$ProcessName, [int[]]$IgnoredProcessIds = @() ) foreach ($Process in @(Get-Process -Name $ProcessName -ErrorAction SilentlyContinue)) { if ($IgnoredProcessIds -notcontains $Process.Id) { [void]$OwnedProcessIds.Add([int]$Process.Id) } } } function Measure-HyperTwistBitmap { param( [Parameter(Mandatory = $true)] [string]$Path, [int]$Stride = 6 ) Add-Type -AssemblyName System.Drawing $Bitmap = [System.Drawing.Bitmap]::FromFile($Path) try { $SafeStride = [Math]::Max($Stride, 1) $SampleCount = 0 $NonBlackCount = 0 $LuminanceSum = 0.0 $LuminanceSquaredSum = 0.0 $MinimumLuminance = 255.0 $MaximumLuminance = 0.0 for ($Y = 0; $Y -lt $Bitmap.Height; $Y += $SafeStride) { for ($X = 0; $X -lt $Bitmap.Width; $X += $SafeStride) { $Pixel = $Bitmap.GetPixel($X, $Y) $Luminance = (0.2126 * $Pixel.R) + (0.7152 * $Pixel.G) + (0.0722 * $Pixel.B) $SampleCount += 1 $LuminanceSum += $Luminance $LuminanceSquaredSum += ($Luminance * $Luminance) $MinimumLuminance = [Math]::Min($MinimumLuminance, $Luminance) $MaximumLuminance = [Math]::Max($MaximumLuminance, $Luminance) if ($Luminance -gt 10.0) { $NonBlackCount += 1 } } } if ($SampleCount -le 0) { throw 'The off-screen frame contained no image samples.' } $AverageLuminance = $LuminanceSum / $SampleCount $Variance = [Math]::Max( ($LuminanceSquaredSum / $SampleCount) - ($AverageLuminance * $AverageLuminance), 0.0 ) return [ordered]@{ width = $Bitmap.Width height = $Bitmap.Height sampleStride = $SafeStride sampleCount = $SampleCount nonBlackRatio = $NonBlackCount / $SampleCount averageLuminance = $AverageLuminance luminanceDeviation = [Math]::Sqrt($Variance) minimumLuminance = $MinimumLuminance maximumLuminance = $MaximumLuminance luminanceRange = $MaximumLuminance - $MinimumLuminance } } finally { $Bitmap.Dispose() } } if (-not (Test-Path -LiteralPath $ExecutablePath -PathType Leaf)) { throw "HyperTwist executable '$ExecutablePath' does not exist." } New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null $ResolvedExecutablePath = (Resolve-Path -LiteralPath $ExecutablePath).Path $ResolvedOutputDirectory = (Resolve-Path -LiteralPath $OutputDirectory).Path $ExecutableDirectory = Split-Path -Parent $ResolvedExecutablePath $ExecutableProcessName = [System.IO.Path]::GetFileNameWithoutExtension($ResolvedExecutablePath) $SavedDirectory = Join-Path $ExecutableDirectory 'UnrealHyperTwist\Saved' $ScreenshotDirectory = if ($CaptureTrigger -eq 'RuntimeReady') { Join-Path $SavedDirectory 'Screenshots\HyperTwistDiagnostics' } else { Join-Path $SavedDirectory 'Screenshots\Windows' } $RuntimeLogPath = Join-Path $SavedDirectory 'Logs\UnrealHyperTwist.log' $CapturedFramePath = Join-Path $ResolvedOutputDirectory 'HyperTwist-offscreen-frame.png' $CapturedLogPath = Join-Path $ResolvedOutputDirectory 'UnrealHyperTwist.log' $ReportPath = Join-Path $ResolvedOutputDirectory 'HyperTwist-offscreen-visual-report.json' $ExistingProcessIds = @( Get-Process -Name $ExecutableProcessName -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id ) $ExistingScreenshotSignatures = @{} foreach ($ExistingScreenshot in @( Get-ChildItem -LiteralPath $ScreenshotDirectory -File -ErrorAction SilentlyContinue )) { $ExistingScreenshotSignatures[$ExistingScreenshot.FullName.ToLowerInvariant()] = '{0}:{1}' -f $ExistingScreenshot.LastWriteTimeUtc.Ticks, $ExistingScreenshot.Length } $OwnedProcessIds = New-Object 'System.Collections.Generic.HashSet[int]' $LaunchArguments = @() if (-not [string]::IsNullOrWhiteSpace($TargetMap)) { $LaunchArguments += $TargetMap } $LaunchArguments += @( '-RenderOffScreen', '-windowed', '-ResX=1280', '-ResY=720' ) if ($CaptureTrigger -eq 'RuntimeReady') { $LaunchArguments += '-HyperTwistCaptureWhenReady' } else { $LaunchArguments += '-ExecCmds="SHOT SHOWUI"' } $Result = [ordered]@{ reportVersion = 'ht-packaged-offscreen-visual/v1' generatedAtUtc = [DateTime]::UtcNow.ToString('o') executablePath = $ResolvedExecutablePath executableSha256 = (Get-FileHash -LiteralPath $ResolvedExecutablePath -Algorithm SHA256).Hash.ToLowerInvariant() targetMap = $TargetMap captureTrigger = $CaptureTrigger launchArguments = @($LaunchArguments) expectedLogSubstrings = @($ExpectedLogSubstring) matchedLogSubstrings = @() screenshotPath = $CapturedFramePath screenshotSha256 = $null runtimeLogPath = $CapturedLogPath metrics = $null thresholds = [ordered]@{ minimumNonBlackRatio = $MinimumNonBlackRatio minimumLuminanceDeviation = $MinimumLuminanceDeviation minimumLuminanceRange = $MinimumLuminanceRange } processIds = @() terminatedProcessIds = @() result = 'failed' error = $null } $LaunchedProcess = $null try { $LaunchStartedAtUtc = [DateTime]::UtcNow $LaunchedProcess = Start-Process ` -FilePath $ResolvedExecutablePath ` -ArgumentList $LaunchArguments ` -WorkingDirectory $ExecutableDirectory ` -PassThru [void]$OwnedProcessIds.Add($LaunchedProcess.Id) $Deadline = $LaunchStartedAtUtc.AddSeconds([Math]::Max($TimeoutSeconds, 1)) $FreshScreenshot = $null $RuntimeLog = '' while ([DateTime]::UtcNow -lt $Deadline) { Update-HyperTwistOwnedProcessIds ` -OwnedProcessIds $OwnedProcessIds ` -ProcessName $ExecutableProcessName ` -IgnoredProcessIds $ExistingProcessIds $FreshScreenshot = Get-ChildItem -LiteralPath $ScreenshotDirectory -File -ErrorAction SilentlyContinue | Where-Object { $ScreenshotKey = $_.FullName.ToLowerInvariant() $CurrentSignature = '{0}:{1}' -f $_.LastWriteTimeUtc.Ticks, $_.Length -not $ExistingScreenshotSignatures.ContainsKey($ScreenshotKey) ` -or $ExistingScreenshotSignatures[$ScreenshotKey] -ne $CurrentSignature } | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 if (Test-Path -LiteralPath $RuntimeLogPath) { $RuntimeLogItem = Get-Item -LiteralPath $RuntimeLogPath if ($RuntimeLogItem.LastWriteTimeUtc -ge $LaunchStartedAtUtc.AddSeconds(-1)) { $LoadedRuntimeLog = Get-Content -LiteralPath $RuntimeLogPath -Raw $RuntimeLog = if ($null -eq $LoadedRuntimeLog) { '' } else { [string]$LoadedRuntimeLog } } } $MatchedLogSubstrings = @( $ExpectedLogSubstring | Where-Object { $RuntimeLog.IndexOf($_, [System.StringComparison]::Ordinal) -ge 0 } ) $Result.matchedLogSubstrings = @($MatchedLogSubstrings) if ($null -ne $FreshScreenshot ` -and $MatchedLogSubstrings.Count -eq $ExpectedLogSubstring.Count) { break } $LiveOwnedProcesses = @( $OwnedProcessIds | ForEach-Object { Get-Process -Id $_ -ErrorAction SilentlyContinue } | Where-Object { $null -ne $_ } ) if ($LiveOwnedProcesses.Count -eq 0) { throw 'HyperTwist exited before off-screen visual validation completed.' } Start-Sleep -Milliseconds 500 } if ($null -eq $FreshScreenshot) { throw "HyperTwist did not write a fresh UI-inclusive screenshot within $TimeoutSeconds seconds." } if ($Result.matchedLogSubstrings.Count -ne $ExpectedLogSubstring.Count) { $MissingLogSubstrings = @( $ExpectedLogSubstring | Where-Object { $Result.matchedLogSubstrings -notcontains $_ } ) throw "The current runtime log omitted required markers: $($MissingLogSubstrings -join '; ')" } if ($RuntimeLog -match '(?im)Fatal error:|Unhandled Exception|Assertion failed|CreateSwapChainResult failed') { throw 'The current runtime log contains a fatal startup signature.' } Copy-Item -LiteralPath $FreshScreenshot.FullName -Destination $CapturedFramePath -Force Copy-Item -LiteralPath $RuntimeLogPath -Destination $CapturedLogPath -Force $Result.screenshotSha256 = ( Get-FileHash -LiteralPath $CapturedFramePath -Algorithm SHA256 ).Hash.ToLowerInvariant() $Result.metrics = Measure-HyperTwistBitmap -Path $CapturedFramePath -Stride $SampleStride if ($Result.metrics.nonBlackRatio -lt $MinimumNonBlackRatio ` -or $Result.metrics.luminanceDeviation -lt $MinimumLuminanceDeviation ` -or $Result.metrics.luminanceRange -lt $MinimumLuminanceRange) { throw 'The UI-inclusive off-screen frame did not satisfy the configured visual thresholds.' } $Result.processIds = @($OwnedProcessIds | Sort-Object) $Result.result = 'passed' } catch { $Result.error = $_.Exception.Message } finally { Update-HyperTwistOwnedProcessIds ` -OwnedProcessIds $OwnedProcessIds ` -ProcessName $ExecutableProcessName ` -IgnoredProcessIds $ExistingProcessIds foreach ($ProcessId in @($OwnedProcessIds | Sort-Object -Descending)) { $Process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue if ($null -ne $Process) { Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue $Result.terminatedProcessIds += $ProcessId } } Write-Utf8JsonFile -Path $ReportPath -Value $Result } if ($Result.result -ne 'passed') { throw $Result.error } Write-Output "HyperTwist off-screen visual validation passed: $ReportPath"