hypertwist/scripts/Test-HyperTwistWindowsReadiness.ps1
2026-07-26 09:41:16 +00:00

617 lines
21 KiB
PowerShell

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