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 }