fix(collision gate): stop freezing the player on a stale prediction

The owner fell through a surface-only world and hovered in mid-air: log showed 'ready=1 predicted_wait=1' for 50 s. Two causes, both fixed: (1) engaging the gate zeroes velocity, so the predicted-support probe never re-ran and the gate kept waiting on the tile remembered from when the pawn was still moving -- the prediction is now re-derived every tick from the pawn's current pose; (2) a tile the streamer never requests (out of layout, or outside the desired set) could never become ready, so the wait was unbounded -- such tiles are now logged and treated as nonblocking, and any wait is bounded to 2 s with a warning.

Repro added to the harness (surface-fall scenario): before, the pawn stops at Z=1315.9 with collision_gate_prediction_deadlock; after, it reaches the ground in 2.25 s at Z=-45.6. Strate crossing still protected: 145.49 s route, max gate wait 0.109 s (limit 10 s). Perf S/M/S/M 24.265/54.262 and 23.947/53.057 s, within repeat noise. Owner and canonical geometry unchanged (44E7C813 / 94B47A1C).

Known red, pre-existing and unrelated: VoxelForge.Composer.ParameterRoll still reports false box verdicts in the owner's current single-strate surface layout (5 violations, identical in the baseline). Next round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-09-22 11:17:01 +02:00
1 parent 7af8d0e870
commit 0dd45ca410
3 files changed
+697 -35

No files matched your search

+100 -3
View File
@@ -1,6 +1,6 @@
[CmdletBinding()]
param(
[ValidateSet('canonical', 'owner', 'probe', 'perf', 'tests', 'parity')]
[ValidateSet('canonical', 'owner', 'probe', 'perf', 'tests', 'surface-fall', 'crossing', 'parity')]
[string]$Scenario = 'canonical',
[switch]$Build,
[ValidateSet('owner', 'default')]
@@ -43,6 +43,8 @@ $BuildRecord = [ordered]@{ requested = [bool]$Build; status = 'not_requested' }
$AssetEvidence = @()
$CommandletMetrics = [ordered]@{}
$GameMetrics = [ordered]@{}
$SurfaceFallMetrics = [ordered]@{ requested = $Scenario -in @('tests', 'surface-fall'); status = 'not_run' }
$StrateCrossingMetrics = [ordered]@{ requested = $Scenario -eq 'crossing'; status = 'not_run' }
$TestMetrics = @()
$ParityEvidence = [ordered]@{ requested = $Scenario -eq 'parity'; status = 'not_run' }
$DllEvidence = [ordered]@{
@@ -290,7 +292,13 @@ function Invoke-UnrealLaunch([string]$Kind, [string[]]$Arguments, [string]$LogPa
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
$ledger.pid = [int]$process.Id
@@ -301,7 +309,10 @@ function Invoke-UnrealLaunch([string]$Kind, [string[]]$Arguments, [string]$LogPa
} 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)
@@ -325,12 +336,16 @@ function Invoke-BuildProcess([string[]]$Arguments, [string]$LogPath, [string]$Er
$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
$wait = Wait-OwnedProcess $process $LaunchTimeoutSeconds
@@ -338,6 +353,7 @@ function Invoke-BuildProcess([string[]]$Arguments, [string]$LogPath, [string]$Er
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
@@ -350,6 +366,7 @@ function Invoke-BuildProcess([string[]]$Arguments, [string]$LogPath, [string]$Er
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' }
}
}
@@ -409,7 +426,7 @@ function Sync-OwnerAssets {
$null = Copy-Item -LiteralPath $source -Destination $target -Force -PassThru
[void]$rows.Add([ordered]@{
name = $assetName
used_by_scenario = $Scenario -in @('owner', 'probe', 'parity')
used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing')
source_path = $source
source_sha256 = Get-Sha256 $source
staged_path = $target
@@ -457,7 +474,7 @@ function Get-AssetEvidence {
$target = Join-Path $HostRoot "Content\VoxelForge\$assetName.uasset"
[void]$rows.Add([ordered]@{
name = $assetName
used_by_scenario = $Scenario -in @('owner', 'probe', 'parity')
used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing')
source_path = $source
source_sha256 = Get-Sha256 $source
staged_path = $target
@@ -644,6 +661,36 @@ function Get-StreamingMetrics([string]$LogPath) {
return $metrics
}
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-TestMetrics($Ledger) {
if ($null -eq $Ledger) { return @() }
$logPaths = @($Ledger.log, $Ledger.stdout, $Ledger.stderr) | Where-Object {
@@ -904,6 +951,35 @@ try {
(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')
}
'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') {
Add-Failure "surface-fall repro did not pass: $($SurfaceFallMetrics.status) $($SurfaceFallMetrics.reason)"
}
}
'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') {
Add-Failure "strate-crossing test did not pass: $($StrateCrossingMetrics.status) $($StrateCrossingMetrics.reason)"
}
}
'tests' {
$testPath = if ([string]::IsNullOrWhiteSpace($TestFilter)) { 'VoxelForge' } else { $TestFilter }
$baseCvars['ExecCmds'] = "Automation RunTests $testPath; Quit"
@@ -925,6 +1001,23 @@ try {
$failedNames = ($failedTests | ForEach-Object { $_.path }) -join ', '
Add-Failure "automation tests failed: $failedNames"
}
# The runtime collision-gate repro is part of the tests scenario as an integration
# test alongside the 44 pure automation tests. It uses the same isolated host/assets
# and gets its own launch ledger entry and log.
$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') {
Add-Failure "surface-fall repro did not pass: $($SurfaceFallMetrics.status) $($SurfaceFallMetrics.reason)"
}
}
'parity' {
$parityArgs = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\parity-commandlet.log') (Merge-Cvars $baseCvars)
@@ -1015,6 +1108,8 @@ $result = [ordered]@{
probe_amplitudes = if ($CommandletMetrics.Contains('probe_amplitudes')) { $CommandletMetrics.probe_amplitudes } else { [ordered]@{} }
commandlet = $CommandletMetrics
game = $GameMetrics
surface_fall = $SurfaceFallMetrics
strate_crossing = $StrateCrossingMetrics
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 }
@@ -1044,6 +1139,8 @@ try {
[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("probe_amplitudes=$(ConvertTo-CompactJson $result.probe_amplitudes)")
[void]$summaryLines.Add("worker_seconds=$(ConvertTo-CompactJson $result.worker_seconds)")
[void]$summaryLines.Add("p50_p95=$(ConvertTo-CompactJson $result.p50_p95)")