diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index b8cd023..4d1663d 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -1899,6 +1899,7 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest() HeadlessCollisionGateStressTestGateStartSeconds = 0.0; HeadlessCollisionGateStressTestFullStopStartSeconds = 0.0; HeadlessCollisionGateStressTestDistanceCm = 0.0; + HeadlessCollisionGateStressTestGateDistanceCm = 0.0; HeadlessCollisionGateStressTestTotalGateDurationSeconds = 0.0; HeadlessCollisionGateStressTestMaxGateDurationSeconds = 0.0; HeadlessCollisionGateStressTestFullStopDurationSeconds = 0.0; @@ -3044,6 +3045,10 @@ void AVoxelWorld::ObserveHeadlessCollisionGateStressTest( HeadlessCollisionGateStressTestLastActualPosition = PlayerPosition; const bool bGateEngaged = bPawnCollisionGateEngaged; + if (bGateEngaged || bHeadlessCollisionGateStressTestGateWasEngaged) + { + HeadlessCollisionGateStressTestGateDistanceCm += HorizontalDelta; + } if (bGateEngaged && !bHeadlessCollisionGateStressTestGateWasEngaged) { ++HeadlessCollisionGateStressTestGateHoldCount; @@ -3449,18 +3454,32 @@ void AVoxelWorld::MaybeFinishHeadlessCollisionGateStressTest() && Now - HeadlessCollisionGateStressTestStartSeconds >= static_cast(HeadlessCollisionGateStressTestDurationSeconds)) { - const double ExpectedDistanceCm = - static_cast(HeadlessCollisionGateStressTestSpeedCmPerSecond) - * static_cast(HeadlessCollisionGateStressTestDurationSeconds); - if (HeadlessCollisionGateStressTestFullStopCount > 0) + // Total travel also reflects authored terrain along this deterministic path. Keep it as + // diagnostic output; judge the gate on support readiness and progress during a real hold. + if (!bStartupCollisionGateTimingCaptured) { bHeadlessCollisionGateStressTestFailed = true; - HeadlessCollisionGateStressTestFailureReason = TEXT("full_horizontal_stop"); + HeadlessCollisionGateStressTestFailureReason = TEXT("support_not_requested"); } - else if (HeadlessCollisionGateStressTestDistanceCm < ExpectedDistanceCm * 0.65) + else if (StartupCollisionGateSubmittedSeconds <= 0.0) { bHeadlessCollisionGateStressTestFailed = true; - HeadlessCollisionGateStressTestFailureReason = TEXT("horizontal_progress_shortfall"); + HeadlessCollisionGateStressTestFailureReason = TEXT("support_not_submitted"); + } + else if (StartupCollisionGateReadySeconds <= 0.0) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = TEXT("support_never_collision_ready"); + } + else if (HeadlessCollisionGateStressTestGateHoldCount == 0) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = TEXT("gate_not_engaged"); + } + else if (HeadlessCollisionGateStressTestGateDistanceCm < 10.0) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = TEXT("horizontal_progress_during_gate_shortfall"); } else { @@ -3502,11 +3521,23 @@ void AVoxelWorld::MaybeFinishHeadlessCollisionGateStressTest() const double ExpectedDistanceCm = static_cast(HeadlessCollisionGateStressTestSpeedCmPerSecond) * static_cast(HeadlessCollisionGateStressTestDurationSeconds); + const double SupportSubmitLatencySeconds = bStartupCollisionGateTimingCaptured + && StartupCollisionGateSubmittedSeconds > 0.0 + ? FMath::Max(0.0, StartupCollisionGateSubmittedSeconds + - StartupCollisionGateFirstCheckSeconds) + : -1.0; + const double SupportReadyLatencySeconds = bStartupCollisionGateTimingCaptured + && StartupCollisionGateReadySeconds > 0.0 + ? FMath::Max(0.0, StartupCollisionGateReadySeconds + - StartupCollisionGateFirstCheckSeconds) + : -1.0; UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCollisionGateStressTest] result=%s reason=%s elapsed_s=%.6f " "distance_m=%.6f expected_distance_m=%.6f speed_cm_s=%.1f " - "gate_holds=%d gate_total_s=%.6f max_gate_s=%.6f " + "gate_holds=%d gate_total_s=%.6f max_gate_s=%.6f gate_progress_m=%.6f " "full_stops=%d full_stop_total_s=%.6f collision_gate=%d " + "support_tile=(%d,%d,%d) support_first_check_to_submit_s=%.6f " + "support_first_check_to_collision_ready_s=%.6f " "final_position=(%.1f,%.1f,%.1f)"), bPassed ? TEXT("PASS") : TEXT("FAIL"), bPassed ? TEXT("continuous_horizontal_progress") @@ -3518,9 +3549,15 @@ void AVoxelWorld::MaybeFinishHeadlessCollisionGateStressTest() HeadlessCollisionGateStressTestGateHoldCount, HeadlessCollisionGateStressTestTotalGateDurationSeconds, HeadlessCollisionGateStressTestMaxGateDurationSeconds, + HeadlessCollisionGateStressTestGateDistanceCm / 100.0, HeadlessCollisionGateStressTestFullStopCount, HeadlessCollisionGateStressTestFullStopDurationSeconds, bCollisionGateEnabled ? 1 : 0, + StartupCollisionGateSupportTile.Coord.X, + StartupCollisionGateSupportTile.Coord.Y, + StartupCollisionGateSupportTile.Coord.Z, + SupportSubmitLatencySeconds, + SupportReadyLatencySeconds, HeadlessCollisionGateStressTestLastActualPosition.X, HeadlessCollisionGateStressTestLastActualPosition.Y, HeadlessCollisionGateStressTestLastActualPosition.Z); @@ -4505,6 +4542,14 @@ void AVoxelWorld::RequestCollisionGateSupportTile(const FVoxelTileKey& SupportTi return; } + const double RequestCheckSeconds = FPlatformTime::Seconds(); + if (!bStartupCollisionGateTimingCaptured) + { + StartupCollisionGateSupportTile = SupportTile; + bStartupCollisionGateTimingCaptured = true; + StartupCollisionGateFirstCheckSeconds = RequestCheckSeconds; + } + if (!bPawnGateSupportRequestReported || PawnGateRequestedSupportTile.Coord != SupportTile.Coord || PawnGateRequestedSupportTile.Level != SupportTile.Level) @@ -4513,38 +4558,51 @@ void AVoxelWorld::RequestCollisionGateSupportTile(const FVoxelTileKey& SupportTi bPawnGateSupportRequestReported = false; } - // The gate runs before UpdateChunksAroundPosition. If the floor has just moved outside the - // old desired stamp, ask that normal rebuild to make it the critical floor request rather than - // generating an orphan tile that the streaming policy could immediately cull. - if (DesiredStamp == 0 || !IsDesired(SupportTile)) + // A gate check can run in BeginPlay, before the first desired set, and before the rebuild on a + // teleport/crossing tick. Submit the level-0 collision tile immediately; the desired rebuild + // below adopts the same key through PendingTiles and IsDesired, so it is neither duplicated nor + // cancelled when the full clipmap arrives. + const bool bDesiredAtRequest = DesiredStamp != 0 && IsDesired(SupportTile); + if (!bDesiredAtRequest) { bForceDesiredRebuild = true; - if (!bPawnGateSupportRequestReported) - { - UE_LOG(LogTemp, Display, - TEXT("[VoxelForgeCollisionGate] support_request_deferred tile=(%d,%d,%d) " - "reason=outside_desired_set"), - SupportTile.Coord.X, SupportTile.Coord.Y, SupportTile.Coord.Z); - bPawnGateSupportRequestReported = true; - } - return; } - // Preserve the existing critical floor ordering for the next submit pass, and make an - // immediate game-thread request when no mesh/cook is already in flight. The critical work - // class uses the established BackgroundHigh streaming priority. - CriticalDesiredTiles.Remove(SupportTile); - CriticalDesiredTiles.Insert(SupportTile, 0); + // Preserve the established critical floor ordering when it already belongs to this desired + // epoch. Before the first build there is no array to promote into; LoadTile below still uses + // the same CollisionCritical work class and priority queue. + if (bDesiredAtRequest) + { + CriticalDesiredTiles.Remove(SupportTile); + CriticalDesiredTiles.Insert(SupportTile, 0); + } if (PendingTiles.Contains(SupportTile) || LoadedTiles.Contains(SupportTile)) { + if (bStartupCollisionGateTimingCaptured + && StartupCollisionGateSupportTile.Coord == SupportTile.Coord + && StartupCollisionGateSupportTile.Level == SupportTile.Level + && StartupCollisionGateSubmittedSeconds <= 0.0) + { + // A prior normal streaming submit is already an accepted request. The exact earlier + // submission is outside the gate's sample, so report zero gate-to-submit latency. + StartupCollisionGateSubmittedSeconds = RequestCheckSeconds; + } if (!bPawnGateSupportRequestReported) { UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCollisionGate] support_request_present tile=(%d,%d,%d) " - "pending=%d loaded=%d"), + "pending=%d loaded=%d desired=%d first_check_to_submit_s=%.6f"), SupportTile.Coord.X, SupportTile.Coord.Y, SupportTile.Coord.Z, PendingTiles.Contains(SupportTile) ? 1 : 0, - LoadedTiles.Contains(SupportTile) ? 1 : 0); + LoadedTiles.Contains(SupportTile) ? 1 : 0, + bDesiredAtRequest ? 1 : 0, + bStartupCollisionGateTimingCaptured + && StartupCollisionGateSupportTile.Coord == SupportTile.Coord + && StartupCollisionGateSupportTile.Level == SupportTile.Level + && StartupCollisionGateSubmittedSeconds > 0.0 + ? FMath::Max(0.0, StartupCollisionGateSubmittedSeconds + - StartupCollisionGateFirstCheckSeconds) + : -1.0); bPawnGateSupportRequestReported = true; } return; @@ -4552,17 +4610,28 @@ void AVoxelWorld::RequestCollisionGateSupportTile(const FVoxelTileKey& SupportTi const int32 PendingBefore = PendingTiles.Num(); LoadTile(SupportTile, EVoxelTileWorkPriority::CollisionCritical); - if (PendingTiles.Num() > PendingBefore) + const bool bSubmitted = PendingTiles.Num() > PendingBefore + || PendingTiles.Contains(SupportTile); + if (bSubmitted) { bAllChunksLoaded = false; } - if (!bPawnGateSupportRequestReported) + if (!bPawnGateSupportRequestReported || bSubmitted) { UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCollisionGate] support_request tile=(%d,%d,%d) " - "submitted=%d priority=high"), + "submitted=%d desired=%d priority=collision_critical " + "first_check_to_submit_s=%.6f"), SupportTile.Coord.X, SupportTile.Coord.Y, SupportTile.Coord.Z, - PendingTiles.Contains(SupportTile) ? 1 : 0); + bSubmitted ? 1 : 0, + bDesiredAtRequest ? 1 : 0, + bStartupCollisionGateTimingCaptured + && StartupCollisionGateSupportTile.Coord == SupportTile.Coord + && StartupCollisionGateSupportTile.Level == SupportTile.Level + && StartupCollisionGateSubmittedSeconds > 0.0 + ? FMath::Max(0.0, StartupCollisionGateSubmittedSeconds + - StartupCollisionGateFirstCheckSeconds) + : -1.0); bPawnGateSupportRequestReported = true; } } @@ -4658,12 +4727,23 @@ void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosi bPawnGateUnavailableReported = false; } + if (!bCurrentSupportReady && !bStartupCollisionGateTimingCaptured) + { + StartupCollisionGateSupportTile = CurrentSupportTile; + bStartupCollisionGateTimingCaptured = true; + StartupCollisionGateFirstCheckSeconds = FPlatformTime::Seconds(); + } + if (bCurrentSupportReady) { ReleasePawnCollisionGate(); return; } + // Request collision even when the pawn is moving up and the gate will not hold it. A spawn or + // teleport should not have to wait for downward velocity before the current floor is queued. + RequestCollisionGateSupportTile(CurrentSupportTile); + // An unresolved tile above the pawn is not a landing hazard. Let upward movement continue; // only a downward/standing pawn needs its fall cancelled until the current support resolves. if (const ACharacter* Character = Cast(Pawn)) @@ -4678,27 +4758,6 @@ void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosi } } - RequestCollisionGateSupportTile(CurrentSupportTile); - - constexpr double MaxCollisionGateWaitSeconds = 2.0; - if (bPawnCollisionGateEngaged && PawnGateEngagedAtSeconds > 0.0) - { - const double WaitSeconds = FPlatformTime::Seconds() - PawnGateEngagedAtSeconds; - if (WaitSeconds >= MaxCollisionGateWaitSeconds) - { - UE_LOG(LogTemp, Warning, - TEXT("[VoxelForgeCollisionGate] wait_timeout pawn=%s waited_s=%.3f " - "current_ready=%d support=(%d,%d,%d); releasing safety gate"), - *Pawn->GetName(), - WaitSeconds, - bCurrentSupportReady ? 1 : 0, - CurrentSupportTile.Coord.X, CurrentSupportTile.Coord.Y, - CurrentSupportTile.Coord.Z); - ReleasePawnCollisionGate(); - return; - } - } - if (!bPawnCollisionGateEngaged) { UE_LOG(LogTemp, Display, @@ -5137,6 +5196,30 @@ void AVoxelWorld::HandleTileCollisionCookComplete( if (bBodyInstalled) { CollisionReadyTiles.Add(Tile); + if (bStartupCollisionGateTimingCaptured + && StartupCollisionGateSupportTile.Coord == Tile.Coord + && StartupCollisionGateSupportTile.Level == Tile.Level + && StartupCollisionGateReadySeconds <= 0.0) + { + StartupCollisionGateReadySeconds = FPlatformTime::Seconds(); + const double FirstCheckToSubmitSeconds = StartupCollisionGateSubmittedSeconds > 0.0 + ? FMath::Max(0.0, StartupCollisionGateSubmittedSeconds + - StartupCollisionGateFirstCheckSeconds) + : -1.0; + const double FirstCheckToReadySeconds = FMath::Max( + 0.0, StartupCollisionGateReadySeconds + - StartupCollisionGateFirstCheckSeconds); + const double SubmitToReadySeconds = StartupCollisionGateSubmittedSeconds > 0.0 + ? FMath::Max(0.0, StartupCollisionGateReadySeconds + - StartupCollisionGateSubmittedSeconds) + : -1.0; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGate] support_collision_ready tile=(%d,%d,%d) " + "first_check_to_submit_s=%.6f first_check_to_collision_ready_s=%.6f " + "submit_to_ready_s=%.6f"), + Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z, + FirstCheckToSubmitSeconds, FirstCheckToReadySeconds, SubmitToReadySeconds); + } } else { @@ -5914,6 +5997,22 @@ void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, const FVector& Pla } } + if (bStartupCollisionGateTimingCaptured + && !bStartupCollisionGateAdoptionReported + && PendingTiles.Contains(StartupCollisionGateSupportTile)) + { + const bool bSupportAdopted = IsDesired(StartupCollisionGateSupportTile); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGate] support_request_desired_reconcile tile=(%d,%d,%d) " + "adopted=%d desired_epoch=%u pending=%d"), + StartupCollisionGateSupportTile.Coord.X, + StartupCollisionGateSupportTile.Coord.Y, + StartupCollisionGateSupportTile.Coord.Z, + bSupportAdopted ? 1 : 0, DesiredEpoch, + PendingTiles.Contains(StartupCollisionGateSupportTile) ? 1 : 0); + bStartupCollisionGateAdoptionReported = true; + } + // Cancel work that no longer belongs to this desired set before scheduling replacements. A // worker owns no mutable world state; it only observes its request token in GenerateTileResult. CancelObsoleteTileWork(); @@ -6709,6 +6808,13 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, EVoxelTileWorkPriority Wor return; // Budget full — wait for a task to finish. } PendingTiles.Add(Tile); + if (bStartupCollisionGateTimingCaptured + && StartupCollisionGateSupportTile.Coord == Tile.Coord + && StartupCollisionGateSupportTile.Level == Tile.Level + && StartupCollisionGateSubmittedSeconds <= 0.0) + { + StartupCollisionGateSubmittedSeconds = FPlatformTime::Seconds(); + } const uint64 RequestStartCycles = FPlatformTime::Cycles64(); const FIntVector OriginVoxels = Tile.OriginVoxels(); // min corner, voxel coords diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index 3e2eb87..70f6c5e 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -1118,6 +1118,7 @@ public: double HeadlessCollisionGateStressTestGateStartSeconds = 0.0; double HeadlessCollisionGateStressTestFullStopStartSeconds = 0.0; double HeadlessCollisionGateStressTestDistanceCm = 0.0; + double HeadlessCollisionGateStressTestGateDistanceCm = 0.0; double HeadlessCollisionGateStressTestTotalGateDurationSeconds = 0.0; double HeadlessCollisionGateStressTestMaxGateDurationSeconds = 0.0; double HeadlessCollisionGateStressTestFullStopDurationSeconds = 0.0; @@ -1226,6 +1227,14 @@ public: bool bPawnGateUnsupportedReported = false; FVoxelTileKey PawnGateRequestedSupportTile; bool bPawnGateSupportRequestReported = false; + // Keep the first unresolved support request's timing for the startup gate repro. This is + // intentionally retained after release so the stress-test result can report the bootstrap path. + FVoxelTileKey StartupCollisionGateSupportTile; + bool bStartupCollisionGateTimingCaptured = false; + bool bStartupCollisionGateAdoptionReported = false; + double StartupCollisionGateFirstCheckSeconds = 0.0; + double StartupCollisionGateSubmittedSeconds = 0.0; + double StartupCollisionGateReadySeconds = 0.0; // Desired-set membership STAMPÉE : clé → numéro du dernier crossing où la tuile était désirée. // BuildDesiredTiles upserte le stamp courant puis balaie la map UNE fois : les entrées à stamp diff --git a/Tools/VoxelForgeTest.ps1 b/Tools/VoxelForgeTest.ps1 index 3a37439..87bd186 100644 --- a/Tools/VoxelForgeTest.ps1 +++ b/Tools/VoxelForgeTest.ps1 @@ -992,7 +992,9 @@ function Get-CollisionGateStressMetrics([string]$LogPath) { 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', 'gate_holds', 'gate_total_s', 'max_gate_s', 'full_stops', - 'full_stop_total_s', 'collision_gate')) { + 'full_stop_total_s', 'collision_gate', 'gate_progress_m', 'support_tile', + 'support_first_check_to_submit_s', + 'support_first_check_to_collision_ready_s')) { $match = [regex]::Match($line, ('{0}=(?[^ ]+)' -f $key)) if ($match.Success) { $metrics[$key] = $match.Groups['v'].Value } }