343 lines
11 KiB
PowerShell
343 lines
11 KiB
PowerShell
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"
|