param( [Parameter(Mandatory = $true)] [string]$ExecutablePath, [Parameter(Mandatory = $true)] [string]$OutputDirectory, [string[]]$LaunchArguments = @( '-windowed', '-ResX=1280', '-ResY=720' ), [int]$StartupTimeoutSeconds = 45, [int]$LayoutSettleSeconds = 5, [int]$SampleStride = 6, [double]$MinimumNonBlackRatio = 0.02, [double]$MinimumBrightRatio = 0.001, [double]$MinimumLuminanceDeviation = 2.5, [double]$MinimumLuminanceRange = 20.0, [double]$ClickNormalizedX = -1.0, [double]$ClickNormalizedY = -1.0, [ValidateSet('PhysicalMouse', 'WindowMessage')] [string]$ClickInjectionMode = 'PhysicalMouse', [int]$PostClickSettleSeconds = 5, [string]$ExpectedWindowClassName = 'UnrealWindow', [string]$ExpectedStartupDiagnosticsResult = '', [string]$ExpectedSemanticReadyCaptureName = '', [int]$SemanticReadyCaptureTimeoutSeconds = 30, [switch]$KeepProcess ) $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 Write-HyperTwistVisualCheckpoint { param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [string]$Stage ) $Line = "{0}`t{1}{2}" -f ` [DateTime]::UtcNow.ToString('o'), ` $Stage, ` [Environment]::NewLine [System.IO.File]::AppendAllText( $Path, $Line, (New-Object System.Text.UTF8Encoding($false)) ) } function Get-HyperTwistClientBounds { param( [Parameter(Mandatory = $true)] [IntPtr]$WindowHandle ) $ClientRect = New-Object HyperTwistVisualGate.NativeMethods+RECT if (-not [HyperTwistVisualGate.NativeMethods]::GetClientRect($WindowHandle, [ref]$ClientRect)) { throw 'GetClientRect failed for the HyperTwist window.' } $ClientOrigin = New-Object HyperTwistVisualGate.NativeMethods+POINT $ClientOrigin.X = 0 $ClientOrigin.Y = 0 if (-not [HyperTwistVisualGate.NativeMethods]::ClientToScreen($WindowHandle, [ref]$ClientOrigin)) { throw 'ClientToScreen failed for the HyperTwist window.' } return [ordered]@{ x = $ClientOrigin.X y = $ClientOrigin.Y width = $ClientRect.Right - $ClientRect.Left height = $ClientRect.Bottom - $ClientRect.Top } } function Set-HyperTwistWindowForeground { param( [Parameter(Mandatory = $true)] [IntPtr]$WindowHandle, [switch]$RequireForeground ) for ($Attempt = 1; $Attempt -le 3; $Attempt += 1) { [void][HyperTwistVisualGate.NativeMethods]::ShowWindowAsync($WindowHandle, 9) # Pulse topmost status to expose an obscured game window, then restore # normal z-order before capture. [void][HyperTwistVisualGate.NativeMethods]::SetWindowPos( $WindowHandle, [IntPtr](-1), 0, 0, 0, 0, 0x0043 ) [void][HyperTwistVisualGate.NativeMethods]::SetWindowPos( $WindowHandle, [IntPtr](-2), 0, 0, 0, 0, 0x0043 ) [void][HyperTwistVisualGate.NativeMethods]::BringWindowToTop($WindowHandle) [void][HyperTwistVisualGate.NativeMethods]::SetForegroundWindow($WindowHandle) Start-Sleep -Milliseconds 300 if ([HyperTwistVisualGate.NativeMethods]::GetForegroundWindow() -eq $WindowHandle) { return "topmost-attempt-$Attempt" } Start-Sleep -Milliseconds 300 } if ($RequireForeground) { throw 'Could not focus the HyperTwist window before interactive visual capture.' } return 'topmost-foreground-unconfirmed' } function Measure-HyperTwistBitmap { param( [Parameter(Mandatory = $true)] [System.Drawing.Bitmap]$Bitmap, [int]$Stride = 6 ) $SafeStride = [Math]::Max($Stride, 1) $SampleCount = 0 $NonBlackCount = 0 $BrightCount = 0 $LuminanceSum = 0.0 $LuminanceSquaredSum = 0.0 $MinimumLuminance = 255.0 $MaximumLuminance = 0.0 $ColorBuckets = New-Object 'System.Collections.Generic.HashSet[string]' 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 ($Luminance -gt 35.0) { $BrightCount += 1 } $Bucket = '{0}-{1}-{2}' -f ` [Math]::Floor($Pixel.R / 16), ` [Math]::Floor($Pixel.G / 16), ` [Math]::Floor($Pixel.B / 16) [void]$ColorBuckets.Add($Bucket) } } if ($SampleCount -le 0) { throw 'The captured HyperTwist client image contained no samples.' } $AverageLuminance = $LuminanceSum / $SampleCount $Variance = [Math]::Max( ($LuminanceSquaredSum / $SampleCount) - ($AverageLuminance * $AverageLuminance), 0.0 ) return [ordered]@{ sampleStride = $SafeStride sampleCount = $SampleCount nonBlackRatio = $NonBlackCount / $SampleCount brightRatio = $BrightCount / $SampleCount averageLuminance = $AverageLuminance luminanceDeviation = [Math]::Sqrt($Variance) minimumLuminance = $MinimumLuminance maximumLuminance = $MaximumLuminance luminanceRange = $MaximumLuminance - $MinimumLuminance quantizedColorBucketCount = $ColorBuckets.Count } } function Update-HyperTwistOwnedProcessIds { param( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [System.Collections.Generic.HashSet[int]]$OwnedProcessIds, [Parameter(Mandatory = $true)] [string]$ResolvedExecutableRoot, [int[]]$IgnoredProcessIds = @() ) $RootPrefix = $ResolvedExecutableRoot.TrimEnd( [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar ) + [System.IO.Path]::DirectorySeparatorChar $ProcessSnapshots = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue) foreach ($ProcessSnapshot in $ProcessSnapshots) { $ExecutablePath = [string]$ProcessSnapshot.ExecutablePath if ($IgnoredProcessIds -notcontains [int]$ProcessSnapshot.ProcessId ` -and ([string]$ProcessSnapshot.Name) -like 'UnrealHyperTwist*.exe' ` -and -not [string]::IsNullOrWhiteSpace($ExecutablePath) ` -and $ExecutablePath.StartsWith( $RootPrefix, [System.StringComparison]::OrdinalIgnoreCase )) { [void]$OwnedProcessIds.Add([int]$ProcessSnapshot.ProcessId) } } # Preserve ownership across the bootstrap-to-Shipping handoff even when # Windows temporarily withholds a child's ExecutablePath during startup. $AddedDescendant = $true while ($AddedDescendant) { $AddedDescendant = $false foreach ($ProcessSnapshot in $ProcessSnapshots) { if ($IgnoredProcessIds -notcontains [int]$ProcessSnapshot.ProcessId ` -and $OwnedProcessIds.Contains([int]$ProcessSnapshot.ParentProcessId) ` -and $OwnedProcessIds.Add([int]$ProcessSnapshot.ProcessId)) { $AddedDescendant = $true } } } } 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 $CaptureToken = [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ') $ScreenshotPath = Join-Path $ResolvedOutputDirectory "HyperTwist-window-$CaptureToken.png" $ReportPath = Join-Path $ResolvedOutputDirectory "HyperTwist-window-$CaptureToken.json" $ProgressPath = Join-Path $ResolvedOutputDirectory "HyperTwist-window-$CaptureToken.progress.log" $RuntimeDiagnosticsPath = Join-Path $ResolvedOutputDirectory "HyperTwist-window-$CaptureToken.hyperdiagnostics.log" $EffectiveLaunchArguments = @($LaunchArguments) if (-not @($EffectiveLaunchArguments | Where-Object { $_ -like '-HyperTwistDiagnosticsLog=*' }).Count) { $EffectiveLaunchArguments += "-HyperTwistDiagnosticsLog=`"$RuntimeDiagnosticsPath`"" } $SemanticReadyCaptureCandidates = @() if (-not [string]::IsNullOrWhiteSpace($ExpectedSemanticReadyCaptureName)) { if ([System.IO.Path]::GetFileName($ExpectedSemanticReadyCaptureName) ` -ne $ExpectedSemanticReadyCaptureName ` -or [System.IO.Path]::GetExtension($ExpectedSemanticReadyCaptureName) -ne '.png') { throw 'ExpectedSemanticReadyCaptureName must be one PNG file name without a path.' } $SemanticReadyCaptureCandidates = @( (Join-Path ` $env:LOCALAPPDATA ` "UnrealHyperTwist\Saved\Screenshots\HyperTwistDiagnostics\$ExpectedSemanticReadyCaptureName"), (Join-Path ` $ExecutableDirectory ` "UnrealHyperTwist\Saved\Screenshots\HyperTwistDiagnostics\$ExpectedSemanticReadyCaptureName"), (Join-Path ` $ExecutableDirectory ` "Saved\Screenshots\HyperTwistDiagnostics\$ExpectedSemanticReadyCaptureName") ) | Select-Object -Unique } $ExistingOwnedProcessIds = New-Object 'System.Collections.Generic.HashSet[int]' Update-HyperTwistOwnedProcessIds ` -OwnedProcessIds $ExistingOwnedProcessIds ` -ResolvedExecutableRoot $ExecutableDirectory $ExistingProcessIds = @($ExistingOwnedProcessIds) Add-Type -AssemblyName System.Drawing if (-not ('HyperTwistVisualGate.NativeMethods' -as [type])) { Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; using System.Text; namespace HyperTwistVisualGate { public static class NativeMethods { [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; } [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetClientRect(IntPtr hWnd, ref RECT lpRect); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint); [DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool BringWindowToTop(IntPtr hWnd); [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool SetWindowPos( IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint flags ); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool SetCursorPos(int x, int y); [DllImport("user32.dll")] public static extern void mouse_event( uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo ); [DllImport("user32.dll")] public static extern IntPtr SendMessage( IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam ); [DllImport("user32.dll", CharSet = CharSet.Auto)] private static extern int GetClassName( IntPtr hWnd, StringBuilder lpClassName, int nMaxCount ); public static string ReadWindowClassName(IntPtr hWnd) { var className = new StringBuilder(256); return GetClassName(hWnd, className, className.Capacity) > 0 ? className.ToString() : String.Empty; } } } '@ } # CopyFromScreen uses physical pixels. Match the calling thread to modern UE # per-monitor DPI coordinates so the measured rectangle cannot drift onto the # surrounding desktop at display scaling values above 100 percent. $PreviousDpiAwarenessContext = [HyperTwistVisualGate.NativeMethods]::SetThreadDpiAwarenessContext( [IntPtr](-4) ) if ($PreviousDpiAwarenessContext -eq [IntPtr]::Zero) { throw 'Could not enable per-monitor-v2 DPI awareness for the visual capture thread.' } $Result = [ordered]@{ reportVersion = 'ht-packaged-window-visual/v2' generatedAtUtc = [DateTime]::UtcNow.ToString('o') launchStartedAtUtc = $null executablePath = $ResolvedExecutablePath executableSha256 = (Get-FileHash -LiteralPath $ResolvedExecutablePath -Algorithm SHA256).Hash.ToLowerInvariant() launchArguments = @($EffectiveLaunchArguments) perMonitorV2DpiAware = $true processId = $null windowTitle = $null windowClassName = $null expectedWindowClassName = $ExpectedWindowClassName clientBounds = $null screenshotPath = $ScreenshotPath progressPath = $ProgressPath screenshotSha256 = $null captureMethod = $null expectedSemanticReadyCaptureName = $ExpectedSemanticReadyCaptureName semanticReadyCaptureSourcePath = $null semanticReadyCaptureSourceSha256 = $null metrics = $null thresholds = [ordered]@{ minimumNonBlackRatio = $MinimumNonBlackRatio minimumBrightRatio = $MinimumBrightRatio minimumLuminanceDeviation = $MinimumLuminanceDeviation minimumLuminanceRange = $MinimumLuminanceRange } interaction = [ordered]@{ requested = $ClickNormalizedX -ge 0.0 -or $ClickNormalizedY -ge 0.0 normalizedX = $ClickNormalizedX normalizedY = $ClickNormalizedY screenX = $null screenY = $null clientX = $null clientY = $null injectionMethod = $null clicked = $false expectedStartupDiagnosticsResult = $ExpectedStartupDiagnosticsResult } startupDiagnosticsPath = $null startupDiagnosticsLastWriteUtc = $null startupDiagnosticsResult = $null runtimeDiagnosticsPath = $RuntimeDiagnosticsPath runtimeDiagnosticsExists = $false runtimeDiagnosticsLines = @() processCommandLines = @() ownedListeningTcpEndpoints = @() traceControlListenerLines = @() traceControlListeningEndpoints = @() focusMethod = $null result = 'failed' error = $null } $LaunchedProcess = $null $WindowProcess = $null $OwnedProcessIds = New-Object 'System.Collections.Generic.HashSet[int]' try { Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'launch-starting' $LaunchStartedAtUtc = [DateTime]::UtcNow $Result.launchStartedAtUtc = $LaunchStartedAtUtc.ToString('o') $LaunchedProcess = Start-Process ` -FilePath $ResolvedExecutablePath ` -ArgumentList $EffectiveLaunchArguments ` -WorkingDirectory $ExecutableDirectory ` -PassThru [void]$OwnedProcessIds.Add($LaunchedProcess.Id) Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'launch-process-started' $Deadline = [DateTime]::UtcNow.AddSeconds([Math]::Max($StartupTimeoutSeconds, 1)) while ([DateTime]::UtcNow -lt $Deadline) { Update-HyperTwistOwnedProcessIds ` -OwnedProcessIds $OwnedProcessIds ` -ResolvedExecutableRoot $ExecutableDirectory ` -IgnoredProcessIds $ExistingProcessIds $CandidateProcesses = @($OwnedProcessIds) | Where-Object { $ExistingProcessIds -notcontains $_ } | ForEach-Object { Get-Process -Id $_ -ErrorAction SilentlyContinue } | Where-Object { $null -ne $_ } foreach ($Candidate in $CandidateProcesses) { [void]$OwnedProcessIds.Add($Candidate.Id) $Candidate.Refresh() if ($Candidate.MainWindowHandle -ne [IntPtr]::Zero) { $CandidateWindowClass = [HyperTwistVisualGate.NativeMethods]::ReadWindowClassName( $Candidate.MainWindowHandle ) if ($CandidateWindowClass -eq 'ConsoleWindowClass') { continue } if (-not [string]::IsNullOrWhiteSpace($ExpectedWindowClassName) ` -and $CandidateWindowClass -ne $ExpectedWindowClassName) { continue } $WindowProcess = $Candidate break } } if ($null -ne $WindowProcess) { break } if ($LaunchedProcess.HasExited) { throw "HyperTwist exited with code $($LaunchedProcess.ExitCode) before creating a window." } Start-Sleep -Milliseconds 500 } if ($null -eq $WindowProcess) { throw "HyperTwist did not expose a top-level window within $StartupTimeoutSeconds seconds." } Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'window-detected' Start-Sleep -Seconds ([Math]::Max($LayoutSettleSeconds, 0)) Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'initial-layout-settled' $WindowProcess.Refresh() if ($WindowProcess.HasExited -or $WindowProcess.MainWindowHandle -eq [IntPtr]::Zero) { throw 'The HyperTwist window closed before visual capture.' } Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'initial-window-refreshed' $Bounds = Get-HyperTwistClientBounds -WindowHandle $WindowProcess.MainWindowHandle if ($Bounds.width -lt 320 -or $Bounds.height -lt 200) { throw "HyperTwist exposed an invalid client area ($($Bounds.width)x$($Bounds.height))." } Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'initial-bounds-measured' $InteractionRequested = $ClickNormalizedX -ge 0.0 -or $ClickNormalizedY -ge 0.0 $Result.focusMethod = Set-HyperTwistWindowForeground ` -WindowHandle $WindowProcess.MainWindowHandle ` -RequireForeground:$InteractionRequested Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'initial-window-focused' if ($InteractionRequested) { if ($ClickNormalizedX -lt 0.0 -or $ClickNormalizedX -gt 1.0 ` -or $ClickNormalizedY -lt 0.0 -or $ClickNormalizedY -gt 1.0) { throw 'Provide both normalized click coordinates within the inclusive range 0.0 to 1.0.' } $ClickX = [int][Math]::Round( $Bounds.x + ([Math]::Max($Bounds.width - 1, 0) * $ClickNormalizedX) ) $ClickY = [int][Math]::Round( $Bounds.y + ([Math]::Max($Bounds.height - 1, 0) * $ClickNormalizedY) ) $ClientClickX = [int][Math]::Round( [Math]::Max($Bounds.width - 1, 0) * $ClickNormalizedX ) $ClientClickY = [int][Math]::Round( [Math]::Max($Bounds.height - 1, 0) * $ClickNormalizedY ) if (-not [HyperTwistVisualGate.NativeMethods]::SetCursorPos($ClickX, $ClickY)) { throw 'Could not position the cursor for the requested HyperTwist interaction.' } Start-Sleep -Milliseconds 300 if ($ClickInjectionMode -eq 'PhysicalMouse') { [HyperTwistVisualGate.NativeMethods]::mouse_event( 0x0002, 0, 0, 0, [UIntPtr]::Zero ) Start-Sleep -Milliseconds 75 [HyperTwistVisualGate.NativeMethods]::mouse_event( 0x0004, 0, 0, 0, [UIntPtr]::Zero ) $Result.interaction.injectionMethod = 'physical-mouse-event' } else { $PackedClientPoint = [IntPtr]( (($ClientClickY -band 0xffff) -shl 16) ` -bor ($ClientClickX -band 0xffff) ) [void][HyperTwistVisualGate.NativeMethods]::SendMessage( $WindowProcess.MainWindowHandle, 0x0200, [IntPtr]::Zero, $PackedClientPoint ) [void][HyperTwistVisualGate.NativeMethods]::SendMessage( $WindowProcess.MainWindowHandle, 0x0201, [IntPtr](1), $PackedClientPoint ) Start-Sleep -Milliseconds 75 [void][HyperTwistVisualGate.NativeMethods]::SendMessage( $WindowProcess.MainWindowHandle, 0x0202, [IntPtr]::Zero, $PackedClientPoint ) $Result.interaction.injectionMethod = 'window-client-message' } $Result.interaction.screenX = $ClickX $Result.interaction.screenY = $ClickY $Result.interaction.clientX = $ClientClickX $Result.interaction.clientY = $ClientClickY $Result.interaction.clicked = $true Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'interaction-injected' Start-Sleep -Seconds ([Math]::Max($PostClickSettleSeconds, 0)) Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'post-interaction-settled' $WindowProcess.Refresh() if ($WindowProcess.HasExited -or $WindowProcess.MainWindowHandle -eq [IntPtr]::Zero) { throw 'The HyperTwist window closed after the requested interaction.' } Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'post-interaction-window-refreshed' $Bounds = Get-HyperTwistClientBounds -WindowHandle $WindowProcess.MainWindowHandle Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'post-interaction-bounds-measured' } # The initial focus is retained through physical interaction. Refocusing # after OpenLevel is both redundant and unsafe because Windows can block an # activation request while Unreal is replacing its viewport. Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'capture-window-ready' $SemanticReadyCaptureSourcePath = $null if ($SemanticReadyCaptureCandidates.Count -gt 0) { Write-HyperTwistVisualCheckpoint ` -Path $ProgressPath ` -Stage 'semantic-ready-capture-waiting' $SemanticReadyCaptureDeadline = [DateTime]::UtcNow.AddSeconds( [Math]::Max($SemanticReadyCaptureTimeoutSeconds, 1) ) while ([DateTime]::UtcNow -lt $SemanticReadyCaptureDeadline) { $SemanticReadyCaptureSourcePath = $SemanticReadyCaptureCandidates | Where-Object { (Test-Path -LiteralPath $_ -PathType Leaf) ` -and (Get-Item -LiteralPath $_).Length -gt 0 ` -and (Get-Item -LiteralPath $_).LastWriteTimeUtc ` -ge $LaunchStartedAtUtc.AddSeconds(-2) } | Select-Object -First 1 if (-not [string]::IsNullOrWhiteSpace($SemanticReadyCaptureSourcePath)) { break } Start-Sleep -Milliseconds 250 } if ([string]::IsNullOrWhiteSpace($SemanticReadyCaptureSourcePath)) { throw ( "HyperTwist did not write fresh semantic-ready frame " + "'$ExpectedSemanticReadyCaptureName' within " + "$SemanticReadyCaptureTimeoutSeconds seconds." ) } Write-HyperTwistVisualCheckpoint ` -Path $ProgressPath ` -Stage 'semantic-ready-capture-detected' } if (-not [string]::IsNullOrWhiteSpace($SemanticReadyCaptureSourcePath)) { $SemanticReadyImage = [System.Drawing.Image]::FromFile( $SemanticReadyCaptureSourcePath ) try { $Bitmap = New-Object System.Drawing.Bitmap($SemanticReadyImage) } finally { $SemanticReadyImage.Dispose() } $Result.captureMethod = 'hyper-twist-semantic-ready-frame' $Result.semanticReadyCaptureSourcePath = $SemanticReadyCaptureSourcePath $Result.semanticReadyCaptureSourceSha256 = ( Get-FileHash ` -LiteralPath $SemanticReadyCaptureSourcePath ` -Algorithm SHA256 ).Hash.ToLowerInvariant() } else { $Bitmap = New-Object System.Drawing.Bitmap( $Bounds.width, $Bounds.height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb ) $Result.captureMethod = 'physical-client-screen-copy' } try { Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'capture-bitmap-created' if ([string]::IsNullOrWhiteSpace($SemanticReadyCaptureSourcePath)) { $Graphics = [System.Drawing.Graphics]::FromImage($Bitmap) try { Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'screen-copy-starting' $Graphics.CopyFromScreen( $Bounds.x, $Bounds.y, 0, 0, (New-Object System.Drawing.Size($Bounds.width, $Bounds.height)), [System.Drawing.CopyPixelOperation]::SourceCopy ) Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'screen-copy-complete' } finally { $Graphics.Dispose() } } Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'bitmap-measurement-starting' $Metrics = Measure-HyperTwistBitmap -Bitmap $Bitmap -Stride $SampleStride Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'bitmap-measurement-complete' $Bitmap.Save($ScreenshotPath, [System.Drawing.Imaging.ImageFormat]::Png) Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'screenshot-saved' } finally { $Bitmap.Dispose() } $Result.processId = $WindowProcess.Id $Result.windowTitle = $WindowProcess.MainWindowTitle $Result.windowClassName = [HyperTwistVisualGate.NativeMethods]::ReadWindowClassName( $WindowProcess.MainWindowHandle ) if (-not [string]::IsNullOrWhiteSpace($ExpectedWindowClassName) ` -and $Result.windowClassName -ne $ExpectedWindowClassName) { throw ( "Expected window class '$ExpectedWindowClassName' but observed " + "'$($Result.windowClassName)'." ) } $Result.clientBounds = $Bounds $Result.screenshotSha256 = (Get-FileHash -LiteralPath $ScreenshotPath -Algorithm SHA256).Hash.ToLowerInvariant() $Result.metrics = $Metrics $StartupDiagnosticsCandidates = @( (Join-Path $ExecutableDirectory 'UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log'), (Join-Path $ExecutableDirectory 'Windows\UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log'), (Join-Path $env:LOCALAPPDATA 'UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log') ) $StartupDiagnosticsPath = $StartupDiagnosticsCandidates | Where-Object { (Test-Path -LiteralPath $_ -PathType Leaf) ` -and (Get-Item -LiteralPath $_).LastWriteTimeUtc -ge $LaunchStartedAtUtc.AddSeconds(-2) } | Select-Object -First 1 if (-not [string]::IsNullOrWhiteSpace($StartupDiagnosticsPath)) { $Result.startupDiagnosticsPath = $StartupDiagnosticsPath $Result.startupDiagnosticsLastWriteUtc = ( Get-Item -LiteralPath $StartupDiagnosticsPath ).LastWriteTimeUtc.ToString('o') # Detach provider metadata before JSON serialization; decorated FileSystem # strings otherwise expand recursively under Windows PowerShell 5.1. $Result.startupDiagnosticsResult = [string]( Get-Content -LiteralPath $StartupDiagnosticsPath | ForEach-Object { $_.ToString() } | Where-Object { $_ -like 'result=*' } | Select-Object -Last 1 ) } if (-not [string]::IsNullOrWhiteSpace($ExpectedStartupDiagnosticsResult) ` -and $Result.startupDiagnosticsResult -ne "result=$ExpectedStartupDiagnosticsResult") { throw ( "Expected startup diagnostics result '$ExpectedStartupDiagnosticsResult' but observed " + "'$($Result.startupDiagnosticsResult)'." ) } $Result.runtimeDiagnosticsExists = Test-Path -LiteralPath $RuntimeDiagnosticsPath -PathType Leaf if (-not $Result.runtimeDiagnosticsExists) { throw 'HyperTwist did not write its direct Shipping-compatible runtime diagnostics file.' } $Result.runtimeDiagnosticsLines = @( Get-Content -LiteralPath $RuntimeDiagnosticsPath | ForEach-Object { $_.ToString() } ) if (-not @($Result.runtimeDiagnosticsLines | Where-Object { $_ -like '*HyperTwist runtime module initialized.*' }).Count) { throw 'HyperTwist direct diagnostics did not confirm runtime-module initialization.' } Update-HyperTwistOwnedProcessIds ` -OwnedProcessIds $OwnedProcessIds ` -ResolvedExecutableRoot $ExecutableDirectory ` -IgnoredProcessIds $ExistingProcessIds $OwnedProcessIdSnapshot = [int[]]@($OwnedProcessIds) $Result.processCommandLines = @( foreach ($OwnedProcessId in $OwnedProcessIdSnapshot) { Get-CimInstance Win32_Process ` -Filter "ProcessId = $OwnedProcessId" ` -ErrorAction SilentlyContinue | ForEach-Object { [string]$_.CommandLine } } ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } $Result.ownedListeningTcpEndpoints = @( Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { $OwnedProcessIdSnapshot -contains [int]$_.OwningProcess } | Sort-Object OwningProcess, LocalPort | ForEach-Object { [pscustomobject][ordered]@{ processId = [int]$_.OwningProcess localAddress = [string]$_.LocalAddress localPort = [int]$_.LocalPort } } ) $Result.traceControlListenerLines = @( $Result.runtimeDiagnosticsLines | Where-Object { $_.IndexOf( 'Control listening on port', [System.StringComparison]::OrdinalIgnoreCase ) -ge 0 } ) $Result.traceControlListeningEndpoints = @( $Result.ownedListeningTcpEndpoints | Where-Object { [int]$_.localPort -eq 1985 } ) if ($Result.traceControlListenerLines.Count -gt 0 ` -or $Result.traceControlListeningEndpoints.Count -gt 0) { throw 'HyperTwist opened a Development trace-control listener during the visual gate.' } $VisualPassed = $Metrics.nonBlackRatio -ge $MinimumNonBlackRatio ` -and $Metrics.brightRatio -ge $MinimumBrightRatio ` -and $Metrics.luminanceDeviation -ge $MinimumLuminanceDeviation ` -and $Metrics.luminanceRange -ge $MinimumLuminanceRange if (-not $VisualPassed) { throw ( 'HyperTwist client capture is blank or visually uniform: ' + "nonBlack=$($Metrics.nonBlackRatio), bright=$($Metrics.brightRatio), " + "deviation=$($Metrics.luminanceDeviation), range=$($Metrics.luminanceRange)." ) } $Result.result = 'visual-passed' Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'visual-gate-passed' } catch { $Result.error = $_.Exception.Message Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'visual-gate-failed' } finally { if (-not $KeepProcess) { Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'owned-process-scan-starting' Update-HyperTwistOwnedProcessIds ` -OwnedProcessIds $OwnedProcessIds ` -ResolvedExecutableRoot $ExecutableDirectory ` -IgnoredProcessIds $ExistingProcessIds Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'owned-process-scan-complete' foreach ($OwnedProcessId in @($OwnedProcessIds)) { Stop-Process -Id $OwnedProcessId -Force -ErrorAction SilentlyContinue } Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'owned-process-cleanup-complete' } } Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'report-write-starting' Write-Utf8JsonFile -Path $ReportPath -Value $Result Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'report-write-complete' $Result | ConvertTo-Json -Depth 12 if ($Result.result -ne 'visual-passed') { throw $Result.error }