param( [string]$PayloadRoot = '', [string]$TargetInstallRoot = '', [string]$ExpectedPayloadManifestPath = '', [string]$ReportPath = '', [Int64]$MinimumMemoryBytes = 8589934592, [Int64]$MinimumFreeSpaceBytes = 4294967296, [int]$MinimumWindowsBuild = 19045, [switch]$VerifyPayloadHashes, [switch]$WaitForUser, [switch]$PassThru ) $ErrorActionPreference = 'Stop' $ExpectedSolverTableSizeBytes = 676207080 $ExpectedSolverTableSha256 = 'dc6de3d909a37afe2ba7f1f978543a13eae215bd20d954d66813524a8ca20034' $RequiredVcRuntimeVersion = [version]'14.44.35211.0' 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 Get-NormalizedRelativePath { param( [Parameter(Mandatory = $true)] [string]$RootPath, [Parameter(Mandatory = $true)] [string]$FilePath ) $RootPrefix = $RootPath.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar $ResolvedFilePath = [System.IO.Path]::GetFullPath($FilePath) if (-not $ResolvedFilePath.StartsWith( $RootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "File '$ResolvedFilePath' is outside payload root '$RootPath'." } return $ResolvedFilePath.Substring($RootPrefix.Length).Replace('\', '/') } function Resolve-PayloadRelativeFilePath { param( [Parameter(Mandatory = $true)] [string]$RootPath, [Parameter(Mandatory = $true)] [string]$RelativePath ) if ([string]::IsNullOrWhiteSpace($RelativePath)) { throw 'A payload-relative path cannot be empty.' } $WindowsRelativePath = $RelativePath.Replace('/', '\') if ([System.IO.Path]::IsPathRooted($WindowsRelativePath)) { throw "Payload-relative path '$RelativePath' must not be rooted." } $ResolvedRootPath = [System.IO.Path]::GetFullPath($RootPath) $RootPrefix = $ResolvedRootPath.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar $ResolvedFilePath = [System.IO.Path]::GetFullPath( (Join-Path $ResolvedRootPath $WindowsRelativePath) ) if (-not $ResolvedFilePath.StartsWith( $RootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Payload-relative path '$RelativePath' escapes root '$RootPath'." } return $ResolvedFilePath } function Get-ExistingPathRoot { param( [Parameter(Mandatory = $true)] [string]$Path ) $CandidatePath = [System.IO.Path]::GetFullPath($Path) while (-not (Test-Path -LiteralPath $CandidatePath)) { $ParentPath = Split-Path -Parent $CandidatePath if ([string]::IsNullOrWhiteSpace($ParentPath) -or $ParentPath -eq $CandidatePath) { throw "No existing parent could be resolved for '$Path'." } $CandidatePath = $ParentPath } return $CandidatePath } function Get-VcRuntimeEvidence { $RegistryPaths = @( 'HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64' ) foreach ($RegistryPath in $RegistryPaths) { if (-not (Test-Path -LiteralPath $RegistryPath)) { continue } $Runtime = Get-ItemProperty -LiteralPath $RegistryPath $VersionText = ([string]$Runtime.Version).TrimStart('v') try { $ParsedVersion = [version]$VersionText } catch { continue } return [pscustomobject][ordered]@{ registryPath = $RegistryPath installed = [int]$Runtime.Installed -eq 1 version = $ParsedVersion.ToString() } } return $null } if ([string]::IsNullOrWhiteSpace($TargetInstallRoot)) { $TargetInstallRoot = Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'HyperTwist' } if ([string]::IsNullOrWhiteSpace($ReportPath)) { $ReportPath = Join-Path $env:TEMP 'HyperTwist-readiness.json' } $Checks = New-Object System.Collections.ArrayList function Add-ReadinessCheck { param( [Parameter(Mandatory = $true)] [string]$Id, [Parameter(Mandatory = $true)] [ValidateSet('required', 'optional', 'informational')] [string]$Class, [Parameter(Mandatory = $true)] [bool]$Passed, [Parameter(Mandatory = $true)] [string]$Summary, [object]$Evidence = $null ) [void]$Checks.Add([pscustomobject][ordered]@{ id = $Id class = $Class passed = $Passed summary = $Summary evidence = $Evidence }) } $OperatingSystem = Get-CimInstance Win32_OperatingSystem $ComputerSystem = Get-CimInstance Win32_ComputerSystem $WindowsBuild = [int]$OperatingSystem.BuildNumber $Is64BitOperatingSystem = [Environment]::Is64BitOperatingSystem Add-ReadinessCheck ` -Id 'windows-x64' ` -Class 'required' ` -Passed $Is64BitOperatingSystem ` -Summary 'HyperTwist requires a 64-bit Windows operating system.' ` -Evidence ([pscustomobject][ordered]@{ architecture = [string]$OperatingSystem.OSArchitecture processArchitecture = [string]$env:PROCESSOR_ARCHITECTURE }) Add-ReadinessCheck ` -Id 'windows-supported-build' ` -Class 'required' ` -Passed ($WindowsBuild -ge $MinimumWindowsBuild) ` -Summary "HyperTwist requires Windows build $MinimumWindowsBuild or newer." ` -Evidence ([pscustomobject][ordered]@{ caption = [string]$OperatingSystem.Caption version = [string]$OperatingSystem.Version build = $WindowsBuild minimumBuild = $MinimumWindowsBuild }) $TotalMemoryBytes = [Int64]$ComputerSystem.TotalPhysicalMemory Add-ReadinessCheck ` -Id 'system-memory' ` -Class 'required' ` -Passed ($TotalMemoryBytes -ge $MinimumMemoryBytes) ` -Summary 'At least 8 GiB of physical memory is required for the desktop Alpha.' ` -Evidence ([pscustomobject][ordered]@{ totalBytes = $TotalMemoryBytes minimumBytes = $MinimumMemoryBytes }) $ExistingInstallPathRoot = Get-ExistingPathRoot -Path $TargetInstallRoot $TargetDriveName = [System.IO.Path]::GetPathRoot($ExistingInstallPathRoot).TrimEnd('\').TrimEnd(':') $TargetDrive = Get-PSDrive -Name $TargetDriveName $AvailableFreeSpaceBytes = [Int64]$TargetDrive.Free Add-ReadinessCheck ` -Id 'install-volume-free-space' ` -Class 'required' ` -Passed ($AvailableFreeSpaceBytes -ge $MinimumFreeSpaceBytes) ` -Summary 'The target volume must have enough free space for installation and first-run caches.' ` -Evidence ([pscustomobject][ordered]@{ targetInstallRoot = [System.IO.Path]::GetFullPath($TargetInstallRoot) availableBytes = $AvailableFreeSpaceBytes minimumBytes = $MinimumFreeSpaceBytes }) $VcRuntime = Get-VcRuntimeEvidence $VcRuntimeReady = $null -ne $VcRuntime ` -and $VcRuntime.installed ` -and [version]$VcRuntime.version -ge $RequiredVcRuntimeVersion Add-ReadinessCheck ` -Id 'vc-runtime-x64' ` -Class 'required' ` -Passed $VcRuntimeReady ` -Summary "Microsoft Visual C++ 2015-2022 x64 runtime $RequiredVcRuntimeVersion or newer is required and is bundled by the installer." ` -Evidence $VcRuntime $SystemDirectory = [Environment]::SystemDirectory $RequiredSystemLibraries = @( 'd3d11.dll', 'd3d12.dll', 'dxgi.dll', 'mf.dll', 'mfplat.dll', 'mfreadwrite.dll', 'xinput1_4.dll' ) $MissingSystemLibraries = @( $RequiredSystemLibraries | Where-Object { -not (Test-Path -LiteralPath (Join-Path $SystemDirectory $_)) } ) Add-ReadinessCheck ` -Id 'windows-media-and-graphics-libraries' ` -Class 'required' ` -Passed ($MissingSystemLibraries.Count -eq 0) ` -Summary 'Windows DirectX, XInput, and Media Foundation runtime libraries must be available.' ` -Evidence ([pscustomobject][ordered]@{ systemDirectory = $SystemDirectory requiredLibraries = $RequiredSystemLibraries missingLibraries = $MissingSystemLibraries }) $VideoControllers = @( Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | ForEach-Object { [pscustomobject][ordered]@{ name = [string]$_.Name driverVersion = [string]$_.DriverVersion driverDate = if ($null -eq $_.DriverDate) { $null } else { $_.DriverDate.ToString('o') } adapterRamBytes = [Int64]$_.AdapterRAM status = [string]$_.Status } } ) Add-ReadinessCheck ` -Id 'graphics-adapter' ` -Class 'required' ` -Passed ($VideoControllers.Count -gt 0) ` -Summary 'At least one Windows graphics adapter must be visible.' ` -Evidence $VideoControllers $SoundDevices = @( Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue | ForEach-Object { [pscustomobject][ordered]@{ name = [string]$_.Name status = [string]$_.Status } } ) Add-ReadinessCheck ` -Id 'audio-device' ` -Class 'optional' ` -Passed ($SoundDevices.Count -gt 0) ` -Summary 'An audio device is optional for puzzle play and recommended for narration and speech features.' ` -Evidence $SoundDevices $OpenXrRegistryPaths = @( 'HKLM:\SOFTWARE\Khronos\OpenXR\1', 'HKLM:\SOFTWARE\WOW6432Node\Khronos\OpenXR\1' ) $OpenXrRuntime = $null foreach ($OpenXrRegistryPath in $OpenXrRegistryPaths) { if (Test-Path -LiteralPath $OpenXrRegistryPath) { $OpenXrRuntime = Get-ItemProperty -LiteralPath $OpenXrRegistryPath break } } Add-ReadinessCheck ` -Id 'openxr-runtime' ` -Class 'optional' ` -Passed ($null -ne $OpenXrRuntime -and -not [string]::IsNullOrWhiteSpace([string]$OpenXrRuntime.ActiveRuntime)) ` -Summary 'An active OpenXR runtime is optional for keyboard/mouse play and required only for live headset sessions.' ` -Evidence $(if ($null -eq $OpenXrRuntime) { $null } else { [pscustomobject][ordered]@{ activeRuntime = [string]$OpenXrRuntime.ActiveRuntime } }) $ResolvedPayloadRoot = $null if (-not [string]::IsNullOrWhiteSpace($PayloadRoot)) { if (-not (Test-Path -LiteralPath $PayloadRoot -PathType Container)) { throw "Payload root '$PayloadRoot' was not found." } $ResolvedPayloadRoot = (Resolve-Path -LiteralPath $PayloadRoot).Path $RequiredPayloadFiles = @( [pscustomobject]@{ path = 'UnrealHyperTwist.exe'; size = -1; sha256 = '' }, [pscustomobject]@{ path = 'UnrealHyperTwist\Binaries\Win64\UnrealHyperTwist-Win64-Shipping.exe'; size = -1; sha256 = '' }, [pscustomobject]@{ path = 'UnrealHyperTwist\Binaries\Win64\tbb12.dll'; size = -1; sha256 = '' }, [pscustomobject]@{ path = 'UnrealHyperTwist\Binaries\Win64\tbbmalloc.dll'; size = -1; sha256 = '' }, [pscustomobject]@{ path = 'UnrealHyperTwist\Content\Paks\UnrealHyperTwist-Windows.pak'; size = -1; sha256 = '' }, [pscustomobject]@{ path = 'UnrealHyperTwist\Content\Paks\UnrealHyperTwist-Windows.utoc'; size = -1; sha256 = '' }, [pscustomobject]@{ path = 'UnrealHyperTwist\Content\Paks\UnrealHyperTwist-Windows.ucas'; size = -1; sha256 = '' }, [pscustomobject]@{ path = 'UnrealHyperTwist\Saved\twophase-ht.tbl'; size = $ExpectedSolverTableSizeBytes; sha256 = $ExpectedSolverTableSha256 }, [pscustomobject]@{ path = 'Content\Browser\index.html'; size = -1; sha256 = '' }, [pscustomobject]@{ path = 'Content\Browser\src\browser-runtime-bootstrap.js'; size = -1; sha256 = '' }, [pscustomobject]@{ path = 'Content\Browser\src\browser-spatial-runtime-fallback.js'; size = -1; sha256 = '' }, [pscustomobject]@{ path = 'Content\Browser\dist\browser-spatial-runtime.js'; size = -1; sha256 = '' }, [pscustomobject]@{ path = 'Engine\Binaries\ThirdParty\Windows\XAudio2_9\x64\XAudio2_9Redist.dll'; size = -1; sha256 = '' }, [pscustomobject]@{ path = 'Engine\Binaries\ThirdParty\OpenXR\win64\openxr_loader.dll'; size = -1; sha256 = '' } ) $PayloadFailures = New-Object System.Collections.ArrayList foreach ($RequiredPayloadFile in $RequiredPayloadFiles) { $RequiredPath = Join-Path $ResolvedPayloadRoot $RequiredPayloadFile.path if (-not (Test-Path -LiteralPath $RequiredPath -PathType Leaf)) { [void]$PayloadFailures.Add("missing:$($RequiredPayloadFile.path)") continue } $RequiredItem = Get-Item -LiteralPath $RequiredPath if ($RequiredItem.Length -le 0) { [void]$PayloadFailures.Add("empty:$($RequiredPayloadFile.path)") continue } if ([Int64]$RequiredPayloadFile.size -ge 0 ` -and $RequiredItem.Length -ne [Int64]$RequiredPayloadFile.size) { [void]$PayloadFailures.Add("size:$($RequiredPayloadFile.path)") continue } if (-not [string]::IsNullOrWhiteSpace([string]$RequiredPayloadFile.sha256)) { $ActualHash = (Get-FileHash -LiteralPath $RequiredPath -Algorithm SHA256).Hash.ToLowerInvariant() if ($ActualHash -ne ([string]$RequiredPayloadFile.sha256).ToLowerInvariant()) { [void]$PayloadFailures.Add("sha256:$($RequiredPayloadFile.path)") } } } $CefRuntimeFiles = @( Get-ChildItem ` -LiteralPath (Join-Path $ResolvedPayloadRoot 'Engine\Binaries\ThirdParty\CEF3\Win64') ` -Filter 'libcef.dll' ` -File ` -Recurse ` -ErrorAction SilentlyContinue ) if ($CefRuntimeFiles.Count -eq 0) { [void]$PayloadFailures.Add('missing:Engine/Binaries/ThirdParty/CEF3/Win64/*/libcef.dll') } Add-ReadinessCheck ` -Id 'required-payload-files' ` -Class 'required' ` -Passed ($PayloadFailures.Count -eq 0) ` -Summary 'The installed payload must contain every required executable, runtime, cooked-data, browser, solver, and OpenXR file.' ` -Evidence ([pscustomobject][ordered]@{ payloadRoot = $ResolvedPayloadRoot requiredFileCount = $RequiredPayloadFiles.Count + 1 cefRuntimeFiles = @( $CefRuntimeFiles | ForEach-Object { Get-NormalizedRelativePath ` -RootPath $ResolvedPayloadRoot ` -FilePath $_.FullName } ) failures = @($PayloadFailures) }) $OuterExecutablePath = Join-Path $ResolvedPayloadRoot 'UnrealHyperTwist.exe' if (Test-Path -LiteralPath $OuterExecutablePath -PathType Leaf) { $Signature = Get-AuthenticodeSignature -LiteralPath $OuterExecutablePath Add-ReadinessCheck ` -Id 'desktop-code-signature' ` -Class 'optional' ` -Passed ($Signature.Status -eq [System.Management.Automation.SignatureStatus]::Valid) ` -Summary 'The Alpha payload is currently unsigned; production distribution requires Authenticode signing.' ` -Evidence ([pscustomobject][ordered]@{ status = $Signature.Status.ToString() signer = if ($null -eq $Signature.SignerCertificate) { $null } else { [string]$Signature.SignerCertificate.Subject } }) } } if (-not [string]::IsNullOrWhiteSpace($ExpectedPayloadManifestPath)) { if ($null -eq $ResolvedPayloadRoot) { throw 'ExpectedPayloadManifestPath requires PayloadRoot.' } if (-not (Test-Path -LiteralPath $ExpectedPayloadManifestPath -PathType Leaf)) { throw "Payload manifest '$ExpectedPayloadManifestPath' was not found." } $Manifest = Get-Content -LiteralPath $ExpectedPayloadManifestPath -Raw | ConvertFrom-Json $ManifestFailures = New-Object System.Collections.ArrayList $SeenManifestPaths = @{} $ManifestEntries = @($Manifest.files) if ([string]$Manifest.manifestVersion -ne 'ht-windows-installer-payload/v1') { [void]$ManifestFailures.Add("manifest-version:$($Manifest.manifestVersion)") } $DeclaredFileCount = 0 if (-not [int]::TryParse([string]$Manifest.fileCount, [ref]$DeclaredFileCount) ` -or $DeclaredFileCount -ne $ManifestEntries.Count) { [void]$ManifestFailures.Add("manifest-file-count:$($Manifest.fileCount)") } $DeclaredTotalSizeBytes = 0L if (-not [Int64]::TryParse( [string]$Manifest.totalSizeBytes, [ref]$DeclaredTotalSizeBytes ) -or $DeclaredTotalSizeBytes -lt 0) { [void]$ManifestFailures.Add("manifest-total-size:$($Manifest.totalSizeBytes)") } $ComputedTotalSizeBytes = 0L foreach ($ManifestEntry in $ManifestEntries) { $ManifestRelativePath = [string]$ManifestEntry.relativePath $ManifestPathKey = $ManifestRelativePath.Replace('\', '/').ToLowerInvariant() if ($SeenManifestPaths.ContainsKey($ManifestPathKey)) { [void]$ManifestFailures.Add("duplicate:$ManifestRelativePath") continue } $SeenManifestPaths[$ManifestPathKey] = $true $ManifestEntrySizeBytes = 0L if (-not [Int64]::TryParse( [string]$ManifestEntry.sizeBytes, [ref]$ManifestEntrySizeBytes ) -or $ManifestEntrySizeBytes -lt 0) { [void]$ManifestFailures.Add("invalid-size:$ManifestRelativePath") continue } $ComputedTotalSizeBytes += $ManifestEntrySizeBytes $ExpectedManifestHash = ([string]$ManifestEntry.sha256).ToLowerInvariant() if ($ExpectedManifestHash -notmatch '^[0-9a-f]{64}$') { [void]$ManifestFailures.Add("invalid-sha256:$ManifestRelativePath") continue } try { $ManifestFilePath = Resolve-PayloadRelativeFilePath ` -RootPath $ResolvedPayloadRoot ` -RelativePath $ManifestRelativePath } catch { [void]$ManifestFailures.Add("invalid-path:$ManifestRelativePath") continue } if (-not (Test-Path -LiteralPath $ManifestFilePath -PathType Leaf)) { [void]$ManifestFailures.Add("missing:$ManifestRelativePath") continue } $ManifestItem = Get-Item -LiteralPath $ManifestFilePath if ($ManifestItem.Length -ne $ManifestEntrySizeBytes) { [void]$ManifestFailures.Add("size:$ManifestRelativePath") continue } if ($VerifyPayloadHashes) { $ManifestHash = (Get-FileHash -LiteralPath $ManifestFilePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($ManifestHash -ne $ExpectedManifestHash) { [void]$ManifestFailures.Add("sha256:$ManifestRelativePath") } } } if ($DeclaredTotalSizeBytes -ne $ComputedTotalSizeBytes) { [void]$ManifestFailures.Add( "manifest-total-size-parity:$DeclaredTotalSizeBytes/$ComputedTotalSizeBytes" ) } Add-ReadinessCheck ` -Id 'payload-manifest-parity' ` -Class 'required' ` -Passed ($ManifestFailures.Count -eq 0) ` -Summary 'The installed payload must match the release manifest without missing, truncated, or altered files.' ` -Evidence ([pscustomobject][ordered]@{ manifestPath = (Resolve-Path -LiteralPath $ExpectedPayloadManifestPath).Path manifestVersion = [string]$Manifest.manifestVersion manifestEntryCount = $ManifestEntries.Count declaredFileCount = $DeclaredFileCount declaredTotalSizeBytes = $DeclaredTotalSizeBytes computedTotalSizeBytes = $ComputedTotalSizeBytes hashesVerified = [bool]$VerifyPayloadHashes failures = @($ManifestFailures) }) } $RequiredFailures = @( $Checks | Where-Object { $_.class -eq 'required' -and -not $_.passed } ) $OptionalWarnings = @( $Checks | Where-Object { $_.class -eq 'optional' -and -not $_.passed } ) $Result = if ($RequiredFailures.Count -gt 0) { 'blocked' } elseif ($OptionalWarnings.Count -gt 0) { 'ready-with-warnings' } else { 'ready' } $Report = [pscustomobject][ordered]@{ reportVersion = 'ht-windows-readiness/v1' generatedAtUtc = [DateTime]::UtcNow.ToString('o') result = $Result payloadRoot = $ResolvedPayloadRoot targetInstallRoot = [System.IO.Path]::GetFullPath($TargetInstallRoot) requiredFailureCount = $RequiredFailures.Count optionalWarningCount = $OptionalWarnings.Count checks = @($Checks) } Write-Utf8JsonFile -Path $ReportPath -Value $Report [Console]::Out.WriteLine(( "HyperTwist Windows readiness: {0} ({1} required failure(s), {2} optional warning(s))." -f $Result, $RequiredFailures.Count, $OptionalWarnings.Count )) [Console]::Out.WriteLine("Report: $([System.IO.Path]::GetFullPath($ReportPath))") if ($PassThru) { return $Report } if ($WaitForUser) { [void](Read-Host 'Press Enter to close the HyperTwist readiness report') } if ($Result -eq 'blocked') { exit 2 } exit 0