Files
VoxelForge/Tools/VoxelForgeTest.ps1
T
Fr0zkaandClaude Opus 5.5 116b57f01e wip(gate): support column kept through rebuilds, hold only on swept contact -- NEVER COMPILED OR RUN
Stopped mid-round: the PC ran out of memory (another app held 11 GB), new processes failed to start
(0xC0000142), and the round ended without verifying anything after the edit.

Confirmed in code (performance review finding 1): a support request outside the stamped desired set
forces a rebuild that cancels it, the gate asks for one unresolved tile at a time, and it holds
before the pending tile is anywhere near the pawn's next step.

Uncompiled edits: a short collision-only support column retained through desired rebuilds and
prioritised; hold only when the swept capsule is about to enter a pending tile; downward speed
restored on release; opt-in deep-drop fixture (carved shaft) in the stress harness.

Evidence so far: shallow 10-voxel fall, gate on, before the edit: 1 hold of 1.51 s, clearance
+120.6 cm. The 80/160-voxel attempts spawned OUTSIDE the strate layout (support unavailable,
zero holds) and failed the penetration check at -11.1 cm: not the reviewed loop, but worth a look.
Compile risks: GetGravityZ(), TSet pruning in the rebuild, the fixture's carve/log arguments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-28 19:54:28 +02:00

1679 lines
87 KiB
PowerShell

[CmdletBinding()]
param(
[ValidateSet('canonical', 'owner', 'probe', 'perf', 'visual-edit', 'visual-lod', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'parity', 'horizon', 'horizon-render', 'determinism')]
[string]$Scenario = 'canonical',
[switch]$Build,
[ValidateSet('owner', 'default')]
[string]$Assets = 'owner',
[hashtable]$Cvars = @{},
[string]$TestFilter,
[string]$Out,
[string]$Label,
[ValidateRange(0, 8)]
[int]$HorizonLevel = 5,
[ValidateRange(1, 100000)]
[int]$HorizonRenderDistanceChunks = 768,
[string]$SharedDDC
)
# One guarded entry point for all VoxelForge measurements. It intentionally keeps the Unreal
# command lines in this file: callers choose a scenario and data, never an arbitrary process.
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ScriptFile = [IO.Path]::GetFullPath($PSCommandPath)
$ToolsRoot = Split-Path -Parent $ScriptFile
$PluginRoot = Split-Path -Parent $ToolsRoot
$ProjectRoot = Split-Path -Parent (Split-Path -Parent $PluginRoot)
$HostRoot = Join-Path $PluginRoot 'Saved\BuildHost\HostProject'
$HostProject = Join-Path $HostRoot 'HostProject.uproject'
$StagePlugin = Join-Path $HostRoot 'Plugins\VoxelForge'
$ProjectFile = Join-Path $ProjectRoot 'VoxelM.uproject'
$EngineRoot = 'E:\Program Files\Epic Games\UE_5.7'
$EditorExe = Join-Path $EngineRoot 'Engine\Binaries\Win64\UnrealEditor-Cmd.exe'
$UbtDll = Join-Path $EngineRoot 'Engine\Binaries\DotNET\UnrealBuildTool\UnrealBuildTool.dll'
$LaunchTimeoutSeconds = 1800
$UnrealPrivateMemoryLimitBytes = 8GB
# The VoxelForge category includes two late, several-minute op-stack sweeps. They remain in
# the suite; when the serialized aggregate launch reaches this 30-minute guard, validate those
# named tests with -TestFilter so every test still runs under the standing launch limit.
# Fresh branch-tip baseline at 1c642fd1824a7e0e4f88a8fc1f42b9e23fdb1821 after a clean staged
# sync (synthetic canonical, walk+export+probe). This is intentionally asserted so any
# field-affecting edit makes canonical fail loudly.
$CanonicalBaselineCommit = '1c642fd1824a7e0e4f88a8fc1f42b9e23fdb1821'
$CanonicalObjSha256 = '1280524041AB4932E6CFE74D57C349F84C6557C02CB7958C694DE734EF347E2B'
$Failures = [System.Collections.Generic.List[string]]::new()
$LaunchLedger = [System.Collections.Generic.List[object]]::new()
$BuildRecord = [ordered]@{ requested = [bool]$Build; status = 'not_requested' }
$AssetEvidence = @()
$CommandletMetrics = [ordered]@{}
$GameMetrics = [ordered]@{}
$HorizonMetrics = [ordered]@{
requested = $Scenario -in @('horizon', 'horizon-render')
status = 'not_run'
level = $HorizonLevel
render_distance_chunks = $HorizonRenderDistanceChunks
}
$HorizonRenderMetrics = [ordered]@{ requested = $Scenario -eq 'horizon-render'; status = 'not_run' }
$DeterminismEvidence = [ordered]@{ requested = $Scenario -eq 'determinism'; status = 'not_run' }
$SurfaceFallMetrics = [ordered]@{
requested = $Scenario -eq 'surface-fall' -or
($Scenario -eq 'tests' -and [string]::IsNullOrWhiteSpace($TestFilter))
status = 'not_run'
}
$StrateCrossingMetrics = [ordered]@{ requested = $Scenario -eq 'crossing'; status = 'not_run' }
$VisualStalenessMetrics = [ordered]@{
requested = $Scenario -in @('visual-edit', 'visual-lod')
status = 'not_run'
}
$CollisionGateStressMetrics = [ordered]@{
requested = $Scenario -eq 'gate-stress' -or
($Scenario -eq 'tests' -and [string]::IsNullOrWhiteSpace($TestFilter))
status = 'not_run'
}
$TestMetrics = @()
$ParityEvidence = [ordered]@{ requested = $Scenario -eq 'parity'; status = 'not_run' }
$DllEvidence = [ordered]@{
runtime = [ordered]@{ path = Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForge.dll'; sha256 = $null }
editor = [ordered]@{ path = Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForgeEditor.dll'; sha256 = $null }
}
$LocalAppDataWrites = @()
$LocalAppDataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnrealEngine'
$RunRoot = $null
$ResultPath = $null
$SummaryPath = $null
$DDCPath = $null
function Add-Failure([string]$Reason) {
if ([string]::IsNullOrWhiteSpace($Reason)) { return }
if (-not $Failures.Contains($Reason)) { [void]$Failures.Add($Reason) }
}
function Ensure-Directory([string]$Path) {
if ([string]::IsNullOrWhiteSpace($Path)) { throw 'Cannot create an empty directory path.' }
if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
[IO.Directory]::CreateDirectory($Path) | Out-Null
}
}
function Get-FullPath([string]$Path) {
return [IO.Path]::GetFullPath($Path)
}
function Test-UnderPath([string]$Candidate, [string]$Root) {
$candidateFull = (Get-FullPath $Candidate).TrimEnd('\', '/')
$rootFull = (Get-FullPath $Root).TrimEnd('\', '/')
return $candidateFull.Equals($rootFull, [StringComparison]::OrdinalIgnoreCase) -or
$candidateFull.StartsWith($rootFull + '\', [StringComparison]::OrdinalIgnoreCase) -or
$candidateFull.StartsWith($rootFull + '/', [StringComparison]::OrdinalIgnoreCase)
}
function Get-Sha256([string]$Path) {
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
return (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToUpperInvariant()
}
function Get-SourceSnapshot([string]$Root) {
$snapshot = [ordered]@{}
$sourceRoot = Join-Path $Root 'Source'
if (Test-Path -LiteralPath $sourceRoot -PathType Container) {
foreach ($file in @(Get-ChildItem -LiteralPath $sourceRoot -File -Recurse -Force)) {
$relative = $file.FullName.Substring($Root.Length).TrimStart('\', '/')
$snapshot[$relative.ToLowerInvariant()] = Get-Sha256 $file.FullName
}
}
$manifest = Join-Path $Root 'VoxelForge.uplugin'
if (Test-Path -LiteralPath $manifest -PathType Leaf) {
$snapshot['voxelforge.uplugin'] = Get-Sha256 $manifest
}
return $snapshot
}
function Get-ChangedSnapshotKeys($Before, $After) {
$keys = @($Before.Keys) + @($After.Keys) | Sort-Object -Unique
$changed = [System.Collections.Generic.List[string]]::new()
foreach ($key in $keys) {
if (-not $Before.Contains($key) -or -not $After.Contains($key) -or
$Before[$key] -ne $After[$key]) {
[void]$changed.Add([string]$key)
}
}
return @($changed)
}
function Get-StateSnapshot([string]$Root) {
$snapshot = [ordered]@{}
if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return $snapshot }
foreach ($item in @(Get-ChildItem -LiteralPath $Root -Recurse -Force -ErrorAction SilentlyContinue)) {
$relative = $item.FullName.Substring($Root.Length).TrimStart('\', '/').ToLowerInvariant()
$kind = if ($item.PSIsContainer) { 'D' } else { 'F' }
$time = $item.LastWriteTimeUtc.Ticks
$length = if ($item.PSIsContainer) { 0 } else { $item.Length }
$snapshot["$kind|$relative"] = "$time|$length"
}
return $snapshot
}
function Get-StateChanges($Before, $After) {
$keys = @($Before.Keys) + @($After.Keys) | Sort-Object -Unique
$changed = [System.Collections.Generic.List[string]]::new()
foreach ($key in $keys) {
if (-not $Before.Contains($key) -or -not $After.Contains($key) -or $Before[$key] -ne $After[$key]) {
[void]$changed.Add([string]$key)
}
}
return @($changed)
}
function Get-JsonValue($Object, [string]$Name, $Default = $null) {
if ($null -eq $Object) { return $Default }
if ($Object -is [System.Collections.IDictionary]) {
if ($Object.Contains($Name)) { return $Object[$Name] }
return $Default
}
$property = $Object.PSObject.Properties[$Name]
if ($null -eq $property) { return $Default }
return $property.Value
}
function ConvertTo-CompactJson($Object) {
if ($null -eq $Object) { return 'null' }
return ($Object | ConvertTo-Json -Compress -Depth 30)
}
function Get-UnrealProcesses {
# Get-Process is deliberately the primary guard. Some managed shells deny Win32_Process
# command-line inspection, but they still expose the PID/name needed to protect an owner
# editor before a launch.
$rows = @(Get-Process -ErrorAction SilentlyContinue |
Where-Object { $_.ProcessName -like 'UnrealEditor*' } |
ForEach-Object {
[pscustomobject]@{
ProcessId = [int]$_.Id
Name = "$($_.ProcessName).exe"
ExecutablePath = $null
CommandLine = $null
}
})
return @($rows | ForEach-Object {
[pscustomobject]@{
pid = [int]$_.ProcessId
name = [string]$_.Name
executable = [string]$_.ExecutablePath
command_line = [string]$_.CommandLine
}
})
}
function Assert-NoForeignUnreal([string]$When) {
$processes = @(Get-UnrealProcesses)
if ($processes.Count -gt 0) {
$detail = ($processes | ForEach-Object {
"PID=$($_.pid) Name=$($_.name) Exe=$($_.executable)"
}) -join '; '
throw "UnrealEditor process already exists $When; refusing to touch it: $detail"
}
}
function Get-CrashSnapshot {
$snapshot = [ordered]@{}
$roots = @(
(Join-Path $HostRoot 'Saved\Crashes'),
(Join-Path $StagePlugin 'Saved\Crashes'),
(Join-Path $RunRoot 'Crashes')
)
foreach ($root in $roots) {
if (-not (Test-Path -LiteralPath $root -PathType Container)) { continue }
foreach ($dir in @(Get-ChildItem -LiteralPath $root -Directory -Filter 'UECC-*' -Recurse -Force -ErrorAction SilentlyContinue)) {
$snapshot[$dir.FullName.ToLowerInvariant()] = $dir.LastWriteTimeUtc.Ticks
}
}
return $snapshot
}
function Get-CrashReporterProcesses {
$rows = @()
try {
$rows = @(Get-CimInstance -ClassName Win32_Process -ErrorAction Stop 2>$null |
Where-Object { $_.Name -like '*CrashReportClient*' })
} catch {
# Without a parent/command line the process cannot be proven to be ours, so leave it
# untouched. The ledger records the absence of a safely attributable reporter.
$Error.Clear()
return @()
}
return @($rows | ForEach-Object {
[pscustomobject]@{
pid = [int]$_.ProcessId
name = [string]$_.Name
command_line = [string]$_.CommandLine
}
})
}
function Reap-OwnCrashReporters($Before, [datetime]$StartedUtc, [string]$LogPath) {
$beforePids = @($Before | ForEach-Object { [int]$_.pid })
$reaped = [System.Collections.Generic.List[int]]::new()
foreach ($reporter in @(Get-CrashReporterProcesses)) {
if ($beforePids -contains [int]$reporter.pid) { continue }
$commandLine = [string]$reporter.command_line
$belongsToRun = $commandLine.IndexOf($RunRoot, [StringComparison]::OrdinalIgnoreCase) -ge 0 -or
$commandLine.IndexOf($HostProject, [StringComparison]::OrdinalIgnoreCase) -ge 0 -or
$commandLine.IndexOf($LogPath, [StringComparison]::OrdinalIgnoreCase) -ge 0
if (-not $belongsToRun) { continue }
try {
Stop-Process -Id ([int]$reporter.pid) -Force -ErrorAction Stop
[void]$reaped.Add([int]$reporter.pid)
} catch {
Add-Failure "Could not reap own crash reporter PID $($reporter.pid): $($_.Exception.Message)"
}
}
return @($reaped)
}
function Get-NewCrashes($Before, [datetime]$StartedUtc) {
$after = Get-CrashSnapshot
$new = [System.Collections.Generic.List[string]]::new()
foreach ($path in $after.Keys) {
if (-not $Before.Contains($path) -or [int64]$after[$path] -ge $StartedUtc.ToUniversalTime().Ticks) {
[void]$new.Add($path)
}
}
return @($new)
}
function Wait-OwnedProcess($Process, [int]$TimeoutSeconds, [switch]$EnforceMemoryLimit) {
$watch = [Diagnostics.Stopwatch]::StartNew()
$peakPrivateBytes = 0L
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
if ($Process.HasExited) {
[void]$Process.WaitForExit()
return [ordered]@{
timed_out = $false
memory_limit_hit = $false
peak_private_bytes = $peakPrivateBytes
exit_code = [int]$Process.ExitCode
}
}
if ($EnforceMemoryLimit) {
$owned = Get-Process -Id ([int]$Process.Id) -ErrorAction SilentlyContinue
if ($null -ne $owned) {
$peakPrivateBytes = [Math]::Max($peakPrivateBytes, [int64]$owned.PrivateMemorySize64)
if ([int64]$owned.PrivateMemorySize64 -gt $UnrealPrivateMemoryLimitBytes) {
# This PID came from our Start-Process call. Do not use a name-wide kill here.
try { Stop-Process -Id ([int]$Process.Id) -Force -ErrorAction Stop } catch { }
try { [void]$Process.WaitForExit(5000) } catch { }
return [ordered]@{
timed_out = $false
memory_limit_hit = $true
peak_private_bytes = $peakPrivateBytes
exit_code = $null
}
}
}
}
Start-Sleep -Milliseconds 1000
}
# This PID came from our Start-Process call. Never use a name-based kill or a broad process
# query here: an owner's editor may be unrelated and must remain untouched.
try { Stop-Process -Id ([int]$Process.Id) -Force -ErrorAction Stop } catch { }
try { [void]$Process.WaitForExit(5000) } catch { }
return [ordered]@{
timed_out = $true
memory_limit_hit = $false
peak_private_bytes = $peakPrivateBytes
exit_code = $null
}
}
function Invoke-UnrealLaunch([string]$Kind, [string[]]$Arguments, [string]$LogPath,
[string]$StdoutPath, [string]$StderrPath) {
Assert-NoForeignUnreal "before $Kind launch"
Ensure-Directory (Split-Path -Parent $LogPath)
Ensure-Directory (Split-Path -Parent $StdoutPath)
Ensure-Directory (Split-Path -Parent $StderrPath)
$startedUtc = [datetime]::UtcNow
$crashesBefore = Get-CrashSnapshot
$reportersBefore = @(Get-CrashReporterProcesses)
$ledger = [ordered]@{
kind = $Kind
pid = $null
started_utc = $startedUtc.ToString('o')
ended_utc = $null
exit_code = $null
timed_out = $false
memory_limit_hit = $false
peak_private_bytes = 0L
crash_reporters_reaped = @()
new_crashes = @()
log = $LogPath
stdout = $StdoutPath
stderr = $StderrPath
arguments = @($Arguments)
status = 'not_started'
}
$isolatedLocalAppData = Join-Path $RunRoot 'LocalAppData'
Ensure-Directory $isolatedLocalAppData
$oldLocalAppData = $env:LOCALAPPDATA
try {
# Unreal resolves several engine-wide stores from LOCALAPPDATA even when -userdir and
# -LocalDataCachePath are supplied. Keep this child process inside the run directory.
$env:LOCALAPPDATA = $isolatedLocalAppData
$process = Start-Process -FilePath $EditorExe -ArgumentList $Arguments -WorkingDirectory $HostRoot `
-RedirectStandardOutput $StdoutPath -RedirectStandardError $StderrPath -WindowStyle Hidden `
-PassThru
try { $process.PriorityClass = [Diagnostics.ProcessPriorityClass]::BelowNormal }
catch {
try { Stop-Process -Id ([int]$process.Id) -Force -ErrorAction Stop } catch { }
throw "Could not lower owned Unreal PID $($process.Id) to BelowNormal: $($_.Exception.Message)"
}
$ledger.pid = [int]$process.Id
$wait = Wait-OwnedProcess $process $LaunchTimeoutSeconds -EnforceMemoryLimit
$ledger.timed_out = [bool]$wait.timed_out
$ledger.memory_limit_hit = [bool]$wait.memory_limit_hit
$ledger.peak_private_bytes = [int64]$wait.peak_private_bytes
$ledger.exit_code = $wait.exit_code
$ledger.status = if ($ledger.timed_out) { 'timeout' }
elseif ($ledger.memory_limit_hit) { 'memory_limit' }
elseif ($ledger.exit_code -eq 0) { 'passed' } else { 'failed' }
} catch {
$ledger.status = 'launch_error'
$ledger.error = $_.Exception.Message
} finally {
if ($null -eq $oldLocalAppData) { Remove-Item Env:LOCALAPPDATA -ErrorAction SilentlyContinue } else { $env:LOCALAPPDATA = $oldLocalAppData }
}
$ledger.isolated_local_app_data = $isolatedLocalAppData
$ledger.ended_utc = [datetime]::UtcNow.ToString('o')
$ledger.crash_reporters_reaped = @(Reap-OwnCrashReporters $reportersBefore $startedUtc $LogPath)
$ledger.new_crashes = @(Get-NewCrashes $crashesBefore $startedUtc)
[void]$LaunchLedger.Add($ledger)
if ($ledger.status -ne 'passed') {
Add-Failure "$Kind launch $($ledger.status) (PID=$($ledger.pid), exit=$($ledger.exit_code))"
}
if ($ledger.new_crashes.Count -gt 0) {
Add-Failure "$Kind produced new UECC crash directory: $($ledger.new_crashes[0])"
}
return $ledger
}
function Invoke-BuildProcess([string[]]$Arguments, [string]$LogPath, [string]$ErrorPath,
[string]$StdoutPath = $LogPath) {
Ensure-Directory (Split-Path -Parent $LogPath)
$dotnetHome = Join-Path $RunRoot 'DotnetHome'
Ensure-Directory $dotnetHome
$dotnet = (Get-Command dotnet.exe -ErrorAction Stop).Source
$started = [datetime]::UtcNow
$oldDotnetCliHome = $env:DOTNET_CLI_HOME
$oldDotnetFirstUse = $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE
$oldDotnetNoLogo = $env:DOTNET_NOLOGO
$isolatedLocalAppData = Join-Path $RunRoot 'LocalAppData'
Ensure-Directory $isolatedLocalAppData
$oldLocalAppData = $env:LOCALAPPDATA
try {
# Keep the build tool's first-use sentinel and package metadata inside this run. This is
# separate from Unreal's LocalAppData guard and avoids touching the owner's profile.
$env:DOTNET_CLI_HOME = $dotnetHome
$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = '1'
$env:DOTNET_NOLOGO = '1'
$env:LOCALAPPDATA = $isolatedLocalAppData
$process = Start-Process -FilePath $dotnet -ArgumentList $Arguments -WorkingDirectory $PluginRoot `
-RedirectStandardOutput $StdoutPath -RedirectStandardError $ErrorPath -WindowStyle Hidden `
-PassThru
try { $process.PriorityClass = [Diagnostics.ProcessPriorityClass]::BelowNormal }
catch {
try { Stop-Process -Id ([int]$process.Id) -Force -ErrorAction Stop } catch { }
throw "Could not lower owned build PID $($process.Id) to BelowNormal: $($_.Exception.Message)"
}
$wait = Wait-OwnedProcess $process $LaunchTimeoutSeconds
} 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
log = $LogPath
stdout = $StdoutPath
error_log = $ErrorPath
arguments = @($Arguments)
isolated_local_app_data = $isolatedLocalAppData
status = if ($wait.timed_out) { 'timeout' } elseif ($wait.exit_code -eq 0) { 'passed' } else { 'failed' }
}
}
function Copy-DirectoryContents([string]$Source, [string]$Destination) {
Ensure-Directory $Destination
foreach ($item in @(Get-ChildItem -LiteralPath $Source -Force)) {
$target = Join-Path $Destination $item.Name
$null = Copy-Item -LiteralPath $item.FullName -Destination $target -Recurse -Force -PassThru
}
}
function Sync-StagedHost {
if (-not (Test-Path -LiteralPath $HostProject -PathType Leaf)) {
throw "Staged host project is missing: $HostProject"
}
if (-not (Test-Path -LiteralPath $StagePlugin -PathType Container)) {
throw "Staged VoxelForge plugin is missing: $StagePlugin"
}
$stageSource = Join-Path $StagePlugin 'Source'
if (Test-Path -LiteralPath $stageSource -PathType Container) {
if (-not (Test-UnderPath $stageSource $StagePlugin)) {
throw "Refusing to clean a staged source path outside the staged plugin: $stageSource"
}
Remove-Item -LiteralPath $stageSource -Recurse -Force
}
Copy-DirectoryContents (Join-Path $PluginRoot 'Source') $stageSource
$null = Copy-Item -LiteralPath (Join-Path $PluginRoot 'VoxelForge.uplugin') `
-Destination (Join-Path $StagePlugin 'VoxelForge.uplugin') -Force -PassThru
# The staged map is a host copy; these copies never write the owner's project.
$stageContent = Join-Path $HostRoot 'Content'
$stageVoxelContent = Join-Path $stageContent 'VoxelForge'
Ensure-Directory $stageVoxelContent
foreach ($relative in @('Untitled.umap', 'VoxelForge\BP_VoxelWorld.uasset')) {
$source = Join-Path (Join-Path $ProjectRoot 'Content') $relative
if (Test-Path -LiteralPath $source -PathType Leaf) {
$target = Join-Path $stageContent $relative
Ensure-Directory (Split-Path -Parent $target)
$null = Copy-Item -LiteralPath $source -Destination $target -Force -PassThru
}
}
}
function Sync-OwnerAssets {
$rows = [System.Collections.Generic.List[object]]::new()
# Composer tests discover the authored strate corpus from the staged asset registry. Keep the
# complete owner corpus here; the explore commandlet still selects DA_Strate3 explicitly for
# the owner-world scenario below.
foreach ($assetName in @('DA_Strate1', 'DA_Strate2', 'DA_Strate3', 'DA_Strate4', 'DA_Settings')) {
$source = Join-Path $ProjectRoot "Content\VoxelForge\$assetName.uasset"
$target = Join-Path $HostRoot "Content\VoxelForge\$assetName.uasset"
if (-not (Test-Path -LiteralPath $source -PathType Leaf)) {
throw "Owner asset is missing: $source"
}
Ensure-Directory (Split-Path -Parent $target)
$null = Copy-Item -LiteralPath $source -Destination $target -Force -PassThru
[void]$rows.Add([ordered]@{
name = $assetName
used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'visual-edit', 'visual-lod', 'horizon', 'horizon-render', 'determinism')
source_path = $source
source_sha256 = Get-Sha256 $source
staged_path = $target
staged_sha256 = Get-Sha256 $target
})
}
return @($rows)
}
function Sync-TestCharacterFixture {
$rows = [System.Collections.Generic.List[object]]::new()
# ScaleDiagnosis reads the owner's character CDO for movement limits. The isolated host
# already contains the CDO, but the CDO's animation blueprint loads the complete unarmed
# blend-space sample set. Keep that dependency closure in the harness instead of turning a
# missing staged asset into an automation-test error. These are copies into the host only.
$relativeDirectories = @('Characters\Mannequins\Anims\Unarmed')
foreach ($relativeDirectory in $relativeDirectories) {
$sourceDirectory = Join-Path $ProjectRoot "Content\$relativeDirectory"
$targetDirectory = Join-Path $HostRoot "Content\$relativeDirectory"
if (-not (Test-Path -LiteralPath $sourceDirectory -PathType Container)) {
throw "Test fixture directory is missing: $sourceDirectory"
}
Copy-DirectoryContents $sourceDirectory $targetDirectory
foreach ($source in @(Get-ChildItem -LiteralPath $sourceDirectory -Recurse -File)) {
$relativeFile = $source.FullName.Substring($sourceDirectory.Length).TrimStart('\')
$target = Join-Path $targetDirectory $relativeFile
[void]$rows.Add([ordered]@{
name = "TestFixture/$relativeDirectory/$relativeFile"
used_by_scenario = $true
source_path = $source.FullName
source_sha256 = Get-Sha256 $source.FullName
staged_path = $target
staged_sha256 = Get-Sha256 $target
})
}
}
return @($rows)
}
function Get-AssetEvidence {
$rows = [System.Collections.Generic.List[object]]::new()
foreach ($assetName in @('DA_Strate1', 'DA_Strate2', 'DA_Strate3', 'DA_Strate4', 'DA_Settings')) {
$source = Join-Path $ProjectRoot "Content\VoxelForge\$assetName.uasset"
$target = Join-Path $HostRoot "Content\VoxelForge\$assetName.uasset"
[void]$rows.Add([ordered]@{
name = $assetName
used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'visual-edit', 'visual-lod', 'horizon', 'horizon-render', 'determinism')
source_path = $source
source_sha256 = Get-Sha256 $source
staged_path = $target
staged_sha256 = Get-Sha256 $target
})
}
return @($rows)
}
function Get-ValueText($Value) {
if ($Value -is [bool]) { return $(if ($Value) { '1' } else { '0' }) }
if ($Value -is [System.Array]) { return (($Value | ForEach-Object { Get-ValueText $_ }) -join ',') }
return [string]$Value
}
function Merge-Cvars([System.Collections.IDictionary]$Base) {
$merged = [ordered]@{}
foreach ($key in $Base.Keys) { $merged[[string]$key] = $Base[$key] }
if ($null -ne $Cvars) {
foreach ($key in $Cvars.Keys) { $merged[[string]$key] = $Cvars[$key] }
}
return $merged
}
function New-CommonArguments([string]$Kind, [string]$LogPath, $CvarMap, [bool]$EnableRender = $false) {
$safeLabel = ($Label -replace '[^A-Za-z0-9_.-]', '_')
$args = [System.Collections.Generic.List[string]]::new()
# Quoting the whole -project= token is intentional: this is the failure mode that used to
# swallow every argument after "Projet Unreal".
[void]$args.Add(('-project="{0}"' -f $HostProject))
if ($EnableRender) {
[void]$args.Add('-RenderOffscreen')
[void]$args.Add('-ResX=1280')
[void]$args.Add('-ResY=720')
} else {
[void]$args.Add('-nullrhi')
}
[void]$args.Add('-DDC-ForceMemoryCache')
[void]$args.Add('-unattended')
[void]$args.Add('-nop4')
[void]$args.Add('-nosplash')
[void]$args.Add('-NoSound')
[void]$args.Add('-stdout')
[void]$args.Add('-FullStdOutLogOutput')
[void]$args.Add('-forcelogflush')
[void]$args.Add('-NoZenAutoLaunch')
[void]$args.Add('-notraceserver')
[void]$args.Add('-nocrashreports')
[void]$args.Add('-WindowsPlatformCrashContext.ForceCrashReportDialogOff=1')
[void]$args.Add("-enginesaveddirsuffix=VoxelForgeTest_$safeLabel")
[void]$args.Add(('-userdir="{0}"' -f (Join-Path $RunRoot 'User')))
[void]$args.Add(('-ZenDataPath="{0}"' -f (Join-Path $RunRoot 'ZenData')))
[void]$args.Add('-ddc=NoZenLocalFallback')
[void]$args.Add(('-LocalDataCachePath="{0}"' -f $DDCPath))
[void]$args.Add(('-abslog="{0}"' -f $LogPath))
if ($Kind -eq 'commandlet') {
[void]$args.Add('-run=VoxelForgeExplore')
} elseif ($Kind -eq 'game') {
[void]$args.Add('-game')
[void]$args.Add('-map=/Game/Untitled')
}
foreach ($key in @($CvarMap.Keys | Sort-Object)) {
$switch = [string]$key
if (-not $switch.StartsWith('-')) { $switch = "-$switch" }
$value = Get-ValueText $CvarMap[$key]
if ($value -match '\s' -or $value.Contains(',')) {
[void]$args.Add(('{0}="{1}"' -f $switch, $value))
} else {
[void]$args.Add("$switch=$value")
}
}
return @($args)
}
function New-CommandletArguments([string]$CommandletOut, [string]$Modes, [bool]$UseOwnerAssets,
[bool]$GameRuntimeConfig, [int]$ExportSize = 128,
[int]$Slot = 1) {
$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 ($GameRuntimeConfig) { [void]$args.Add('-gameconfig=1') }
if ($UseOwnerAssets) {
[void]$args.Add('-strateref=/Game/VoxelForge/DA_Strate3.DA_Strate3')
[void]$args.Add('-settingsref=/Game/VoxelForge/DA_Settings.DA_Settings')
}
[void]$args.Add(('-out="{0}"' -f $CommandletOut))
return @($args)
}
function Read-JsonFile([string]$Path) {
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
return (Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json)
}
function Get-CommandletMetrics([string]$OutputDirectory, [string]$Use) {
$jsonPath = Join-Path $OutputDirectory 'explore.json'
$json = Read-JsonFile $jsonPath
if ($null -eq $json) {
return [ordered]@{ status = 'missing'; output = $OutputDirectory }
}
$summary = Get-JsonValue $json 'summary'
$export = Get-JsonValue $json 'export'
$walk = Get-JsonValue $json 'walk'
$ledger = Get-JsonValue $summary 'tile_ledger'
$objPath = Join-Path $OutputDirectory 'geometry.obj'
$capabilities = [ordered]@{}
foreach ($name in @('player_fit_volume_cells', 'reachable_player_fit_cells',
'walk_only_reachable_player_fit_cells', 'climb_reachable_player_fit_cells',
'arrival_passage_count', 'departure_passage_count', 'triangle_count',
'tile_count', 'completed_tiles')) {
$value = Get-JsonValue $walk $name $null
if ($null -eq $value) { $value = Get-JsonValue $summary $name $null }
if ($null -eq $value) { $value = Get-JsonValue $export $name $null }
if ($null -ne $value) { $capabilities[$name] = $value }
}
$probe = Get-JsonValue $json 'tunnel_core_probe'
$probeAmplitudes = [ordered]@{}
foreach ($sectionName in @('floor', 'wall', 'ceiling', 'mouth_probe')) {
$section = Get-JsonValue $probe $sectionName $null
if ($null -eq $section) { continue }
foreach ($property in @($section.PSObject.Properties)) {
if ($property.Name -match 'amplitude|period') {
$probeAmplitudes["$sectionName.$($property.Name)"] = $property.Value
}
}
}
foreach ($name in @('periodic_amplitude_voxels', 'periodic_amplitude_m',
'amplitude_voxels', 'amplitude', 'periodic_period_voxels')) {
$value = Get-JsonValue $probe $name $null
if ($null -ne $value) { $probeAmplitudes[$name] = $value }
}
$meshSeconds = Get-JsonValue $summary 'mesh_seconds' $null
$workerSeconds = Get-JsonValue (Get-JsonValue $summary 'mesher_tasks') 'job_sum_seconds' $meshSeconds
$connectivity = [ordered]@{
result = Get-JsonValue $walk 'connectivity_result' $null
can_reach_departure = Get-JsonValue $walk 'can_reach_departure' $null
walk_only_can_reach_departure = Get-JsonValue $walk 'walk_only_can_reach_departure' $null
}
return [ordered]@{
status = Get-JsonValue $json 'status' 'ok'
use = $Use
output = $OutputDirectory
json = $jsonPath
capability_counts = $capabilities
connectivity = $connectivity
geometry_hash = Get-JsonValue $summary 'geometry_hash' (Get-JsonValue $export 'geometry_hash' $null)
obj_sha256 = Get-Sha256 $objPath
obj_path = $objPath
probe_amplitudes = $probeAmplitudes
worker_seconds = $workerSeconds
mesh_seconds = $meshSeconds
tile_ledger = $ledger
input = Get-JsonValue $json 'input' $null
game_runtime_config = Get-JsonValue (Get-JsonValue $json 'input') 'game_runtime_config' $false
}
}
function Get-StreamingMetrics([string]$LogPath) {
$lineMatch = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeStreamingLatency\]' -SimpleMatch:$false -ErrorAction SilentlyContinue |
Select-Object -Last 1)
if ($lineMatch.Count -eq 0) { return [ordered]@{ status = 'missing'; log = $LogPath } }
$line = [string]$lineMatch[0].Line
$metrics = [ordered]@{ status = 'ok'; log = $LogPath; raw = $line }
$patterns = [ordered]@{
lod0_ready_samples = 'lod0_ready_samples=(?<v>[-+0-9.eE]+)'
request_to_ready_p50 = 'request_to_ready_s\[p50=(?<v>[-+0-9.eE]+)'
request_to_ready_p95 = 'request_to_ready_s\[p50=[^]]*p95=(?<v>[-+0-9.eE]+)'
queue_wait_p50 = 'queue_wait_s\[p50=(?<v>[-+0-9.eE]+)'
queue_wait_p95 = 'queue_wait_s\[p50=[^]]*p95=(?<v>[-+0-9.eE]+)'
generation_p50 = 'generation_s\[p50=(?<v>[-+0-9.eE]+)'
generation_p95 = 'generation_s\[p50=[^]]*p95=(?<v>[-+0-9.eE]+)'
worker_generation_sum_s = 'worker_generation_sum_s=(?<v>[-+0-9.eE]+)'
applied_tiles = 'applied_tiles=(?<v>[-+0-9.eE]+)'
applied_triangles = 'applied_triangles=(?<v>[-+0-9.eE]+)'
}
foreach ($key in $patterns.Keys) {
$match = [regex]::Match($line, $patterns[$key])
if ($match.Success) { $metrics[$key] = $match.Groups['v'].Value }
}
$metrics['p50_p95'] = [ordered]@{
request_to_ready = [ordered]@{ p50 = $metrics['request_to_ready_p50']; p95 = $metrics['request_to_ready_p95'] }
queue_wait = [ordered]@{ p50 = $metrics['queue_wait_p50']; p95 = $metrics['queue_wait_p95'] }
generation = [ordered]@{ p50 = $metrics['generation_p50']; p95 = $metrics['generation_p95'] }
}
return $metrics
}
function Get-HorizonMetrics([string]$TracePath, $Ledger, $LatencyMetrics) {
$metrics = [ordered]@{
status = 'missing'
trace = $TracePath
desired_tiles_by_level = [ordered]@{}
loaded_tiles_by_level = [ordered]@{}
vertices_by_level = [ordered]@{}
triangles_by_level = [ordered]@{}
geometry_memory_bytes_by_level = [ordered]@{}
geometry_memory_mib_by_level = [ordered]@{}
desired_tiles_total = 0
loaded_tiles_total = 0
vertices_total = 0
triangles_total = 0
geometry_memory_bytes_total = 0
geometry_memory_mib_total = 0.0
worker_generation_seconds = if ($null -ne $LatencyMetrics) {
Get-JsonValue $LatencyMetrics 'worker_generation_sum_s' $null
} else { $null }
settled_trace_seconds = $null
trace_summary_wall_seconds = $null
process_wall_seconds = $null
peak_private_bytes = if ($null -ne $Ledger) {
[int64](Get-JsonValue $Ledger 'peak_private_bytes' 0)
} else { 0L }
peak_private_gib = 0.0
trace_records_seen = $null
trace_records_written = $null
trace_records_suppressed = $null
}
$metrics.peak_private_gib = [Math]::Round($metrics.peak_private_bytes / 1GB, 3)
if (-not (Test-Path -LiteralPath $TracePath -PathType Leaf)) { return $metrics }
$events = [System.Collections.Generic.List[object]]::new()
foreach ($line in @(Get-Content -LiteralPath $TracePath -ErrorAction SilentlyContinue)) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
try { [void]$events.Add(($line | ConvertFrom-Json -ErrorAction Stop)) } catch { }
}
$steady = @($events | Where-Object { [string](Get-JsonValue $_ 'type' '') -eq 'steady_state' } | Select-Object -Last 1)
$summary = @($events | Where-Object { [string](Get-JsonValue $_ 'type' '') -eq 'summary' } | Select-Object -Last 1)
$desiredEvents = @($events | Where-Object { [string](Get-JsonValue $_ 'type' '') -eq 'steady_desired_level' })
$loadedEvents = @($events | Where-Object { [string](Get-JsonValue $_ 'type' '') -eq 'steady_loaded_level' })
if ($steady.Count -eq 0 -or $summary.Count -eq 0 -or $desiredEvents.Count -eq 0) {
return $metrics
}
foreach ($event in $desiredEvents) {
$level = [string][int](Get-JsonValue $event 'level' -1)
$metrics.desired_tiles_by_level[$level] = [int](Get-JsonValue $event 'tiles' 0)
}
foreach ($event in $loadedEvents) {
$level = [string][int](Get-JsonValue $event 'level' -1)
$metrics.loaded_tiles_by_level[$level] = [int](Get-JsonValue $event 'tiles' 0)
$metrics.vertices_by_level[$level] = [int64](Get-JsonValue $event 'vertices' 0)
$metrics.triangles_by_level[$level] = [int64](Get-JsonValue $event 'triangles' 0)
$bytes = [int64](Get-JsonValue $event 'geometry_bytes' 0)
$metrics.geometry_memory_bytes_by_level[$level] = $bytes
$metrics.geometry_memory_mib_by_level[$level] = [Math]::Round($bytes / 1MB, 3)
}
foreach ($value in $metrics.desired_tiles_by_level.Values) { $metrics.desired_tiles_total += [int64]$value }
foreach ($value in $metrics.loaded_tiles_by_level.Values) { $metrics.loaded_tiles_total += [int64]$value }
foreach ($value in $metrics.vertices_by_level.Values) { $metrics.vertices_total += [int64]$value }
foreach ($value in $metrics.triangles_by_level.Values) { $metrics.triangles_total += [int64]$value }
foreach ($value in $metrics.geometry_memory_bytes_by_level.Values) { $metrics.geometry_memory_bytes_total += [int64]$value }
$metrics.geometry_memory_mib_total = [Math]::Round($metrics.geometry_memory_bytes_total / 1MB, 3)
$metrics.settled_trace_seconds = [double](Get-JsonValue $steady[0] 't_s' 0.0)
$metrics.trace_summary_wall_seconds = [double](Get-JsonValue $summary[0] 'wall_s' 0.0)
$metrics.trace_records_seen = [int](Get-JsonValue $summary[0] 'tile_records_seen' 0)
$metrics.trace_records_written = [int](Get-JsonValue $summary[0] 'tile_records_written' 0)
$metrics.trace_records_suppressed = [int](Get-JsonValue $summary[0] 'tile_records_suppressed' 0)
if ($null -ne $Ledger) {
$started = [datetime]::Parse([string](Get-JsonValue $Ledger 'started_utc' ''))
$ended = [datetime]::Parse([string](Get-JsonValue $Ledger 'ended_utc' ''))
$metrics.process_wall_seconds = [Math]::Round(($ended.ToUniversalTime() - $started.ToUniversalTime()).TotalSeconds, 3)
}
$metrics.status = 'ok'
return $metrics
}
function Get-HorizonRenderMetrics($Ledger, [string]$LogPath) {
$metrics = [ordered]@{
status = 'failed'
log = $LogPath
capture_requested = $null
capture_elapsed_seconds = $null
capture_visible_tiles = $null
capture_triangles = $null
images = @()
}
$lines = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeTestCeiling\] capture_requested=' -ErrorAction SilentlyContinue)
if ($lines.Count -gt 0) {
$line = [string]$lines[-1].Line
$metrics.raw = $line
foreach ($key in @('capture_requested', 'elapsed_s', 'visible_tiles', 'triangles')) {
$match = [regex]::Match($line, ('{0}=(?<v>[^ ]+)' -f $key))
if ($match.Success) {
if ($key -eq 'elapsed_s') { $metrics.capture_elapsed_seconds = [double]$match.Groups['v'].Value }
elseif ($key -eq 'visible_tiles') { $metrics.capture_visible_tiles = [int64]$match.Groups['v'].Value }
elseif ($key -eq 'triangles') { $metrics.capture_triangles = [int64]$match.Groups['v'].Value }
else { $metrics.capture_requested = [int]$match.Groups['v'].Value }
}
}
}
$started = if ($null -ne $Ledger) {
[datetime]::Parse([string](Get-JsonValue $Ledger 'started_utc' ''))
} else { [datetime]::MinValue }
$roots = @(
(Join-Path $HostRoot 'Saved'),
$RunRoot,
(Join-Path $RunRoot 'User'))
$images = [System.Collections.Generic.List[object]]::new()
foreach ($root in $roots) {
if (-not (Test-Path -LiteralPath $root -PathType Container)) { continue }
foreach ($image in @(Get-ChildItem -LiteralPath $root -Filter '*.png' -File -Recurse -ErrorAction SilentlyContinue)) {
if ($image.LastWriteTimeUtc -lt $started.ToUniversalTime().AddSeconds(-2)) { continue }
[void]$images.Add([ordered]@{
path = $image.FullName
bytes = [int64]$image.Length
modified_utc = $image.LastWriteTimeUtc.ToString('o')
})
}
}
$metrics.images = @($images | Sort-Object path -Unique)
if ($metrics.capture_requested -eq 1 -and $metrics.images.Count -gt 0) {
$metrics.status = 'passed'
} elseif ($null -eq $Ledger -or (Get-JsonValue $Ledger 'status' 'failed') -ne 'passed') {
$metrics.status = 'not_run'
}
return $metrics
}
function Compare-DeterminismDumps([string]$DefaultPath, [string]$CrtFma3OffPath) {
$defaultJson = Read-JsonFile $DefaultPath
$fmaJson = Read-JsonFile $CrtFma3OffPath
$evidence = [ordered]@{
status = 'failed'
default_dump = $DefaultPath
crt_fma3_off_dump = $CrtFma3OffPath
default_record_count = 0
crt_fma3_off_record_count = 0
compared_tile_count = 0
first_differences = @()
}
if ($null -eq $defaultJson -or $null -eq $fmaJson) {
$evidence.reason = 'one or both tile hash dumps are missing'
return $evidence
}
$defaultRecords = @((Get-JsonValue $defaultJson 'records' @()))
$fmaRecords = @((Get-JsonValue $fmaJson 'records' @()))
$evidence.default_record_count = $defaultRecords.Count
$evidence.crt_fma3_off_record_count = $fmaRecords.Count
$defaultByKey = @{}
$fmaByKey = @{}
foreach ($record in $defaultRecords) {
$key = "$($record.tile_x):$($record.tile_y):$($record.tile_z):$($record.level)"
$defaultByKey[$key] = $record
}
foreach ($record in $fmaRecords) {
$key = "$($record.tile_x):$($record.tile_y):$($record.tile_z):$($record.level)"
$fmaByKey[$key] = $record
}
$differences = [System.Collections.Generic.List[object]]::new()
$keys = @($defaultByKey.Keys) + @($fmaByKey.Keys) | Sort-Object -Unique
foreach ($key in $keys) {
if (-not $defaultByKey.ContainsKey($key)) {
[void]$differences.Add([ordered]@{ tile = $key; reason = 'missing_from_default' })
continue
}
if (-not $fmaByKey.ContainsKey($key)) {
[void]$differences.Add([ordered]@{ tile = $key; reason = 'missing_from_crt_fma3_off' })
continue
}
$left = $defaultByKey[$key]
$right = $fmaByKey[$key]
foreach ($property in @('origin_x_voxels', 'origin_y_voxels', 'origin_z_voxels',
'step', 'cells', 'band_chunk_lo', 'band_chunk_hi',
'sheet_tile', 'empty', 'triangle_count', 'geometry_hash')) {
if ([string](Get-JsonValue $left $property '') -ne [string](Get-JsonValue $right $property '')) {
[void]$differences.Add([ordered]@{
tile = $key
reason = "property_mismatch:$property"
default = Get-JsonValue $left $property $null
crt_fma3_off = Get-JsonValue $right $property $null
})
break
}
}
}
$evidence.compared_tile_count = [Math]::Min($defaultByKey.Count, $fmaByKey.Count)
$evidence.first_differences = @($differences | Select-Object -First 10)
$leftConfig = Get-JsonValue $defaultJson 'runtime_config' $null
$rightConfig = Get-JsonValue $fmaJson 'runtime_config' $null
$evidence.runtime_config_match = (ConvertTo-CompactJson $leftConfig) -eq (ConvertTo-CompactJson $rightConfig)
$evidence.default_runtime_config = $leftConfig
$evidence.crt_fma3_off_runtime_config = $rightConfig
if ($differences.Count -eq 0 -and $evidence.runtime_config_match -and
$defaultByKey.Count -eq $fmaByKey.Count) {
$evidence.status = 'passed'
} else {
$evidence.reason = if ($differences.Count -gt 0) { 'per-tile geometry differs' }
elseif (-not $evidence.runtime_config_match) { 'runtime configuration differs' }
else { 'tile record counts differ' }
}
return $evidence
}
function Get-SurfaceFallMetrics([string]$LogPath) {
$lineMatch = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeSurfaceFallTest\] result=' -ErrorAction SilentlyContinue |
Select-Object -Last 1)
if ($lineMatch.Count -eq 0) { return [ordered]@{ status = 'missing'; log = $LogPath } }
$line = [string]$lineMatch[0].Line
$metrics = [ordered]@{ status = 'failed'; log = $LogPath; raw = $line }
$result = [regex]::Match($line, 'result=(?<v>PASS|FAIL)')
if ($result.Success -and $result.Groups['v'].Value -eq 'PASS') { $metrics.status = 'passed' }
foreach ($key in @('reason', 'elapsed_s', 'distance_m', 'terrain_vox')) {
$match = [regex]::Match($line, ('{0}=(?<v>[^ ]+)' -f $key))
if ($match.Success) { $metrics[$key] = $match.Groups['v'].Value }
}
return $metrics
}
function Get-StrateCrossingMetrics([string]$LogPath) {
$lineMatch = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeStrateCrossingTest\] result=' -ErrorAction SilentlyContinue |
Select-Object -Last 1)
if ($lineMatch.Count -eq 0) { return [ordered]@{ status = 'missing'; log = $LogPath } }
$line = [string]$lineMatch[0].Line
$metrics = [ordered]@{ status = 'failed'; log = $LogPath; raw = $line }
$result = [regex]::Match($line, 'result=(?<v>PASS|FAIL)')
if ($result.Success -and $result.Groups['v'].Value -eq 'PASS') { $metrics.status = 'passed' }
foreach ($key in @('reason', 'elapsed_s', 'distance_m', 'max_gate_s', 'gate_limit_s', 'gate_total_s', 'min_floor_clearance_vox')) {
$match = [regex]::Match($line, ('{0}=(?<v>[^ ]+)' -f $key))
if ($match.Success) { $metrics[$key] = $match.Groups['v'].Value }
}
return $metrics
}
function Get-CollisionGateStressMetrics([string]$LogPath) {
$lineMatch = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeCollisionGateStressTest\] result=' -ErrorAction SilentlyContinue |
Select-Object -Last 1)
if ($lineMatch.Count -eq 0) { return [ordered]@{ status = 'missing'; log = $LogPath } }
$line = [string]$lineMatch[0].Line
$metrics = [ordered]@{ status = 'failed'; log = $LogPath; raw = $line }
$result = [regex]::Match($line, 'result=(?<v>PASS|FAIL)')
if ($result.Success -and $result.Groups['v'].Value -eq 'PASS') { $metrics.status = 'passed' }
foreach ($key in @('reason', 'elapsed_s', 'distance_m', 'expected_distance_m', 'speed_cm_s',
'spawn_height_vox', 'deep_drop',
'gate_holds', 'gate_total_s', 'max_gate_s', 'full_stops',
'full_stop_total_s', 'collision_gate', 'gate_progress_m', '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-TestMetrics($Ledger) {
if ($null -eq $Ledger) { return @() }
$logPaths = @($Ledger.log, $Ledger.stdout, $Ledger.stderr) | Where-Object {
$_ -and (Test-Path -LiteralPath $_ -PathType Leaf)
}
$rows = [System.Collections.Generic.List[object]]::new()
$seen = @{}
foreach ($path in $logPaths) {
foreach ($matchLine in @(Select-String -LiteralPath $path -Pattern 'Test Completed\. Result=' -ErrorAction SilentlyContinue)) {
$match = [regex]::Match([string]$matchLine.Line,
'Test Completed\. Result=\{(?<result>[^}]+)\} Name=\{(?<name>[^}]+)\} Path=\{(?<path>[^}]+)\}')
if (-not $match.Success) { continue }
$testPath = $match.Groups['path'].Value
$key = if ([string]::IsNullOrWhiteSpace($testPath)) { $match.Groups['name'].Value } else { $testPath }
if ($seen.ContainsKey($key)) { continue }
$seen[$key] = $true
$rawResult = $match.Groups['result'].Value
$status = if ($rawResult -match '^(Success|Passed|Pass)$') { 'passed' } else { 'failed' }
[void]$rows.Add([ordered]@{
name = $match.Groups['name'].Value
path = $testPath
status = $status
raw_result = $rawResult
})
}
}
return @($rows | Sort-Object path, name)
}
function Get-ParityKey($Tile) {
return "$($Tile.absolute_tile_x):$($Tile.absolute_tile_y):$($Tile.absolute_tile_z):$($Tile.lod)"
}
function Get-ParityConfigEvidence([string]$CommandletLogPath, [string]$GameLogPath) {
$pattern = '\[StrateManager\] (Strate |Initialized|Operator-stack)|\[VoxelForgeExplore\]\[OwnerAssets\]'
$commandletLines = @()
$gameLines = @()
if (Test-Path -LiteralPath $CommandletLogPath -PathType Leaf) {
$commandletLines = @(Select-String -LiteralPath $CommandletLogPath -Pattern $pattern -ErrorAction SilentlyContinue |
ForEach-Object { $_.Line })
}
if (Test-Path -LiteralPath $GameLogPath -PathType Leaf) {
$gameLines = @(Select-String -LiteralPath $GameLogPath -Pattern $pattern -ErrorAction SilentlyContinue |
ForEach-Object { $_.Line })
}
$getCount = {
param([object[]]$Lines)
foreach ($line in $Lines) {
$match = [regex]::Match([string]$line, 'Initialized (?<count>\d+) strates')
if ($match.Success) { return [int]$match.Groups['count'].Value }
}
return $null
}
$commandletCount = & $getCount $commandletLines
$gameCount = & $getCount $gameLines
return [ordered]@{
commandlet = [ordered]@{
initialized_strates = $commandletCount
relevant_log_lines = @($commandletLines | Select-Object -Last 30)
}
game = [ordered]@{
initialized_strates = $gameCount
relevant_log_lines = @($gameLines | Select-Object -Last 30)
}
layout_matches = ($null -ne $commandletCount -and $null -ne $gameCount -and $commandletCount -eq $gameCount)
interpretation = 'StrateManager lines expose the authored settings/strate layout used by each path; tile step/cells and content-band fields remain in the per-tile records.'
}
}
function Compare-Parity($CommandletJson, [string]$GameDumpPath,
[string]$CommandletLogPath, [string]$GameLogPath) {
$expected = @((Get-JsonValue (Get-JsonValue $CommandletJson 'summary') 'tile_ledger').tiles)
$gameJson = Read-JsonFile $GameDumpPath
$configEvidence = Get-ParityConfigEvidence $CommandletLogPath $GameLogPath
if ($null -eq $gameJson) {
return [ordered]@{
status = 'failed'
reason = "game tile hash dump missing: $GameDumpPath"
configuration = $configEvidence
}
}
$gameByKey = @{}
foreach ($tile in @((Get-JsonValue $gameJson 'records'))) {
$key = "$($tile.tile_x):$($tile.tile_y):$($tile.tile_z):$($tile.level)"
$gameByKey[$key] = $tile
}
$firstDifference = $null
foreach ($tile in $expected) {
$key = Get-ParityKey $tile
if (-not $gameByKey.ContainsKey($key)) {
$firstDifference = [ordered]@{ tile = $key; reason = 'missing_from_game'; commandlet = $tile }
break
}
$gameTile = $gameByKey[$key]
if ([string]$tile.geometry_hash -ne [string]$gameTile.geometry_hash) {
$diagnosis = [System.Collections.Generic.List[string]]::new()
if ([int]$gameTile.step -ne 1 -or [int]$gameTile.cells -ne 32) {
[void]$diagnosis.Add('LOD/step/cells differ from the level-0 export')
}
if ([int]$gameTile.band_chunk_lo -ne -2147483648 -or [int]$gameTile.band_chunk_hi -ne 2147483647) {
[void]$diagnosis.Add('runtime content-band cut is active')
}
if ($configEvidence.layout_matches -eq $false) {
[void]$diagnosis.Add(('settings/strate layout differs: commandlet initialized {0}, game initialized {1}' -f
$configEvidence.commandlet.initialized_strates, $configEvidence.game.initialized_strates))
}
[void]$diagnosis.Add('cvar defaults and authored settings are recorded in both artifacts')
[void]$diagnosis.Add('if those inputs match, the remaining candidate is the fused evaluator/runtime route')
$firstDifference = [ordered]@{
tile = $key
reason = 'geometry_hash_mismatch'
commandlet_hash = [string]$tile.geometry_hash
game_hash = [string]$gameTile.geometry_hash
commandlet_tile = $tile
game_tile = $gameTile
diagnosis = @($diagnosis)
}
break
}
}
if ($null -ne $firstDifference) {
return [ordered]@{
status = 'failed'
first_difference = $firstDifference
commandlet_tile_count = $expected.Count
game_record_count = @((Get-JsonValue $gameJson 'records')).Count
game_dump = $GameDumpPath
configuration = $configEvidence
}
}
return [ordered]@{
status = 'passed'
compared_tile_count = $expected.Count
commandlet_tile_count = $expected.Count
game_record_count = @((Get-JsonValue $gameJson 'records')).Count
game_dump = $GameDumpPath
configuration = $configEvidence
}
}
try {
if ([string]::IsNullOrWhiteSpace($Label)) { $Label = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss') }
$Label = ($Label -replace '[^A-Za-z0-9_.-]', '_')
if ([string]::IsNullOrWhiteSpace($Out)) {
$Out = Join-Path $PluginRoot "Saved\TestHarness\${Label}_${Scenario}"
}
elseif (-not [IO.Path]::IsPathRooted($Out)) {
throw "-Out must be an absolute path: $Out"
}
$RunRoot = Get-FullPath $Out
if (-not (Test-UnderPath $RunRoot (Join-Path $PluginRoot 'Saved'))) {
throw "-Out must stay under the plugin Saved directory: $RunRoot"
}
Ensure-Directory $RunRoot
$ResultPath = Join-Path $RunRoot 'result.json'
$SummaryPath = Join-Path $RunRoot 'summary.txt'
Ensure-Directory (Join-Path $RunRoot 'Logs')
Ensure-Directory (Join-Path $RunRoot 'User')
Ensure-Directory (Join-Path $RunRoot 'ZenData')
$DDCPath = Join-Path $RunRoot 'DDC'
if (-not [string]::IsNullOrWhiteSpace($SharedDDC)) {
if (-not [IO.Path]::IsPathRooted($SharedDDC)) {
throw "-SharedDDC must be an absolute path: $SharedDDC"
}
$DDCPath = Get-FullPath $SharedDDC
if (-not (Test-UnderPath $DDCPath (Join-Path $PluginRoot 'Saved'))) {
throw "-SharedDDC must stay under the plugin Saved directory: $DDCPath"
}
}
Ensure-Directory $DDCPath
$localBefore = Get-StateSnapshot $LocalAppDataRoot
foreach ($required in @($EditorExe, $UbtDll, $HostProject, $ProjectFile)) {
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Required file is missing: $required" }
}
if ($Build) {
$sourceBefore = Get-SourceSnapshot $PluginRoot
$stageBefore = Get-SourceSnapshot $StagePlugin
$sourceChangedKeys = @(Get-ChangedSnapshotKeys $stageBefore $sourceBefore)
$runtimeBefore = Get-Sha256 (Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForge.dll')
$editorBefore = Get-Sha256 (Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForgeEditor.dll')
Assert-NoForeignUnreal 'before staged sync/build'
Sync-StagedHost
$buildLog = Join-Path $RunRoot 'Logs\ubt.log'
$buildStdout = Join-Path $RunRoot 'Logs\build.stdout.log'
$buildError = Join-Path $RunRoot 'Logs\build.err.log'
$buildArgs = @(('"{0}"' -f $UbtDll), 'UnrealEditor', 'Win64', 'Development', ('-Project="{0}"' -f $HostProject),
'-WaitMutex', '-FromMsBuild', '-architecture=x64', '-NoUBA', '-MaxParallelActions=4',
('-Log="{0}"' -f $buildLog))
$BuildRecord = Invoke-BuildProcess $buildArgs $buildLog $buildError $buildStdout
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 ($runtimeChanged -and $runtimeBefore -eq $runtimeAfter) {
throw 'Source changed under Source/VoxelForge but the staged runtime DLL SHA-256 did not change.'
}
if ($editorChanged -and $editorBefore -eq $editorAfter) {
throw 'Source changed under Source/VoxelForgeEditor but the staged editor DLL SHA-256 did not change.'
}
}
if ($Assets -eq 'owner') {
$AssetEvidence = @(Sync-OwnerAssets)
} else {
$AssetEvidence = @(Get-AssetEvidence)
}
if ($Scenario -eq 'tests') {
$AssetEvidence += @(Sync-TestCharacterFixture)
}
$DllEvidence.runtime = [ordered]@{
path = Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForge.dll'
sha256 = Get-Sha256 (Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForge.dll')
}
$DllEvidence.editor = [ordered]@{
path = Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForgeEditor.dll'
sha256 = Get-Sha256 (Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForgeEditor.dll')
}
if (-not $DllEvidence.runtime.sha256 -or -not $DllEvidence.editor.sha256) {
throw "Staged runtime/editor DLL is missing under $StagePlugin; use -Build."
}
# VoxelForgeExplore deliberately rejects output outside its staged plugin Saved root. The
# harness result stays in $RunRoot; this is the commandlet's internal artifact directory.
$commandletOut = Join-Path $StagePlugin "Saved\TestHarness\${Label}_${Scenario}"
$baseCvars = [ordered]@{}
$commandletLedger = $null
$gameLedger = $null
switch ($Scenario) {
'canonical' {
$baseCvars['voxel.OuterClassifierMode'] = 0
$args = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\canonical.log') (Merge-Cvars $baseCvars)
$args += New-CommandletArguments $commandletOut 'walk,export,tunnelcoreprobe' $false $false 128
$commandletLedger = Invoke-UnrealLaunch 'canonical-commandlet' $args `
(Join-Path $RunRoot 'Logs\canonical.log') (Join-Path $RunRoot 'Logs\canonical.stdout.log') (Join-Path $RunRoot 'Logs\canonical.stderr.log')
$CommandletMetrics = Get-CommandletMetrics $commandletOut 'synthetic canonical'
$canonicalObjSha = Get-JsonValue $CommandletMetrics 'obj_sha256'
if ($canonicalObjSha -and $canonicalObjSha -ne $CanonicalObjSha256) {
Add-Failure "canonical OBJ SHA-256 changed: expected $CanonicalObjSha256, got $canonicalObjSha"
}
}
'owner' {
$args = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\owner.log') (Merge-Cvars $baseCvars)
$args += New-CommandletArguments $commandletOut 'walk,export' ($Assets -eq 'owner') $false 128
$commandletLedger = Invoke-UnrealLaunch 'owner-commandlet' $args `
(Join-Path $RunRoot 'Logs\owner.log') (Join-Path $RunRoot 'Logs\owner.stdout.log') (Join-Path $RunRoot 'Logs\owner.stderr.log')
$CommandletMetrics = Get-CommandletMetrics $commandletOut 'owner assets'
}
'probe' {
$args = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\probe.log') (Merge-Cvars $baseCvars)
$args += New-CommandletArguments $commandletOut 'tunnelcoreprobe' ($Assets -eq 'owner') $false 128
$commandletLedger = Invoke-UnrealLaunch 'probe-commandlet' $args `
(Join-Path $RunRoot 'Logs\probe.log') (Join-Path $RunRoot 'Logs\probe.stdout.log') (Join-Path $RunRoot 'Logs\probe.stderr.log')
$CommandletMetrics = Get-CommandletMetrics $commandletOut 'owner probe'
}
'horizon' {
$tracePath = Join-Path $RunRoot 'horizon_trace.jsonl'
$baseCvars['voxel.TestRenderDistanceChunks'] = $HorizonRenderDistanceChunks
$baseCvars['voxel.TestMaxClipLevel'] = $HorizonLevel
$baseCvars['voxel.StartupTraceFile'] = $tracePath
$baseCvars['voxel.StartupTraceMaxTiles'] = 16384
$baseCvars['voxel.TestExitOnSteady'] = 1
$baseCvars['voxel.OuterClassifierMode'] = 0
$args = New-CommonArguments 'game' (Join-Path $RunRoot 'Logs\horizon.log') (Merge-Cvars $baseCvars)
$gameLedger = Invoke-UnrealLaunch "horizon-l$($HorizonLevel)-$($HorizonRenderDistanceChunks)-game" $args `
(Join-Path $RunRoot 'Logs\horizon.log') (Join-Path $RunRoot 'Logs\horizon.stdout.log') (Join-Path $RunRoot 'Logs\horizon.stderr.log')
$GameMetrics = Get-StreamingMetrics (Join-Path $RunRoot 'Logs\horizon.log')
$HorizonMetrics = Get-HorizonMetrics $tracePath $gameLedger $GameMetrics
if ($HorizonMetrics.status -ne 'ok') {
Add-Failure "horizon measurement trace did not settle: $($HorizonMetrics.status)"
}
}
'horizon-render' {
$tracePath = Join-Path $RunRoot 'horizon_render_trace.jsonl'
$baseCvars['voxel.TestRenderDistanceChunks'] = $HorizonRenderDistanceChunks
$baseCvars['voxel.TestMaxClipLevel'] = $HorizonLevel
$baseCvars['voxel.StartupTraceFile'] = $tracePath
$baseCvars['voxel.StartupTraceMaxTiles'] = 16384
$baseCvars['voxel.TestCeilingView'] = 1
$baseCvars['voxel.TestCeilingCaptureDelaySeconds'] = 90
$baseCvars['voxel.TestExitSeconds'] = 180
$baseCvars['voxel.OuterClassifierMode'] = 0
$renderLog = Join-Path $RunRoot 'Logs\horizon-render.log'
$args = New-CommonArguments 'game' $renderLog (Merge-Cvars $baseCvars) $true
$gameLedger = Invoke-UnrealLaunch "horizon-l$($HorizonLevel)-$($HorizonRenderDistanceChunks)-render" $args `
$renderLog (Join-Path $RunRoot 'Logs\horizon-render.stdout.log') (Join-Path $RunRoot 'Logs\horizon-render.stderr.log')
$GameMetrics = Get-StreamingMetrics $renderLog
$HorizonMetrics = Get-HorizonMetrics $tracePath $gameLedger $GameMetrics
$HorizonRenderMetrics = Get-HorizonRenderMetrics $gameLedger $renderLog
if ($HorizonMetrics.status -ne 'ok') {
Add-Failure "horizon render trace did not settle: $($HorizonMetrics.status)"
}
if ($HorizonRenderMetrics.status -ne 'passed') {
Add-Failure "horizon render did not produce a capture: $($HorizonRenderMetrics.status)"
}
}
'determinism' {
$defaultDump = Join-Path $RunRoot 'tile_hashes_default.json'
$crtFma3OffDump = Join-Path $RunRoot 'tile_hashes_crt_fma3_off.json'
$defaultTrace = Join-Path $RunRoot 'determinism_default_trace.jsonl'
$crtFma3OffTrace = Join-Path $RunRoot 'determinism_crt_fma3_off_trace.jsonl'
$determinismBase = [ordered]@{
'voxel.TestRenderDistanceChunks' = $HorizonRenderDistanceChunks
'voxel.TestMaxClipLevel' = $HorizonLevel
'voxel.TestExitOnSteady' = 1
'voxel.StartupTraceMaxTiles' = 16384
'voxel.OuterClassifierMode' = 0
}
$defaultCvars = [ordered]@{}
foreach ($key in $determinismBase.Keys) { $defaultCvars[$key] = $determinismBase[$key] }
$defaultCvars['voxel.StartupTraceFile'] = $defaultTrace
$defaultCvars['voxel.TileHashDump'] = $defaultDump
$defaultLog = Join-Path $RunRoot 'Logs\determinism-default.log'
$defaultArgs = New-CommonArguments 'game' $defaultLog (Merge-Cvars $defaultCvars)
$defaultLedger = Invoke-UnrealLaunch "determinism-default-l$($HorizonLevel)-$($HorizonRenderDistanceChunks)" $defaultArgs `
$defaultLog (Join-Path $RunRoot 'Logs\determinism-default.stdout.log') (Join-Path $RunRoot 'Logs\determinism-default.stderr.log')
$defaultMetrics = Get-HorizonMetrics $defaultTrace $defaultLedger (Get-StreamingMetrics $defaultLog)
$crtFma3OffCvars = [ordered]@{}
foreach ($key in $determinismBase.Keys) { $crtFma3OffCvars[$key] = $determinismBase[$key] }
$crtFma3OffCvars['voxel.StartupTraceFile'] = $crtFma3OffTrace
$crtFma3OffCvars['voxel.TileHashDump'] = $crtFma3OffDump
$crtFma3OffCvars['voxel.CrtFma3'] = 0
$crtFma3OffLog = Join-Path $RunRoot 'Logs\determinism-crt-fma3-off.log'
$crtFma3OffArgs = New-CommonArguments 'game' $crtFma3OffLog (Merge-Cvars $crtFma3OffCvars)
$crtFma3OffLedger = Invoke-UnrealLaunch "determinism-crt-fma3-off-l$($HorizonLevel)-$($HorizonRenderDistanceChunks)" $crtFma3OffArgs `
$crtFma3OffLog (Join-Path $RunRoot 'Logs\determinism-crt-fma3-off.stdout.log') (Join-Path $RunRoot 'Logs\determinism-crt-fma3-off.stderr.log')
$crtFma3OffMetrics = Get-HorizonMetrics $crtFma3OffTrace $crtFma3OffLedger (Get-StreamingMetrics $crtFma3OffLog)
$DeterminismEvidence = Compare-DeterminismDumps $defaultDump $crtFma3OffDump
$DeterminismEvidence.level = $HorizonLevel
$DeterminismEvidence.render_distance_chunks = $HorizonRenderDistanceChunks
$DeterminismEvidence.default_measurement = $defaultMetrics
$DeterminismEvidence.crt_fma3_off_measurement = $crtFma3OffMetrics
$GameMetrics = $crtFma3OffMetrics
if ($DeterminismEvidence.status -ne 'passed') {
Add-Failure "determinism comparison failed: $(Get-JsonValue $DeterminismEvidence 'reason' 'unknown difference')"
}
}
'perf' {
$baseCvars['voxel.TestExitSeconds'] = 15
$baseCvars['voxel.ProfileTileGeneration'] = 1
$baseCvars['voxel.UseBlockEarlyOut'] = 0
$baseCvars['voxel.OuterClassifierMode'] = 0
$baseCvars['voxel.SealedSolidProof'] = 0
$args = New-CommonArguments 'game' (Join-Path $RunRoot 'Logs\perf.log') (Merge-Cvars $baseCvars)
$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)"
}
}
'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)
# Use the same 128-voxel / 64-tile bounded region as the canonical exporter. The game
# harness streams a larger clipmap around its center; comparison is restricted to these
# exact level-0 tiles, including non-empty geometry rather than only an air tile.
$parityArgs += New-CommandletArguments $commandletOut 'export' $true $true 128 0
$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' 128)
$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'
$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)
$gameLedger = Invoke-UnrealLaunch 'parity-game' $gameArgs `
(Join-Path $RunRoot 'Logs\parity-game.log') (Join-Path $RunRoot 'Logs\parity-game.stdout.log') (Join-Path $RunRoot 'Logs\parity-game.stderr.log')
$GameMetrics = Get-StreamingMetrics (Join-Path $RunRoot 'Logs\parity-game.log')
$ParityEvidence = Compare-Parity $parityJson $gameDump `
(Join-Path $RunRoot 'Logs\parity-commandlet.log') (Join-Path $RunRoot 'Logs\parity-game.log')
if ($ParityEvidence.status -ne 'passed') {
$difference = Get-JsonValue $ParityEvidence 'first_difference' $null
if ($null -ne $difference) {
Add-Failure "parity verdict failed: first differing tile $($difference.tile) ($($difference.reason))"
} else {
Add-Failure "parity verdict failed: $(Get-JsonValue $ParityEvidence 'reason' 'unknown parity failure')"
}
}
}
}
$localAfter = Get-StateSnapshot $LocalAppDataRoot
$LocalAppDataWrites = @(Get-StateChanges $localBefore $localAfter)
if ($LocalAppDataWrites.Count -gt 0) {
Add-Failure "writes detected under ${LocalAppDataRoot}: $($LocalAppDataWrites[0])"
}
} catch {
Add-Failure $_.Exception.Message
}
if ($null -eq $RunRoot) {
$RunRoot = Join-Path $PluginRoot 'Saved\TestHarness\failed-before-output'
Ensure-Directory $RunRoot
}
if ($null -eq $ResultPath) { $ResultPath = Join-Path $RunRoot 'result.json' }
if ($null -eq $SummaryPath) { $SummaryPath = Join-Path $RunRoot 'summary.txt' }
$pass = $Failures.Count -eq 0
$reason = if ($pass) { 'all requested checks passed' } else { [string]$Failures[0] }
$result = [ordered]@{
schema_version = 1
pass = $pass
status = if ($pass) { 'PASS' } else { 'FAIL' }
reason = $reason
scenario = $Scenario
label = $Label
run_directory = $RunRoot
ddc_path = $DDCPath
project = $ProjectFile
staged_host = $HostProject
assets_mode = $Assets
requested_cvars = $Cvars
canonical_baseline = [ordered]@{
commit = $CanonicalBaselineCommit
expected_obj_sha256 = $CanonicalObjSha256
checked = $Scenario -eq 'canonical'
}
assets = @($AssetEvidence)
build = $BuildRecord
staged_dlls = $DllEvidence
capability_counts = if ($CommandletMetrics.Contains('capability_counts')) { $CommandletMetrics.capability_counts } else { [ordered]@{} }
connectivity = if ($CommandletMetrics.Contains('connectivity')) { $CommandletMetrics.connectivity } else { [ordered]@{} }
geometry_hash = if ($CommandletMetrics.Contains('geometry_hash')) { $CommandletMetrics.geometry_hash } else { $null }
obj_sha256 = if ($CommandletMetrics.Contains('obj_sha256')) { $CommandletMetrics.obj_sha256 } else { $null }
probe_amplitudes = if ($CommandletMetrics.Contains('probe_amplitudes')) { $CommandletMetrics.probe_amplitudes } else { [ordered]@{} }
commandlet = $CommandletMetrics
game = $GameMetrics
horizon = $HorizonMetrics
horizon_render = $HorizonRenderMetrics
determinism = $DeterminismEvidence
surface_fall = $SurfaceFallMetrics
strate_crossing = $StrateCrossingMetrics
visual_staleness = $VisualStalenessMetrics
collision_gate_stress = $CollisionGateStressMetrics
worker_seconds = [ordered]@{
commandlet = if ($CommandletMetrics.Contains('worker_seconds')) { $CommandletMetrics.worker_seconds } else { $null }
game = if ($GameMetrics.Contains('worker_generation_sum_s')) { $GameMetrics.worker_generation_sum_s } else { $null }
}
p50_p95 = if ($GameMetrics.Contains('p50_p95')) { $GameMetrics.p50_p95 } else { [ordered]@{} }
tests = @($TestMetrics)
parity = $ParityEvidence
localappdata_unrealengine = [ordered]@{
path = $LocalAppDataRoot
writes_detected = @($LocalAppDataWrites)
verified_clean = $LocalAppDataWrites.Count -eq 0
}
failures = @($Failures)
launch_ledger = @($LaunchLedger)
}
try {
Ensure-Directory (Split-Path -Parent $ResultPath)
($result | ConvertTo-Json -Compress -Depth 30) | Set-Content -LiteralPath $ResultPath -Encoding UTF8
$summaryLines = [System.Collections.Generic.List[string]]::new()
[void]$summaryLines.Add("$(if ($pass) { 'PASS' } else { 'FAIL' }) $reason")
[void]$summaryLines.Add("scenario=$Scenario label=$Label")
[void]$summaryLines.Add("run_directory=$RunRoot")
[void]$summaryLines.Add("ddc_path=$DDCPath")
[void]$summaryLines.Add("assets=$Assets")
[void]$summaryLines.Add("canonical_baseline=$(ConvertTo-CompactJson $result.canonical_baseline)")
[void]$summaryLines.Add("capability_counts=$(ConvertTo-CompactJson $result.capability_counts)")
[void]$summaryLines.Add("connectivity=$(ConvertTo-CompactJson $result.connectivity)")
[void]$summaryLines.Add("geometry_hash=$($result.geometry_hash)")
[void]$summaryLines.Add("obj_sha256=$($result.obj_sha256)")
[void]$summaryLines.Add("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("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