hypertwist/scripts/Test-HyperTwistPackagedWindowVisual.ps1
2026-07-23 08:42:30 +00:00

939 lines
34 KiB
PowerShell

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
}