939 lines
34 KiB
PowerShell
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
|
|
}
|