hypertwist/scripts/Launch-HyperTwistXrPackage.ps1
2026-07-02 08:21:24 +00:00

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
}