Files
VoxelForge/Tools/VoxelForgeTest.ps1
T
Fr0zkaandClaude Opus 5.5 9136f5f0e7 chore(unreal): U2 -- owner's four-generator plan staged; owner_world.json re-exported
Owner re-saved strates 2026-10-26: DA_Strate1 SurfaceWorld, 2 FloatingIslands,
3 TunnelNetwork, 4 VerticalShafts (default params). BuildHost copies and the
enforced SHA-256 tables updated (old U1 set kept in the report); the export
commandlet handles the four-strate plan; Standalone/data/owner_world.json
regenerated (SHA-256 F42F96AD...). One normal BuildHost gate passed (64/64
tiles) but its top-strate window produced 0 triangles, and a missing
NewDataAsset reference warns -- both to check. No measurements this round.
Report: Standalone/Research/WorldAssemblerM3/Unreal2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:18:19 +01:00

2538 lines
136 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', 'standalone-export', 'compile-only', 'step2d', 'step2e', 'step2f', 'step3-scale', 'step2f-capture', 'step3-game')]
[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 = 1536,
[ValidateRange(0, 7)]
[int]$Step2fProfile = 0,
[ValidateSet('inside', 'wall-closeup')]
[string]$Step2fCaptureView = 'inside',
[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
$HostLogicalProcessorLimit = [Math]::Min(12, [Environment]::ProcessorCount)
$HostProcessorAffinityMask = ([int64]1 -shl $HostLogicalProcessorLimit) - 1
# 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]@{}
$Step3ScaleMetrics = [ordered]@{ requested = $Scenario -eq 'step3-scale'; status = 'not_run' }
$Step3GameMetrics = [ordered]@{ requested = $Scenario -eq 'step3-game'; status = 'not_run' }
$GameMetrics = [ordered]@{}
$HorizonMetrics = [ordered]@{
requested = $Scenario -in @('horizon', 'horizon-render', 'step2f-capture')
status = 'not_run'
level = $HorizonLevel
render_distance_chunks = $HorizonRenderDistanceChunks
}
$HorizonRenderMetrics = [ordered]@{ requested = $Scenario -in @('horizon-render', 'step2f-capture'); 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 = @()
$OwnerEditorPidsAtStart = @()
$LocalAppDataChangesDuringOwnerEditor = $false
$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 {
# Only commandlet processes conflict with this serialized BuildHost harness. The owner's
# interactive UnrealEditor stays open and is never inspected for command-line or touched.
$rows = @(Get-Process -ErrorAction SilentlyContinue |
Where-Object { $_.ProcessName -like 'UnrealEditor-Cmd*' } |
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-Cmd process already exists $When; refusing to overlap BuildHost launches: $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);
}
'@
}
if (-not ('VoxelForgeTestSystemTimes' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
using System.Threading;
public static class VoxelForgeTestSystemTimes {
[StructLayout(LayoutKind.Sequential)]
public struct FILETIME { public uint Low; public uint High; }
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetSystemTimes(out FILETIME idle, out FILETIME kernel, out FILETIME user);
private static ulong ToUInt64(FILETIME value) { return ((ulong)value.High << 32) | value.Low; }
public static double ReadBusyPercent(int intervalMilliseconds) {
FILETIME idle0, kernel0, user0, idle1, kernel1, user1;
if (!GetSystemTimes(out idle0, out kernel0, out user0)) throw new InvalidOperationException("GetSystemTimes failed before sample.");
Thread.Sleep(intervalMilliseconds);
if (!GetSystemTimes(out idle1, out kernel1, out user1)) throw new InvalidOperationException("GetSystemTimes failed after sample.");
ulong idle = ToUInt64(idle1) - ToUInt64(idle0);
ulong kernel = ToUInt64(kernel1) - ToUInt64(kernel0);
ulong user = ToUInt64(user1) - ToUInt64(user0);
ulong total = kernel + user;
if (total == 0) return 100.0;
ulong busy = total > idle ? total - idle : 0;
return 100.0 * busy / total;
}
}
'@
}
function Wait-ForIdleMachine([string]$When) {
$deadline = [datetime]::UtcNow.AddMinutes(10)
$consecutiveIdleSamples = 0
$lastBusyPercent = 100.0
while ([datetime]::UtcNow -lt $deadline) {
Assert-NoForeignUnreal "$When idle check"
try { $lastBusyPercent = [VoxelForgeTestSystemTimes]::ReadBusyPercent(3000) }
catch { throw "Could not measure system CPU use before $When; refusing to time this run: $($_.Exception.Message)" }
if ($lastBusyPercent -le 15.0) {
++$consecutiveIdleSamples
if ($consecutiveIdleSamples -ge 2) {
Write-Host ("Machine idle before {0}: two 3-second system samples, latest busy={1:N1}%." -f $When, $lastBusyPercent)
return $lastBusyPercent
}
} else {
$consecutiveIdleSamples = 0
Write-Host ("Waiting for idle machine before {0}: 3-second system busy={1:N1}% (limit 15%)." -f $When, $lastBusyPercent)
}
}
throw ("Machine did not stay under 15% CPU busy for two 3-second samples before {0}; last={1:N1}%." -f $When, $lastBusyPercent)
}
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, [switch]$ShowWindow) {
Assert-NoForeignUnreal "before $Kind launch"
$idleCpuBusyPercent = Wait-ForIdleMachine "$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
prelaunch_cpu_busy_percent = [Math]::Round($idleCpuBusyPercent, 2)
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
$windowStyle = if ($ShowWindow) { 'Normal' } else { 'Hidden' }
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 $windowStyle `
-PassThru
try { $process.PriorityClass = [Diagnostics.ProcessPriorityClass]::Normal }
catch {
try { Stop-Process -Id ([int]$process.Id) -Force -ErrorAction Stop } catch { }
throw "Could not set owned Unreal PID $($process.Id) to Normal priority: $($_.Exception.Message)"
}
try { $process.ProcessorAffinity = [IntPtr]$HostProcessorAffinityMask }
catch {
try { Stop-Process -Id ([int]$process.Id) -Force -ErrorAction Stop } catch { }
throw "Could not cap owned Unreal PID $($process.Id) to $HostLogicalProcessorLimit logical processors: $($_.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' }
if ($Kind -like '*commandlet*') {
$completion = @(Select-String -LiteralPath $LogPath `
-Pattern 'Commandlet VoxelForgeExploreCommandlet_\d+ finished execution \(result (-?\d+)\)' `
-ErrorAction SilentlyContinue | Select-Object -Last 1)
if ($completion.Count -eq 0) {
$ledger.commandlet_result_code = $null
$ledger.commandlet_completion = 'missing'
$ledger.status = 'failed'
} else {
$match = [regex]::Match([string]$completion[0].Line,
'Commandlet VoxelForgeExploreCommandlet_\d+ finished execution \(result (-?\d+)\)')
$ledger.commandlet_result_code = [int]$match.Groups[1].Value
$ledger.commandlet_completion = 'observed'
if ($ledger.commandlet_result_code -ne 0) { $ledger.status = '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]::Normal }
catch {
try { Stop-Process -Id ([int]$process.Id) -Force -ErrorAction Stop } catch { }
throw "Could not set owned build PID $($process.Id) to Normal priority: $($_.Exception.Message)"
}
try { $process.ProcessorAffinity = [IntPtr]$HostProcessorAffinityMask }
catch {
try { Stop-Process -Id ([int]$process.Id) -Force -ErrorAction Stop } catch { }
throw "Could not cap owned build PID $($process.Id) to $HostLogicalProcessorLimit logical processors: $($_.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) {
if (-not (Test-Path -LiteralPath $Source -PathType Container)) {
throw "Directory copy source is missing: $Source"
}
Ensure-Directory $Destination
foreach ($item in @(Get-ChildItem -LiteralPath $Source -Force)) {
$target = Join-Path $Destination $item.Name
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Refusing to stage a linked directory entry: $($item.FullName)"
}
if ($item.PSIsContainer) {
Ensure-Directory $target
Copy-DirectoryContents $item.FullName $target
} else {
$null = Copy-Item -LiteralPath $item.FullName -Destination $target -Force -PassThru
}
}
}
function Sync-StagedCoreSources {
# UBT reads these canonical files through the plugin module's include roots. Keep the host
# copy source-only; Core verification logs and standalone binaries are not part of the module.
$stageStandalone = Join-Path $StagePlugin 'Standalone'
$sourceStandalone = Join-Path $PluginRoot 'Standalone'
Ensure-Directory $stageStandalone
foreach ($relative in @('Core\include', 'Core\src', 'Compat', 'Source')) {
Copy-DirectoryContents (Join-Path $sourceStandalone $relative) `
(Join-Path $stageStandalone $relative)
}
$null = Copy-Item -LiteralPath (Join-Path $sourceStandalone 'Core\core_sources.txt') `
-Destination (Join-Path $stageStandalone 'Core\core_sources.txt') -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
Sync-StagedCoreSources
$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', 'standalone-export', 'step2d', 'step2e', 'step2f', 'step3-scale', 'step2f-capture', 'step3-game')
source_path = $source
source_sha256 = Get-Sha256 $source
staged_path = $target
staged_sha256 = Get-Sha256 $target
})
}
return @($rows)
}
function Sync-Step2fCaptureMaterialDependencies {
# The authored terrain materials depend on packages outside the plugin. Copy their exact
# source packages into the isolated host so the in-game capture uses the owner's material
# graph instead of Unreal's fallback material. Source assets remain untouched.
$rows = [System.Collections.Generic.List[object]]::new()
$relativePackages = @(
'Magic_Forest\Landscape_Material\Rock_Cliffs\T_Rock_C',
'Magic_Forest\Mesh\Rock\Textures\T_Rocks_N',
'VoxelForge\MF_LayerWeight',
'VoxelForge\MF_Triplanar',
'VoxelForge\MF_VoxelOrbLighting',
'LUSH_StylizedEnvironmentSet\Foliage\Grass\LG_Grass',
'LUSH_StylizedEnvironmentSet\Landscape\Textures\T_BeachSand_N',
'UltraDynamicSky\Textures\3D_Clouds\3DCells_128'
)
foreach ($relativePackage in $relativePackages) {
$sourceBase = Join-Path (Join-Path $ProjectRoot 'Content') $relativePackage
$targetBase = Join-Path (Join-Path $HostRoot 'Content') $relativePackage
$sourceAsset = "$sourceBase.uasset"
if (-not (Test-Path -LiteralPath $sourceAsset -PathType Leaf)) {
throw "Step2f capture material dependency is missing: $sourceAsset"
}
Ensure-Directory (Split-Path -Parent $targetBase)
$copied = [System.Collections.Generic.List[string]]::new()
foreach ($extension in @('.uasset', '.uexp', '.ubulk', '.uptnl')) {
$source = "$sourceBase$extension"
if (Test-Path -LiteralPath $source -PathType Leaf) {
$target = "$targetBase$extension"
$null = Copy-Item -LiteralPath $source -Destination $target -Force -PassThru
[void]$copied.Add($target)
}
}
foreach ($target in $copied) {
$relativeTarget = $target.Substring((Join-Path $HostRoot 'Content').Length).TrimStart('\')
[void]$rows.Add([ordered]@{
name = "Step2fCapture/$relativeTarget"
used_by_scenario = $true
source_path = (Join-Path (Join-Path $ProjectRoot 'Content') $relativeTarget)
source_sha256 = Get-Sha256 (Join-Path (Join-Path $ProjectRoot 'Content') $relativeTarget)
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', 'standalone-export', 'step2d', 'step2e', 'step2f', 'step3-scale', 'step2f-capture', 'step3-game')
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('-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("-corelimit=$HostLogicalProcessorLimit")
[void]$args.Add('-nocrashreports')
# The editor's default usage-data setting creates a process summary under the
# machine-wide Epic analytics directory, outside this run's isolated output.
# Keep harness launches self-contained and let the external-state guard verify it.
[void]$args.Add('-ini:EditorSettings:/Script/UnrealEd.AnalyticsPrivacySettings:bSendUsageData=false')
[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 Get-Step3GameMetrics([string]$LogPath, [string]$TracePath, $Ledger, $RenderMetrics) {
$metrics = [ordered]@{
status = 'missing'
log = $LogPath
startup_trace = $TracePath
l0_tile_count = 0
tile_generation_seconds_p50 = $null
tile_generation_seconds_p95 = $null
tile_generation_seconds_max = $null
full_detail_ring_fill_seconds = $null
full_detail_ring_lod0_tiles = $null
moving_frames = $null
moving_window_seconds = $null
moving_frame_ms_p50 = $null
moving_frame_ms_p95 = $null
moving_frame_ms_p99 = $null
moving_frame_ms_max = $null
brick_evaluator_mesher_route_seen = $false
profile_switch_applied = $false
profile_switch_rebuilt_loaded_tiles = $false
profile_switch_cleared_tile_count = 0
captures = @()
}
if (Test-Path -LiteralPath $LogPath -PathType Leaf) {
$metrics.brick_evaluator_mesher_route_seen = @(
Select-String -LiteralPath $LogPath `
-Pattern '\[VoxelForgeResearchBrickEvaluator\] mesher_grid=1' `
-ErrorAction SilentlyContinue).Count -gt 0
$tileSeconds = [System.Collections.Generic.List[double]]::new()
foreach ($match in @(Select-String -LiteralPath $LogPath `
-Pattern '\[VoxelForgeTileProfile\].*level=0\b.*aborted=0' `
-ErrorAction SilentlyContinue)) {
$seconds = [regex]::Match([string]$match.Line, 'seconds=(?<v>[-+0-9.eE]+)')
if ($seconds.Success) {
$value = [double]$seconds.Groups['v'].Value
if ($value -eq $value -and -not [double]::IsInfinity($value) -and $value -ge 0.0) {
$tileSeconds.Add($value)
}
}
}
$tileSeconds.Sort()
$metrics.l0_tile_count = $tileSeconds.Count
if ($tileSeconds.Count -gt 0) {
$metrics.tile_generation_seconds_p50 = $tileSeconds[[Math]::Max(0, [Math]::Ceiling(0.50 * $tileSeconds.Count) - 1)]
$metrics.tile_generation_seconds_p95 = $tileSeconds[[Math]::Max(0, [Math]::Ceiling(0.95 * $tileSeconds.Count) - 1)]
$metrics.tile_generation_seconds_max = $tileSeconds[$tileSeconds.Count - 1]
}
$perfLine = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeResearchGamePerf\]' -ErrorAction SilentlyContinue | Select-Object -Last 1)
if ($perfLine.Count -gt 0) {
$line = [string]$perfLine[0].Line
foreach ($key in @('moving_frames', 'moving_window_s', 'p50_ms', 'p95_ms', 'p99_ms', 'max_ms')) {
$value = [regex]::Match($line, ('{0}=(?<v>[-+0-9.eE]+)' -f [regex]::Escape($key)))
if ($value.Success) {
$target = switch ($key) {
'moving_frames' { 'moving_frames' }
'moving_window_s' { 'moving_window_seconds' }
'p50_ms' { 'moving_frame_ms_p50' }
'p95_ms' { 'moving_frame_ms_p95' }
'p99_ms' { 'moving_frame_ms_p99' }
'max_ms' { 'moving_frame_ms_max' }
}
$metrics[$target] = [double]$value.Groups['v'].Value
}
}
}
$switchLine = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeResearchProfileSwitch\] applied=1' -ErrorAction SilentlyContinue | Select-Object -Last 1)
$rebuildLine = @(Select-String -LiteralPath $LogPath -Pattern 'research_field_selection_changed.*rebuilding loaded world' -ErrorAction SilentlyContinue | Select-Object -Last 1)
if ($switchLine.Count -gt 0) { $metrics.profile_switch_applied = $true }
if ($rebuildLine.Count -gt 0) {
$metrics.profile_switch_rebuilt_loaded_tiles = [regex]::IsMatch(
[string]$switchLine[0].Line, 'loaded_tiles=(?<n>[1-9][0-9]*)')
$regenerate = @(Select-String -LiteralPath $LogPath `
-Pattern '\[VoxelWorld\] RegenerateAllChunks .*cleared (?<n>[0-9]+) tiles' `
-ErrorAction SilentlyContinue |
Where-Object { $_.LineNumber -gt $rebuildLine[0].LineNumber } |
Select-Object -First 1)
if ($regenerate.Count -gt 0) {
$cleared = [regex]::Match([string]$regenerate[0].Line, 'cleared (?<n>[0-9]+) tiles')
if ($cleared.Success) {
$metrics.profile_switch_cleared_tile_count = [int]$cleared.Groups['n'].Value
$metrics.profile_switch_rebuilt_loaded_tiles =
$metrics.profile_switch_rebuilt_loaded_tiles -and
$metrics.profile_switch_cleared_tile_count -gt 0
}
} else {
$metrics.profile_switch_rebuilt_loaded_tiles = $false
}
}
$captureLines = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeTestCeiling\] capture_index=' -ErrorAction SilentlyContinue)
foreach ($captureLine in $captureLines) {
$index = [regex]::Match([string]$captureLine.Line, 'capture_index=(?<v>[12])')
$accepted = [regex]::Match([string]$captureLine.Line, 'capture_requested=(?<v>[01])')
if ($index.Success) {
$metrics.captures += [ordered]@{
index = [int]$index.Groups['v'].Value
requested = $accepted.Success -and $accepted.Groups['v'].Value -eq '1'
line = [string]$captureLine.Line
}
}
}
}
if (Test-Path -LiteralPath $TracePath -PathType Leaf) {
$ringEvents = [System.Collections.Generic.List[object]]::new()
foreach ($line in @(Get-Content -LiteralPath $TracePath -ErrorAction SilentlyContinue)) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
try {
$event = $line | ConvertFrom-Json -ErrorAction Stop
if ([string](Get-JsonValue $event 'type' '') -eq 'lod0_ring_ready') {
$ringEvents.Add($event)
}
} catch { }
}
if ($ringEvents.Count -gt 0) {
$metrics.full_detail_ring_fill_seconds = [double](Get-JsonValue $ringEvents[0] 'duration_s' 0.0)
$metrics.full_detail_ring_lod0_tiles = [int](Get-JsonValue $ringEvents[0] 'lod0_tiles' 0)
}
}
$metrics.render = $RenderMetrics
$metrics.streaming = Get-StreamingMetrics $LogPath
$metrics.launch = if ($null -ne $Ledger) {
[ordered]@{
status = Get-JsonValue $Ledger 'status' 'missing'
peak_private_bytes = Get-JsonValue $Ledger 'peak_private_bytes' 0
pid = Get-JsonValue $Ledger 'pid' $null
}
} else { $null }
$step3Passed = ($metrics.l0_tile_count -gt 0 -and
$null -ne $metrics.full_detail_ring_fill_seconds -and
$metrics.moving_frames -gt 0 -and
$metrics.brick_evaluator_mesher_route_seen -and
$metrics.profile_switch_applied -and
$metrics.profile_switch_rebuilt_loaded_tiles -and
$metrics.profile_switch_cleared_tile_count -gt 0 -and
@($metrics.captures | Where-Object { $_.requested }).Count -ge 2 -and
$null -ne $Ledger -and (Get-JsonValue $Ledger 'status' 'failed') -eq 'passed')
$metrics.status = if ($step3Passed) {
'passed'
} else { 'failed' }
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',
'full_stop_probe_applied', 'full_stop_probe_observed', '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
$OwnerEditorPidsAtStart = @(Get-Process -Name UnrealEditor -ErrorAction SilentlyContinue |
ForEach-Object { [int]$_.Id })
$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 -in @('step2f-capture', 'horizon-render')) {
$AssetEvidence += @(Sync-Step2fCaptureMaterialDependencies)
}
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) {
'compile-only' { }
'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)
if ($Assets -eq 'owner') {
# The top slot has no arrival passage, so exercise generation/export on the current
# four-asset owner plan instead of the interior-only passage walk.
$args += New-CommandletArguments $commandletOut 'export' $false $false 128 0
$args += @('-settingsref=/Game/VoxelForge/DA_Settings.DA_Settings', '-ownerfourstrateplan=1')
} else {
$args += New-CommandletArguments $commandletOut 'walk,export' $false $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'
if ($CommandletMetrics.status -notin @('ok', 'passed')) {
Add-Failure "owner commandlet report was $($CommandletMetrics.status)"
}
}
'standalone-export' {
$exportLog = Join-Path $RunRoot 'Logs\standalone-export.log'
$args = New-CommonArguments 'commandlet' $exportLog (Merge-Cvars $baseCvars)
$args += @(
'-seed=0', '-archetype=TunnelNetwork', '-slot=0', '-modes=export',
'-blockearlyout=0', '-gameconfig=1', '-standaloneworldexport=1',
'-settingsref=/Game/VoxelForge/DA_Settings.DA_Settings',
'-ownerfourstrateplan=1', '-skipstandalonecoreadapterparity=1',
('-out="{0}"' -f $commandletOut)
)
$commandletLedger = Invoke-UnrealLaunch 'standalone-export-commandlet' $args `
$exportLog (Join-Path $RunRoot 'Logs\standalone-export.stdout.log') `
(Join-Path $RunRoot 'Logs\standalone-export.stderr.log')
$ownerWorldPath = Join-Path $commandletOut 'owner_world.json'
if ($commandletLedger.status -eq 'passed' -and (Test-Path -LiteralPath $ownerWorldPath -PathType Leaf)) {
$standaloneData = Join-Path $PluginRoot 'Standalone\data'
Ensure-Directory $standaloneData
Copy-Item -LiteralPath $ownerWorldPath -Destination (Join-Path $standaloneData 'owner_world.json') -Force
$exported = Read-JsonFile $ownerWorldPath
$CommandletMetrics = [ordered]@{
status = 'exported'
output = $ownerWorldPath
standalone_data = Join-Path $standaloneData 'owner_world.json'
seed = Get-JsonValue $exported 'seed'
voxel_size_cm = Get-JsonValue $exported 'voxel_size_cm'
strate_count = @((Get-JsonValue $exported 'strates')).Count
passage_count = @((Get-JsonValue $exported 'passages')).Count
density_sample_count = @((Get-JsonValue $exported 'density_samples')).Count
viewer_start = Get-JsonValue $exported 'viewer_start'
}
} else {
Add-Failure "standalone exporter did not produce $ownerWorldPath"
$CommandletMetrics = [ordered]@{ status = 'missing'; output = $ownerWorldPath }
}
}
'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'
}
}
}
'step2d' {
$step2dLog = Join-Path $RunRoot 'Logs\step2d.log'
$args = New-CommonArguments 'commandlet' $step2dLog (Merge-Cvars $baseCvars)
$args += New-CommandletArguments $commandletOut 'walk,export' ($Assets -eq 'owner') $false 128
$researchOut = Join-Path $PluginRoot "Saved\Research\step2d\$Label"
$args += @('-step2d', '-regionworkers=6', ('-researchout="{0}"' -f $researchOut))
$commandletLedger = Invoke-UnrealLaunch 'step2d-commandlet' $args `
$step2dLog (Join-Path $RunRoot 'Logs\step2d.stdout.log') `
(Join-Path $RunRoot 'Logs\step2d.stderr.log')
$step2dReportPath = Join-Path $researchOut 'step2d_report.json'
$step2dReport = Read-JsonFile $step2dReportPath
if ($null -eq $step2dReport) {
$CommandletMetrics = [ordered]@{ status = 'missing'; output = $researchOut; report = $step2dReportPath }
Add-Failure "step2d did not produce $step2dReportPath"
} else {
$CommandletMetrics = [ordered]@{
status = if ((Get-JsonValue $step2dReport 'pass' $false) -and $commandletLedger.status -eq 'passed') { 'passed' } else { 'failed' }
output = $researchOut
report = $step2dReportPath
result = $step2dReport
}
if (-not (Get-JsonValue $step2dReport 'pass' $false)) {
Add-Failure 'step2d speed, detail, boundary, or determinism gate failed'
}
}
}
'step2e' {
$step2eLog = Join-Path $RunRoot 'Logs\step2e.log'
$args = New-CommonArguments 'commandlet' $step2eLog (Merge-Cvars $baseCvars)
$args += New-CommandletArguments $commandletOut 'walk,export' ($Assets -eq 'owner') $false 128
$researchOut = Join-Path $PluginRoot "Saved\Research\step2e\$Label"
$args += @('-step2e', '-regionworkers=6', ('-researchout="{0}"' -f $researchOut))
$commandletLedger = Invoke-UnrealLaunch 'step2e-commandlet' $args `
$step2eLog (Join-Path $RunRoot 'Logs\step2e.stdout.log') `
(Join-Path $RunRoot 'Logs\step2e.stderr.log')
$step2eReportPath = Join-Path $researchOut 'step2e_report.json'
$step2eReport = Read-JsonFile $step2eReportPath
if ($null -eq $step2eReport) {
$CommandletMetrics = [ordered]@{ status = 'missing'; output = $researchOut; report = $step2eReportPath }
Add-Failure "step2e did not produce $step2eReportPath"
} else {
$CommandletMetrics = [ordered]@{
status = if ((Get-JsonValue $step2eReport 'pass' $false) -and $commandletLedger.status -eq 'passed') { 'passed' } else { 'failed' }
output = $researchOut
report = $step2eReportPath
worst_profile_meets_600ms = Get-JsonValue $step2eReport 'worst_profile_meets_600ms' $false
result = $step2eReport
}
if (-not (Get-JsonValue $step2eReport 'pass' $false)) {
Add-Failure 'step2e profile speed, detail, boundary, bound-audit, or determinism gate failed'
}
}
}
'step2f' {
$step2fLog = Join-Path $RunRoot 'Logs\step2f.log'
$args = New-CommonArguments 'commandlet' $step2fLog (Merge-Cvars $baseCvars)
$args += New-CommandletArguments $commandletOut 'walk,export' ($Assets -eq 'owner') $false 128
$researchOut = Join-Path $PluginRoot "Saved\Research\step2f\$Label"
$args += @('-step2f', '-regionworkers=6', ('-researchout="{0}"' -f $researchOut))
$commandletLedger = Invoke-UnrealLaunch 'step2f-commandlet' $args `
$step2fLog (Join-Path $RunRoot 'Logs\step2f.stdout.log') `
(Join-Path $RunRoot 'Logs\step2f.stderr.log')
$step2fReportPath = Join-Path $researchOut 'step2f_report.json'
$step2fReport = Read-JsonFile $step2fReportPath
if ($null -eq $step2fReport) {
$CommandletMetrics = [ordered]@{ status = 'missing'; output = $researchOut; report = $step2fReportPath }
Add-Failure "step2f did not produce $step2fReportPath"
} else {
$CommandletMetrics = [ordered]@{
status = if ((Get-JsonValue $step2fReport 'pass' $false) -and $commandletLedger.status -eq 'passed') { 'passed' } else { 'failed' }
output = $researchOut
report = $step2fReportPath
worst_profile_meets_600ms = Get-JsonValue $step2fReport 'worst_profile_meets_600ms' $false
result = $step2fReport
}
if (-not (Get-JsonValue $step2fReport 'pass' $false)) {
Add-Failure 'step2f geometry gates, 2e rejection audit, speed, sound proofs, seams, or determinism failed'
}
}
}
'step3-scale' {
$scaleLog = Join-Path $RunRoot 'Logs\step3-scale.log'
$args = New-CommonArguments 'commandlet' $scaleLog (Merge-Cvars $baseCvars)
$args += New-CommandletArguments $commandletOut 'walk,export' ($Assets -eq 'owner') $false 128
$researchOut = Join-Path $PluginRoot "Saved\Research\step3\scale\$Label"
$args += @('-step3scale', '-regionworkers=6', '-profile=0', ('-researchout="{0}"' -f $researchOut))
$commandletLedger = Invoke-UnrealLaunch 'step3-scale-commandlet' $args `
$scaleLog (Join-Path $RunRoot 'Logs\step3-scale.stdout.log') `
(Join-Path $RunRoot 'Logs\step3-scale.stderr.log')
$scaleReportPath = Join-Path $researchOut 'step3_scale_benchmark.json'
$scaleReport = Read-JsonFile $scaleReportPath
if ($null -eq $scaleReport) {
$Step3ScaleMetrics = [ordered]@{ requested = $true; status = 'missing'; output = $researchOut; report = $scaleReportPath }
Add-Failure "step3 scale benchmark did not produce $scaleReportPath"
} else {
$Step3ScaleMetrics = [ordered]@{
requested = $true
status = if ((Get-JsonValue $scaleReport 'pass' $false) -and $commandletLedger.status -eq 'passed') { 'passed' } else { 'failed' }
output = $researchOut
report = $scaleReportPath
result = $scaleReport
}
if (-not (Get-JsonValue $scaleReport 'pass' $false)) {
Add-Failure 'step3 scale sample, determinism, or six-worker 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)"
}
}
'step2f-capture' {
$baseCvars['voxel.TestRenderDistanceChunks'] = $HorizonRenderDistanceChunks
$baseCvars['voxel.TestMaxClipLevel'] = $HorizonLevel
$baseCvars['voxel.TestCeilingCameraX'] = 0
$baseCvars['voxel.TestCeilingCameraY'] = 0
$baseCvars['voxel.TestCeilingCameraZ'] = -8800
$baseCvars['voxel.TestCeilingCameraPitch'] = 0
$baseCvars['voxel.TestCeilingCameraYaw'] = 0
$baseCvars['voxel.TestCeilingWallCloseup'] = if ($Step2fCaptureView -eq 'wall-closeup') { 1 } else { 0 }
$baseCvars['voxel.TestCeilingView'] = 1
$baseCvars['voxel.TestCeilingCaptureDelaySeconds'] = 60
$baseCvars['voxel.TestExitSeconds'] = 120
$baseCvars['voxel.OuterClassifierMode'] = 0
$baseCvars['voxel.ResearchParameterizedCave'] = 1
$baseCvars['voxel.ResearchParameterizedCaveSet'] = $Step2fProfile
$renderLog = Join-Path $RunRoot 'Logs\step2f-capture.log'
$args = New-CommonArguments 'game' $renderLog (Merge-Cvars $baseCvars) $true
$gameLedger = Invoke-UnrealLaunch "step2f-profile-$Step2fProfile-$Step2fCaptureView" $args `
$renderLog (Join-Path $RunRoot 'Logs\step2f-capture.stdout.log') `
(Join-Path $RunRoot 'Logs\step2f-capture.stderr.log')
$GameMetrics = Get-StreamingMetrics $renderLog
$HorizonMetrics = [ordered]@{ requested = $false; status = 'not_requested_for_capture' }
$HorizonRenderMetrics = Get-HorizonRenderMetrics $gameLedger $renderLog
if ($HorizonRenderMetrics.status -ne 'passed') {
Add-Failure "step2f profile $Step2fProfile in-game capture failed: $($HorizonRenderMetrics.status)"
} else {
$profileNames = @('OpenVaults', 'TightNetwork', 'ChamberHalls', 'ShaftForest', 'FloatingArchipelago', 'PillaredCathedral', 'TerracedBasin', 'HyperComplex')
$profileName = $profileNames[$Step2fProfile]
$captureDirectory = Join-Path $PluginRoot 'Saved\Research\step2f\captures'
Ensure-Directory $captureDirectory
$captureName = '{0}_{1}.png' -f $profileName, $Step2fCaptureView
$capturePath = Join-Path $captureDirectory $captureName
$image = @($HorizonRenderMetrics.images | Sort-Object modified_utc | Select-Object -Last 1)
if ($image.Count -eq 0 -or -not (Test-Path -LiteralPath $image[0].path -PathType Leaf)) {
Add-Failure "step2f profile $Step2fProfile image path was missing after capture"
} else {
Copy-Item -LiteralPath $image[0].path -Destination $capturePath -Force
$HorizonRenderMetrics.saved_capture = $capturePath
}
}
}
'step3-game' {
$tracePath = Join-Path $RunRoot 'step3_game_trace.jsonl'
$gameLog = Join-Path $RunRoot 'Logs\step3-game.log'
$baseCvars['voxel.TestRenderDistanceChunks'] = $HorizonRenderDistanceChunks
$baseCvars['voxel.TestClipRadius'] = 6
$baseCvars['voxel.TestMaxClipLevel'] = $HorizonLevel
$baseCvars['voxel.StartupTraceFile'] = $tracePath
$baseCvars['voxel.StartupTraceMaxTiles'] = 16384
$baseCvars['voxel.OuterClassifierMode'] = 0
$baseCvars['voxel.ResearchParameterizedCave'] = 1
$baseCvars['voxel.ResearchParameterizedCaveSet'] = 0
$baseCvars['voxel.ProfileTileGeneration'] = 1
$baseCvars['voxel.ResearchGamePerf'] = 1
$baseCvars['voxel.TestCeilingCameraX'] = 0
$baseCvars['voxel.TestCeilingCameraY'] = 0
$baseCvars['voxel.TestCeilingCameraZ'] = -8800
$baseCvars['voxel.TestCeilingCameraPitch'] = 0
$baseCvars['voxel.TestCeilingCameraYaw'] = 0
$baseCvars['voxel.TestCeilingView'] = 1
$baseCvars['voxel.TestCeilingCaptureDelaySeconds'] = 25
$baseCvars['voxel.TestCeilingSecondCaptureDelaySeconds'] = 70
$baseCvars['voxel.TestCeilingCameraMoveStartSeconds'] = 35
$baseCvars['voxel.TestCeilingCameraMoveDurationSeconds'] = 25
$baseCvars['voxel.TestCeilingCameraMoveSpeedCmPerSecond'] = 600
$baseCvars['voxel.TestCeilingCameraMoveDirection'] = '1,0,0'
$baseCvars['voxel.TestResearchProfileSwitchAfterSeconds'] = 30
$baseCvars['voxel.TestResearchProfileSwitchTo'] = 1
$baseCvars['voxel.TestExitSeconds'] = 85
$args = New-CommonArguments 'game' $gameLog (Merge-Cvars $baseCvars) $true
# Keep a real RHI game viewport for the two lit screenshots. The offscreen
# viewport accepted high-res requests but only wrote its blank startup frame.
$args = @($args | Where-Object { $_ -ne '-RenderOffscreen' })
$args += '-windowed'
$gameLedger = Invoke-UnrealLaunch 'step3-game-brick-evaluator' $args `
$gameLog (Join-Path $RunRoot 'Logs\step3-game.stdout.log') `
(Join-Path $RunRoot 'Logs\step3-game.stderr.log') -ShowWindow
$GameMetrics = Get-StreamingMetrics $gameLog
$HorizonMetrics = [ordered]@{ requested = $false; status = 'ring_and_tile_metrics_are_in_step3_game' }
$HorizonRenderMetrics = Get-HorizonRenderMetrics $gameLedger $gameLog
$Step3GameMetrics = Get-Step3GameMetrics $gameLog $tracePath $gameLedger $HorizonRenderMetrics
$captureDirectory = Join-Path $PluginRoot "Saved\Research\step3\captures\$Label"
Ensure-Directory $captureDirectory
$images = @($HorizonRenderMetrics.images | Sort-Object modified_utc)
if ($images.Count -lt 2) {
Add-Failure "step3 game path did not produce both lit captures; found $($images.Count)"
} else {
$capturePaths = [System.Collections.Generic.List[string]]::new()
$captureNames = @('OpenVaults_before_switch.png', 'TightNetwork_after_switch.png')
for ($captureIndex = 0; $captureIndex -lt 2; ++$captureIndex) {
$sourceImage = $images[$images.Count - 2 + $captureIndex]
$destination = Join-Path $captureDirectory $captureNames[$captureIndex]
Copy-Item -LiteralPath $sourceImage.path -Destination $destination -Force
$capturePaths.Add($destination)
}
$HorizonRenderMetrics.saved_captures = @($capturePaths)
$Step3GameMetrics.saved_captures = @($capturePaths)
}
if ($Step3GameMetrics.status -ne 'passed') {
Add-Failure "step3 game path, profile rebuild, lit capture, ring, or moving frame metric failed: $($Step3GameMetrics.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) {
$activeOwnerEditorPids = @(Get-Process -Name UnrealEditor -ErrorAction SilentlyContinue |
ForEach-Object { [int]$_.Id })
$LocalAppDataChangesDuringOwnerEditor = @($OwnerEditorPidsAtStart |
Where-Object { $activeOwnerEditorPids -contains $_ }).Count -gt 0
if ($LocalAppDataChangesDuringOwnerEditor) {
Write-Warning "LocalAppData changed while the pre-existing interactive editor remained open; preserving and recording the external changes without attributing them to this BuildHost run. First change: $($LocalAppDataWrites[0])"
} else {
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 }
step2e = if ($Scenario -eq 'step2e') { $CommandletMetrics } else { $null }
step2f = if ($Scenario -eq 'step2f') { $CommandletMetrics } else { $null }
step3_scale = $Step3ScaleMetrics
step3_game = $Step3GameMetrics
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
owner_editor_pids_at_start = @($OwnerEditorPidsAtStart)
changes_during_preexisting_owner_editor = $LocalAppDataChangesDuringOwnerEditor
}
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("step3_scale=$(ConvertTo-CompactJson $result.step3_scale)")
[void]$summaryLines.Add("step3_game=$(ConvertTo-CompactJson $result.step3_game)")
[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