Editor-only brick API (<= 8^3 points per call, TunnelNetwork/Underwater op-stack routes, 1.16% scalar fallback). Exact: 0 Q16/sign disagreements on the full 259^3 ring and the five-seed probe set, same hash on 1 and 6 workers, reach shrink still caught (18,107 Q16 / 735 sign). Speed: 69.19 s wall, 17.6 us/sample vs live 7.04 s, 1.65 us/sample. Stack sampler (inclusive): BuildChunkCache 93.5%, PropagateSdfOverBox 92.5%, BuildActiveOpList 92.6% -- each brick rebuilds the cache windows and active-op lists; EvalBlock itself is 3.6%. Live path attribution (inclusive, 68,934 stacks): op-stack RoomGraph EvalBlock 54.4%, SDF and spatial candidate scans ~28%, tunnel core 18.4%, passage floor segment 15.0%, structural posts 14.6%, noise octaves 2.6%, disturbances 0.6%, diff layer 0.3%. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
1955 lines
102 KiB
PowerShell
1955 lines
102 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[ValidateSet('canonical', 'owner', 'probe', 'perf', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'parity', 'horizon', 'horizon-render', 'determinism', 'symbolize-stacks', 'region-eval')]
|
|
[string]$Scenario = 'canonical',
|
|
[switch]$Build,
|
|
[switch]$EnableRender,
|
|
[switch]$SampleDensityStacks,
|
|
[ValidateRange(1, 1000000)]
|
|
[int]$StackSampleIntervalUs = 1000,
|
|
[switch]$RegionHashProbe,
|
|
[ValidateRange(1, 32)]
|
|
[int]$RegionWorkers = 6,
|
|
[string]$RegionReferenceHash = '',
|
|
[ValidateSet('owner', 'default')]
|
|
[string]$Assets = 'owner',
|
|
[hashtable]$Cvars = @{},
|
|
[string]$TestFilter,
|
|
[string]$Out,
|
|
[string]$Label,
|
|
[string]$StackSamplesPath,
|
|
[ValidateRange(0, 8)]
|
|
[int]$HorizonLevel = 5,
|
|
[ValidateRange(1, 100000)]
|
|
[int]$HorizonRenderDistanceChunks = 768,
|
|
[ValidateSet(32, 64, 128)]
|
|
[int]$ParityExportSize = 128,
|
|
[string]$ParityExportTile = '',
|
|
[string]$SharedDDC
|
|
)
|
|
|
|
# One guarded entry point for all VoxelForge measurements. It intentionally keeps the Unreal
|
|
# command lines in this file: callers choose a scenario and data, never an arbitrary process.
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$ScriptFile = [IO.Path]::GetFullPath($PSCommandPath)
|
|
$ToolsRoot = Split-Path -Parent $ScriptFile
|
|
$PluginRoot = Split-Path -Parent $ToolsRoot
|
|
$ProjectRoot = Split-Path -Parent (Split-Path -Parent $PluginRoot)
|
|
$HostRoot = Join-Path $PluginRoot 'Saved\BuildHost\HostProject'
|
|
$HostProject = Join-Path $HostRoot 'HostProject.uproject'
|
|
$StagePlugin = Join-Path $HostRoot 'Plugins\VoxelForge'
|
|
$ProjectFile = Join-Path $ProjectRoot 'VoxelM.uproject'
|
|
$EngineRoot = 'E:\Program Files\Epic Games\UE_5.7'
|
|
$EditorExe = Join-Path $EngineRoot 'Engine\Binaries\Win64\UnrealEditor-Cmd.exe'
|
|
$UbtDll = Join-Path $EngineRoot 'Engine\Binaries\DotNET\UnrealBuildTool\UnrealBuildTool.dll'
|
|
$LaunchTimeoutSeconds = 1800
|
|
$UnrealPrivateMemoryLimitBytes = 8GB
|
|
# The VoxelForge category includes two late, several-minute op-stack sweeps. They remain in
|
|
# the suite; when the serialized aggregate launch reaches this 30-minute guard, validate those
|
|
# named tests with -TestFilter so every test still runs under the standing launch limit.
|
|
# Fresh branch-tip baseline at 1c642fd1824a7e0e4f88a8fc1f42b9e23fdb1821 after a clean staged
|
|
# sync (synthetic canonical, walk+export+probe). This is intentionally asserted so any
|
|
# field-affecting edit makes canonical fail loudly.
|
|
$CanonicalBaselineCommit = '1c642fd1824a7e0e4f88a8fc1f42b9e23fdb1821'
|
|
$CanonicalObjSha256 = '1280524041AB4932E6CFE74D57C349F84C6557C02CB7958C694DE734EF347E2B'
|
|
|
|
$Failures = [System.Collections.Generic.List[string]]::new()
|
|
$LaunchLedger = [System.Collections.Generic.List[object]]::new()
|
|
$BuildRecord = [ordered]@{ requested = [bool]$Build; status = 'not_requested' }
|
|
$AssetEvidence = @()
|
|
$CommandletMetrics = [ordered]@{}
|
|
$GameMetrics = [ordered]@{}
|
|
$HorizonMetrics = [ordered]@{
|
|
requested = $Scenario -in @('horizon', 'horizon-render')
|
|
status = 'not_run'
|
|
level = $HorizonLevel
|
|
render_distance_chunks = $HorizonRenderDistanceChunks
|
|
}
|
|
$HorizonRenderMetrics = [ordered]@{ requested = $Scenario -eq 'horizon-render'; status = 'not_run' }
|
|
$DeterminismEvidence = [ordered]@{ requested = $Scenario -eq 'determinism'; status = 'not_run' }
|
|
$SurfaceFallMetrics = [ordered]@{
|
|
requested = $Scenario -eq 'surface-fall' -or
|
|
($Scenario -eq 'tests' -and [string]::IsNullOrWhiteSpace($TestFilter))
|
|
status = 'not_run'
|
|
}
|
|
$StrateCrossingMetrics = [ordered]@{ requested = $Scenario -eq 'crossing'; status = 'not_run' }
|
|
$VisualStalenessMetrics = [ordered]@{
|
|
requested = $Scenario -in @('visual-edit', 'visual-lod', 'visual-gap-walk')
|
|
status = 'not_run'
|
|
}
|
|
$VisualGapMetrics = [ordered]@{ requested = $Scenario -in @('dig-motion', 'visual-gap-walk', 'visual-gap-inject'); status = 'not_run' }
|
|
$DigMotionMetrics = [ordered]@{ requested = $Scenario -eq 'dig-motion'; status = 'not_run' }
|
|
$CollisionGateStressMetrics = [ordered]@{
|
|
requested = $Scenario -eq 'gate-stress' -or
|
|
($Scenario -eq 'tests' -and [string]::IsNullOrWhiteSpace($TestFilter))
|
|
status = 'not_run'
|
|
}
|
|
$TestMetrics = @()
|
|
$ParityEvidence = [ordered]@{ requested = $Scenario -eq 'parity'; status = 'not_run' }
|
|
$DllEvidence = [ordered]@{
|
|
runtime = [ordered]@{ path = Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForge.dll'; sha256 = $null }
|
|
editor = [ordered]@{ path = Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForgeEditor.dll'; sha256 = $null }
|
|
}
|
|
$LocalAppDataWrites = @()
|
|
$LocalAppDataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnrealEngine'
|
|
$RunRoot = $null
|
|
$ResultPath = $null
|
|
$SummaryPath = $null
|
|
$DDCPath = $null
|
|
|
|
function Add-Failure([string]$Reason) {
|
|
if ([string]::IsNullOrWhiteSpace($Reason)) { return }
|
|
if (-not $Failures.Contains($Reason)) { [void]$Failures.Add($Reason) }
|
|
}
|
|
|
|
function Ensure-Directory([string]$Path) {
|
|
if ([string]::IsNullOrWhiteSpace($Path)) { throw 'Cannot create an empty directory path.' }
|
|
if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
|
|
[IO.Directory]::CreateDirectory($Path) | Out-Null
|
|
}
|
|
}
|
|
|
|
function Get-FullPath([string]$Path) {
|
|
return [IO.Path]::GetFullPath($Path)
|
|
}
|
|
|
|
function Test-UnderPath([string]$Candidate, [string]$Root) {
|
|
$candidateFull = (Get-FullPath $Candidate).TrimEnd('\', '/')
|
|
$rootFull = (Get-FullPath $Root).TrimEnd('\', '/')
|
|
return $candidateFull.Equals($rootFull, [StringComparison]::OrdinalIgnoreCase) -or
|
|
$candidateFull.StartsWith($rootFull + '\', [StringComparison]::OrdinalIgnoreCase) -or
|
|
$candidateFull.StartsWith($rootFull + '/', [StringComparison]::OrdinalIgnoreCase)
|
|
}
|
|
|
|
function Get-Sha256([string]$Path) {
|
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
|
|
return (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToUpperInvariant()
|
|
}
|
|
|
|
function Get-SourceSnapshot([string]$Root) {
|
|
$snapshot = [ordered]@{}
|
|
$sourceRoot = Join-Path $Root 'Source'
|
|
if (Test-Path -LiteralPath $sourceRoot -PathType Container) {
|
|
foreach ($file in @(Get-ChildItem -LiteralPath $sourceRoot -File -Recurse -Force)) {
|
|
$relative = $file.FullName.Substring($Root.Length).TrimStart('\', '/')
|
|
$snapshot[$relative.ToLowerInvariant()] = Get-Sha256 $file.FullName
|
|
}
|
|
}
|
|
$manifest = Join-Path $Root 'VoxelForge.uplugin'
|
|
if (Test-Path -LiteralPath $manifest -PathType Leaf) {
|
|
$snapshot['voxelforge.uplugin'] = Get-Sha256 $manifest
|
|
}
|
|
return $snapshot
|
|
}
|
|
|
|
function Get-ChangedSnapshotKeys($Before, $After) {
|
|
$keys = @($Before.Keys) + @($After.Keys) | Sort-Object -Unique
|
|
$changed = [System.Collections.Generic.List[string]]::new()
|
|
foreach ($key in $keys) {
|
|
if (-not $Before.Contains($key) -or -not $After.Contains($key) -or
|
|
$Before[$key] -ne $After[$key]) {
|
|
[void]$changed.Add([string]$key)
|
|
}
|
|
}
|
|
return @($changed)
|
|
}
|
|
|
|
function Get-StateSnapshot([string]$Root) {
|
|
$snapshot = [ordered]@{}
|
|
if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return $snapshot }
|
|
foreach ($item in @(Get-ChildItem -LiteralPath $Root -Recurse -Force -ErrorAction SilentlyContinue)) {
|
|
$relative = $item.FullName.Substring($Root.Length).TrimStart('\', '/').ToLowerInvariant()
|
|
$kind = if ($item.PSIsContainer) { 'D' } else { 'F' }
|
|
$time = $item.LastWriteTimeUtc.Ticks
|
|
$length = if ($item.PSIsContainer) { 0 } else { $item.Length }
|
|
$snapshot["$kind|$relative"] = "$time|$length"
|
|
}
|
|
return $snapshot
|
|
}
|
|
|
|
function Get-StateChanges($Before, $After) {
|
|
$keys = @($Before.Keys) + @($After.Keys) | Sort-Object -Unique
|
|
$changed = [System.Collections.Generic.List[string]]::new()
|
|
foreach ($key in $keys) {
|
|
if (-not $Before.Contains($key) -or -not $After.Contains($key) -or $Before[$key] -ne $After[$key]) {
|
|
[void]$changed.Add([string]$key)
|
|
}
|
|
}
|
|
return @($changed)
|
|
}
|
|
|
|
function Get-JsonValue($Object, [string]$Name, $Default = $null) {
|
|
if ($null -eq $Object) { return $Default }
|
|
if ($Object -is [System.Collections.IDictionary]) {
|
|
if ($Object.Contains($Name)) { return $Object[$Name] }
|
|
return $Default
|
|
}
|
|
$property = $Object.PSObject.Properties[$Name]
|
|
if ($null -eq $property) { return $Default }
|
|
return $property.Value
|
|
}
|
|
|
|
function ConvertTo-CompactJson($Object) {
|
|
if ($null -eq $Object) { return 'null' }
|
|
return ($Object | ConvertTo-Json -Compress -Depth 30)
|
|
}
|
|
|
|
function Get-UnrealProcesses {
|
|
# Get-Process is deliberately the primary guard. Some managed shells deny Win32_Process
|
|
# command-line inspection, but they still expose the PID/name needed to protect an owner
|
|
# editor before a launch.
|
|
$rows = @(Get-Process -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.ProcessName -like 'UnrealEditor*' } |
|
|
ForEach-Object {
|
|
[pscustomobject]@{
|
|
ProcessId = [int]$_.Id
|
|
Name = "$($_.ProcessName).exe"
|
|
ExecutablePath = $null
|
|
CommandLine = $null
|
|
}
|
|
})
|
|
return @($rows | ForEach-Object {
|
|
[pscustomobject]@{
|
|
pid = [int]$_.ProcessId
|
|
name = [string]$_.Name
|
|
executable = [string]$_.ExecutablePath
|
|
command_line = [string]$_.CommandLine
|
|
}
|
|
})
|
|
}
|
|
|
|
function Assert-NoForeignUnreal([string]$When) {
|
|
$processes = @(Get-UnrealProcesses)
|
|
if ($processes.Count -gt 0) {
|
|
$detail = ($processes | ForEach-Object {
|
|
"PID=$($_.pid) Name=$($_.name) Exe=$($_.executable)"
|
|
}) -join '; '
|
|
throw "UnrealEditor process already exists $When; refusing to touch it: $detail"
|
|
}
|
|
}
|
|
|
|
if (-not ('VoxelForgeTestMemoryStatus' -as [type])) {
|
|
Add-Type -TypeDefinition @'
|
|
using System;
|
|
using System.Runtime.InteropServices;
|
|
public static class VoxelForgeTestMemoryStatus {
|
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
|
|
public class MEMORYSTATUSEX {
|
|
public uint dwLength = (uint)Marshal.SizeOf(typeof(MEMORYSTATUSEX));
|
|
public uint dwMemoryLoad;
|
|
public ulong ullTotalPhys;
|
|
public ulong ullAvailPhys;
|
|
public ulong ullTotalPageFile;
|
|
public ulong ullAvailPageFile;
|
|
public ulong ullTotalVirtual;
|
|
public ulong ullAvailVirtual;
|
|
public ulong ullAvailExtendedVirtual;
|
|
}
|
|
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
|
public static extern bool GlobalMemoryStatusEx([In, Out] MEMORYSTATUSEX lpBuffer);
|
|
}
|
|
'@
|
|
}
|
|
|
|
function Assert-EnoughFreeMemory([string]$When) {
|
|
$deadline = [datetime]::UtcNow.AddMinutes(30)
|
|
$didWait = $false
|
|
while ($true) {
|
|
$status = New-Object VoxelForgeTestMemoryStatus+MEMORYSTATUSEX
|
|
if (-not [VoxelForgeTestMemoryStatus]::GlobalMemoryStatusEx($status)) {
|
|
throw "Could not query free physical memory before $When; refusing to launch."
|
|
}
|
|
$freeGiB = [double]$status.ullAvailPhys / 1GB
|
|
if ($freeGiB -ge 6.0) {
|
|
if ($didWait) {
|
|
Write-Host ("Free memory recovered to {0:N2} GiB before {1}; continuing." -f $freeGiB, $When)
|
|
}
|
|
Assert-NoForeignUnreal "$When after memory check"
|
|
return
|
|
}
|
|
if ([datetime]::UtcNow -ge $deadline) {
|
|
throw ("Free memory stayed at {0:N2} GiB before {1} for 30 minutes; refusing to launch." -f $freeGiB, $When)
|
|
}
|
|
if (-not $didWait) {
|
|
Write-Host ("Waiting for free memory: {0:N2} GiB before {1}; need at least 6.00 GiB." -f $freeGiB, $When)
|
|
$didWait = $true
|
|
}
|
|
Assert-NoForeignUnreal "$When while waiting for memory"
|
|
Start-Sleep -Seconds 15
|
|
}
|
|
}
|
|
|
|
function Get-CrashSnapshot {
|
|
$snapshot = [ordered]@{}
|
|
$roots = @(
|
|
(Join-Path $HostRoot 'Saved\Crashes'),
|
|
(Join-Path $StagePlugin 'Saved\Crashes'),
|
|
(Join-Path $RunRoot 'Crashes')
|
|
)
|
|
foreach ($root in $roots) {
|
|
if (-not (Test-Path -LiteralPath $root -PathType Container)) { continue }
|
|
foreach ($dir in @(Get-ChildItem -LiteralPath $root -Directory -Filter 'UECC-*' -Recurse -Force -ErrorAction SilentlyContinue)) {
|
|
$snapshot[$dir.FullName.ToLowerInvariant()] = $dir.LastWriteTimeUtc.Ticks
|
|
}
|
|
}
|
|
return $snapshot
|
|
}
|
|
|
|
function Get-CrashReporterProcesses {
|
|
$rows = @()
|
|
try {
|
|
$rows = @(Get-CimInstance -ClassName Win32_Process -ErrorAction Stop 2>$null |
|
|
Where-Object { $_.Name -like '*CrashReportClient*' })
|
|
} catch {
|
|
# Without a parent/command line the process cannot be proven to be ours, so leave it
|
|
# untouched. The ledger records the absence of a safely attributable reporter.
|
|
$Error.Clear()
|
|
return @()
|
|
}
|
|
return @($rows | ForEach-Object {
|
|
[pscustomobject]@{
|
|
pid = [int]$_.ProcessId
|
|
name = [string]$_.Name
|
|
command_line = [string]$_.CommandLine
|
|
}
|
|
})
|
|
}
|
|
|
|
function Reap-OwnCrashReporters($Before, [datetime]$StartedUtc, [string]$LogPath) {
|
|
$beforePids = @($Before | ForEach-Object { [int]$_.pid })
|
|
$reaped = [System.Collections.Generic.List[int]]::new()
|
|
foreach ($reporter in @(Get-CrashReporterProcesses)) {
|
|
if ($beforePids -contains [int]$reporter.pid) { continue }
|
|
$commandLine = [string]$reporter.command_line
|
|
$belongsToRun = $commandLine.IndexOf($RunRoot, [StringComparison]::OrdinalIgnoreCase) -ge 0 -or
|
|
$commandLine.IndexOf($HostProject, [StringComparison]::OrdinalIgnoreCase) -ge 0 -or
|
|
$commandLine.IndexOf($LogPath, [StringComparison]::OrdinalIgnoreCase) -ge 0
|
|
if (-not $belongsToRun) { continue }
|
|
try {
|
|
Stop-Process -Id ([int]$reporter.pid) -Force -ErrorAction Stop
|
|
[void]$reaped.Add([int]$reporter.pid)
|
|
} catch {
|
|
Add-Failure "Could not reap own crash reporter PID $($reporter.pid): $($_.Exception.Message)"
|
|
}
|
|
}
|
|
return @($reaped)
|
|
}
|
|
|
|
function Get-NewCrashes($Before, [datetime]$StartedUtc) {
|
|
$after = Get-CrashSnapshot
|
|
$new = [System.Collections.Generic.List[string]]::new()
|
|
foreach ($path in $after.Keys) {
|
|
if (-not $Before.Contains($path) -or [int64]$after[$path] -ge $StartedUtc.ToUniversalTime().Ticks) {
|
|
[void]$new.Add($path)
|
|
}
|
|
}
|
|
return @($new)
|
|
}
|
|
|
|
function Wait-OwnedProcess($Process, [int]$TimeoutSeconds, [switch]$EnforceMemoryLimit) {
|
|
$watch = [Diagnostics.Stopwatch]::StartNew()
|
|
$peakPrivateBytes = 0L
|
|
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
|
|
if ($Process.HasExited) {
|
|
[void]$Process.WaitForExit()
|
|
return [ordered]@{
|
|
timed_out = $false
|
|
memory_limit_hit = $false
|
|
peak_private_bytes = $peakPrivateBytes
|
|
exit_code = [int]$Process.ExitCode
|
|
}
|
|
}
|
|
if ($EnforceMemoryLimit) {
|
|
$owned = Get-Process -Id ([int]$Process.Id) -ErrorAction SilentlyContinue
|
|
if ($null -ne $owned) {
|
|
$peakPrivateBytes = [Math]::Max($peakPrivateBytes, [int64]$owned.PrivateMemorySize64)
|
|
if ([int64]$owned.PrivateMemorySize64 -gt $UnrealPrivateMemoryLimitBytes) {
|
|
# This PID came from our Start-Process call. Do not use a name-wide kill here.
|
|
try { Stop-Process -Id ([int]$Process.Id) -Force -ErrorAction Stop } catch { }
|
|
try { [void]$Process.WaitForExit(5000) } catch { }
|
|
return [ordered]@{
|
|
timed_out = $false
|
|
memory_limit_hit = $true
|
|
peak_private_bytes = $peakPrivateBytes
|
|
exit_code = $null
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Start-Sleep -Milliseconds 1000
|
|
}
|
|
# This PID came from our Start-Process call. Never use a name-based kill or a broad process
|
|
# query here: an owner's editor may be unrelated and must remain untouched.
|
|
try { Stop-Process -Id ([int]$Process.Id) -Force -ErrorAction Stop } catch { }
|
|
try { [void]$Process.WaitForExit(5000) } catch { }
|
|
return [ordered]@{
|
|
timed_out = $true
|
|
memory_limit_hit = $false
|
|
peak_private_bytes = $peakPrivateBytes
|
|
exit_code = $null
|
|
}
|
|
}
|
|
|
|
function Invoke-UnrealLaunch([string]$Kind, [string[]]$Arguments, [string]$LogPath,
|
|
[string]$StdoutPath, [string]$StderrPath) {
|
|
Assert-NoForeignUnreal "before $Kind launch"
|
|
Assert-EnoughFreeMemory "$Kind launch"
|
|
Ensure-Directory (Split-Path -Parent $LogPath)
|
|
Ensure-Directory (Split-Path -Parent $StdoutPath)
|
|
Ensure-Directory (Split-Path -Parent $StderrPath)
|
|
$startedUtc = [datetime]::UtcNow
|
|
$crashesBefore = Get-CrashSnapshot
|
|
$reportersBefore = @(Get-CrashReporterProcesses)
|
|
$ledger = [ordered]@{
|
|
kind = $Kind
|
|
pid = $null
|
|
started_utc = $startedUtc.ToString('o')
|
|
ended_utc = $null
|
|
exit_code = $null
|
|
timed_out = $false
|
|
memory_limit_hit = $false
|
|
peak_private_bytes = 0L
|
|
crash_reporters_reaped = @()
|
|
new_crashes = @()
|
|
log = $LogPath
|
|
stdout = $StdoutPath
|
|
stderr = $StderrPath
|
|
arguments = @($Arguments)
|
|
status = 'not_started'
|
|
}
|
|
$isolatedLocalAppData = Join-Path $RunRoot 'LocalAppData'
|
|
Ensure-Directory $isolatedLocalAppData
|
|
$oldLocalAppData = $env:LOCALAPPDATA
|
|
try {
|
|
# Unreal resolves several engine-wide stores from LOCALAPPDATA even when -userdir and
|
|
# -LocalDataCachePath are supplied. Keep this child process inside the run directory.
|
|
$env:LOCALAPPDATA = $isolatedLocalAppData
|
|
$process = Start-Process -FilePath $EditorExe -ArgumentList $Arguments -WorkingDirectory $HostRoot `
|
|
-RedirectStandardOutput $StdoutPath -RedirectStandardError $StderrPath -WindowStyle Hidden `
|
|
-PassThru
|
|
try { $process.PriorityClass = [Diagnostics.ProcessPriorityClass]::BelowNormal }
|
|
catch {
|
|
try { Stop-Process -Id ([int]$process.Id) -Force -ErrorAction Stop } catch { }
|
|
throw "Could not lower owned Unreal PID $($process.Id) to BelowNormal: $($_.Exception.Message)"
|
|
}
|
|
$ledger.pid = [int]$process.Id
|
|
$wait = Wait-OwnedProcess $process $LaunchTimeoutSeconds -EnforceMemoryLimit
|
|
$ledger.timed_out = [bool]$wait.timed_out
|
|
$ledger.memory_limit_hit = [bool]$wait.memory_limit_hit
|
|
$ledger.peak_private_bytes = [int64]$wait.peak_private_bytes
|
|
$ledger.exit_code = $wait.exit_code
|
|
$ledger.status = if ($ledger.timed_out) { 'timeout' }
|
|
elseif ($ledger.memory_limit_hit) { 'memory_limit' }
|
|
elseif ($ledger.exit_code -eq 0) { 'passed' } else { 'failed' }
|
|
} catch {
|
|
$ledger.status = 'launch_error'
|
|
$ledger.error = $_.Exception.Message
|
|
} finally {
|
|
if ($null -eq $oldLocalAppData) { Remove-Item Env:LOCALAPPDATA -ErrorAction SilentlyContinue } else { $env:LOCALAPPDATA = $oldLocalAppData }
|
|
}
|
|
$ledger.isolated_local_app_data = $isolatedLocalAppData
|
|
$ledger.ended_utc = [datetime]::UtcNow.ToString('o')
|
|
$ledger.crash_reporters_reaped = @(Reap-OwnCrashReporters $reportersBefore $startedUtc $LogPath)
|
|
$ledger.new_crashes = @(Get-NewCrashes $crashesBefore $startedUtc)
|
|
[void]$LaunchLedger.Add($ledger)
|
|
if ($ledger.status -ne 'passed') {
|
|
Add-Failure "$Kind launch $($ledger.status) (PID=$($ledger.pid), exit=$($ledger.exit_code))"
|
|
}
|
|
if ($ledger.new_crashes.Count -gt 0) {
|
|
Add-Failure "$Kind produced new UECC crash directory: $($ledger.new_crashes[0])"
|
|
}
|
|
return $ledger
|
|
}
|
|
|
|
function Invoke-BuildProcess([string[]]$Arguments, [string]$LogPath, [string]$ErrorPath,
|
|
[string]$StdoutPath = $LogPath) {
|
|
Assert-NoForeignUnreal 'before staged build launch'
|
|
Assert-EnoughFreeMemory 'staged build launch'
|
|
Ensure-Directory (Split-Path -Parent $LogPath)
|
|
$dotnetHome = Join-Path $RunRoot 'DotnetHome'
|
|
Ensure-Directory $dotnetHome
|
|
$dotnet = (Get-Command dotnet.exe -ErrorAction Stop).Source
|
|
$started = [datetime]::UtcNow
|
|
$oldDotnetCliHome = $env:DOTNET_CLI_HOME
|
|
$oldDotnetFirstUse = $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE
|
|
$oldDotnetNoLogo = $env:DOTNET_NOLOGO
|
|
$isolatedLocalAppData = Join-Path $RunRoot 'LocalAppData'
|
|
Ensure-Directory $isolatedLocalAppData
|
|
$oldLocalAppData = $env:LOCALAPPDATA
|
|
try {
|
|
# Keep the build tool's first-use sentinel and package metadata inside this run. This is
|
|
# separate from Unreal's LocalAppData guard and avoids touching the owner's profile.
|
|
$env:DOTNET_CLI_HOME = $dotnetHome
|
|
$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = '1'
|
|
$env:DOTNET_NOLOGO = '1'
|
|
$env:LOCALAPPDATA = $isolatedLocalAppData
|
|
$process = Start-Process -FilePath $dotnet -ArgumentList $Arguments -WorkingDirectory $PluginRoot `
|
|
-RedirectStandardOutput $StdoutPath -RedirectStandardError $ErrorPath -WindowStyle Hidden `
|
|
-PassThru
|
|
try { $process.PriorityClass = [Diagnostics.ProcessPriorityClass]::BelowNormal }
|
|
catch {
|
|
try { Stop-Process -Id ([int]$process.Id) -Force -ErrorAction Stop } catch { }
|
|
throw "Could not lower owned build PID $($process.Id) to BelowNormal: $($_.Exception.Message)"
|
|
}
|
|
$wait = Wait-OwnedProcess $process $LaunchTimeoutSeconds -EnforceMemoryLimit
|
|
} finally {
|
|
if ($null -eq $oldDotnetCliHome) { Remove-Item Env:DOTNET_CLI_HOME -ErrorAction SilentlyContinue } else { $env:DOTNET_CLI_HOME = $oldDotnetCliHome }
|
|
if ($null -eq $oldDotnetFirstUse) { Remove-Item Env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE -ErrorAction SilentlyContinue } else { $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = $oldDotnetFirstUse }
|
|
if ($null -eq $oldDotnetNoLogo) { Remove-Item Env:DOTNET_NOLOGO -ErrorAction SilentlyContinue } else { $env:DOTNET_NOLOGO = $oldDotnetNoLogo }
|
|
if ($null -eq $oldLocalAppData) { Remove-Item Env:LOCALAPPDATA -ErrorAction SilentlyContinue } else { $env:LOCALAPPDATA = $oldLocalAppData }
|
|
}
|
|
return [ordered]@{
|
|
tool = $dotnet
|
|
pid = [int]$process.Id
|
|
started_utc = $started.ToString('o')
|
|
ended_utc = [datetime]::UtcNow.ToString('o')
|
|
exit_code = $wait.exit_code
|
|
timed_out = [bool]$wait.timed_out
|
|
memory_limit_hit = [bool]$wait.memory_limit_hit
|
|
peak_private_bytes = [int64]$wait.peak_private_bytes
|
|
log = $LogPath
|
|
stdout = $StdoutPath
|
|
error_log = $ErrorPath
|
|
arguments = @($Arguments)
|
|
isolated_local_app_data = $isolatedLocalAppData
|
|
status = if ($wait.timed_out) { 'timeout' }
|
|
elseif ($wait.memory_limit_hit) { 'memory_limit' }
|
|
elseif ($wait.exit_code -eq 0) { 'passed' } else { 'failed' }
|
|
}
|
|
}
|
|
|
|
function Copy-DirectoryContents([string]$Source, [string]$Destination) {
|
|
Ensure-Directory $Destination
|
|
foreach ($item in @(Get-ChildItem -LiteralPath $Source -Force)) {
|
|
$target = Join-Path $Destination $item.Name
|
|
$null = Copy-Item -LiteralPath $item.FullName -Destination $target -Recurse -Force -PassThru
|
|
}
|
|
}
|
|
|
|
function Sync-StagedHost {
|
|
if (-not (Test-Path -LiteralPath $HostProject -PathType Leaf)) {
|
|
throw "Staged host project is missing: $HostProject"
|
|
}
|
|
if (-not (Test-Path -LiteralPath $StagePlugin -PathType Container)) {
|
|
throw "Staged VoxelForge plugin is missing: $StagePlugin"
|
|
}
|
|
$stageSource = Join-Path $StagePlugin 'Source'
|
|
if (Test-Path -LiteralPath $stageSource -PathType Container) {
|
|
if (-not (Test-UnderPath $stageSource $StagePlugin)) {
|
|
throw "Refusing to clean a staged source path outside the staged plugin: $stageSource"
|
|
}
|
|
Remove-Item -LiteralPath $stageSource -Recurse -Force
|
|
}
|
|
Copy-DirectoryContents (Join-Path $PluginRoot 'Source') $stageSource
|
|
$null = Copy-Item -LiteralPath (Join-Path $PluginRoot 'VoxelForge.uplugin') `
|
|
-Destination (Join-Path $StagePlugin 'VoxelForge.uplugin') -Force -PassThru
|
|
|
|
# The staged map is a host copy; these copies never write the owner's project.
|
|
$stageContent = Join-Path $HostRoot 'Content'
|
|
$stageVoxelContent = Join-Path $stageContent 'VoxelForge'
|
|
Ensure-Directory $stageVoxelContent
|
|
foreach ($relative in @('Untitled.umap', 'VoxelForge\BP_VoxelWorld.uasset')) {
|
|
$source = Join-Path (Join-Path $ProjectRoot 'Content') $relative
|
|
if (Test-Path -LiteralPath $source -PathType Leaf) {
|
|
$target = Join-Path $stageContent $relative
|
|
Ensure-Directory (Split-Path -Parent $target)
|
|
$null = Copy-Item -LiteralPath $source -Destination $target -Force -PassThru
|
|
}
|
|
}
|
|
}
|
|
|
|
function Sync-OwnerAssets {
|
|
$rows = [System.Collections.Generic.List[object]]::new()
|
|
# Composer tests discover the authored strate corpus from the staged asset registry. Keep the
|
|
# complete owner corpus here; the explore commandlet still selects DA_Strate3 explicitly for
|
|
# the owner-world scenario below.
|
|
foreach ($assetName in @('DA_Strate1', 'DA_Strate2', 'DA_Strate3', 'DA_Strate4', 'DA_Settings')) {
|
|
$source = Join-Path $ProjectRoot "Content\VoxelForge\$assetName.uasset"
|
|
$target = Join-Path $HostRoot "Content\VoxelForge\$assetName.uasset"
|
|
if (-not (Test-Path -LiteralPath $source -PathType Leaf)) {
|
|
throw "Owner asset is missing: $source"
|
|
}
|
|
Ensure-Directory (Split-Path -Parent $target)
|
|
$null = Copy-Item -LiteralPath $source -Destination $target -Force -PassThru
|
|
[void]$rows.Add([ordered]@{
|
|
name = $assetName
|
|
used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'horizon', 'horizon-render', 'determinism', 'region-eval')
|
|
source_path = $source
|
|
source_sha256 = Get-Sha256 $source
|
|
staged_path = $target
|
|
staged_sha256 = Get-Sha256 $target
|
|
})
|
|
}
|
|
return @($rows)
|
|
}
|
|
|
|
function Sync-TestCharacterFixture {
|
|
$rows = [System.Collections.Generic.List[object]]::new()
|
|
|
|
# ScaleDiagnosis reads the owner's character CDO for movement limits. The isolated host
|
|
# already contains the CDO, but the CDO's animation blueprint loads the complete unarmed
|
|
# blend-space sample set. Keep that dependency closure in the harness instead of turning a
|
|
# missing staged asset into an automation-test error. These are copies into the host only.
|
|
$relativeDirectories = @('Characters\Mannequins\Anims\Unarmed')
|
|
foreach ($relativeDirectory in $relativeDirectories) {
|
|
$sourceDirectory = Join-Path $ProjectRoot "Content\$relativeDirectory"
|
|
$targetDirectory = Join-Path $HostRoot "Content\$relativeDirectory"
|
|
if (-not (Test-Path -LiteralPath $sourceDirectory -PathType Container)) {
|
|
throw "Test fixture directory is missing: $sourceDirectory"
|
|
}
|
|
Copy-DirectoryContents $sourceDirectory $targetDirectory
|
|
foreach ($source in @(Get-ChildItem -LiteralPath $sourceDirectory -Recurse -File)) {
|
|
$relativeFile = $source.FullName.Substring($sourceDirectory.Length).TrimStart('\')
|
|
$target = Join-Path $targetDirectory $relativeFile
|
|
[void]$rows.Add([ordered]@{
|
|
name = "TestFixture/$relativeDirectory/$relativeFile"
|
|
used_by_scenario = $true
|
|
source_path = $source.FullName
|
|
source_sha256 = Get-Sha256 $source.FullName
|
|
staged_path = $target
|
|
staged_sha256 = Get-Sha256 $target
|
|
})
|
|
}
|
|
}
|
|
return @($rows)
|
|
}
|
|
|
|
function Get-AssetEvidence {
|
|
$rows = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($assetName in @('DA_Strate1', 'DA_Strate2', 'DA_Strate3', 'DA_Strate4', 'DA_Settings')) {
|
|
$source = Join-Path $ProjectRoot "Content\VoxelForge\$assetName.uasset"
|
|
$target = Join-Path $HostRoot "Content\VoxelForge\$assetName.uasset"
|
|
[void]$rows.Add([ordered]@{
|
|
name = $assetName
|
|
used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'horizon', 'horizon-render', 'determinism', 'region-eval')
|
|
source_path = $source
|
|
source_sha256 = Get-Sha256 $source
|
|
staged_path = $target
|
|
staged_sha256 = Get-Sha256 $target
|
|
})
|
|
}
|
|
return @($rows)
|
|
}
|
|
|
|
function Get-ValueText($Value) {
|
|
if ($Value -is [bool]) { return $(if ($Value) { '1' } else { '0' }) }
|
|
if ($Value -is [System.Array]) { return (($Value | ForEach-Object { Get-ValueText $_ }) -join ',') }
|
|
return [string]$Value
|
|
}
|
|
|
|
function Merge-Cvars([System.Collections.IDictionary]$Base) {
|
|
$merged = [ordered]@{}
|
|
foreach ($key in $Base.Keys) { $merged[[string]$key] = $Base[$key] }
|
|
if ($null -ne $Cvars) {
|
|
foreach ($key in $Cvars.Keys) { $merged[[string]$key] = $Cvars[$key] }
|
|
}
|
|
return $merged
|
|
}
|
|
|
|
function New-CommonArguments([string]$Kind, [string]$LogPath, $CvarMap, [bool]$EnableRender = $false) {
|
|
$safeLabel = ($Label -replace '[^A-Za-z0-9_.-]', '_')
|
|
$args = [System.Collections.Generic.List[string]]::new()
|
|
# Quoting the whole -project= token is intentional: this is the failure mode that used to
|
|
# swallow every argument after "Projet Unreal".
|
|
[void]$args.Add(('-project="{0}"' -f $HostProject))
|
|
if ($EnableRender) {
|
|
[void]$args.Add('-RenderOffscreen')
|
|
[void]$args.Add('-ResX=1280')
|
|
[void]$args.Add('-ResY=720')
|
|
} else {
|
|
[void]$args.Add('-nullrhi')
|
|
}
|
|
[void]$args.Add('-DDC-ForceMemoryCache')
|
|
[void]$args.Add('-unattended')
|
|
[void]$args.Add('-nop4')
|
|
[void]$args.Add('-nosplash')
|
|
[void]$args.Add('-NoSound')
|
|
[void]$args.Add('-stdout')
|
|
[void]$args.Add('-FullStdOutLogOutput')
|
|
[void]$args.Add('-forcelogflush')
|
|
[void]$args.Add('-NoZenAutoLaunch')
|
|
[void]$args.Add('-notraceserver')
|
|
[void]$args.Add('-nocrashreports')
|
|
[void]$args.Add('-WindowsPlatformCrashContext.ForceCrashReportDialogOff=1')
|
|
[void]$args.Add("-enginesaveddirsuffix=VoxelForgeTest_$safeLabel")
|
|
[void]$args.Add(('-userdir="{0}"' -f (Join-Path $RunRoot 'User')))
|
|
[void]$args.Add(('-ZenDataPath="{0}"' -f (Join-Path $RunRoot 'ZenData')))
|
|
[void]$args.Add('-ddc=NoZenLocalFallback')
|
|
[void]$args.Add(('-LocalDataCachePath="{0}"' -f $DDCPath))
|
|
[void]$args.Add(('-abslog="{0}"' -f $LogPath))
|
|
if ($Kind -eq 'commandlet') {
|
|
[void]$args.Add('-run=VoxelForgeExplore')
|
|
} elseif ($Kind -eq 'game') {
|
|
[void]$args.Add('-game')
|
|
[void]$args.Add('-map=/Game/Untitled')
|
|
}
|
|
foreach ($key in @($CvarMap.Keys | Sort-Object)) {
|
|
$switch = [string]$key
|
|
if (-not $switch.StartsWith('-')) { $switch = "-$switch" }
|
|
$value = Get-ValueText $CvarMap[$key]
|
|
if ($value -match '\s' -or $value.Contains(',')) {
|
|
[void]$args.Add(('{0}="{1}"' -f $switch, $value))
|
|
} else {
|
|
[void]$args.Add("$switch=$value")
|
|
}
|
|
}
|
|
return @($args)
|
|
}
|
|
|
|
function New-CommandletArguments([string]$CommandletOut, [string]$Modes, [bool]$UseOwnerAssets,
|
|
[bool]$GameRuntimeConfig, [int]$ExportSize = 128,
|
|
[int]$Slot = 1, [string]$ExportTile = '') {
|
|
$args = [System.Collections.Generic.List[string]]::new()
|
|
[void]$args.Add('-seed=0')
|
|
[void]$args.Add('-archetype=TunnelNetwork')
|
|
[void]$args.Add("-slot=$Slot")
|
|
[void]$args.Add(('-modes="{0}"' -f $Modes))
|
|
[void]$args.Add('-blockearlyout=0')
|
|
[void]$args.Add('-densitygridreuse=1')
|
|
[void]$args.Add("-exportsize=$ExportSize")
|
|
if (-not [string]::IsNullOrWhiteSpace($ExportTile)) {
|
|
if ($ExportTile -notmatch '^\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*$') {
|
|
throw "-ParityExportTile must be three comma-separated level-0 tile coordinates: $ExportTile"
|
|
}
|
|
[void]$args.Add("-exporttilex=$($Matches[1])")
|
|
[void]$args.Add("-exporttiley=$($Matches[2])")
|
|
[void]$args.Add("-exporttilez=$($Matches[3])")
|
|
}
|
|
if ($GameRuntimeConfig) { [void]$args.Add('-gameconfig=1') }
|
|
if ($UseOwnerAssets) {
|
|
[void]$args.Add('-strateref=/Game/VoxelForge/DA_Strate3.DA_Strate3')
|
|
[void]$args.Add('-settingsref=/Game/VoxelForge/DA_Settings.DA_Settings')
|
|
}
|
|
[void]$args.Add(('-out="{0}"' -f $CommandletOut))
|
|
return @($args)
|
|
}
|
|
|
|
function Read-JsonFile([string]$Path) {
|
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
|
|
return (Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json)
|
|
}
|
|
|
|
function Get-CommandletMetrics([string]$OutputDirectory, [string]$Use) {
|
|
$jsonPath = Join-Path $OutputDirectory 'explore.json'
|
|
$json = Read-JsonFile $jsonPath
|
|
if ($null -eq $json) {
|
|
return [ordered]@{ status = 'missing'; output = $OutputDirectory }
|
|
}
|
|
$summary = Get-JsonValue $json 'summary'
|
|
$export = Get-JsonValue $json 'export'
|
|
$walk = Get-JsonValue $json 'walk'
|
|
$ledger = Get-JsonValue $summary 'tile_ledger'
|
|
$objPath = Join-Path $OutputDirectory 'geometry.obj'
|
|
$capabilities = [ordered]@{}
|
|
foreach ($name in @('player_fit_volume_cells', 'reachable_player_fit_cells',
|
|
'walk_only_reachable_player_fit_cells', 'climb_reachable_player_fit_cells',
|
|
'arrival_passage_count', 'departure_passage_count', 'triangle_count',
|
|
'tile_count', 'completed_tiles')) {
|
|
$value = Get-JsonValue $walk $name $null
|
|
if ($null -eq $value) { $value = Get-JsonValue $summary $name $null }
|
|
if ($null -eq $value) { $value = Get-JsonValue $export $name $null }
|
|
if ($null -ne $value) { $capabilities[$name] = $value }
|
|
}
|
|
$probe = Get-JsonValue $json 'tunnel_core_probe'
|
|
$probeAmplitudes = [ordered]@{}
|
|
foreach ($sectionName in @('floor', 'wall', 'ceiling', 'mouth_probe')) {
|
|
$section = Get-JsonValue $probe $sectionName $null
|
|
if ($null -eq $section) { continue }
|
|
foreach ($property in @($section.PSObject.Properties)) {
|
|
if ($property.Name -match 'amplitude|period') {
|
|
$probeAmplitudes["$sectionName.$($property.Name)"] = $property.Value
|
|
}
|
|
}
|
|
}
|
|
foreach ($name in @('periodic_amplitude_voxels', 'periodic_amplitude_m',
|
|
'amplitude_voxels', 'amplitude', 'periodic_period_voxels')) {
|
|
$value = Get-JsonValue $probe $name $null
|
|
if ($null -ne $value) { $probeAmplitudes[$name] = $value }
|
|
}
|
|
$meshSeconds = Get-JsonValue $summary 'mesh_seconds' $null
|
|
$workerSeconds = Get-JsonValue (Get-JsonValue $summary 'mesher_tasks') 'job_sum_seconds' $meshSeconds
|
|
$connectivity = [ordered]@{
|
|
result = Get-JsonValue $walk 'connectivity_result' $null
|
|
can_reach_departure = Get-JsonValue $walk 'can_reach_departure' $null
|
|
walk_only_can_reach_departure = Get-JsonValue $walk 'walk_only_can_reach_departure' $null
|
|
}
|
|
return [ordered]@{
|
|
status = Get-JsonValue $json 'status' 'ok'
|
|
use = $Use
|
|
output = $OutputDirectory
|
|
json = $jsonPath
|
|
capability_counts = $capabilities
|
|
connectivity = $connectivity
|
|
geometry_hash = Get-JsonValue $summary 'geometry_hash' (Get-JsonValue $export 'geometry_hash' $null)
|
|
obj_sha256 = Get-Sha256 $objPath
|
|
obj_path = $objPath
|
|
probe_amplitudes = $probeAmplitudes
|
|
worker_seconds = $workerSeconds
|
|
mesh_seconds = $meshSeconds
|
|
tile_ledger = $ledger
|
|
input = Get-JsonValue $json 'input' $null
|
|
game_runtime_config = Get-JsonValue (Get-JsonValue $json 'input') 'game_runtime_config' $false
|
|
}
|
|
}
|
|
|
|
function Get-StreamingMetrics([string]$LogPath) {
|
|
$lineMatch = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeStreamingLatency\]' -SimpleMatch:$false -ErrorAction SilentlyContinue |
|
|
Select-Object -Last 1)
|
|
if ($lineMatch.Count -eq 0) { return [ordered]@{ status = 'missing'; log = $LogPath } }
|
|
$line = [string]$lineMatch[0].Line
|
|
$metrics = [ordered]@{ status = 'ok'; log = $LogPath; raw = $line }
|
|
$patterns = [ordered]@{
|
|
lod0_ready_samples = 'lod0_ready_samples=(?<v>[-+0-9.eE]+)'
|
|
request_to_ready_p50 = 'request_to_ready_s\[p50=(?<v>[-+0-9.eE]+)'
|
|
request_to_ready_p95 = 'request_to_ready_s\[p50=[^]]*p95=(?<v>[-+0-9.eE]+)'
|
|
queue_wait_p50 = 'queue_wait_s\[p50=(?<v>[-+0-9.eE]+)'
|
|
queue_wait_p95 = 'queue_wait_s\[p50=[^]]*p95=(?<v>[-+0-9.eE]+)'
|
|
generation_p50 = 'generation_s\[p50=(?<v>[-+0-9.eE]+)'
|
|
generation_p95 = 'generation_s\[p50=[^]]*p95=(?<v>[-+0-9.eE]+)'
|
|
worker_generation_sum_s = 'worker_generation_sum_s=(?<v>[-+0-9.eE]+)'
|
|
applied_tiles = 'applied_tiles=(?<v>[-+0-9.eE]+)'
|
|
applied_triangles = 'applied_triangles=(?<v>[-+0-9.eE]+)'
|
|
}
|
|
foreach ($key in $patterns.Keys) {
|
|
$match = [regex]::Match($line, $patterns[$key])
|
|
if ($match.Success) { $metrics[$key] = $match.Groups['v'].Value }
|
|
}
|
|
$metrics['p50_p95'] = [ordered]@{
|
|
request_to_ready = [ordered]@{ p50 = $metrics['request_to_ready_p50']; p95 = $metrics['request_to_ready_p95'] }
|
|
queue_wait = [ordered]@{ p50 = $metrics['queue_wait_p50']; p95 = $metrics['queue_wait_p95'] }
|
|
generation = [ordered]@{ p50 = $metrics['generation_p50']; p95 = $metrics['generation_p95'] }
|
|
}
|
|
return $metrics
|
|
}
|
|
|
|
function Get-HorizonMetrics([string]$TracePath, $Ledger, $LatencyMetrics) {
|
|
$metrics = [ordered]@{
|
|
status = 'missing'
|
|
trace = $TracePath
|
|
desired_tiles_by_level = [ordered]@{}
|
|
loaded_tiles_by_level = [ordered]@{}
|
|
vertices_by_level = [ordered]@{}
|
|
triangles_by_level = [ordered]@{}
|
|
geometry_memory_bytes_by_level = [ordered]@{}
|
|
geometry_memory_mib_by_level = [ordered]@{}
|
|
desired_tiles_total = 0
|
|
loaded_tiles_total = 0
|
|
vertices_total = 0
|
|
triangles_total = 0
|
|
geometry_memory_bytes_total = 0
|
|
geometry_memory_mib_total = 0.0
|
|
worker_generation_seconds = if ($null -ne $LatencyMetrics) {
|
|
Get-JsonValue $LatencyMetrics 'worker_generation_sum_s' $null
|
|
} else { $null }
|
|
settled_trace_seconds = $null
|
|
trace_summary_wall_seconds = $null
|
|
process_wall_seconds = $null
|
|
peak_private_bytes = if ($null -ne $Ledger) {
|
|
[int64](Get-JsonValue $Ledger 'peak_private_bytes' 0)
|
|
} else { 0L }
|
|
peak_private_gib = 0.0
|
|
trace_records_seen = $null
|
|
trace_records_written = $null
|
|
trace_records_suppressed = $null
|
|
}
|
|
$metrics.peak_private_gib = [Math]::Round($metrics.peak_private_bytes / 1GB, 3)
|
|
|
|
if (-not (Test-Path -LiteralPath $TracePath -PathType Leaf)) { return $metrics }
|
|
$events = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($line in @(Get-Content -LiteralPath $TracePath -ErrorAction SilentlyContinue)) {
|
|
if ([string]::IsNullOrWhiteSpace($line)) { continue }
|
|
try { [void]$events.Add(($line | ConvertFrom-Json -ErrorAction Stop)) } catch { }
|
|
}
|
|
$steady = @($events | Where-Object { [string](Get-JsonValue $_ 'type' '') -eq 'steady_state' } | Select-Object -Last 1)
|
|
$summary = @($events | Where-Object { [string](Get-JsonValue $_ 'type' '') -eq 'summary' } | Select-Object -Last 1)
|
|
$desiredEvents = @($events | Where-Object { [string](Get-JsonValue $_ 'type' '') -eq 'steady_desired_level' })
|
|
$loadedEvents = @($events | Where-Object { [string](Get-JsonValue $_ 'type' '') -eq 'steady_loaded_level' })
|
|
if ($steady.Count -eq 0 -or $summary.Count -eq 0 -or $desiredEvents.Count -eq 0) {
|
|
return $metrics
|
|
}
|
|
|
|
foreach ($event in $desiredEvents) {
|
|
$level = [string][int](Get-JsonValue $event 'level' -1)
|
|
$metrics.desired_tiles_by_level[$level] = [int](Get-JsonValue $event 'tiles' 0)
|
|
}
|
|
foreach ($event in $loadedEvents) {
|
|
$level = [string][int](Get-JsonValue $event 'level' -1)
|
|
$metrics.loaded_tiles_by_level[$level] = [int](Get-JsonValue $event 'tiles' 0)
|
|
$metrics.vertices_by_level[$level] = [int64](Get-JsonValue $event 'vertices' 0)
|
|
$metrics.triangles_by_level[$level] = [int64](Get-JsonValue $event 'triangles' 0)
|
|
$bytes = [int64](Get-JsonValue $event 'geometry_bytes' 0)
|
|
$metrics.geometry_memory_bytes_by_level[$level] = $bytes
|
|
$metrics.geometry_memory_mib_by_level[$level] = [Math]::Round($bytes / 1MB, 3)
|
|
}
|
|
foreach ($value in $metrics.desired_tiles_by_level.Values) { $metrics.desired_tiles_total += [int64]$value }
|
|
foreach ($value in $metrics.loaded_tiles_by_level.Values) { $metrics.loaded_tiles_total += [int64]$value }
|
|
foreach ($value in $metrics.vertices_by_level.Values) { $metrics.vertices_total += [int64]$value }
|
|
foreach ($value in $metrics.triangles_by_level.Values) { $metrics.triangles_total += [int64]$value }
|
|
foreach ($value in $metrics.geometry_memory_bytes_by_level.Values) { $metrics.geometry_memory_bytes_total += [int64]$value }
|
|
$metrics.geometry_memory_mib_total = [Math]::Round($metrics.geometry_memory_bytes_total / 1MB, 3)
|
|
$metrics.settled_trace_seconds = [double](Get-JsonValue $steady[0] 't_s' 0.0)
|
|
$metrics.trace_summary_wall_seconds = [double](Get-JsonValue $summary[0] 'wall_s' 0.0)
|
|
$metrics.trace_records_seen = [int](Get-JsonValue $summary[0] 'tile_records_seen' 0)
|
|
$metrics.trace_records_written = [int](Get-JsonValue $summary[0] 'tile_records_written' 0)
|
|
$metrics.trace_records_suppressed = [int](Get-JsonValue $summary[0] 'tile_records_suppressed' 0)
|
|
if ($null -ne $Ledger) {
|
|
$started = [datetime]::Parse([string](Get-JsonValue $Ledger 'started_utc' ''))
|
|
$ended = [datetime]::Parse([string](Get-JsonValue $Ledger 'ended_utc' ''))
|
|
$metrics.process_wall_seconds = [Math]::Round(($ended.ToUniversalTime() - $started.ToUniversalTime()).TotalSeconds, 3)
|
|
}
|
|
$metrics.status = 'ok'
|
|
return $metrics
|
|
}
|
|
|
|
function Get-HorizonRenderMetrics($Ledger, [string]$LogPath) {
|
|
$metrics = [ordered]@{
|
|
status = 'failed'
|
|
log = $LogPath
|
|
capture_requested = $null
|
|
capture_elapsed_seconds = $null
|
|
capture_visible_tiles = $null
|
|
capture_triangles = $null
|
|
images = @()
|
|
}
|
|
$lines = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeTestCeiling\] capture_requested=' -ErrorAction SilentlyContinue)
|
|
if ($lines.Count -gt 0) {
|
|
$line = [string]$lines[-1].Line
|
|
$metrics.raw = $line
|
|
foreach ($key in @('capture_requested', 'elapsed_s', 'visible_tiles', 'triangles')) {
|
|
$match = [regex]::Match($line, ('{0}=(?<v>[^ ]+)' -f $key))
|
|
if ($match.Success) {
|
|
if ($key -eq 'elapsed_s') { $metrics.capture_elapsed_seconds = [double]$match.Groups['v'].Value }
|
|
elseif ($key -eq 'visible_tiles') { $metrics.capture_visible_tiles = [int64]$match.Groups['v'].Value }
|
|
elseif ($key -eq 'triangles') { $metrics.capture_triangles = [int64]$match.Groups['v'].Value }
|
|
else { $metrics.capture_requested = [int]$match.Groups['v'].Value }
|
|
}
|
|
}
|
|
}
|
|
$started = if ($null -ne $Ledger) {
|
|
[datetime]::Parse([string](Get-JsonValue $Ledger 'started_utc' ''))
|
|
} else { [datetime]::MinValue }
|
|
$roots = @(
|
|
(Join-Path $HostRoot 'Saved'),
|
|
$RunRoot,
|
|
(Join-Path $RunRoot 'User'))
|
|
$images = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($root in $roots) {
|
|
if (-not (Test-Path -LiteralPath $root -PathType Container)) { continue }
|
|
foreach ($image in @(Get-ChildItem -LiteralPath $root -Filter '*.png' -File -Recurse -ErrorAction SilentlyContinue)) {
|
|
if ($image.LastWriteTimeUtc -lt $started.ToUniversalTime().AddSeconds(-2)) { continue }
|
|
[void]$images.Add([ordered]@{
|
|
path = $image.FullName
|
|
bytes = [int64]$image.Length
|
|
modified_utc = $image.LastWriteTimeUtc.ToString('o')
|
|
})
|
|
}
|
|
}
|
|
$metrics.images = @($images | Sort-Object path -Unique)
|
|
if ($metrics.capture_requested -eq 1 -and $metrics.images.Count -gt 0) {
|
|
$metrics.status = 'passed'
|
|
} elseif ($null -eq $Ledger -or (Get-JsonValue $Ledger 'status' 'failed') -ne 'passed') {
|
|
$metrics.status = 'not_run'
|
|
}
|
|
return $metrics
|
|
}
|
|
|
|
function Compare-DeterminismDumps([string]$DefaultPath, [string]$CrtFma3OffPath) {
|
|
$defaultJson = Read-JsonFile $DefaultPath
|
|
$fmaJson = Read-JsonFile $CrtFma3OffPath
|
|
$evidence = [ordered]@{
|
|
status = 'failed'
|
|
default_dump = $DefaultPath
|
|
crt_fma3_off_dump = $CrtFma3OffPath
|
|
default_record_count = 0
|
|
crt_fma3_off_record_count = 0
|
|
compared_tile_count = 0
|
|
first_differences = @()
|
|
}
|
|
if ($null -eq $defaultJson -or $null -eq $fmaJson) {
|
|
$evidence.reason = 'one or both tile hash dumps are missing'
|
|
return $evidence
|
|
}
|
|
$defaultRecords = @((Get-JsonValue $defaultJson 'records' @()))
|
|
$fmaRecords = @((Get-JsonValue $fmaJson 'records' @()))
|
|
$evidence.default_record_count = $defaultRecords.Count
|
|
$evidence.crt_fma3_off_record_count = $fmaRecords.Count
|
|
$defaultByKey = @{}
|
|
$fmaByKey = @{}
|
|
foreach ($record in $defaultRecords) {
|
|
$key = "$($record.tile_x):$($record.tile_y):$($record.tile_z):$($record.level)"
|
|
$defaultByKey[$key] = $record
|
|
}
|
|
foreach ($record in $fmaRecords) {
|
|
$key = "$($record.tile_x):$($record.tile_y):$($record.tile_z):$($record.level)"
|
|
$fmaByKey[$key] = $record
|
|
}
|
|
$differences = [System.Collections.Generic.List[object]]::new()
|
|
$keys = @($defaultByKey.Keys) + @($fmaByKey.Keys) | Sort-Object -Unique
|
|
foreach ($key in $keys) {
|
|
if (-not $defaultByKey.ContainsKey($key)) {
|
|
[void]$differences.Add([ordered]@{ tile = $key; reason = 'missing_from_default' })
|
|
continue
|
|
}
|
|
if (-not $fmaByKey.ContainsKey($key)) {
|
|
[void]$differences.Add([ordered]@{ tile = $key; reason = 'missing_from_crt_fma3_off' })
|
|
continue
|
|
}
|
|
$left = $defaultByKey[$key]
|
|
$right = $fmaByKey[$key]
|
|
foreach ($property in @('origin_x_voxels', 'origin_y_voxels', 'origin_z_voxels',
|
|
'step', 'cells', 'band_chunk_lo', 'band_chunk_hi',
|
|
'sheet_tile', 'empty', 'triangle_count', 'geometry_hash')) {
|
|
if ([string](Get-JsonValue $left $property '') -ne [string](Get-JsonValue $right $property '')) {
|
|
[void]$differences.Add([ordered]@{
|
|
tile = $key
|
|
reason = "property_mismatch:$property"
|
|
default = Get-JsonValue $left $property $null
|
|
crt_fma3_off = Get-JsonValue $right $property $null
|
|
})
|
|
break
|
|
}
|
|
}
|
|
}
|
|
$evidence.compared_tile_count = [Math]::Min($defaultByKey.Count, $fmaByKey.Count)
|
|
$evidence.first_differences = @($differences | Select-Object -First 10)
|
|
$leftConfig = Get-JsonValue $defaultJson 'runtime_config' $null
|
|
$rightConfig = Get-JsonValue $fmaJson 'runtime_config' $null
|
|
$evidence.runtime_config_match = (ConvertTo-CompactJson $leftConfig) -eq (ConvertTo-CompactJson $rightConfig)
|
|
$evidence.default_runtime_config = $leftConfig
|
|
$evidence.crt_fma3_off_runtime_config = $rightConfig
|
|
if ($differences.Count -eq 0 -and $evidence.runtime_config_match -and
|
|
$defaultByKey.Count -eq $fmaByKey.Count) {
|
|
$evidence.status = 'passed'
|
|
} else {
|
|
$evidence.reason = if ($differences.Count -gt 0) { 'per-tile geometry differs' }
|
|
elseif (-not $evidence.runtime_config_match) { 'runtime configuration differs' }
|
|
else { 'tile record counts differ' }
|
|
}
|
|
return $evidence
|
|
}
|
|
|
|
function Get-SurfaceFallMetrics([string]$LogPath) {
|
|
$lineMatch = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeSurfaceFallTest\] result=' -ErrorAction SilentlyContinue |
|
|
Select-Object -Last 1)
|
|
if ($lineMatch.Count -eq 0) { return [ordered]@{ status = 'missing'; log = $LogPath } }
|
|
$line = [string]$lineMatch[0].Line
|
|
$metrics = [ordered]@{ status = 'failed'; log = $LogPath; raw = $line }
|
|
$result = [regex]::Match($line, 'result=(?<v>PASS|FAIL)')
|
|
if ($result.Success -and $result.Groups['v'].Value -eq 'PASS') { $metrics.status = 'passed' }
|
|
foreach ($key in @('reason', 'elapsed_s', 'distance_m', 'terrain_vox')) {
|
|
$match = [regex]::Match($line, ('{0}=(?<v>[^ ]+)' -f $key))
|
|
if ($match.Success) { $metrics[$key] = $match.Groups['v'].Value }
|
|
}
|
|
return $metrics
|
|
}
|
|
|
|
function Get-StrateCrossingMetrics([string]$LogPath) {
|
|
$lineMatch = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeStrateCrossingTest\] result=' -ErrorAction SilentlyContinue |
|
|
Select-Object -Last 1)
|
|
if ($lineMatch.Count -eq 0) { return [ordered]@{ status = 'missing'; log = $LogPath } }
|
|
$line = [string]$lineMatch[0].Line
|
|
$metrics = [ordered]@{ status = 'failed'; log = $LogPath; raw = $line }
|
|
$result = [regex]::Match($line, 'result=(?<v>PASS|FAIL)')
|
|
if ($result.Success -and $result.Groups['v'].Value -eq 'PASS') { $metrics.status = 'passed' }
|
|
foreach ($key in @('reason', 'elapsed_s', 'distance_m', 'max_gate_s', 'gate_limit_s', 'gate_total_s', 'min_floor_clearance_vox')) {
|
|
$match = [regex]::Match($line, ('{0}=(?<v>[^ ]+)' -f $key))
|
|
if ($match.Success) { $metrics[$key] = $match.Groups['v'].Value }
|
|
}
|
|
return $metrics
|
|
}
|
|
|
|
function Get-CollisionGateStressMetrics([string]$LogPath) {
|
|
$lineMatch = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeCollisionGateStressTest\] result=' -ErrorAction SilentlyContinue |
|
|
Select-Object -Last 1)
|
|
if ($lineMatch.Count -eq 0) { return [ordered]@{ status = 'missing'; log = $LogPath } }
|
|
$line = [string]$lineMatch[0].Line
|
|
$metrics = [ordered]@{ status = 'failed'; log = $LogPath; raw = $line }
|
|
$result = [regex]::Match($line, 'result=(?<v>PASS|FAIL)')
|
|
if ($result.Success -and $result.Groups['v'].Value -eq 'PASS') { $metrics.status = 'passed' }
|
|
foreach ($key in @('reason', 'elapsed_s', 'distance_m', 'expected_distance_m', 'speed_cm_s',
|
|
'spawn_height_vox', 'deep_drop',
|
|
'gate_holds', 'gate_total_s', 'max_gate_s', 'full_stops',
|
|
'full_stop_total_s', 'collision_gate', 'gate_progress_m',
|
|
'horizontal_gate_probe', 'support_tile',
|
|
'support_first_check_to_submit_s',
|
|
'support_first_check_to_collision_ready_s', 'test_support_tile',
|
|
'test_support_first_check_to_submit_s',
|
|
'test_support_first_check_to_collision_ready_s', 'landed', 'terrain_samples',
|
|
'terrain_measurement_missing', 'start_height_crossed',
|
|
'min_clearance_cm', 'min_clearance_position', 'min_feet_density',
|
|
'min_surface_vox_z', 'final_position')) {
|
|
$match = [regex]::Match($line, ('{0}=(?<v>[^ ]+)' -f $key))
|
|
if ($match.Success) { $metrics[$key] = $match.Groups['v'].Value }
|
|
}
|
|
return $metrics
|
|
}
|
|
|
|
function Get-VisualStalenessMetrics([string]$LogPath) {
|
|
$lineMatch = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeVisualStaleness\] result=' -ErrorAction SilentlyContinue |
|
|
Select-Object -Last 1)
|
|
if ($lineMatch.Count -eq 0) { return [ordered]@{ status = 'missing'; log = $LogPath } }
|
|
$line = [string]$lineMatch[0].Line
|
|
$metrics = [ordered]@{ status = 'failed'; log = $LogPath; raw = $line }
|
|
$result = [regex]::Match($line, 'result=(?<v>PASS|FAIL)')
|
|
if ($result.Success -and $result.Groups['v'].Value -eq 'PASS') { $metrics.status = 'passed' }
|
|
foreach ($key in @('mode', 'edit_applied', 'edit_resolved', 'edit_stale_duration_s',
|
|
'edit_stale_tiles_max', 'lod_overlap_max_s', 'lod_overlap_events',
|
|
'lod_overlap_pairs_max')) {
|
|
$match = [regex]::Match($line, ('{0}=(?<v>[^ ]+)' -f $key))
|
|
if (-not $match.Success) { continue }
|
|
if ($key -in @('mode', 'edit_applied', 'edit_resolved', 'edit_stale_tiles_max',
|
|
'lod_overlap_events', 'lod_overlap_pairs_max')) {
|
|
$metrics[$key] = [int]$match.Groups['v'].Value
|
|
} else {
|
|
$metrics[$key] = [double]$match.Groups['v'].Value
|
|
}
|
|
}
|
|
return $metrics
|
|
}
|
|
|
|
function Get-VisualGapMetrics([string]$LogPath) {
|
|
$lineMatch = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeVisualGapAudit\] result=' -ErrorAction SilentlyContinue |
|
|
Select-Object -Last 1)
|
|
if ($lineMatch.Count -eq 0) { return [ordered]@{ status = 'missing'; log = $LogPath } }
|
|
$line = [string]$lineMatch[0].Line
|
|
$metrics = [ordered]@{ status = 'failed'; log = $LogPath; raw = $line }
|
|
$result = [regex]::Match($line, 'result=(?<v>PASS|FAIL)')
|
|
if ($result.Success -and $result.Groups['v'].Value -eq 'PASS') { $metrics.status = 'passed' }
|
|
foreach ($key in @('window_s', 'window_frames', 'camera_view_frames', 'surface_point_frames', 'gap_count', 'gap_frames',
|
|
'gap_point_frames', 'max_missing_points', 'longest_gap_ms',
|
|
'longest_gap_frames', 'injected_early_hide', 'injection_caught')) {
|
|
$match = [regex]::Match($line, ('{0}=(?<v>[^ ]+)' -f $key))
|
|
if (-not $match.Success) { continue }
|
|
if ($key -in @('window_frames', 'camera_view_frames', 'surface_point_frames', 'gap_count', 'gap_frames', 'gap_point_frames',
|
|
'longest_gap_frames',
|
|
'max_missing_points', 'injected_early_hide', 'injection_caught')) {
|
|
$metrics[$key] = [int]$match.Groups['v'].Value
|
|
} else {
|
|
$metrics[$key] = [double]$match.Groups['v'].Value
|
|
}
|
|
}
|
|
return $metrics
|
|
}
|
|
|
|
function Get-DigMotionMetrics([string]$LogPath) {
|
|
$lineMatch = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeDigMotion\] result=' -ErrorAction SilentlyContinue |
|
|
Select-Object -Last 1)
|
|
if ($lineMatch.Count -eq 0) { return [ordered]@{ status = 'missing'; log = $LogPath } }
|
|
$line = [string]$lineMatch[0].Line
|
|
$metrics = [ordered]@{ status = 'failed'; log = $LogPath; raw = $line }
|
|
$result = [regex]::Match($line, 'result=(?<v>PASS|FAIL)')
|
|
if ($result.Success -and $result.Groups['v'].Value -eq 'PASS') { $metrics.status = 'passed' }
|
|
foreach ($key in @('near_digs', 'near_frames', 'near_gate_frames', 'near_falling_frames',
|
|
'near_vertical_motion_frames', 'under_saw_falling', 'under_gate_frames',
|
|
'under_fall_cm', 'under_max_downward_velocity_cm_s', 'under_digs',
|
|
'first_frame_checks', 'support_observed_frames',
|
|
'false_support_frames', 'false_air_frames',
|
|
'invented_downward_velocity_events', 'field_override')) {
|
|
$match = [regex]::Match($line, ('{0}=(?<v>[^ ]+)' -f $key))
|
|
if (-not $match.Success) { continue }
|
|
if ($key -in @('near_digs', 'near_frames', 'near_gate_frames', 'near_falling_frames',
|
|
'near_vertical_motion_frames', 'under_saw_falling', 'under_gate_frames',
|
|
'under_digs', 'first_frame_checks', 'false_support_frames',
|
|
'support_observed_frames',
|
|
'false_air_frames', 'invented_downward_velocity_events', 'field_override')) {
|
|
$metrics[$key] = [int]$match.Groups['v'].Value
|
|
} else {
|
|
$metrics[$key] = [double]$match.Groups['v'].Value
|
|
}
|
|
}
|
|
return $metrics
|
|
}
|
|
|
|
function Get-TestMetrics($Ledger) {
|
|
if ($null -eq $Ledger) { return @() }
|
|
$logPaths = @($Ledger.log, $Ledger.stdout, $Ledger.stderr) | Where-Object {
|
|
$_ -and (Test-Path -LiteralPath $_ -PathType Leaf)
|
|
}
|
|
$rows = [System.Collections.Generic.List[object]]::new()
|
|
$seen = @{}
|
|
foreach ($path in $logPaths) {
|
|
foreach ($matchLine in @(Select-String -LiteralPath $path -Pattern 'Test Completed\. Result=' -ErrorAction SilentlyContinue)) {
|
|
$match = [regex]::Match([string]$matchLine.Line,
|
|
'Test Completed\. Result=\{(?<result>[^}]+)\} Name=\{(?<name>[^}]+)\} Path=\{(?<path>[^}]+)\}')
|
|
if (-not $match.Success) { continue }
|
|
$testPath = $match.Groups['path'].Value
|
|
$key = if ([string]::IsNullOrWhiteSpace($testPath)) { $match.Groups['name'].Value } else { $testPath }
|
|
if ($seen.ContainsKey($key)) { continue }
|
|
$seen[$key] = $true
|
|
$rawResult = $match.Groups['result'].Value
|
|
$status = if ($rawResult -match '^(Success|Passed|Pass)$') { 'passed' } else { 'failed' }
|
|
[void]$rows.Add([ordered]@{
|
|
name = $match.Groups['name'].Value
|
|
path = $testPath
|
|
status = $status
|
|
raw_result = $rawResult
|
|
})
|
|
}
|
|
}
|
|
return @($rows | Sort-Object path, name)
|
|
}
|
|
|
|
function Get-ParityKey($Tile) {
|
|
return "$($Tile.absolute_tile_x):$($Tile.absolute_tile_y):$($Tile.absolute_tile_z):$($Tile.lod)"
|
|
}
|
|
|
|
function Get-ParityConfigEvidence([string]$CommandletLogPath, [string]$GameLogPath) {
|
|
$pattern = '\[StrateManager\] (Strate |Initialized|Operator-stack)|\[VoxelForgeExplore\]\[OwnerAssets\]'
|
|
$commandletLines = @()
|
|
$gameLines = @()
|
|
if (Test-Path -LiteralPath $CommandletLogPath -PathType Leaf) {
|
|
$commandletLines = @(Select-String -LiteralPath $CommandletLogPath -Pattern $pattern -ErrorAction SilentlyContinue |
|
|
ForEach-Object { $_.Line })
|
|
}
|
|
if (Test-Path -LiteralPath $GameLogPath -PathType Leaf) {
|
|
$gameLines = @(Select-String -LiteralPath $GameLogPath -Pattern $pattern -ErrorAction SilentlyContinue |
|
|
ForEach-Object { $_.Line })
|
|
}
|
|
$getCount = {
|
|
param([object[]]$Lines)
|
|
foreach ($line in $Lines) {
|
|
$match = [regex]::Match([string]$line, 'Initialized (?<count>\d+) strates')
|
|
if ($match.Success) { return [int]$match.Groups['count'].Value }
|
|
}
|
|
return $null
|
|
}
|
|
$commandletCount = & $getCount $commandletLines
|
|
$gameCount = & $getCount $gameLines
|
|
return [ordered]@{
|
|
commandlet = [ordered]@{
|
|
initialized_strates = $commandletCount
|
|
relevant_log_lines = @($commandletLines | Select-Object -Last 30)
|
|
}
|
|
game = [ordered]@{
|
|
initialized_strates = $gameCount
|
|
relevant_log_lines = @($gameLines | Select-Object -Last 30)
|
|
}
|
|
layout_matches = ($null -ne $commandletCount -and $null -ne $gameCount -and $commandletCount -eq $gameCount)
|
|
interpretation = 'StrateManager lines expose the authored settings/strate layout used by each path; tile step/cells and content-band fields remain in the per-tile records.'
|
|
}
|
|
}
|
|
|
|
function Compare-Parity($CommandletJson, [string]$GameDumpPath,
|
|
[string]$CommandletLogPath, [string]$GameLogPath) {
|
|
$expected = @((Get-JsonValue (Get-JsonValue $CommandletJson 'summary') 'tile_ledger').tiles)
|
|
$gameJson = Read-JsonFile $GameDumpPath
|
|
$configEvidence = Get-ParityConfigEvidence $CommandletLogPath $GameLogPath
|
|
if ($null -eq $gameJson) {
|
|
return [ordered]@{
|
|
status = 'failed'
|
|
reason = "game tile hash dump missing: $GameDumpPath"
|
|
configuration = $configEvidence
|
|
}
|
|
}
|
|
$gameByKey = @{}
|
|
foreach ($tile in @((Get-JsonValue $gameJson 'records'))) {
|
|
$key = "$($tile.tile_x):$($tile.tile_y):$($tile.tile_z):$($tile.level)"
|
|
$gameByKey[$key] = $tile
|
|
}
|
|
$firstDifference = $null
|
|
foreach ($tile in $expected) {
|
|
$key = Get-ParityKey $tile
|
|
if (-not $gameByKey.ContainsKey($key)) {
|
|
$firstDifference = [ordered]@{ tile = $key; reason = 'missing_from_game'; commandlet = $tile }
|
|
break
|
|
}
|
|
$gameTile = $gameByKey[$key]
|
|
if ([string]$tile.geometry_hash -ne [string]$gameTile.geometry_hash) {
|
|
$diagnosis = [System.Collections.Generic.List[string]]::new()
|
|
if ([int]$gameTile.step -ne 1 -or [int]$gameTile.cells -ne 32) {
|
|
[void]$diagnosis.Add('LOD/step/cells differ from the level-0 export')
|
|
}
|
|
if ([int]$gameTile.band_chunk_lo -ne -2147483648 -or [int]$gameTile.band_chunk_hi -ne 2147483647) {
|
|
[void]$diagnosis.Add('runtime content-band cut is active')
|
|
}
|
|
if ($configEvidence.layout_matches -eq $false) {
|
|
[void]$diagnosis.Add(('settings/strate layout differs: commandlet initialized {0}, game initialized {1}' -f
|
|
$configEvidence.commandlet.initialized_strates, $configEvidence.game.initialized_strates))
|
|
}
|
|
[void]$diagnosis.Add('cvar defaults and authored settings are recorded in both artifacts')
|
|
[void]$diagnosis.Add('if those inputs match, the remaining candidate is the fused evaluator/runtime route')
|
|
$firstDifference = [ordered]@{
|
|
tile = $key
|
|
reason = 'geometry_hash_mismatch'
|
|
commandlet_hash = [string]$tile.geometry_hash
|
|
game_hash = [string]$gameTile.geometry_hash
|
|
commandlet_tile = $tile
|
|
game_tile = $gameTile
|
|
diagnosis = @($diagnosis)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if ($null -ne $firstDifference) {
|
|
return [ordered]@{
|
|
status = 'failed'
|
|
first_difference = $firstDifference
|
|
commandlet_tile_count = $expected.Count
|
|
game_record_count = @((Get-JsonValue $gameJson 'records')).Count
|
|
game_dump = $GameDumpPath
|
|
configuration = $configEvidence
|
|
}
|
|
}
|
|
return [ordered]@{
|
|
status = 'passed'
|
|
compared_tile_count = $expected.Count
|
|
commandlet_tile_count = $expected.Count
|
|
game_record_count = @((Get-JsonValue $gameJson 'records')).Count
|
|
game_dump = $GameDumpPath
|
|
configuration = $configEvidence
|
|
}
|
|
}
|
|
|
|
try {
|
|
if ([string]::IsNullOrWhiteSpace($Label)) { $Label = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss') }
|
|
$Label = ($Label -replace '[^A-Za-z0-9_.-]', '_')
|
|
if ([string]::IsNullOrWhiteSpace($Out)) {
|
|
$Out = Join-Path $PluginRoot "Saved\TestHarness\${Label}_${Scenario}"
|
|
}
|
|
elseif (-not [IO.Path]::IsPathRooted($Out)) {
|
|
throw "-Out must be an absolute path: $Out"
|
|
}
|
|
$RunRoot = Get-FullPath $Out
|
|
if (-not (Test-UnderPath $RunRoot (Join-Path $PluginRoot 'Saved'))) {
|
|
throw "-Out must stay under the plugin Saved directory: $RunRoot"
|
|
}
|
|
Ensure-Directory $RunRoot
|
|
$ResultPath = Join-Path $RunRoot 'result.json'
|
|
$SummaryPath = Join-Path $RunRoot 'summary.txt'
|
|
Ensure-Directory (Join-Path $RunRoot 'Logs')
|
|
Ensure-Directory (Join-Path $RunRoot 'User')
|
|
Ensure-Directory (Join-Path $RunRoot 'ZenData')
|
|
$DDCPath = Join-Path $RunRoot 'DDC'
|
|
if (-not [string]::IsNullOrWhiteSpace($SharedDDC)) {
|
|
if (-not [IO.Path]::IsPathRooted($SharedDDC)) {
|
|
throw "-SharedDDC must be an absolute path: $SharedDDC"
|
|
}
|
|
$DDCPath = Get-FullPath $SharedDDC
|
|
if (-not (Test-UnderPath $DDCPath (Join-Path $PluginRoot 'Saved'))) {
|
|
throw "-SharedDDC must stay under the plugin Saved directory: $DDCPath"
|
|
}
|
|
}
|
|
Ensure-Directory $DDCPath
|
|
$localBefore = Get-StateSnapshot $LocalAppDataRoot
|
|
|
|
foreach ($required in @($EditorExe, $UbtDll, $HostProject, $ProjectFile)) {
|
|
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Required file is missing: $required" }
|
|
}
|
|
if ($Build) {
|
|
$sourceBefore = Get-SourceSnapshot $PluginRoot
|
|
$stageBefore = Get-SourceSnapshot $StagePlugin
|
|
$sourceChangedKeys = @(Get-ChangedSnapshotKeys $stageBefore $sourceBefore)
|
|
$runtimeBefore = Get-Sha256 (Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForge.dll')
|
|
$editorBefore = Get-Sha256 (Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForgeEditor.dll')
|
|
Assert-NoForeignUnreal 'before staged sync/build'
|
|
Sync-StagedHost
|
|
$buildLog = Join-Path $RunRoot 'Logs\ubt.log'
|
|
$buildStdout = Join-Path $RunRoot 'Logs\build.stdout.log'
|
|
$buildError = Join-Path $RunRoot 'Logs\build.err.log'
|
|
$buildArgs = @(('"{0}"' -f $UbtDll), 'UnrealEditor', 'Win64', 'Development', ('-Project="{0}"' -f $HostProject),
|
|
'-WaitMutex', '-FromMsBuild', '-architecture=x64', '-NoUBA', '-ForceHeaderGeneration', '-MaxParallelActions=4',
|
|
('-Log="{0}"' -f $buildLog))
|
|
$BuildRecord = Invoke-BuildProcess $buildArgs $buildLog $buildError $buildStdout
|
|
$ubtCompilerFailures = @(Select-String -LiteralPath $buildLog `
|
|
-Pattern 'Result:\s*Failed|error C\d+|fatal error C\d+' -ErrorAction SilentlyContinue |
|
|
Select-Object -First 20 | ForEach-Object { $_.Line.Trim() })
|
|
if ($ubtCompilerFailures.Count -gt 0) {
|
|
$BuildRecord.status = 'failed'
|
|
$BuildRecord.compiler_diagnostics = $ubtCompilerFailures
|
|
}
|
|
if ($BuildRecord.status -ne 'passed') { Add-Failure "staged build $($BuildRecord.status)" }
|
|
$runtimeAfter = Get-Sha256 (Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForge.dll')
|
|
$editorAfter = Get-Sha256 (Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForgeEditor.dll')
|
|
$BuildRecord.source_changed_keys = @($sourceChangedKeys)
|
|
$BuildRecord.runtime_dll_before = $runtimeBefore
|
|
$BuildRecord.runtime_dll_after = $runtimeAfter
|
|
$BuildRecord.editor_dll_before = $editorBefore
|
|
$BuildRecord.editor_dll_after = $editorAfter
|
|
$runtimeChanged = @($sourceChangedKeys | Where-Object { $_ -like 'source\voxelforge\*' }).Count -gt 0
|
|
$editorChanged = @($sourceChangedKeys | Where-Object { $_ -like 'source\voxelforgeeditor\*' }).Count -gt 0
|
|
if ($BuildRecord.status -eq 'passed' -and $runtimeChanged -and $runtimeBefore -eq $runtimeAfter) {
|
|
throw 'Source changed under Source/VoxelForge but the staged runtime DLL SHA-256 did not change.'
|
|
}
|
|
if ($BuildRecord.status -eq 'passed' -and $editorChanged -and $editorBefore -eq $editorAfter) {
|
|
throw 'Source changed under Source/VoxelForgeEditor but the staged editor DLL SHA-256 did not change.'
|
|
}
|
|
if ($BuildRecord.status -ne 'passed') {
|
|
throw "Staged UBT build failed; skipping the region-eval commandlet. See $buildLog."
|
|
}
|
|
}
|
|
if ($Assets -eq 'owner') {
|
|
$AssetEvidence = @(Sync-OwnerAssets)
|
|
} else {
|
|
$AssetEvidence = @(Get-AssetEvidence)
|
|
}
|
|
if ($Scenario -eq 'tests') {
|
|
$AssetEvidence += @(Sync-TestCharacterFixture)
|
|
}
|
|
$DllEvidence.runtime = [ordered]@{
|
|
path = Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForge.dll'
|
|
sha256 = Get-Sha256 (Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForge.dll')
|
|
}
|
|
$DllEvidence.editor = [ordered]@{
|
|
path = Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForgeEditor.dll'
|
|
sha256 = Get-Sha256 (Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForgeEditor.dll')
|
|
}
|
|
if (-not $DllEvidence.runtime.sha256 -or -not $DllEvidence.editor.sha256) {
|
|
throw "Staged runtime/editor DLL is missing under $StagePlugin; use -Build."
|
|
}
|
|
|
|
# VoxelForgeExplore deliberately rejects output outside its staged plugin Saved root. The
|
|
# harness result stays in $RunRoot; this is the commandlet's internal artifact directory.
|
|
$commandletOut = Join-Path $StagePlugin "Saved\TestHarness\${Label}_${Scenario}"
|
|
$baseCvars = [ordered]@{}
|
|
$commandletLedger = $null
|
|
$gameLedger = $null
|
|
switch ($Scenario) {
|
|
'canonical' {
|
|
$baseCvars['voxel.OuterClassifierMode'] = 0
|
|
$args = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\canonical.log') (Merge-Cvars $baseCvars)
|
|
$args += New-CommandletArguments $commandletOut 'walk,export,tunnelcoreprobe' $false $false 128
|
|
$commandletLedger = Invoke-UnrealLaunch 'canonical-commandlet' $args `
|
|
(Join-Path $RunRoot 'Logs\canonical.log') (Join-Path $RunRoot 'Logs\canonical.stdout.log') (Join-Path $RunRoot 'Logs\canonical.stderr.log')
|
|
$CommandletMetrics = Get-CommandletMetrics $commandletOut 'synthetic canonical'
|
|
$canonicalObjSha = Get-JsonValue $CommandletMetrics 'obj_sha256'
|
|
if ($canonicalObjSha -and $canonicalObjSha -ne $CanonicalObjSha256) {
|
|
Add-Failure "canonical OBJ SHA-256 changed: expected $CanonicalObjSha256, got $canonicalObjSha"
|
|
}
|
|
}
|
|
'owner' {
|
|
$args = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\owner.log') (Merge-Cvars $baseCvars)
|
|
$args += New-CommandletArguments $commandletOut 'walk,export' ($Assets -eq 'owner') $false 128
|
|
$commandletLedger = Invoke-UnrealLaunch 'owner-commandlet' $args `
|
|
(Join-Path $RunRoot 'Logs\owner.log') (Join-Path $RunRoot 'Logs\owner.stdout.log') (Join-Path $RunRoot 'Logs\owner.stderr.log')
|
|
$CommandletMetrics = Get-CommandletMetrics $commandletOut 'owner assets'
|
|
}
|
|
'probe' {
|
|
$args = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\probe.log') (Merge-Cvars $baseCvars)
|
|
$args += New-CommandletArguments $commandletOut 'tunnelcoreprobe' ($Assets -eq 'owner') $false 128
|
|
$commandletLedger = Invoke-UnrealLaunch 'probe-commandlet' $args `
|
|
(Join-Path $RunRoot 'Logs\probe.log') (Join-Path $RunRoot 'Logs\probe.stdout.log') (Join-Path $RunRoot 'Logs\probe.stderr.log')
|
|
$CommandletMetrics = Get-CommandletMetrics $commandletOut 'owner probe'
|
|
}
|
|
'region-eval' {
|
|
$regionLog = Join-Path $RunRoot 'Logs\region-eval.log'
|
|
$args = New-CommonArguments 'commandlet' $regionLog (Merge-Cvars $baseCvars)
|
|
$args += New-CommandletArguments $commandletOut 'walk,export' ($Assets -eq 'owner') $false 128
|
|
$args += @('-regioneval', '-regionsamples=262144')
|
|
if ($SampleDensityStacks) { $args += "-samplestacks=$StackSampleIntervalUs" }
|
|
if ($RegionHashProbe) {
|
|
if ([string]::IsNullOrWhiteSpace($RegionReferenceHash)) {
|
|
throw '-RegionHashProbe requires -RegionReferenceHash from a reference full-ring run.'
|
|
}
|
|
$args += @(
|
|
'-regionhashprobe',
|
|
"-regionworkers=$RegionWorkers",
|
|
"-regionreferencehash=$RegionReferenceHash"
|
|
)
|
|
}
|
|
$commandletLedger = Invoke-UnrealLaunch 'region-eval-commandlet' $args `
|
|
$regionLog (Join-Path $RunRoot 'Logs\region-eval.stdout.log') `
|
|
(Join-Path $RunRoot 'Logs\region-eval.stderr.log')
|
|
$regionReportPath = Join-Path $commandletOut 'region_eval.json'
|
|
$regionReport = Read-JsonFile $regionReportPath
|
|
if ($null -eq $regionReport) {
|
|
$CommandletMetrics = [ordered]@{
|
|
status = 'missing'
|
|
output = $commandletOut
|
|
report = $regionReportPath
|
|
}
|
|
Add-Failure "region-eval did not produce $regionReportPath"
|
|
} else {
|
|
$CommandletMetrics = [ordered]@{
|
|
status = if ((Get-JsonValue $regionReport 'pass' $false) -and $commandletLedger.status -eq 'passed') { 'correctness_pass' } else { 'failed' }
|
|
output = $commandletOut
|
|
report = $regionReportPath
|
|
speed_pass_600ms = Get-JsonValue $regionReport 'speed_pass_600ms' $false
|
|
result = $regionReport
|
|
}
|
|
if (-not (Get-JsonValue $regionReport 'pass' $false)) {
|
|
Add-Failure 'region-eval correctness, determinism, or deliberate reach-shrink gate failed'
|
|
}
|
|
}
|
|
}
|
|
'symbolize-stacks' {
|
|
if (([string]::IsNullOrWhiteSpace($StackSamplesPath)) -or -not (Test-Path -LiteralPath $StackSamplesPath -PathType Leaf)) {
|
|
throw "-StackSamplesPath must name an existing raw sampler CSV."
|
|
}
|
|
$symbolizedOutput = Join-Path $RunRoot 'VoxelStackResolved.tsv'
|
|
$symbolizeCvars = [ordered]@{
|
|
symbolizestacks = [IO.Path]::GetFullPath($StackSamplesPath)
|
|
symbolizedout = $symbolizedOutput
|
|
}
|
|
$symbolizeLog = Join-Path $RunRoot 'Logs\symbolize-stacks.log'
|
|
$args = New-CommonArguments 'commandlet' $symbolizeLog (Merge-Cvars $symbolizeCvars)
|
|
$commandletLedger = Invoke-UnrealLaunch 'offline-stack-symbolizer' $args `
|
|
$symbolizeLog (Join-Path $RunRoot 'Logs\symbolize-stacks.stdout.log') `
|
|
(Join-Path $RunRoot 'Logs\symbolize-stacks.stderr.log')
|
|
$CommandletMetrics = [ordered]@{
|
|
status = if (($commandletLedger.status -eq 'passed') -and (Test-Path -LiteralPath $symbolizedOutput -PathType Leaf)) { 'ok' } else { 'missing' }
|
|
raw_samples = [IO.Path]::GetFullPath($StackSamplesPath)
|
|
resolved_stacks = $symbolizedOutput
|
|
log = $symbolizeLog
|
|
}
|
|
if ($CommandletMetrics.status -ne 'ok') {
|
|
Add-Failure 'offline stack symbolizer did not produce the resolved stack table'
|
|
}
|
|
}
|
|
'horizon' {
|
|
$tracePath = Join-Path $RunRoot 'horizon_trace.jsonl'
|
|
$baseCvars['voxel.TestRenderDistanceChunks'] = $HorizonRenderDistanceChunks
|
|
$baseCvars['voxel.TestMaxClipLevel'] = $HorizonLevel
|
|
$baseCvars['voxel.StartupTraceFile'] = $tracePath
|
|
$baseCvars['voxel.StartupTraceMaxTiles'] = 16384
|
|
$baseCvars['voxel.TestExitOnSteady'] = 1
|
|
$baseCvars['voxel.OuterClassifierMode'] = 0
|
|
$args = New-CommonArguments 'game' (Join-Path $RunRoot 'Logs\horizon.log') (Merge-Cvars $baseCvars)
|
|
$gameLedger = Invoke-UnrealLaunch "horizon-l$($HorizonLevel)-$($HorizonRenderDistanceChunks)-game" $args `
|
|
(Join-Path $RunRoot 'Logs\horizon.log') (Join-Path $RunRoot 'Logs\horizon.stdout.log') (Join-Path $RunRoot 'Logs\horizon.stderr.log')
|
|
$GameMetrics = Get-StreamingMetrics (Join-Path $RunRoot 'Logs\horizon.log')
|
|
$HorizonMetrics = Get-HorizonMetrics $tracePath $gameLedger $GameMetrics
|
|
if ($HorizonMetrics.status -ne 'ok') {
|
|
Add-Failure "horizon measurement trace did not settle: $($HorizonMetrics.status)"
|
|
}
|
|
}
|
|
'horizon-render' {
|
|
$tracePath = Join-Path $RunRoot 'horizon_render_trace.jsonl'
|
|
$baseCvars['voxel.TestRenderDistanceChunks'] = $HorizonRenderDistanceChunks
|
|
$baseCvars['voxel.TestMaxClipLevel'] = $HorizonLevel
|
|
$baseCvars['voxel.StartupTraceFile'] = $tracePath
|
|
$baseCvars['voxel.StartupTraceMaxTiles'] = 16384
|
|
$baseCvars['voxel.TestCeilingView'] = 1
|
|
$baseCvars['voxel.TestCeilingCaptureDelaySeconds'] = 90
|
|
$baseCvars['voxel.TestExitSeconds'] = 180
|
|
$baseCvars['voxel.OuterClassifierMode'] = 0
|
|
$renderLog = Join-Path $RunRoot 'Logs\horizon-render.log'
|
|
$args = New-CommonArguments 'game' $renderLog (Merge-Cvars $baseCvars) $true
|
|
$gameLedger = Invoke-UnrealLaunch "horizon-l$($HorizonLevel)-$($HorizonRenderDistanceChunks)-render" $args `
|
|
$renderLog (Join-Path $RunRoot 'Logs\horizon-render.stdout.log') (Join-Path $RunRoot 'Logs\horizon-render.stderr.log')
|
|
$GameMetrics = Get-StreamingMetrics $renderLog
|
|
$HorizonMetrics = Get-HorizonMetrics $tracePath $gameLedger $GameMetrics
|
|
$HorizonRenderMetrics = Get-HorizonRenderMetrics $gameLedger $renderLog
|
|
if ($HorizonMetrics.status -ne 'ok') {
|
|
Add-Failure "horizon render trace did not settle: $($HorizonMetrics.status)"
|
|
}
|
|
if ($HorizonRenderMetrics.status -ne 'passed') {
|
|
Add-Failure "horizon render did not produce a capture: $($HorizonRenderMetrics.status)"
|
|
}
|
|
}
|
|
'determinism' {
|
|
$defaultDump = Join-Path $RunRoot 'tile_hashes_default.json'
|
|
$crtFma3OffDump = Join-Path $RunRoot 'tile_hashes_crt_fma3_off.json'
|
|
$defaultTrace = Join-Path $RunRoot 'determinism_default_trace.jsonl'
|
|
$crtFma3OffTrace = Join-Path $RunRoot 'determinism_crt_fma3_off_trace.jsonl'
|
|
$determinismBase = [ordered]@{
|
|
'voxel.TestRenderDistanceChunks' = $HorizonRenderDistanceChunks
|
|
'voxel.TestMaxClipLevel' = $HorizonLevel
|
|
'voxel.TestExitOnSteady' = 1
|
|
'voxel.StartupTraceMaxTiles' = 16384
|
|
'voxel.OuterClassifierMode' = 0
|
|
}
|
|
$defaultCvars = [ordered]@{}
|
|
foreach ($key in $determinismBase.Keys) { $defaultCvars[$key] = $determinismBase[$key] }
|
|
$defaultCvars['voxel.StartupTraceFile'] = $defaultTrace
|
|
$defaultCvars['voxel.TileHashDump'] = $defaultDump
|
|
$defaultLog = Join-Path $RunRoot 'Logs\determinism-default.log'
|
|
$defaultArgs = New-CommonArguments 'game' $defaultLog (Merge-Cvars $defaultCvars)
|
|
$defaultLedger = Invoke-UnrealLaunch "determinism-default-l$($HorizonLevel)-$($HorizonRenderDistanceChunks)" $defaultArgs `
|
|
$defaultLog (Join-Path $RunRoot 'Logs\determinism-default.stdout.log') (Join-Path $RunRoot 'Logs\determinism-default.stderr.log')
|
|
$defaultMetrics = Get-HorizonMetrics $defaultTrace $defaultLedger (Get-StreamingMetrics $defaultLog)
|
|
|
|
$crtFma3OffCvars = [ordered]@{}
|
|
foreach ($key in $determinismBase.Keys) { $crtFma3OffCvars[$key] = $determinismBase[$key] }
|
|
$crtFma3OffCvars['voxel.StartupTraceFile'] = $crtFma3OffTrace
|
|
$crtFma3OffCvars['voxel.TileHashDump'] = $crtFma3OffDump
|
|
$crtFma3OffCvars['voxel.CrtFma3'] = 0
|
|
$crtFma3OffLog = Join-Path $RunRoot 'Logs\determinism-crt-fma3-off.log'
|
|
$crtFma3OffArgs = New-CommonArguments 'game' $crtFma3OffLog (Merge-Cvars $crtFma3OffCvars)
|
|
$crtFma3OffLedger = Invoke-UnrealLaunch "determinism-crt-fma3-off-l$($HorizonLevel)-$($HorizonRenderDistanceChunks)" $crtFma3OffArgs `
|
|
$crtFma3OffLog (Join-Path $RunRoot 'Logs\determinism-crt-fma3-off.stdout.log') (Join-Path $RunRoot 'Logs\determinism-crt-fma3-off.stderr.log')
|
|
$crtFma3OffMetrics = Get-HorizonMetrics $crtFma3OffTrace $crtFma3OffLedger (Get-StreamingMetrics $crtFma3OffLog)
|
|
|
|
$DeterminismEvidence = Compare-DeterminismDumps $defaultDump $crtFma3OffDump
|
|
$DeterminismEvidence.level = $HorizonLevel
|
|
$DeterminismEvidence.render_distance_chunks = $HorizonRenderDistanceChunks
|
|
$DeterminismEvidence.default_measurement = $defaultMetrics
|
|
$DeterminismEvidence.crt_fma3_off_measurement = $crtFma3OffMetrics
|
|
$GameMetrics = $crtFma3OffMetrics
|
|
if ($DeterminismEvidence.status -ne 'passed') {
|
|
Add-Failure "determinism comparison failed: $(Get-JsonValue $DeterminismEvidence 'reason' 'unknown difference')"
|
|
}
|
|
}
|
|
'perf' {
|
|
$baseCvars['voxel.TestExitSeconds'] = 15
|
|
$baseCvars['voxel.ProfileTileGeneration'] = 1
|
|
$baseCvars['voxel.UseBlockEarlyOut'] = 0
|
|
$baseCvars['voxel.OuterClassifierMode'] = 0
|
|
$baseCvars['voxel.SealedSolidProof'] = 0
|
|
$args = New-CommonArguments 'game' (Join-Path $RunRoot 'Logs\perf.log') (Merge-Cvars $baseCvars) $EnableRender.IsPresent
|
|
$gameLedger = Invoke-UnrealLaunch 'perf-game' $args `
|
|
(Join-Path $RunRoot 'Logs\perf.log') (Join-Path $RunRoot 'Logs\perf.stdout.log') (Join-Path $RunRoot 'Logs\perf.stderr.log')
|
|
$GameMetrics = Get-StreamingMetrics (Join-Path $RunRoot 'Logs\perf.log')
|
|
}
|
|
'visual-edit' {
|
|
$baseCvars['voxel.TestVisualStaleness'] = 1
|
|
$baseCvars['voxel.TestModification'] = 1
|
|
$baseCvars['voxel.TestModificationTarget'] = 'loaded-geometry'
|
|
$baseCvars['voxel.TestExitSeconds'] = 45
|
|
$baseCvars['voxel.TestMaxClipLevel'] = 5
|
|
$baseCvars['voxel.OuterClassifierMode'] = 0
|
|
$visualLog = Join-Path $RunRoot 'Logs\visual-edit.log'
|
|
$args = New-CommonArguments 'game' $visualLog (Merge-Cvars $baseCvars)
|
|
$gameLedger = Invoke-UnrealLaunch 'visual-edit-game' $args `
|
|
$visualLog (Join-Path $RunRoot 'Logs\visual-edit.stdout.log') (Join-Path $RunRoot 'Logs\visual-edit.stderr.log')
|
|
$VisualStalenessMetrics = Get-VisualStalenessMetrics $visualLog
|
|
if ($VisualStalenessMetrics.status -ne 'passed') {
|
|
Add-Failure "visual edit repro did not pass: $($VisualStalenessMetrics.status)"
|
|
}
|
|
}
|
|
'visual-lod' {
|
|
$baseCvars['voxel.TestVisualStaleness'] = 2
|
|
$baseCvars['voxel.TestMove'] = 1
|
|
$baseCvars['voxel.TestMoveSpeedCmPerSecond'] = 6000
|
|
$baseCvars['voxel.TestMoveStartSeconds'] = 25
|
|
$baseCvars['voxel.TestMoveDirection'] = '1, 0, 0.15'
|
|
$baseCvars['voxel.TestExitSeconds'] = 45
|
|
$baseCvars['voxel.TestMaxClipLevel'] = 5
|
|
$baseCvars['voxel.OuterClassifierMode'] = 0
|
|
$visualLog = Join-Path $RunRoot 'Logs\visual-lod.log'
|
|
$args = New-CommonArguments 'game' $visualLog (Merge-Cvars $baseCvars)
|
|
$gameLedger = Invoke-UnrealLaunch 'visual-lod-game' $args `
|
|
$visualLog (Join-Path $RunRoot 'Logs\visual-lod.stdout.log') (Join-Path $RunRoot 'Logs\visual-lod.stderr.log')
|
|
$VisualStalenessMetrics = Get-VisualStalenessMetrics $visualLog
|
|
if ($VisualStalenessMetrics.status -ne 'passed') {
|
|
Add-Failure "visual LOD repro did not pass: $($VisualStalenessMetrics.status)"
|
|
}
|
|
}
|
|
'visual-gap-walk' {
|
|
$baseCvars['voxel.TestVisualGapAudit'] = 1
|
|
$baseCvars['voxel.TestVisualStaleness'] = 2
|
|
$baseCvars['voxel.TestMove'] = 1
|
|
$baseCvars['voxel.TestMoveSpeedCmPerSecond'] = 6000
|
|
$baseCvars['voxel.TestMoveStartSeconds'] = 15
|
|
$baseCvars['voxel.TestMoveDirection'] = '1, 0, 0.15'
|
|
$baseCvars['voxel.TestExitSeconds'] = 90
|
|
$baseCvars['voxel.TestMaxClipLevel'] = 6
|
|
$baseCvars['voxel.OuterClassifierMode'] = 0
|
|
$visualLog = Join-Path $RunRoot 'Logs\visual-gap-walk.log'
|
|
$args = New-CommonArguments 'game' $visualLog (Merge-Cvars $baseCvars)
|
|
$gameLedger = Invoke-UnrealLaunch 'visual-gap-walk-game' $args `
|
|
$visualLog (Join-Path $RunRoot 'Logs\visual-gap-walk.stdout.log') (Join-Path $RunRoot 'Logs\visual-gap-walk.stderr.log')
|
|
$VisualGapMetrics = Get-VisualGapMetrics $visualLog
|
|
$VisualStalenessMetrics = Get-VisualStalenessMetrics $visualLog
|
|
if ($VisualGapMetrics.status -ne 'passed') {
|
|
Add-Failure "visual gap walk audit did not pass: $($VisualGapMetrics.status)"
|
|
}
|
|
if ($VisualStalenessMetrics.status -ne 'passed') {
|
|
Add-Failure "visual LOD overlap audit did not pass: $($VisualStalenessMetrics.status)"
|
|
}
|
|
}
|
|
'visual-gap-inject' {
|
|
$baseCvars['voxel.TestVisualGapAudit'] = 1
|
|
$baseCvars['voxel.TestVisualGapInjectEarlyHide'] = 1
|
|
$baseCvars['voxel.TestExitSeconds'] = 45
|
|
$baseCvars['voxel.TestMaxClipLevel'] = 6
|
|
$baseCvars['voxel.OuterClassifierMode'] = 0
|
|
$visualLog = Join-Path $RunRoot 'Logs\visual-gap-inject.log'
|
|
$args = New-CommonArguments 'game' $visualLog (Merge-Cvars $baseCvars)
|
|
$gameLedger = Invoke-UnrealLaunch 'visual-gap-inject-game' $args `
|
|
$visualLog (Join-Path $RunRoot 'Logs\visual-gap-inject.stdout.log') (Join-Path $RunRoot 'Logs\visual-gap-inject.stderr.log')
|
|
$VisualGapMetrics = Get-VisualGapMetrics $visualLog
|
|
if ($VisualGapMetrics.status -ne 'passed' -or $VisualGapMetrics.injection_caught -ne 1) {
|
|
Add-Failure "visual gap audit failed its injected early-hide sensitivity check: $($VisualGapMetrics.status)"
|
|
}
|
|
}
|
|
'dig-motion' {
|
|
$baseCvars['voxel.TestDigMotion'] = 1
|
|
$baseCvars['voxel.TestDigMotionNearDigCount'] = 8
|
|
$baseCvars['voxel.TestDigMotionIntervalSeconds'] = 0.35
|
|
$baseCvars['voxel.TestVisualGapAudit'] = 1
|
|
$baseCvars['voxel.TestMaxClipLevel'] = 6
|
|
$baseCvars['voxel.OuterClassifierMode'] = 0
|
|
$digLog = Join-Path $RunRoot 'Logs\dig-motion.log'
|
|
$args = New-CommonArguments 'game' $digLog (Merge-Cvars $baseCvars)
|
|
$gameLedger = Invoke-UnrealLaunch 'dig-motion-game' $args `
|
|
$digLog (Join-Path $RunRoot 'Logs\dig-motion.stdout.log') (Join-Path $RunRoot 'Logs\dig-motion.stderr.log')
|
|
$DigMotionMetrics = Get-DigMotionMetrics $digLog
|
|
$VisualGapMetrics = Get-VisualGapMetrics $digLog
|
|
if ($DigMotionMetrics.status -ne 'passed') {
|
|
Add-Failure "dig-motion repro did not pass: $($DigMotionMetrics.status)"
|
|
}
|
|
if ($VisualGapMetrics.status -ne 'passed') {
|
|
Add-Failure "dig-session visual gap audit did not pass: $($VisualGapMetrics.status)"
|
|
}
|
|
}
|
|
'surface-fall' {
|
|
$baseCvars['voxel.TestSurfaceFall'] = 1
|
|
$baseCvars['voxel.TestSurfaceFallSpawnHeightVoxels'] = 20
|
|
$baseCvars['voxel.TestSurfaceFallGroundToleranceVoxels'] = 4
|
|
$baseCvars['voxel.TestSurfaceFallTimeoutSeconds'] = 8
|
|
$args = New-CommonArguments 'game' (Join-Path $RunRoot 'Logs\surface-fall.log') (Merge-Cvars $baseCvars)
|
|
$gameLedger = Invoke-UnrealLaunch 'surface-fall-game' $args `
|
|
(Join-Path $RunRoot 'Logs\surface-fall.log') (Join-Path $RunRoot 'Logs\surface-fall.stdout.log') (Join-Path $RunRoot 'Logs\surface-fall.stderr.log')
|
|
$SurfaceFallMetrics = Get-SurfaceFallMetrics (Join-Path $RunRoot 'Logs\surface-fall.log')
|
|
if ($SurfaceFallMetrics.status -ne 'passed') {
|
|
$surfaceReason = [string](Get-JsonValue $SurfaceFallMetrics 'reason' '')
|
|
Add-Failure "surface-fall repro did not pass: $($SurfaceFallMetrics.status) $surfaceReason"
|
|
}
|
|
}
|
|
'crossing' {
|
|
$baseCvars['voxel.TestStrateCrossing'] = 1
|
|
# The authored four-strate route is a real switchback whose vertical drop is hundreds
|
|
# of metres. Use a fixed diagnostic speed and enough wall time for initial collision
|
|
# cooking; the game code still uses swept character movement and the collision gate.
|
|
$baseCvars['voxel.TestStrateCrossingSpeedCmPerSecond'] = 2000
|
|
$baseCvars['voxel.TestStrateCrossingTimeoutSeconds'] = 360
|
|
$baseCvars['voxel.TestStrateCrossingMaxGateSeconds'] = 10
|
|
$args = New-CommonArguments 'game' (Join-Path $RunRoot 'Logs\crossing.log') (Merge-Cvars $baseCvars)
|
|
$gameLedger = Invoke-UnrealLaunch 'strate-crossing-game' $args `
|
|
(Join-Path $RunRoot 'Logs\crossing.log') (Join-Path $RunRoot 'Logs\crossing.stdout.log') (Join-Path $RunRoot 'Logs\crossing.stderr.log')
|
|
$StrateCrossingMetrics = Get-StrateCrossingMetrics (Join-Path $RunRoot 'Logs\crossing.log')
|
|
if ($StrateCrossingMetrics.status -ne 'passed') {
|
|
$crossingReason = [string](Get-JsonValue $StrateCrossingMetrics 'reason' '')
|
|
Add-Failure "strate-crossing test did not pass: $($StrateCrossingMetrics.status) $crossingReason"
|
|
}
|
|
}
|
|
'gate-stress' {
|
|
$baseCvars['voxel.TestCollisionGateStress'] = 1
|
|
$baseCvars['voxel.TestCollisionGateStressSpeedCmPerSecond'] = 6000
|
|
$baseCvars['voxel.TestCollisionGateStressDurationSeconds'] = 2
|
|
$baseCvars['voxel.TestCollisionGateStressTimeoutSeconds'] = 30
|
|
$args = New-CommonArguments 'game' (Join-Path $RunRoot 'Logs\gate-stress.log') (Merge-Cvars $baseCvars)
|
|
$gameLedger = Invoke-UnrealLaunch 'collision-gate-stress-game' $args `
|
|
(Join-Path $RunRoot 'Logs\gate-stress.log') (Join-Path $RunRoot 'Logs\gate-stress.stdout.log') (Join-Path $RunRoot 'Logs\gate-stress.stderr.log')
|
|
$CollisionGateStressMetrics = Get-CollisionGateStressMetrics (Join-Path $RunRoot 'Logs\gate-stress.log')
|
|
if ($CollisionGateStressMetrics.status -ne 'passed') {
|
|
$stressReason = [string](Get-JsonValue $CollisionGateStressMetrics 'reason' '')
|
|
Add-Failure "collision-gate stress repro did not pass: $($CollisionGateStressMetrics.status) $stressReason"
|
|
}
|
|
}
|
|
'tests' {
|
|
$testPath = if ([string]::IsNullOrWhiteSpace($TestFilter)) { 'VoxelForge' } else { $TestFilter }
|
|
$baseCvars['ExecCmds'] = "Automation RunTests $testPath; Quit"
|
|
$baseCvars['ReportExportPath'] = (Join-Path $RunRoot 'AutomationReport')
|
|
$args = New-CommonArguments 'tests' (Join-Path $RunRoot 'Logs\tests.log') (Merge-Cvars $baseCvars)
|
|
# ExecCmds is a command-line value, not a cvar; replace the generated -ExecCmds token
|
|
# with the quoted form accepted by Unreal's parser.
|
|
$args = @($args | Where-Object { $_ -notlike '-ExecCmds=*' -and $_ -notlike '-ReportExportPath=*' })
|
|
$args += ('-ExecCmds="Automation RunTests {0}; Quit"' -f $testPath)
|
|
$args += ('-ReportExportPath="{0}"' -f (Join-Path $RunRoot 'AutomationReport'))
|
|
$gameLedger = Invoke-UnrealLaunch 'automation-tests' $args `
|
|
(Join-Path $RunRoot 'Logs\tests.log') (Join-Path $RunRoot 'Logs\tests.stdout.log') (Join-Path $RunRoot 'Logs\tests.stderr.log')
|
|
$TestMetrics = @(Get-TestMetrics $gameLedger)
|
|
if ($TestMetrics.Count -eq 0 -and $gameLedger.status -eq 'passed') {
|
|
Add-Failure 'automation suite produced no per-test completion records'
|
|
}
|
|
$failedTests = @($TestMetrics | Where-Object { $_.status -eq 'failed' })
|
|
if ($failedTests.Count -gt 0) {
|
|
$failedNames = ($failedTests | ForEach-Object { $_.path }) -join ', '
|
|
Add-Failure "automation tests failed: $failedNames"
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($TestFilter)) {
|
|
# The unfiltered suite includes its collision integration repros. A filtered run is
|
|
# bounded to its named automation tests and does not launch unrelated scenarios.
|
|
$surfaceBaseCvars = [ordered]@{
|
|
'voxel.TestSurfaceFall' = 1
|
|
'voxel.TestSurfaceFallSpawnHeightVoxels' = 20
|
|
'voxel.TestSurfaceFallGroundToleranceVoxels' = 4
|
|
'voxel.TestSurfaceFallTimeoutSeconds' = 8
|
|
}
|
|
$surfaceArgs = New-CommonArguments 'game' (Join-Path $RunRoot 'Logs\surface-fall.log') (Merge-Cvars $surfaceBaseCvars)
|
|
$surfaceFallLedger = Invoke-UnrealLaunch 'surface-fall-game' $surfaceArgs `
|
|
(Join-Path $RunRoot 'Logs\surface-fall.log') (Join-Path $RunRoot 'Logs\surface-fall.stdout.log') (Join-Path $RunRoot 'Logs\surface-fall.stderr.log')
|
|
$SurfaceFallMetrics = Get-SurfaceFallMetrics (Join-Path $RunRoot 'Logs\surface-fall.log')
|
|
if ($SurfaceFallMetrics.status -ne 'passed') {
|
|
$surfaceReason = [string](Get-JsonValue $SurfaceFallMetrics 'reason' '')
|
|
Add-Failure "surface-fall repro did not pass: $($SurfaceFallMetrics.status) $surfaceReason"
|
|
}
|
|
|
|
$stressBaseCvars = [ordered]@{
|
|
'voxel.TestCollisionGateStress' = 1
|
|
'voxel.TestCollisionGateStressSpeedCmPerSecond' = 6000
|
|
'voxel.TestCollisionGateStressDurationSeconds' = 2
|
|
'voxel.TestCollisionGateStressTimeoutSeconds' = 30
|
|
}
|
|
$stressArgs = New-CommonArguments 'game' (Join-Path $RunRoot 'Logs\gate-stress.log') (Merge-Cvars $stressBaseCvars)
|
|
$stressLedger = Invoke-UnrealLaunch 'collision-gate-stress-game' $stressArgs `
|
|
(Join-Path $RunRoot 'Logs\gate-stress.log') (Join-Path $RunRoot 'Logs\gate-stress.stdout.log') (Join-Path $RunRoot 'Logs\gate-stress.stderr.log')
|
|
$CollisionGateStressMetrics = Get-CollisionGateStressMetrics (Join-Path $RunRoot 'Logs\gate-stress.log')
|
|
if ($CollisionGateStressMetrics.status -ne 'passed') {
|
|
$stressReason = [string](Get-JsonValue $CollisionGateStressMetrics 'reason' '')
|
|
Add-Failure "collision-gate stress repro did not pass: $($CollisionGateStressMetrics.status) $stressReason"
|
|
}
|
|
}
|
|
}
|
|
'parity' {
|
|
$parityArgs = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\parity-commandlet.log') (Merge-Cvars $baseCvars)
|
|
# Export one selected region size. The game harness streams a clipmap around its center;
|
|
# comparison is restricted to these exact level-0 tiles, including non-empty geometry.
|
|
$parityArgs += New-CommandletArguments $commandletOut 'export' $true $true `
|
|
$ParityExportSize 0 $ParityExportTile
|
|
$commandletLedger = Invoke-UnrealLaunch 'parity-commandlet' $parityArgs `
|
|
(Join-Path $RunRoot 'Logs\parity-commandlet.log') (Join-Path $RunRoot 'Logs\parity-commandlet.stdout.log') (Join-Path $RunRoot 'Logs\parity-commandlet.stderr.log')
|
|
$CommandletMetrics = Get-CommandletMetrics $commandletOut 'owner assets; game_runtime_config=1'
|
|
$parityJson = Read-JsonFile (Join-Path $commandletOut 'explore.json')
|
|
if ($null -eq $parityJson) { throw 'Parity commandlet did not produce explore.json.' }
|
|
$parityExport = Get-JsonValue $parityJson 'export'
|
|
$region = Get-JsonValue $parityExport 'region_origin_voxels'
|
|
if ($null -eq $region) { throw 'Parity commandlet did not report export.region_origin_voxels.' }
|
|
$paritySize = [int](Get-JsonValue $parityExport 'region_size_cells_per_axis' $ParityExportSize)
|
|
$centerOffset = [int]($paritySize / 2)
|
|
$centerX = [int]$region.x + $centerOffset
|
|
$centerY = [int]$region.y + $centerOffset
|
|
$centerZ = [int]$region.z + $centerOffset
|
|
$gameDump = Join-Path $RunRoot 'game_tile_hashes.json'
|
|
if ($null -eq $Cvars -or -not $Cvars.Contains('voxel.TestExitSeconds')) {
|
|
$baseCvars['voxel.TestExitSeconds'] = 15
|
|
}
|
|
$baseCvars['voxel.StreamingTestCenterX'] = $centerX
|
|
$baseCvars['voxel.StreamingTestCenterY'] = $centerY
|
|
$baseCvars['voxel.StreamingTestCenterZ'] = $centerZ
|
|
$baseCvars['voxel.TileHashDump'] = $gameDump
|
|
$baseCvars['voxel.UseBlockEarlyOut'] = 0
|
|
$baseCvars['voxel.OuterClassifierMode'] = 0
|
|
$baseCvars['voxel.SealedSolidProof'] = 0
|
|
$gameArgs = New-CommonArguments 'game' (Join-Path $RunRoot 'Logs\parity-game.log') `
|
|
(Merge-Cvars $baseCvars) $EnableRender.IsPresent
|
|
$gameLedger = Invoke-UnrealLaunch 'parity-game' $gameArgs `
|
|
(Join-Path $RunRoot 'Logs\parity-game.log') (Join-Path $RunRoot 'Logs\parity-game.stdout.log') (Join-Path $RunRoot 'Logs\parity-game.stderr.log')
|
|
$GameMetrics = Get-StreamingMetrics (Join-Path $RunRoot 'Logs\parity-game.log')
|
|
$ParityEvidence = Compare-Parity $parityJson $gameDump `
|
|
(Join-Path $RunRoot 'Logs\parity-commandlet.log') (Join-Path $RunRoot 'Logs\parity-game.log')
|
|
if ($ParityEvidence.status -ne 'passed') {
|
|
$difference = Get-JsonValue $ParityEvidence 'first_difference' $null
|
|
if ($null -ne $difference) {
|
|
Add-Failure "parity verdict failed: first differing tile $($difference.tile) ($($difference.reason))"
|
|
} else {
|
|
Add-Failure "parity verdict failed: $(Get-JsonValue $ParityEvidence 'reason' 'unknown parity failure')"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
$localAfter = Get-StateSnapshot $LocalAppDataRoot
|
|
$LocalAppDataWrites = @(Get-StateChanges $localBefore $localAfter)
|
|
if ($LocalAppDataWrites.Count -gt 0) {
|
|
Add-Failure "writes detected under ${LocalAppDataRoot}: $($LocalAppDataWrites[0])"
|
|
}
|
|
} catch {
|
|
Add-Failure $_.Exception.Message
|
|
}
|
|
|
|
if ($null -eq $RunRoot) {
|
|
$RunRoot = Join-Path $PluginRoot 'Saved\TestHarness\failed-before-output'
|
|
Ensure-Directory $RunRoot
|
|
}
|
|
if ($null -eq $ResultPath) { $ResultPath = Join-Path $RunRoot 'result.json' }
|
|
if ($null -eq $SummaryPath) { $SummaryPath = Join-Path $RunRoot 'summary.txt' }
|
|
|
|
$pass = $Failures.Count -eq 0
|
|
$reason = if ($pass) { 'all requested checks passed' } else { [string]$Failures[0] }
|
|
$result = [ordered]@{
|
|
schema_version = 1
|
|
pass = $pass
|
|
status = if ($pass) { 'PASS' } else { 'FAIL' }
|
|
reason = $reason
|
|
scenario = $Scenario
|
|
label = $Label
|
|
run_directory = $RunRoot
|
|
ddc_path = $DDCPath
|
|
project = $ProjectFile
|
|
staged_host = $HostProject
|
|
assets_mode = $Assets
|
|
requested_cvars = $Cvars
|
|
canonical_baseline = [ordered]@{
|
|
commit = $CanonicalBaselineCommit
|
|
expected_obj_sha256 = $CanonicalObjSha256
|
|
checked = $Scenario -eq 'canonical'
|
|
}
|
|
assets = @($AssetEvidence)
|
|
build = $BuildRecord
|
|
staged_dlls = $DllEvidence
|
|
capability_counts = if ($CommandletMetrics.Contains('capability_counts')) { $CommandletMetrics.capability_counts } else { [ordered]@{} }
|
|
connectivity = if ($CommandletMetrics.Contains('connectivity')) { $CommandletMetrics.connectivity } else { [ordered]@{} }
|
|
geometry_hash = if ($CommandletMetrics.Contains('geometry_hash')) { $CommandletMetrics.geometry_hash } else { $null }
|
|
obj_sha256 = if ($CommandletMetrics.Contains('obj_sha256')) { $CommandletMetrics.obj_sha256 } else { $null }
|
|
probe_amplitudes = if ($CommandletMetrics.Contains('probe_amplitudes')) { $CommandletMetrics.probe_amplitudes } else { [ordered]@{} }
|
|
commandlet = $CommandletMetrics
|
|
region_eval = if ($Scenario -eq 'region-eval') { $CommandletMetrics } else { $null }
|
|
game = $GameMetrics
|
|
horizon = $HorizonMetrics
|
|
horizon_render = $HorizonRenderMetrics
|
|
determinism = $DeterminismEvidence
|
|
surface_fall = $SurfaceFallMetrics
|
|
strate_crossing = $StrateCrossingMetrics
|
|
visual_staleness = $VisualStalenessMetrics
|
|
visual_gap = $VisualGapMetrics
|
|
dig_motion = $DigMotionMetrics
|
|
collision_gate_stress = $CollisionGateStressMetrics
|
|
worker_seconds = [ordered]@{
|
|
commandlet = if ($CommandletMetrics.Contains('worker_seconds')) { $CommandletMetrics.worker_seconds } else { $null }
|
|
game = if ($GameMetrics.Contains('worker_generation_sum_s')) { $GameMetrics.worker_generation_sum_s } else { $null }
|
|
}
|
|
p50_p95 = if ($GameMetrics.Contains('p50_p95')) { $GameMetrics.p50_p95 } else { [ordered]@{} }
|
|
tests = @($TestMetrics)
|
|
parity = $ParityEvidence
|
|
localappdata_unrealengine = [ordered]@{
|
|
path = $LocalAppDataRoot
|
|
writes_detected = @($LocalAppDataWrites)
|
|
verified_clean = $LocalAppDataWrites.Count -eq 0
|
|
}
|
|
failures = @($Failures)
|
|
launch_ledger = @($LaunchLedger)
|
|
}
|
|
|
|
try {
|
|
Ensure-Directory (Split-Path -Parent $ResultPath)
|
|
($result | ConvertTo-Json -Compress -Depth 30) | Set-Content -LiteralPath $ResultPath -Encoding UTF8
|
|
$summaryLines = [System.Collections.Generic.List[string]]::new()
|
|
[void]$summaryLines.Add("$(if ($pass) { 'PASS' } else { 'FAIL' }) $reason")
|
|
[void]$summaryLines.Add("scenario=$Scenario label=$Label")
|
|
[void]$summaryLines.Add("run_directory=$RunRoot")
|
|
[void]$summaryLines.Add("ddc_path=$DDCPath")
|
|
[void]$summaryLines.Add("assets=$Assets")
|
|
[void]$summaryLines.Add("canonical_baseline=$(ConvertTo-CompactJson $result.canonical_baseline)")
|
|
[void]$summaryLines.Add("capability_counts=$(ConvertTo-CompactJson $result.capability_counts)")
|
|
[void]$summaryLines.Add("connectivity=$(ConvertTo-CompactJson $result.connectivity)")
|
|
[void]$summaryLines.Add("geometry_hash=$($result.geometry_hash)")
|
|
[void]$summaryLines.Add("obj_sha256=$($result.obj_sha256)")
|
|
[void]$summaryLines.Add("region_eval=$(ConvertTo-CompactJson $result.region_eval)")
|
|
[void]$summaryLines.Add("surface_fall=$(ConvertTo-CompactJson $result.surface_fall)")
|
|
[void]$summaryLines.Add("strate_crossing=$(ConvertTo-CompactJson $result.strate_crossing)")
|
|
[void]$summaryLines.Add("visual_staleness=$(ConvertTo-CompactJson $result.visual_staleness)")
|
|
[void]$summaryLines.Add("visual_gap=$(ConvertTo-CompactJson $result.visual_gap)")
|
|
[void]$summaryLines.Add("dig_motion=$(ConvertTo-CompactJson $result.dig_motion)")
|
|
[void]$summaryLines.Add("collision_gate_stress=$(ConvertTo-CompactJson $result.collision_gate_stress)")
|
|
[void]$summaryLines.Add("probe_amplitudes=$(ConvertTo-CompactJson $result.probe_amplitudes)")
|
|
[void]$summaryLines.Add("worker_seconds=$(ConvertTo-CompactJson $result.worker_seconds)")
|
|
[void]$summaryLines.Add("horizon=$(ConvertTo-CompactJson $result.horizon)")
|
|
[void]$summaryLines.Add("horizon_render=$(ConvertTo-CompactJson $result.horizon_render)")
|
|
[void]$summaryLines.Add("determinism=$(ConvertTo-CompactJson $result.determinism)")
|
|
[void]$summaryLines.Add("p50_p95=$(ConvertTo-CompactJson $result.p50_p95)")
|
|
[void]$summaryLines.Add("tests=$(ConvertTo-CompactJson $result.tests)")
|
|
[void]$summaryLines.Add("parity=$(ConvertTo-CompactJson $result.parity)")
|
|
[void]$summaryLines.Add("localappdata_unrealengine_clean=$($result.localappdata_unrealengine.verified_clean)")
|
|
[void]$summaryLines.Add("runtime_dll=$($DllEvidence.runtime.sha256)")
|
|
[void]$summaryLines.Add("editor_dll=$($DllEvidence.editor.sha256)")
|
|
[void]$summaryLines.Add("unreal_launch_count=$($LaunchLedger.Count)")
|
|
foreach ($entry in @($LaunchLedger)) {
|
|
[void]$summaryLines.Add("launch kind=$($entry.kind) pid=$($entry.pid) status=$($entry.status) exit=$($entry.exit_code) timeout=$($entry.timed_out) crashes=$(@($entry.new_crashes).Count)")
|
|
}
|
|
foreach ($failure in @($Failures | Select-Object -Skip 1)) { [void]$summaryLines.Add("failure=$failure") }
|
|
$summaryLines | Select-Object -First 40 | Set-Content -LiteralPath $SummaryPath -Encoding UTF8
|
|
} catch {
|
|
# The primary result path is already in scope. A failed summary write must still make the
|
|
# process non-zero; do not hide the original failure.
|
|
Add-Failure "could not write result files: $($_.Exception.Message)"
|
|
}
|
|
|
|
if ($Failures.Count -gt 0) {
|
|
exit 1
|
|
}
|
|
exit 0
|