592 lines
18 KiB
PowerShell
592 lines
18 KiB
PowerShell
param(
|
|
[string]$PackageRoot = 'C:\HyperTwist\packaged\xr',
|
|
[string]$MapAssetPath = '/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining',
|
|
[double]$ObservationSeconds = 8.0,
|
|
[double]$TimeoutSeconds = 30.0,
|
|
[ValidateSet('nullrhi', 'windowed')]
|
|
[string]$RuntimeLaunchMode = 'nullrhi',
|
|
[int]$ResX = 1600,
|
|
[int]$ResY = 900,
|
|
[string]$OpenXrRuntimePathOverride = '',
|
|
[string]$ReportPath = '',
|
|
[switch]$KeepRunning
|
|
)
|
|
|
|
$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
|
|
}
|
|
|
|
$Json = $Value | ConvertTo-Json -Depth 12
|
|
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
|
|
}
|
|
|
|
function Read-JsonFile {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Path
|
|
)
|
|
|
|
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
|
}
|
|
|
|
function Get-JsonPropertyValue {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[object]$Object,
|
|
[Parameter(Mandatory = $true)]
|
|
[string[]]$Names,
|
|
[object]$Default = $null
|
|
)
|
|
|
|
foreach ($Name in $Names)
|
|
{
|
|
$Property = $Object.PSObject.Properties[$Name]
|
|
if ($null -ne $Property)
|
|
{
|
|
return $Property.Value
|
|
}
|
|
}
|
|
|
|
return $Default
|
|
}
|
|
|
|
function Get-CurrentSessionId {
|
|
$CurrentProcess = Get-Process -Id $PID -ErrorAction Stop
|
|
return [int]$CurrentProcess.SessionId
|
|
}
|
|
|
|
function Get-ExplorerSessionIds {
|
|
return @(
|
|
Get-Process explorer -ErrorAction SilentlyContinue |
|
|
Select-Object -ExpandProperty SessionId -Unique
|
|
)
|
|
}
|
|
|
|
function Get-OpenXrActiveRuntimePath {
|
|
$RegistryPaths = @(
|
|
'HKLM:\SOFTWARE\Khronos\OpenXR\1',
|
|
'HKLM:\SOFTWARE\WOW6432Node\Khronos\OpenXR\1'
|
|
)
|
|
|
|
foreach ($RegistryPath in $RegistryPaths)
|
|
{
|
|
if (-not (Test-Path -LiteralPath $RegistryPath))
|
|
{
|
|
continue
|
|
}
|
|
|
|
try
|
|
{
|
|
$RegistryItem = Get-ItemProperty -LiteralPath $RegistryPath
|
|
if (-not [string]::IsNullOrWhiteSpace($RegistryItem.ActiveRuntime))
|
|
{
|
|
return [string]$RegistryItem.ActiveRuntime
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
continue
|
|
}
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
function Get-ResolvedOpenXrRuntime {
|
|
param(
|
|
[string]$RuntimePathOverride = ''
|
|
)
|
|
|
|
$RegistryRuntimePath = Get-OpenXrActiveRuntimePath
|
|
$ResolvedOverridePath = if ([string]::IsNullOrWhiteSpace($RuntimePathOverride))
|
|
{
|
|
$null
|
|
}
|
|
else
|
|
{
|
|
[string]$RuntimePathOverride
|
|
}
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($ResolvedOverridePath))
|
|
{
|
|
return [ordered]@{
|
|
activeRuntimePath = $ResolvedOverridePath
|
|
runtimeSource = 'environment-override'
|
|
runtimePresent = [bool](Test-Path -LiteralPath $ResolvedOverridePath)
|
|
registryRuntimePath = $RegistryRuntimePath
|
|
environmentOverridePath = $ResolvedOverridePath
|
|
}
|
|
}
|
|
|
|
return [ordered]@{
|
|
activeRuntimePath = $RegistryRuntimePath
|
|
runtimeSource = 'registry'
|
|
runtimePresent =
|
|
-not [string]::IsNullOrWhiteSpace($RegistryRuntimePath) `
|
|
-and [bool](Test-Path -LiteralPath $RegistryRuntimePath)
|
|
registryRuntimePath = $RegistryRuntimePath
|
|
environmentOverridePath = $null
|
|
}
|
|
}
|
|
|
|
function Get-XrWindowedPreflight {
|
|
param(
|
|
[string]$RuntimePathOverride = ''
|
|
)
|
|
|
|
$CurrentSessionId = Get-CurrentSessionId
|
|
$ExplorerSessionIds = @(Get-ExplorerSessionIds)
|
|
$ResolvedOpenXrRuntime = Get-ResolvedOpenXrRuntime -RuntimePathOverride $RuntimePathOverride
|
|
|
|
return [ordered]@{
|
|
currentSessionId = $CurrentSessionId
|
|
explorerSessionIds = @($ExplorerSessionIds)
|
|
isInteractiveDesktopSession = [bool]($ExplorerSessionIds -contains $CurrentSessionId)
|
|
openXrActiveRuntimePath = $ResolvedOpenXrRuntime.activeRuntimePath
|
|
openXrActiveRuntimePresent = [bool]$ResolvedOpenXrRuntime.runtimePresent
|
|
openXrRuntimeSource = $ResolvedOpenXrRuntime.runtimeSource
|
|
openXrRegistryRuntimePath = $ResolvedOpenXrRuntime.registryRuntimePath
|
|
openXrEnvironmentOverridePath = $ResolvedOpenXrRuntime.environmentOverridePath
|
|
}
|
|
}
|
|
|
|
function Assert-XrWindowedPreflight {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[object]$Preflight
|
|
)
|
|
|
|
if (-not [bool]$Preflight.isInteractiveDesktopSession)
|
|
{
|
|
$ExplorerSessionDisplay = if ($null -ne $Preflight.explorerSessionIds `
|
|
-and $Preflight.explorerSessionIds.Count -gt 0)
|
|
{
|
|
($Preflight.explorerSessionIds -join ', ')
|
|
}
|
|
else
|
|
{
|
|
'none'
|
|
}
|
|
|
|
throw (
|
|
"Windowed XR validation must be launched from the interactive Windows desktop session. " +
|
|
"The current PowerShell session id '{0}' did not match Explorer desktop session ids '{1}'. " +
|
|
"Re-run on the logged-in Windows desktop instead of the reverse-SSH/OpenSSH session."
|
|
) -f $Preflight.currentSessionId, $ExplorerSessionDisplay
|
|
}
|
|
|
|
if (-not [bool]$Preflight.openXrActiveRuntimePresent)
|
|
{
|
|
$RuntimeSource = [string](Get-JsonPropertyValue `
|
|
-Object $Preflight `
|
|
-Names @('openXrRuntimeSource', 'OpenXrRuntimeSource') `
|
|
-Default 'registry')
|
|
|
|
if ([string]::IsNullOrWhiteSpace([string]$Preflight.openXrActiveRuntimePath))
|
|
{
|
|
if ($RuntimeSource -eq 'environment-override')
|
|
{
|
|
throw (
|
|
"Windowed XR validation received an OpenXR runtime override request, but the override path was empty. " +
|
|
"Provide a full runtime manifest path or remove the override and activate a local OpenXR runtime."
|
|
)
|
|
}
|
|
|
|
throw (
|
|
"Windowed XR validation requires an installed active OpenXR runtime, but no OpenXR ActiveRuntime registry entry was found. " +
|
|
"Activate SteamVR, Oculus, Windows Mixed Reality, or another local OpenXR runtime on the interactive desktop and rerun."
|
|
)
|
|
}
|
|
|
|
if ($RuntimeSource -eq 'environment-override')
|
|
{
|
|
throw (
|
|
"Windowed XR validation received OpenXR runtime override path '{0}', but that manifest was not present on disk. " +
|
|
"Repair the override path or remove it and reactivate a local OpenXR runtime."
|
|
) -f $Preflight.openXrActiveRuntimePath
|
|
}
|
|
|
|
throw (
|
|
"Windowed XR validation requires an installed active OpenXR runtime, but the configured ActiveRuntime path '{0}' was not present on disk. " +
|
|
"Repair or reactivate the local OpenXR runtime on the interactive desktop and rerun."
|
|
) -f $Preflight.openXrActiveRuntimePath
|
|
}
|
|
}
|
|
|
|
function Format-ProcessArgument {
|
|
param(
|
|
[AllowNull()]
|
|
[string]$Value
|
|
)
|
|
|
|
if ($null -eq $Value)
|
|
{
|
|
return '""'
|
|
}
|
|
|
|
if ($Value -notmatch '[\s"]')
|
|
{
|
|
return $Value
|
|
}
|
|
|
|
$EscapedValue = $Value -replace '(\\*)"', '$1$1\"'
|
|
$EscapedValue = $EscapedValue -replace '(\\+)$', '$1$1'
|
|
return '"' + $EscapedValue + '"'
|
|
}
|
|
|
|
function Start-ObservedProcess {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$FilePath,
|
|
[Parameter(Mandatory = $true)]
|
|
[string[]]$ArgumentList,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$WorkingDirectory,
|
|
[string]$OpenXrRuntimePathOverride = ''
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($OpenXrRuntimePathOverride))
|
|
{
|
|
return Start-Process `
|
|
-FilePath $FilePath `
|
|
-ArgumentList $ArgumentList `
|
|
-WorkingDirectory $WorkingDirectory `
|
|
-PassThru
|
|
}
|
|
|
|
$ProcessStartInfo = New-Object System.Diagnostics.ProcessStartInfo
|
|
$ProcessStartInfo.FileName = $FilePath
|
|
$ProcessStartInfo.Arguments = (
|
|
@($ArgumentList) |
|
|
ForEach-Object { Format-ProcessArgument -Value ([string]$_) }
|
|
) -join ' '
|
|
$ProcessStartInfo.WorkingDirectory = $WorkingDirectory
|
|
$ProcessStartInfo.UseShellExecute = $false
|
|
$ProcessStartInfo.EnvironmentVariables['XR_RUNTIME_JSON'] = $OpenXrRuntimePathOverride
|
|
|
|
return [System.Diagnostics.Process]::Start($ProcessStartInfo)
|
|
}
|
|
|
|
function Convert-PathToken {
|
|
param(
|
|
[string]$Value
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Value))
|
|
{
|
|
return 'unknown'
|
|
}
|
|
|
|
return ($Value -replace '[\\/:*?"<>| ?=&]', '_')
|
|
}
|
|
|
|
$CandidateExecutablePaths = @(
|
|
(Join-Path $PackageRoot 'Windows\UnrealHyperTwist.exe'),
|
|
(Join-Path $PackageRoot 'WindowsNoEditor\UnrealHyperTwist.exe'),
|
|
(Join-Path $PackageRoot 'UnrealHyperTwist.exe')
|
|
)
|
|
|
|
$ExecutablePath = $CandidateExecutablePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
|
|
if ($null -eq $ExecutablePath)
|
|
{
|
|
throw "No packaged UnrealHyperTwist executable was found beneath '$PackageRoot'."
|
|
}
|
|
|
|
$ValidationGameModeClassPath = '/Script/UnrealHyperTwist.HyperTwistXrTrainingGameMode'
|
|
$ResolvedMapUrl = '{0}?game={1}' -f $MapAssetPath, $ValidationGameModeClassPath
|
|
$ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path
|
|
$GeneratedAtUtc = [DateTime]::UtcNow.ToString('o')
|
|
$SanitizedMapToken = Convert-PathToken -Value $MapAssetPath
|
|
$ResolvedRuntimeLaunchMode = if ([string]::IsNullOrWhiteSpace($RuntimeLaunchMode))
|
|
{
|
|
'nullrhi'
|
|
}
|
|
else
|
|
{
|
|
$RuntimeLaunchMode
|
|
}
|
|
$ReportLeafBase = [System.IO.Path]::GetFileNameWithoutExtension($ReportPath)
|
|
$RuntimeReportPath = if (-not [string]::IsNullOrWhiteSpace($ReportPath))
|
|
{
|
|
Join-Path (Split-Path -Parent $ReportPath) ('{0}.runtime.json' -f $ReportLeafBase)
|
|
}
|
|
else
|
|
{
|
|
Join-Path ([System.IO.Path]::GetTempPath()) ('hypertwist-xr-runtime-{0}.json' -f $SanitizedMapToken)
|
|
}
|
|
|
|
if (Test-Path -LiteralPath $RuntimeReportPath)
|
|
{
|
|
Remove-Item -LiteralPath $RuntimeReportPath -Force
|
|
}
|
|
|
|
$WindowedPreflight = if ($ResolvedRuntimeLaunchMode -eq 'windowed')
|
|
{
|
|
Get-XrWindowedPreflight -RuntimePathOverride $OpenXrRuntimePathOverride
|
|
}
|
|
else
|
|
{
|
|
$null
|
|
}
|
|
|
|
$ArgumentList = @(
|
|
$ResolvedMapUrl,
|
|
'-log',
|
|
"-HyperTwistXrValidationReportPath=$RuntimeReportPath",
|
|
"-HyperTwistXrValidationObservationSeconds=$ObservationSeconds"
|
|
)
|
|
|
|
switch ($ResolvedRuntimeLaunchMode)
|
|
{
|
|
'nullrhi'
|
|
{
|
|
$ArgumentList += @(
|
|
'-nullrhi',
|
|
'-unattended',
|
|
'-nosound'
|
|
)
|
|
break
|
|
}
|
|
'windowed'
|
|
{
|
|
$ArgumentList += @(
|
|
"-ResX=$ResX",
|
|
"-ResY=$ResY",
|
|
'-windowed'
|
|
)
|
|
break
|
|
}
|
|
}
|
|
|
|
$Process = $null
|
|
$Report = [ordered]@{
|
|
reportVersion = 'ht-xr-package-smoke/v1'
|
|
generatedAtUtc = $GeneratedAtUtc
|
|
packageRoot = $ResolvedPackageRoot
|
|
executablePath = $ExecutablePath
|
|
mapAssetPath = $MapAssetPath
|
|
mapUrl = $ResolvedMapUrl
|
|
runtimeLaunchMode = $ResolvedRuntimeLaunchMode
|
|
observationSeconds = $ObservationSeconds
|
|
timeoutSeconds = $TimeoutSeconds
|
|
resolution = if ($ResolvedRuntimeLaunchMode -eq 'windowed')
|
|
{
|
|
[ordered]@{
|
|
width = $ResX
|
|
height = $ResY
|
|
}
|
|
}
|
|
else
|
|
{
|
|
$null
|
|
}
|
|
keepRunning = [bool]$KeepRunning
|
|
runtimeReportPath = $RuntimeReportPath
|
|
preflight = $WindowedPreflight
|
|
runtimeReportGraceApplied = $false
|
|
runtimeReportGraceSeconds = if ($ResolvedRuntimeLaunchMode -eq 'windowed')
|
|
{
|
|
[double][Math]::Max(60.0, [Math]::Ceiling($ObservationSeconds * 0.5))
|
|
}
|
|
else
|
|
{
|
|
0.0
|
|
}
|
|
result = 'failed'
|
|
processId = $null
|
|
processStopped = $false
|
|
exitCode = $null
|
|
runtimeReport = $null
|
|
error = $null
|
|
}
|
|
|
|
try
|
|
{
|
|
if ($ResolvedRuntimeLaunchMode -eq 'windowed')
|
|
{
|
|
Assert-XrWindowedPreflight -Preflight $WindowedPreflight
|
|
}
|
|
|
|
Write-Host "Launching packaged XR validation lane from '$ExecutablePath'..."
|
|
$Process = Start-ObservedProcess `
|
|
-FilePath $ExecutablePath `
|
|
-ArgumentList $ArgumentList `
|
|
-WorkingDirectory (Split-Path -Parent $ExecutablePath) `
|
|
-OpenXrRuntimePathOverride $OpenXrRuntimePathOverride
|
|
$Report.processId = $Process.Id
|
|
$Deadline = [DateTime]::UtcNow.AddSeconds([Math]::Max($TimeoutSeconds, $ObservationSeconds + 5.0))
|
|
|
|
while ([DateTime]::UtcNow -lt $Deadline)
|
|
{
|
|
if (Test-Path -LiteralPath $RuntimeReportPath)
|
|
{
|
|
break
|
|
}
|
|
|
|
Start-Sleep -Milliseconds 500
|
|
$Process.Refresh()
|
|
if ($Process.HasExited)
|
|
{
|
|
$Report.exitCode = $Process.ExitCode
|
|
}
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $RuntimeReportPath) `
|
|
-and $ResolvedRuntimeLaunchMode -eq 'windowed' `
|
|
-and $null -ne $Process)
|
|
{
|
|
$Process.Refresh()
|
|
if (-not $Process.HasExited -and $Report.runtimeReportGraceSeconds -gt 0.0)
|
|
{
|
|
$Report.runtimeReportGraceApplied = $true
|
|
$GraceDeadline = [DateTime]::UtcNow.AddSeconds($Report.runtimeReportGraceSeconds)
|
|
while ([DateTime]::UtcNow -lt $GraceDeadline)
|
|
{
|
|
if (Test-Path -LiteralPath $RuntimeReportPath)
|
|
{
|
|
break
|
|
}
|
|
|
|
Start-Sleep -Milliseconds 500
|
|
$Process.Refresh()
|
|
if ($Process.HasExited)
|
|
{
|
|
$Report.exitCode = $Process.ExitCode
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $RuntimeReportPath))
|
|
{
|
|
throw "The packaged XR runtime report was not written to '$RuntimeReportPath' before timeout."
|
|
}
|
|
|
|
$RuntimeReport = Read-JsonFile -Path $RuntimeReportPath
|
|
if ($null -eq $RuntimeReport)
|
|
{
|
|
throw "The packaged XR runtime report '$RuntimeReportPath' could not be parsed."
|
|
}
|
|
|
|
$Report.runtimeReport = $RuntimeReport
|
|
$Process.Refresh()
|
|
if (-not $Process.HasExited)
|
|
{
|
|
$RemainingWaitMs = [Math]::Max(0, [int]($Deadline - [DateTime]::UtcNow).TotalMilliseconds)
|
|
[void]$Process.WaitForExit($RemainingWaitMs)
|
|
$Process.Refresh()
|
|
}
|
|
|
|
if ($Process.HasExited)
|
|
{
|
|
$Report.exitCode = $Process.ExitCode
|
|
}
|
|
elseif (-not $KeepRunning)
|
|
{
|
|
Stop-Process -Id $Process.Id -Force
|
|
$Report.processStopped = $true
|
|
}
|
|
|
|
if ($RuntimeReport.ReportVersion -ne 'ht-xr-packaged-validation/v1')
|
|
{
|
|
throw "The packaged XR runtime report version '$($RuntimeReport.ReportVersion)' was unexpected."
|
|
}
|
|
|
|
if ($RuntimeReport.Result -ne 'passed')
|
|
{
|
|
throw "The packaged XR runtime report recorded Result='$($RuntimeReport.Result)' instead of 'passed'."
|
|
}
|
|
|
|
if ($RuntimeReport.CookMapAssetPath -ne $MapAssetPath)
|
|
{
|
|
throw "The packaged XR runtime report cooked '$($RuntimeReport.CookMapAssetPath)' instead of '$MapAssetPath'."
|
|
}
|
|
|
|
if ($RuntimeReport.LaunchMapUrl -ne $ResolvedMapUrl)
|
|
{
|
|
throw "The packaged XR runtime report launched '$($RuntimeReport.LaunchMapUrl)' instead of '$ResolvedMapUrl'."
|
|
}
|
|
|
|
if ($RuntimeReport.GameModeClassPath -ne $ValidationGameModeClassPath)
|
|
{
|
|
throw "The packaged XR runtime report used game mode '$($RuntimeReport.GameModeClassPath)' instead of '$ValidationGameModeClassPath'."
|
|
}
|
|
|
|
if ($RuntimeReport.RuntimeOwnerId -ne 'xr/openxr-desktop-training-runtime-owner')
|
|
{
|
|
throw "The packaged XR runtime report surfaced runtime owner '$($RuntimeReport.RuntimeOwnerId)' instead of 'xr/openxr-desktop-training-runtime-owner'."
|
|
}
|
|
|
|
if ($RuntimeReport.ControllerSettingsOwnerId -ne 'xr-openxr-controller-settings-owner/v1')
|
|
{
|
|
throw "The packaged XR runtime report surfaced controller settings owner '$($RuntimeReport.ControllerSettingsOwnerId)' instead of 'xr-openxr-controller-settings-owner/v1'."
|
|
}
|
|
|
|
$RuntimeOwnerStructurallyValid = [bool](Get-JsonPropertyValue `
|
|
-Object $RuntimeReport `
|
|
-Names @('bRuntimeOwnerStructurallyValid', 'RuntimeOwnerStructurallyValid') `
|
|
-Default $false)
|
|
if (-not $RuntimeOwnerStructurallyValid)
|
|
{
|
|
throw "The packaged XR runtime report did not keep the runtime-owner surface structurally valid."
|
|
}
|
|
|
|
$ControllerSettingsOwnerStructurallyValid = [bool](Get-JsonPropertyValue `
|
|
-Object $RuntimeReport `
|
|
-Names @('bControllerSettingsOwnerStructurallyValid', 'ControllerSettingsOwnerStructurallyValid') `
|
|
-Default $false)
|
|
if (-not $ControllerSettingsOwnerStructurallyValid)
|
|
{
|
|
throw "The packaged XR runtime report did not keep the controller-settings surface structurally valid."
|
|
}
|
|
|
|
if ($null -ne $Report.exitCode -and $Report.exitCode -ne 0)
|
|
{
|
|
throw "The packaged XR executable exited with code $($Report.exitCode)."
|
|
}
|
|
|
|
$Report.result = 'passed'
|
|
}
|
|
catch
|
|
{
|
|
$Report.error = $_.Exception.Message
|
|
if ($null -ne $Process)
|
|
{
|
|
$Process.Refresh()
|
|
if ($Process.HasExited)
|
|
{
|
|
$Report.exitCode = $Process.ExitCode
|
|
}
|
|
elseif (-not $KeepRunning)
|
|
{
|
|
Stop-Process -Id $Process.Id -Force
|
|
$Report.processStopped = $true
|
|
}
|
|
}
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($ReportPath))
|
|
{
|
|
Write-Utf8JsonFile -Path $ReportPath -Value $Report
|
|
}
|
|
|
|
throw
|
|
}
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($ReportPath))
|
|
{
|
|
Write-Utf8JsonFile -Path $ReportPath -Value $Report
|
|
}
|