From 88f3e44402ba71138da016edbb2e2a3102dcc45f Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Tue, 22 Sep 2026 15:19:12 +0200 Subject: [PATCH] wip(collision gate): stop the fall instead of the player -- UNVALIDATED, after-change run never happened The round was stopped when Windows began refusing to start processes for it (0xC0000142) under memory pressure, before any after-change test run. Treat as a draft: the baseline run happened, the verification did not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ --- Source/VoxelForge/Private/VoxelWorld.cpp | 718 ++++++++++++++++++++--- Source/VoxelForge/Public/VoxelWorld.h | 55 +- Tools/VoxelForgeTest.ps1 | 55 +- 3 files changed, 719 insertions(+), 109 deletions(-) diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index d4b67c7..672dbbc 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -60,6 +60,12 @@ namespace { + int32 GVoxelForgeCollisionGate = 1; + FAutoConsoleVariableRef CVarVoxelForgeCollisionGate( + TEXT("voxel.CollisionGate"), + GVoxelForgeCollisionGate, + TEXT("Protect the pawn from falling through unresolved ground: 0 off, 1 on.")); + int32 GVoxelForgeProfileTileGeneration = 0; FAutoConsoleVariableRef CVarVoxelForgeProfileTileGeneration( TEXT("voxel.ProfileTileGeneration"), @@ -1436,6 +1442,7 @@ void AVoxelWorld::BeginPlay() int32 CommandLineOuterClassifierMode = GVoxelForgeOuterClassifierMode; int32 CommandLineSealedSolidProof = GVoxelForgeSealedSolidProof; int32 CommandLineSampleStacks = GVoxelForgeSampleStacks; + int32 CommandLineCollisionGate = GVoxelForgeCollisionGate; if (FParse::Value(FCommandLine::Get(), TEXT("voxel.ProfileDensity="), CommandLineProfile)) { GVoxelForgeProfileDensity = CommandLineProfile; @@ -1471,6 +1478,10 @@ void AVoxelWorld::BeginPlay() { GVoxelForgeSampleStacks = CommandLineSampleStacks; } + if (FParse::Value(FCommandLine::Get(), TEXT("voxel.CollisionGate="), CommandLineCollisionGate)) + { + GVoxelForgeCollisionGate = CommandLineCollisionGate; + } GVoxelForgeProfileTileGeneration = GVoxelForgeProfileTileGeneration != 0 ? 1 : 0; GVoxelForgeUseBlockEarlyOut = GVoxelForgeUseBlockEarlyOut != 0 ? 1 : 0; GVoxelForgeOuterClassifierMode = FMath::Clamp(GVoxelForgeOuterClassifierMode, 0, 1); @@ -1479,6 +1490,11 @@ void AVoxelWorld::BeginPlay() GVoxelForgeSampleStacks, 0, static_cast(FVoxelStackSampler::MaxIntervalUs)); + GVoxelForgeCollisionGate = GVoxelForgeCollisionGate != 0 ? 1 : 0; + bCollisionGateEnabled = GVoxelForgeCollisionGate != 0; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGate] startup enabled=%d"), + bCollisionGateEnabled ? 1 : 0); if (GVoxelForgePerfAttribution != 0) { @@ -1744,6 +1760,31 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest() HeadlessStreamingTestLastActualPosition = FVector::ZeroVector; HeadlessStreamingTestBeginSeconds = 0.0; HeadlessStreamingTestLastElapsedSeconds = 0.0; + bHeadlessCollisionGateStressTest = false; + bHeadlessCollisionGateStressTestStartPlaced = false; + bHeadlessCollisionGateStressTestStarted = false; + bHeadlessCollisionGateStressTestPassed = false; + bHeadlessCollisionGateStressTestFailed = false; + bHeadlessCollisionGateStressTestExitRequested = false; + bHeadlessCollisionGateStressTestGateWasEngaged = false; + bHeadlessCollisionGateStressTestFullStop = false; + HeadlessCollisionGateStressTestSpeedCmPerSecond = 6000.0f; + HeadlessCollisionGateStressTestStartDelaySeconds = 0.1f; + HeadlessCollisionGateStressTestDurationSeconds = 2.0f; + HeadlessCollisionGateStressTestTimeoutSeconds = 15.0f; + HeadlessCollisionGateStressTestBeginSeconds = 0.0; + HeadlessCollisionGateStressTestStartSeconds = 0.0; + HeadlessCollisionGateStressTestGateStartSeconds = 0.0; + HeadlessCollisionGateStressTestFullStopStartSeconds = 0.0; + HeadlessCollisionGateStressTestDistanceCm = 0.0; + HeadlessCollisionGateStressTestTotalGateDurationSeconds = 0.0; + HeadlessCollisionGateStressTestMaxGateDurationSeconds = 0.0; + HeadlessCollisionGateStressTestFullStopDurationSeconds = 0.0; + HeadlessCollisionGateStressTestGateHoldCount = 0; + HeadlessCollisionGateStressTestFullStopCount = 0; + HeadlessCollisionGateStressTestDirection = FVector::XAxisVector; + HeadlessCollisionGateStressTestLastActualPosition = FVector::ZeroVector; + HeadlessCollisionGateStressTestFailureReason.Reset(); bHeadlessSurfaceFallTest = false; bHeadlessSurfaceFallTestStartPlaced = false; bHeadlessSurfaceFallTestPassed = false; @@ -1776,6 +1817,7 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest() bHeadlessStrateCrossingTestExitRequested = false; bHeadlessStrateCrossingTestGateWasEngaged = false; bHeadlessStrateCrossingTestInitialSupportReported = false; + bHeadlessStrateCrossingTestPendingFloorOverlap = false; HeadlessStrateCrossingTestRequestedPassageIndex = INDEX_NONE; HeadlessStrateCrossingTestPassageIndex = INDEX_NONE; HeadlessStrateCrossingTestUpperStrateIndex = INDEX_NONE; @@ -1976,6 +2018,51 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest() HeadlessStreamingTestDirection.Y, HeadlessStreamingTestDirection.Z); } + + int32 CollisionGateStressValue = 0; + bHeadlessCollisionGateStressTest = + (FParse::Value(CommandLine, TEXT("voxel.TestCollisionGateStress="), CollisionGateStressValue) + && CollisionGateStressValue != 0) + || FParse::Param(CommandLine, TEXT("voxel.TestCollisionGateStress")); + if (bHeadlessCollisionGateStressTest) + { + if (bHeadlessStrateCrossingTest || bHeadlessSurfaceFallTest) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = TEXT("conflicting_headless_tests"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeCollisionGateStressTest] result=FAIL reason=%s"), + *HeadlessCollisionGateStressTestFailureReason); + } + float StressSpeed = HeadlessCollisionGateStressTestSpeedCmPerSecond; + float StressStartDelay = HeadlessCollisionGateStressTestStartDelaySeconds; + float StressDuration = HeadlessCollisionGateStressTestDurationSeconds; + float StressTimeout = HeadlessCollisionGateStressTestTimeoutSeconds; + FParse::Value(CommandLine, TEXT("voxel.TestCollisionGateStressSpeedCmPerSecond="), + StressSpeed); + FParse::Value(CommandLine, TEXT("voxel.TestCollisionGateStressStartDelaySeconds="), + StressStartDelay); + FParse::Value(CommandLine, TEXT("voxel.TestCollisionGateStressDurationSeconds="), + StressDuration); + FParse::Value(CommandLine, TEXT("voxel.TestCollisionGateStressTimeoutSeconds="), + StressTimeout); + HeadlessCollisionGateStressTestSpeedCmPerSecond = FMath::Max(StressSpeed, 1.0f); + HeadlessCollisionGateStressTestStartDelaySeconds = FMath::Max(StressStartDelay, 0.0f); + HeadlessCollisionGateStressTestDurationSeconds = FMath::Max(StressDuration, 0.25f); + HeadlessCollisionGateStressTestTimeoutSeconds = FMath::Max(StressTimeout, 1.0f); + // The stress path owns movement and keeps the streamer centred at the launch tile. Do + // not let the older synthetic TestMove path run in parallel with it. + bHeadlessStreamingTestMovement = false; + HeadlessStreamingTestExitSeconds = 0.0f; + HeadlessCollisionGateStressTestBeginSeconds = 0.0; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGateStressTest] configured=1 speed_cm_s=%.1f " + "start_delay_s=%.3f duration_s=%.3f timeout_s=%.3f"), + HeadlessCollisionGateStressTestSpeedCmPerSecond, + HeadlessCollisionGateStressTestStartDelaySeconds, + HeadlessCollisionGateStressTestDurationSeconds, + HeadlessCollisionGateStressTestTimeoutSeconds); + } } void AVoxelWorld::RecordTileHash(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels, @@ -2469,6 +2556,22 @@ void AVoxelWorld::ObserveHeadlessStrateCrossingTest( const FVoxelPassage& Passage = StrateManager->GetPassages()[HeadlessStrateCrossingTestPassageIndex]; const FVector LocalPosition = WorldToLocalVoxel(PlayerPosition); + FVoxelTileKey PendingFloorTile; + if (IsPawnCapsuleOverlappingPendingFloor( + PlayerPawn, PlayerPosition, &PendingFloorTile)) + { + bHeadlessStrateCrossingTestPendingFloorOverlap = true; + bHeadlessStrateCrossingTestFailed = true; + HeadlessStrateCrossingTestFailureReason = TEXT("capsule_overlaps_pending_floor"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeStrateCrossingTest] assertion_failed=%s tile=(%d,%d,%d) " + "position_vox=(%.2f,%.2f,%.2f) gate=%d"), + *HeadlessStrateCrossingTestFailureReason, + PendingFloorTile.Coord.X, PendingFloorTile.Coord.Y, PendingFloorTile.Coord.Z, + LocalPosition.X, LocalPosition.Y, LocalPosition.Z, + bPawnCollisionGateEngaged ? 1 : 0); + return; + } float PassageFloorZ = 0.0f; if (VF_ProjectHeadlessPassageFloor(Passage, LocalPosition, PassageFloorZ)) { @@ -2570,7 +2673,8 @@ void AVoxelWorld::MaybeFinishHeadlessStrateCrossingTest() TEXT("[VoxelForgeStrateCrossingTest] result=%s reason=%s passage=%d upper_strate=%d " "lower_strate=%d elapsed_s=%.6f distance_m=%.6f route=%d/%d " "max_gate_s=%.6f gate_limit_s=%.3f gate_total_s=%.6f " - "min_floor_clearance_vox=%.6f final_position=(%.1f,%.1f,%.1f)"), + "min_floor_clearance_vox=%.6f pending_floor_overlap=%d " + "final_position=(%.1f,%.1f,%.1f)"), bPassed ? TEXT("PASS") : TEXT("FAIL"), bPassed ? TEXT("reached_lower_strate") : *HeadlessStrateCrossingTestFailureReason, HeadlessStrateCrossingTestPassageIndex, @@ -2584,6 +2688,7 @@ void AVoxelWorld::MaybeFinishHeadlessStrateCrossingTest() HeadlessStrateCrossingTestMaxGateSeconds, HeadlessStrateCrossingTestTotalGateDurationSeconds, HeadlessStrateCrossingTestMinFloorClearanceVoxels, + bHeadlessStrateCrossingTestPendingFloorOverlap ? 1 : 0, HeadlessStrateCrossingTestLastActualPosition.X, HeadlessStrateCrossingTestLastActualPosition.Y, HeadlessStrateCrossingTestLastActualPosition.Z); @@ -2652,6 +2757,176 @@ void AVoxelWorld::AdvanceHeadlessStreamingTest( } } +void AVoxelWorld::AdvanceHeadlessCollisionGateStressTest( + FVector& InOutPlayerPosition, FVector& InOutPlayerHeading, APawn* PlayerPawn) +{ + if (!bHeadlessCollisionGateStressTest || bHeadlessCollisionGateStressTestFailed + || bHeadlessCollisionGateStressTestPassed || !IsValid(PlayerPawn)) + { + return; + } + + const double Now = FPlatformTime::Seconds(); + if (!bHeadlessCollisionGateStressTestStartPlaced) + { + FVoxelTileKey SupportTile; + const TCHAR* SupportUnavailableReason = nullptr; + const bool bSupportReady = IsPlayerSupportCollisionReady( + PlayerPawn, InOutPlayerPosition, &SupportTile, &SupportUnavailableReason); + + const FVector ActualPosition = PlayerPawn->GetActorLocation(); + const FVector LaunchVoxel = WorldToLocalVoxel(ActualPosition); + // Freeze only the streaming centre, not the pawn. This deliberately leaves the desired + // clipmap behind the run while the launch support is already ready. + bHeadlessStreamingTestCenterOverride = true; + HeadlessStreamingTestCenterVoxel = LaunchVoxel; + InOutPlayerPosition = ActualPosition; + InOutPlayerHeading = FVector::ZeroVector; + HeadlessCollisionGateStressTestLastActualPosition = ActualPosition; + bHeadlessCollisionGateStressTestStartPlaced = true; + HeadlessCollisionGateStressTestStartSeconds = Now; + + if (ACharacter* Character = Cast(PlayerPawn)) + { + if (UCharacterMovementComponent* CharacterMovement = + Character->GetCharacterMovement()) + { + CharacterMovement->MaxWalkSpeed = HeadlessCollisionGateStressTestSpeedCmPerSecond; + CharacterMovement->MaxAcceleration = FMath::Max( + CharacterMovement->MaxAcceleration, + HeadlessCollisionGateStressTestSpeedCmPerSecond * 8.0f); + CharacterMovement->BrakingDecelerationWalking = FMath::Max( + CharacterMovement->BrakingDecelerationWalking, + HeadlessCollisionGateStressTestSpeedCmPerSecond * 8.0f); + CharacterMovement->StopMovementImmediately(); + CharacterMovement->SetMovementMode(MOVE_Walking); + } + } + + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGateStressTest] start_placed support=(%d,%d,%d) ready=%d " + "reason=%s " + "position=(%.1f,%.1f,%.1f) launch_vox=(%.2f,%.2f,%.2f)"), + SupportTile.Coord.X, SupportTile.Coord.Y, SupportTile.Coord.Z, + bSupportReady ? 1 : 0, + SupportUnavailableReason != nullptr ? SupportUnavailableReason : TEXT("pending"), + ActualPosition.X, ActualPosition.Y, ActualPosition.Z, + LaunchVoxel.X, LaunchVoxel.Y, LaunchVoxel.Z); + return; + } + + const double SinceLaunch = Now - HeadlessCollisionGateStressTestStartSeconds; + if (!bHeadlessCollisionGateStressTestStarted + && SinceLaunch < static_cast(HeadlessCollisionGateStressTestStartDelaySeconds)) + { + return; + } + if (!bHeadlessCollisionGateStressTestStarted) + { + bHeadlessCollisionGateStressTestStarted = true; + HeadlessCollisionGateStressTestBeginSeconds = Now; + HeadlessCollisionGateStressTestStartSeconds = Now; + HeadlessCollisionGateStressTestLastActualPosition = InOutPlayerPosition; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGateStressTest] movement_started speed_cm_s=%.1f"), + HeadlessCollisionGateStressTestSpeedCmPerSecond); + } + + const FVector WorldDirection = GetActorTransform().TransformVectorNoScale( + HeadlessCollisionGateStressTestDirection).GetSafeNormal(); + if (WorldDirection.IsNearlyZero()) + { + return; + } + + if (ACharacter* Character = Cast(PlayerPawn)) + { + if (UCharacterMovementComponent* CharacterMovement = Character->GetCharacterMovement()) + { + // The baseline gate changes MOVE_Walking to MOVE_None. Do not undo that here; the + // test must observe the resulting full stop. The fall-only gate keeps this input live. + if (!bPawnCollisionGateEngaged + && CharacterMovement->MovementMode == MOVE_None) + { + CharacterMovement->SetMovementMode(MOVE_Walking); + } + PlayerPawn->AddMovementInput(WorldDirection, 1.0f, false); + } + } + else + { + PlayerPawn->AddMovementInput(WorldDirection, 1.0f, false); + } + InOutPlayerHeading = WorldDirection * HeadlessCollisionGateStressTestSpeedCmPerSecond; +} + +void AVoxelWorld::ObserveHeadlessCollisionGateStressTest( + const FVector& PlayerPosition, APawn* PlayerPawn) +{ + if (!bHeadlessCollisionGateStressTest || !bHeadlessCollisionGateStressTestStarted + || !IsValid(PlayerPawn) || bHeadlessCollisionGateStressTestExitRequested) + { + return; + } + + const double Now = FPlatformTime::Seconds(); + const float HorizontalDelta = FVector::Dist2D( + HeadlessCollisionGateStressTestLastActualPosition, PlayerPosition); + HeadlessCollisionGateStressTestDistanceCm += HorizontalDelta; + HeadlessCollisionGateStressTestLastActualPosition = PlayerPosition; + + const bool bGateEngaged = bPawnCollisionGateEngaged; + if (bGateEngaged && !bHeadlessCollisionGateStressTestGateWasEngaged) + { + ++HeadlessCollisionGateStressTestGateHoldCount; + HeadlessCollisionGateStressTestGateStartSeconds = Now; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGateStressTest] gate=engaged elapsed_s=%.6f"), + Now - HeadlessCollisionGateStressTestStartSeconds); + } + else if (!bGateEngaged && bHeadlessCollisionGateStressTestGateWasEngaged) + { + const double GateDuration = FMath::Max( + 0.0, Now - HeadlessCollisionGateStressTestGateStartSeconds); + HeadlessCollisionGateStressTestTotalGateDurationSeconds += GateDuration; + HeadlessCollisionGateStressTestMaxGateDurationSeconds = FMath::Max( + HeadlessCollisionGateStressTestMaxGateDurationSeconds, GateDuration); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGateStressTest] gate=released duration_s=%.6f " + "total_s=%.6f"), + GateDuration, + HeadlessCollisionGateStressTestTotalGateDurationSeconds); + } + bHeadlessCollisionGateStressTestGateWasEngaged = bGateEngaged; + + // At 6000 cm/s a healthy frame advances well beyond one centimetre. A zero horizontal delta + // while the collision gate is engaged is therefore a real full stop, not a low-rate sample. + const bool bFullStop = bGateEngaged && HorizontalDelta <= 0.01f; + if (bFullStop && !bHeadlessCollisionGateStressTestFullStop) + { + ++HeadlessCollisionGateStressTestFullStopCount; + HeadlessCollisionGateStressTestFullStopStartSeconds = Now; + } + else if (!bFullStop && bHeadlessCollisionGateStressTestFullStop) + { + HeadlessCollisionGateStressTestFullStopDurationSeconds += FMath::Max( + 0.0, Now - HeadlessCollisionGateStressTestFullStopStartSeconds); + } + bHeadlessCollisionGateStressTestFullStop = bFullStop; + + if (Now - HeadlessCollisionGateStressTestStartSeconds + >= static_cast(HeadlessCollisionGateStressTestDurationSeconds)) + { + return; + } + if (Now - HeadlessCollisionGateStressTestBeginSeconds + >= static_cast(HeadlessCollisionGateStressTestTimeoutSeconds)) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = TEXT("stress_timeout"); + } +} + void AVoxelWorld::AdvanceHeadlessSurfaceFallTest( FVector& InOutPlayerPosition, FVector& InOutPlayerHeading, APawn* PlayerPawn) { @@ -2965,6 +3240,109 @@ void AVoxelWorld::MaybeFinishHeadlessSurfaceFallTest() false, bPassed ? 0 : 1, TEXT("VoxelForge surface fall test complete")); } +void AVoxelWorld::MaybeFinishHeadlessCollisionGateStressTest() +{ + if (!bHeadlessCollisionGateStressTest || bHeadlessCollisionGateStressTestExitRequested + || HeadlessCollisionGateStressTestBeginSeconds <= 0.0) + { + return; + } + + const double Now = FPlatformTime::Seconds(); + const double SinceBegin = Now - HeadlessCollisionGateStressTestBeginSeconds; + if (!bHeadlessCollisionGateStressTestStartPlaced + && !bHeadlessCollisionGateStressTestPassed + && !bHeadlessCollisionGateStressTestFailed + && SinceBegin >= static_cast(HeadlessCollisionGateStressTestTimeoutSeconds)) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = TEXT("stress_start_not_observed"); + } + else if (bHeadlessCollisionGateStressTestStarted + && !bHeadlessCollisionGateStressTestPassed + && !bHeadlessCollisionGateStressTestFailed + && Now - HeadlessCollisionGateStressTestStartSeconds + >= static_cast(HeadlessCollisionGateStressTestDurationSeconds)) + { + const double ExpectedDistanceCm = + static_cast(HeadlessCollisionGateStressTestSpeedCmPerSecond) + * static_cast(HeadlessCollisionGateStressTestDurationSeconds); + if (HeadlessCollisionGateStressTestFullStopCount > 0) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = TEXT("full_horizontal_stop"); + } + else if (HeadlessCollisionGateStressTestDistanceCm < ExpectedDistanceCm * 0.65) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = TEXT("horizontal_progress_shortfall"); + } + else + { + bHeadlessCollisionGateStressTestPassed = true; + } + } + else if (!bHeadlessCollisionGateStressTestPassed + && !bHeadlessCollisionGateStressTestFailed + && SinceBegin >= static_cast(HeadlessCollisionGateStressTestTimeoutSeconds)) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = TEXT("stress_timeout"); + } + + if (!bHeadlessCollisionGateStressTestPassed && !bHeadlessCollisionGateStressTestFailed) + { + return; + } + + if (bHeadlessCollisionGateStressTestGateWasEngaged + && HeadlessCollisionGateStressTestGateStartSeconds > 0.0) + { + const double GateDuration = FMath::Max( + 0.0, Now - HeadlessCollisionGateStressTestGateStartSeconds); + HeadlessCollisionGateStressTestTotalGateDurationSeconds += GateDuration; + HeadlessCollisionGateStressTestMaxGateDurationSeconds = FMath::Max( + HeadlessCollisionGateStressTestMaxGateDurationSeconds, GateDuration); + } + if (bHeadlessCollisionGateStressTestFullStop + && HeadlessCollisionGateStressTestFullStopStartSeconds > 0.0) + { + HeadlessCollisionGateStressTestFullStopDurationSeconds += FMath::Max( + 0.0, Now - HeadlessCollisionGateStressTestFullStopStartSeconds); + } + + bHeadlessCollisionGateStressTestExitRequested = true; + const bool bPassed = bHeadlessCollisionGateStressTestPassed + && !bHeadlessCollisionGateStressTestFailed; + const double ExpectedDistanceCm = + static_cast(HeadlessCollisionGateStressTestSpeedCmPerSecond) + * static_cast(HeadlessCollisionGateStressTestDurationSeconds); + 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 " + "full_stops=%d full_stop_total_s=%.6f collision_gate=%d " + "final_position=(%.1f,%.1f,%.1f)"), + bPassed ? TEXT("PASS") : TEXT("FAIL"), + bPassed ? TEXT("continuous_horizontal_progress") + : *HeadlessCollisionGateStressTestFailureReason, + Now - HeadlessCollisionGateStressTestBeginSeconds, + HeadlessCollisionGateStressTestDistanceCm / 100.0, + ExpectedDistanceCm / 100.0, + HeadlessCollisionGateStressTestSpeedCmPerSecond, + HeadlessCollisionGateStressTestGateHoldCount, + HeadlessCollisionGateStressTestTotalGateDurationSeconds, + HeadlessCollisionGateStressTestMaxGateDurationSeconds, + HeadlessCollisionGateStressTestFullStopCount, + HeadlessCollisionGateStressTestFullStopDurationSeconds, + bCollisionGateEnabled ? 1 : 0, + HeadlessCollisionGateStressTestLastActualPosition.X, + HeadlessCollisionGateStressTestLastActualPosition.Y, + HeadlessCollisionGateStressTestLastActualPosition.Z); + FPlatformMisc::RequestExitWithStatus( + false, bPassed ? 0 : 1, TEXT("VoxelForge collision-gate stress test complete")); +} + void AVoxelWorld::MaybeFinishHeadlessStreamingTest() { if (bHeadlessStrateCrossingTest) @@ -2979,6 +3357,12 @@ void AVoxelWorld::MaybeFinishHeadlessStreamingTest() return; } + if (bHeadlessCollisionGateStressTest) + { + MaybeFinishHeadlessCollisionGateStressTest(); + return; + } + if (bHeadlessStreamingTestExitRequested || HeadlessStreamingTestExitSeconds <= 0.0f || HeadlessStreamingTestBeginSeconds <= 0.0) { @@ -3018,10 +3402,12 @@ void AVoxelWorld::Tick(float DeltaTime) AdvanceHeadlessStreamingTest(PlayerLastPos, PlayerHeading, PlayerPawn); AdvanceHeadlessStrateCrossingTest(PlayerLastPos, PlayerHeading, PlayerPawn); AdvanceHeadlessSurfaceFallTest(PlayerLastPos, PlayerHeading, PlayerPawn); + AdvanceHeadlessCollisionGateStressTest(PlayerLastPos, PlayerHeading, PlayerPawn); PeakObservedPawnSpeedCmPerSecond = FMath::Max( PeakObservedPawnSpeedCmPerSecond, PlayerHeading.Size2D()); UpdatePawnCollisionGate(PlayerPawn, PlayerLastPos, DeltaTime); ObserveHeadlessStrateCrossingTest(PlayerLastPos, PlayerPawn); + ObserveHeadlessCollisionGateStressTest(PlayerLastPos, PlayerPawn); const FVector StreamingCenterPosition = bHeadlessStreamingTestCenterOverride ? LocalVoxelToWorld(HeadlessStreamingTestCenterVoxel) : PlayerLastPos; @@ -3420,15 +3806,6 @@ bool AVoxelWorld::IsCollisionTileUnavailable(const FVoxelTileKey& Tile, } } - // DesiredStamp is zero only during the initial possession/setup tick, before the first - // desired set has been built. Do not classify that transient state as "outside desired"; - // otherwise the initial safety gate could release before streaming has any authority. - if (DesiredStamp > 0 && !IsDesired(Tile)) - { - OutReason = TEXT("outside_desired_set"); - return true; - } - return false; } @@ -3507,14 +3884,167 @@ bool AVoxelWorld::IsPlayerSupportCollisionReady(APawn* Pawn, const FVector& Play CandidateTile, OutSupportTile, OutUnavailableReason); } +bool AVoxelWorld::IsPawnCapsuleOverlappingPendingFloor( + APawn* Pawn, const FVector& PlayerPosition, FVoxelTileKey* OutPendingTile) const +{ + if (OutPendingTile != nullptr) + { + *OutPendingTile = FVoxelTileKey(); + } + if (!IsValid(Pawn)) + { + return false; + } + + FVoxelTileKey SupportTile; + const TCHAR* SupportUnavailableReason = nullptr; + if (IsPlayerSupportCollisionReady( + Pawn, PlayerPosition, &SupportTile, &SupportUnavailableReason) + || SupportUnavailableReason != nullptr + || SupportTile.Level != 0 + || CollisionNotRequiredTiles.Contains(SupportTile)) + { + return false; + } + + float HalfHeightCm = 0.0f; + float RadiusCm = 0.0f; + if (const ACharacter* Character = Cast(Pawn)) + { + if (const UCapsuleComponent* Capsule = Character->GetCapsuleComponent()) + { + HalfHeightCm = Capsule->GetScaledCapsuleHalfHeight(); + RadiusCm = Capsule->GetScaledCapsuleRadius(); + } + } + else + { + FVector BoundsOrigin = FVector::ZeroVector; + FVector BoundsExtent = FVector::ZeroVector; + Pawn->GetActorBounds(/*bOnlyCollidingComponents*/ true, BoundsOrigin, BoundsExtent); + HalfHeightCm = FMath::Max(0.0f, BoundsExtent.Z); + RadiusCm = FMath::Max(BoundsExtent.X, BoundsExtent.Y); + } + if (!VoxelMath::IsFinite(HalfHeightCm) || !VoxelMath::IsFinite(RadiusCm) + || HalfHeightCm <= 0.0f || RadiusCm < 0.0f) + { + return false; + } + + const FVector LocalPosition = WorldToLocalVoxel(PlayerPosition); + const float HalfHeightVoxels = HalfHeightCm / VOXEL_SIZE; + const float RadiusVoxels = RadiusCm / VOXEL_SIZE; + const float TileMinX = static_cast(SupportTile.Coord.X * CHUNK_SIZE); + const float TileMaxX = static_cast((SupportTile.Coord.X + 1) * CHUNK_SIZE); + const float TileMinY = static_cast(SupportTile.Coord.Y * CHUNK_SIZE); + const float TileMaxY = static_cast((SupportTile.Coord.Y + 1) * CHUNK_SIZE); + const float TileMinZ = static_cast(SupportTile.Coord.Z * CHUNK_SIZE); + const float TileMaxZ = static_cast((SupportTile.Coord.Z + 1) * CHUNK_SIZE); + const float CapsuleMinZ = LocalPosition.Z - HalfHeightVoxels; + const float CapsuleMaxZ = LocalPosition.Z + HalfHeightVoxels; + const bool bHorizontalOverlap = LocalPosition.X + RadiusVoxels > TileMinX + && LocalPosition.X - RadiusVoxels < TileMaxX + && LocalPosition.Y + RadiusVoxels > TileMinY + && LocalPosition.Y - RadiusVoxels < TileMaxY; + const bool bVerticalOverlap = CapsuleMaxZ > TileMinZ && CapsuleMinZ < TileMaxZ; + if (!bHorizontalOverlap || !bVerticalOverlap) + { + return false; + } + + if (OutPendingTile != nullptr) + { + *OutPendingTile = SupportTile; + } + return true; +} + +void AVoxelWorld::RequestCollisionGateSupportTile(const FVoxelTileKey& SupportTile) +{ + if (SupportTile.Level != 0 + || IsTileCollisionReady(SupportTile) + || CollisionNotRequiredTiles.Contains(SupportTile)) + { + return; + } + + if (!bPawnGateSupportRequestReported + || PawnGateRequestedSupportTile != SupportTile) + { + PawnGateRequestedSupportTile = SupportTile; + 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)) + { + 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. LoadTile's true flag + // is the established BackgroundHigh streaming priority. + CriticalDesiredTiles.Remove(SupportTile); + CriticalDesiredTiles.Insert(SupportTile, 0); + if (PendingTiles.Contains(SupportTile) || LoadedTiles.Contains(SupportTile)) + { + if (!bPawnGateSupportRequestReported) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGate] support_request_present tile=(%d,%d,%d) " + "pending=%d loaded=%d"), + SupportTile.Coord.X, SupportTile.Coord.Y, SupportTile.Coord.Z, + PendingTiles.Contains(SupportTile) ? 1 : 0, + LoadedTiles.Contains(SupportTile) ? 1 : 0); + bPawnGateSupportRequestReported = true; + } + return; + } + + const int32 PendingBefore = PendingTiles.Num(); + LoadTile(SupportTile, /*bHighPriority*/ true); + if (PendingTiles.Num() > PendingBefore) + { + bAllChunksLoaded = false; + } + if (!bPawnGateSupportRequestReported) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGate] support_request tile=(%d,%d,%d) " + "submitted=%d priority=high"), + SupportTile.Coord.X, SupportTile.Coord.Y, SupportTile.Coord.Z, + PendingTiles.Contains(SupportTile) ? 1 : 0); + bPawnGateSupportRequestReported = true; + } +} + void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosition, float DeltaTime) { + static_cast(DeltaTime); if (!IsValid(Pawn) || bShuttingDown.load(std::memory_order_relaxed)) { return; } + // This is a startup snapshot of voxel.CollisionGate. The hot path never reads the CVar. + if (!bCollisionGateEnabled) + { + ReleasePawnCollisionGate(); + return; + } + // Tick prerequisites are kept even while the gate is open. That makes the next movement // decision happen before pawn physics, including the first frame after a tile-boundary crossing. if (PawnTickPrerequisite.Get() != Pawn) @@ -3554,7 +4084,7 @@ void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosi && CollisionGatedCharacterMovement.Get() != CurrentMovement && CollisionGatedPawnMovement.Get() != CurrentMovement) { - // The component was replaced while the pawn was stopped. Restore the old component (if it + // The component was replaced while the pawn was held. Restore the old component (if it // still exists) and let the normal path engage the replacement against the same readiness // decision below. ReleasePawnCollisionGate(); @@ -3569,75 +4099,49 @@ void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosi const TCHAR* CurrentSupportUnavailableReason = nullptr; const bool bCurrentSupportReady = IsPlayerSupportCollisionReady( Pawn, PlayerPosition, &CurrentSupportTile, &CurrentSupportUnavailableReason); - bool bCanRelease = bCurrentSupportReady; - bool bWaitingForPredictedSupport = false; - FVoxelTileKey PredictedSupportTile; - const TCHAR* PredictedSupportUnavailableReason = nullptr; - - // Check the movement path ahead while the gate is open. Without this, a pawn could leave a - // ready tile on this frame and enter an un-cooked tile before the next world tick observes it. - if (bCurrentSupportReady && DeltaTime > 0.0f) - { - const FVector Velocity = Pawn->GetVelocity(); - if (!Velocity.IsNearlyZero(1.0f)) - { - constexpr float SupportProbeSpacingCm = CHUNK_SIZE * VOXEL_SIZE * 0.5f; - constexpr int32 MaxPredictedSupportProbes = 64; - const int32 NumPredictedProbes = FMath::Clamp( - FMath::CeilToInt(Velocity.Size() * DeltaTime / SupportProbeSpacingCm), - 1, MaxPredictedSupportProbes); - for (int32 ProbeIndex = 1; ProbeIndex <= NumPredictedProbes; ++ProbeIndex) - { - const float Alpha = static_cast(ProbeIndex) - / static_cast(NumPredictedProbes); - const FVector PredictedPosition = PlayerPosition + Velocity * DeltaTime * Alpha; - const bool bPredictedSupportReady = IsPlayerSupportCollisionReady( - Pawn, PredictedPosition, &PredictedSupportTile, - &PredictedSupportUnavailableReason); - if (!bPredictedSupportReady) - { - bCanRelease = false; - bWaitingForPredictedSupport = true; - break; - } - } - } - } - - const TCHAR* NonBlockingReason = CurrentSupportUnavailableReason != nullptr - ? CurrentSupportUnavailableReason : PredictedSupportUnavailableReason; - if (NonBlockingReason != nullptr) + if (CurrentSupportUnavailableReason != nullptr) { if (!bPawnGateUnavailableReported) { UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCollisionGate] nonblocking pawn=%s reason=%s " - "current_reason=%s predicted_reason=%s current_support=(%d,%d,%d) " - "predicted_support=(%d,%d,%d); tile will not be produced, no collision to wait for"), + "support=(%d,%d,%d); tile will not be produced, no collision to wait for"), *Pawn->GetName(), - NonBlockingReason, - CurrentSupportUnavailableReason != nullptr - ? CurrentSupportUnavailableReason : TEXT("none"), - PredictedSupportUnavailableReason != nullptr - ? PredictedSupportUnavailableReason : TEXT("none"), + CurrentSupportUnavailableReason, CurrentSupportTile.Coord.X, CurrentSupportTile.Coord.Y, - CurrentSupportTile.Coord.Z, - PredictedSupportTile.Coord.X, PredictedSupportTile.Coord.Y, - PredictedSupportTile.Coord.Z); + CurrentSupportTile.Coord.Z); } bPawnGateUnavailableReported = true; + ReleasePawnCollisionGate(); + return; } else { bPawnGateUnavailableReported = false; } - if (bCanRelease) + if (bCurrentSupportReady) { ReleasePawnCollisionGate(); return; } + // 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)) + { + if (const UCharacterMovementComponent* CharacterMovement = Character->GetCharacterMovement()) + { + if (CharacterMovement->Velocity.Z > KINDA_SMALL_NUMBER) + { + ReleasePawnCollisionGate(); + return; + } + } + } + + RequestCollisionGateSupportTile(CurrentSupportTile); + constexpr double MaxCollisionGateWaitSeconds = 2.0; if (bPawnCollisionGateEngaged && PawnGateEngagedAtSeconds > 0.0) { @@ -3646,16 +4150,12 @@ void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosi { UE_LOG(LogTemp, Warning, TEXT("[VoxelForgeCollisionGate] wait_timeout pawn=%s waited_s=%.3f " - "current_ready=%d predicted_wait=%d current_support=(%d,%d,%d) " - "predicted_support=(%d,%d,%d); releasing safety gate"), + "current_ready=%d support=(%d,%d,%d); releasing safety gate"), *Pawn->GetName(), WaitSeconds, bCurrentSupportReady ? 1 : 0, - bWaitingForPredictedSupport ? 1 : 0, CurrentSupportTile.Coord.X, CurrentSupportTile.Coord.Y, - CurrentSupportTile.Coord.Z, - PredictedSupportTile.Coord.X, PredictedSupportTile.Coord.Y, - PredictedSupportTile.Coord.Z); + CurrentSupportTile.Coord.Z); ReleasePawnCollisionGate(); return; } @@ -3665,11 +4165,10 @@ void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosi { UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCollisionGate] waiting pawn=%s current_support=(%d,%d,%d) ready=%d " - "predicted_wait=%d"), + "hold_fall=1"), *Pawn->GetName(), CurrentSupportTile.Coord.X, CurrentSupportTile.Coord.Y, CurrentSupportTile.Coord.Z, - bCurrentSupportReady ? 1 : 0, - bWaitingForPredictedSupport ? 1 : 0); + bCurrentSupportReady ? 1 : 0); } EngagePawnCollisionGate(Pawn); } @@ -3682,6 +4181,29 @@ void AVoxelWorld::EngagePawnCollisionGate(APawn* Pawn) } if (bPawnCollisionGateEngaged) { + // Re-apply the vertical hold every game-thread sample. Movement input and horizontal + // velocity are intentionally untouched. + if (UCharacterMovementComponent* CharacterMovement = + CollisionGatedCharacterMovement.Get()) + { + FVector Velocity = CharacterMovement->Velocity; + if (Velocity.Z < 0.0f) + { + Velocity.Z = 0.0f; + CharacterMovement->Velocity = Velocity; + CharacterMovement->UpdateComponentVelocity(); + } + } + else if (UPawnMovementComponent* PawnMovement = CollisionGatedPawnMovement.Get()) + { + FVector Velocity = PawnMovement->Velocity; + if (Velocity.Z < 0.0f) + { + Velocity.Z = 0.0f; + PawnMovement->Velocity = Velocity; + PawnMovement->UpdateComponentVelocity(); + } + } return; } @@ -3692,12 +4214,17 @@ void AVoxelWorld::EngagePawnCollisionGate(APawn* Pawn) if (UCharacterMovementComponent* CharacterMovement = Character->GetCharacterMovement()) { CollisionGatedCharacterMovement = CharacterMovement; - SavedCharacterMovementMode = static_cast(CharacterMovement->MovementMode); - SavedCharacterCustomMovementMode = CharacterMovement->CustomMovementMode; - bSavedCharacterMovementMode = true; + SavedCharacterGravityScale = CharacterMovement->GravityScale; + bSavedCharacterGravityScale = true; - CharacterMovement->StopMovementImmediately(); - CharacterMovement->SetMovementMode(MOVE_None); + FVector Velocity = CharacterMovement->Velocity; + if (Velocity.Z < 0.0f) + { + Velocity.Z = 0.0f; + CharacterMovement->Velocity = Velocity; + } + CharacterMovement->GravityScale = 0.0f; + CharacterMovement->UpdateComponentVelocity(); bPawnCollisionGateEngaged = true; PawnGateEngagedAtSeconds = FPlatformTime::Seconds(); return; @@ -3707,9 +4234,13 @@ void AVoxelWorld::EngagePawnCollisionGate(APawn* Pawn) if (UPawnMovementComponent* PawnMovement = Pawn->GetMovementComponent()) { CollisionGatedPawnMovement = PawnMovement; - bSavedPawnMovementActive = PawnMovement->IsActive(); - PawnMovement->StopMovementImmediately(); - PawnMovement->Deactivate(); + FVector Velocity = PawnMovement->Velocity; + if (Velocity.Z < 0.0f) + { + Velocity.Z = 0.0f; + PawnMovement->Velocity = Velocity; + PawnMovement->UpdateComponentVelocity(); + } bPawnCollisionGateEngaged = true; PawnGateEngagedAtSeconds = FPlatformTime::Seconds(); return; @@ -3732,44 +4263,37 @@ void AVoxelWorld::ReleasePawnCollisionGate() CollisionGatedPawn.Reset(); CollisionGatedPawnMovement.Reset(); CollisionGatedCharacterMovement.Reset(); - bSavedCharacterMovementMode = false; - bSavedPawnMovementActive = false; + bSavedCharacterGravityScale = false; + bPawnGateSupportRequestReported = false; PawnGateEngagedAtSeconds = 0.0; return; } if (UCharacterMovementComponent* CharacterMovement = CollisionGatedCharacterMovement.Get()) { - if (bSavedCharacterMovementMode) + if (bSavedCharacterGravityScale) { - CharacterMovement->SetMovementMode( - static_cast(SavedCharacterMovementMode), - SavedCharacterCustomMovementMode); - } - } - else if (UPawnMovementComponent* PawnMovement = CollisionGatedPawnMovement.Get()) - { - if (bSavedPawnMovementActive) - { - PawnMovement->Activate(true); - } - else - { - PawnMovement->Deactivate(); + CharacterMovement->GravityScale = SavedCharacterGravityScale; + CharacterMovement->UpdateComponentVelocity(); } } if (APawn* Pawn = CollisionGatedPawn.Get()) { - UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCollisionGate] released pawn=%s"), *Pawn->GetName()); + const double HoldSeconds = PawnGateEngagedAtSeconds > 0.0 + ? FMath::Max(0.0, FPlatformTime::Seconds() - PawnGateEngagedAtSeconds) + : 0.0; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGate] released pawn=%s hold_s=%.6f"), + *Pawn->GetName(), HoldSeconds); } CollisionGatedPawn.Reset(); CollisionGatedPawnMovement.Reset(); CollisionGatedCharacterMovement.Reset(); - bSavedCharacterMovementMode = false; - bSavedPawnMovementActive = false; + bSavedCharacterGravityScale = false; bPawnCollisionGateEngaged = false; PawnGateEngagedAtSeconds = 0.0; + bPawnGateSupportRequestReported = false; } void AVoxelWorld::BindRealtimeMeshCollisionEvent(URealtimeMesh* Mesh) diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index a644852..2d8d532 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -522,6 +522,9 @@ private: void ConfigureHeadlessStreamingTest(); void AdvanceHeadlessStreamingTest(FVector& InOutPlayerPosition, FVector& InOutPlayerHeading, APawn* PlayerPawn); + void AdvanceHeadlessCollisionGateStressTest(FVector& InOutPlayerPosition, + FVector& InOutPlayerHeading, APawn* PlayerPawn); + void ObserveHeadlessCollisionGateStressTest(const FVector& PlayerPosition, APawn* PlayerPawn); void AdvanceHeadlessSurfaceFallTest(FVector& InOutPlayerPosition, FVector& InOutPlayerHeading, APawn* PlayerPawn); void InitializeHeadlessStrateCrossingTest(); @@ -530,6 +533,7 @@ private: void ObserveHeadlessStrateCrossingTest(const FVector& PlayerPosition, APawn* PlayerPawn); void MaybeFinishHeadlessStrateCrossingTest(); void MaybeFinishHeadlessSurfaceFallTest(); + void MaybeFinishHeadlessCollisionGateStressTest(); void MaybeFinishHeadlessStreamingTest(); void RecordTileHash(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels, int32 Step, int32 Cells, int32 BandChunkLo, int32 BandChunkHi, @@ -835,9 +839,12 @@ public: bool IsPlayerSupportCollisionReady(APawn* Pawn, const FVector& PlayerPosition, FVoxelTileKey* OutSupportTile = nullptr, const TCHAR** OutUnavailableReason = nullptr) const; + bool IsPawnCapsuleOverlappingPendingFloor(APawn* Pawn, const FVector& PlayerPosition, + FVoxelTileKey* OutPendingTile = nullptr) const; + void RequestCollisionGateSupportTile(const FVoxelTileKey& SupportTile); - /** Gate pawn movement until its current and, when moving, next support tile have a completed - * level-0 RMC collision body. The world tick is installed as the pawn prerequisite in the + /** Cancel only downward pawn motion while the tile beneath its feet is unresolved. Horizontal + * input and movement remain live. The world tick is installed as the pawn prerequisite in the * implementation (and directly on its movement component) so this check runs before * CharacterMovementComponent physics. */ void UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosition, float DeltaTime); @@ -1004,6 +1011,35 @@ public: double HeadlessStreamingTestBeginSeconds = 0.0; double HeadlessStreamingTestLastElapsedSeconds = 0.0; + // Explicit high-speed collision-gate repro. The streamer stays centred at the launch tile while + // the character uses ordinary swept movement across the still-pending desired set. The test + // distinguishes a gate hold from a full horizontal stop so a fall-only gate can be validated. + bool bHeadlessCollisionGateStressTest = false; + bool bHeadlessCollisionGateStressTestStartPlaced = false; + bool bHeadlessCollisionGateStressTestStarted = false; + bool bHeadlessCollisionGateStressTestPassed = false; + bool bHeadlessCollisionGateStressTestFailed = false; + bool bHeadlessCollisionGateStressTestExitRequested = false; + bool bHeadlessCollisionGateStressTestGateWasEngaged = false; + bool bHeadlessCollisionGateStressTestFullStop = false; + float HeadlessCollisionGateStressTestSpeedCmPerSecond = 6000.0f; + float HeadlessCollisionGateStressTestStartDelaySeconds = 0.1f; + float HeadlessCollisionGateStressTestDurationSeconds = 2.0f; + float HeadlessCollisionGateStressTestTimeoutSeconds = 15.0f; + double HeadlessCollisionGateStressTestBeginSeconds = 0.0; + double HeadlessCollisionGateStressTestStartSeconds = 0.0; + double HeadlessCollisionGateStressTestGateStartSeconds = 0.0; + double HeadlessCollisionGateStressTestFullStopStartSeconds = 0.0; + double HeadlessCollisionGateStressTestDistanceCm = 0.0; + double HeadlessCollisionGateStressTestTotalGateDurationSeconds = 0.0; + double HeadlessCollisionGateStressTestMaxGateDurationSeconds = 0.0; + double HeadlessCollisionGateStressTestFullStopDurationSeconds = 0.0; + int32 HeadlessCollisionGateStressTestGateHoldCount = 0; + int32 HeadlessCollisionGateStressTestFullStopCount = 0; + FVector HeadlessCollisionGateStressTestDirection = FVector::XAxisVector; + FVector HeadlessCollisionGateStressTestLastActualPosition = FVector::ZeroVector; + FString HeadlessCollisionGateStressTestFailureReason; + // Explicit owner-layout collision-gate repro. This is a command-line-only harness path: it // places the possessed character above the authored single-strate surface and lets ordinary // CharacterMovement gravity carry it to the generated ground. @@ -1046,6 +1082,7 @@ public: bool bHeadlessStrateCrossingTestExitRequested = false; bool bHeadlessStrateCrossingTestGateWasEngaged = false; bool bHeadlessStrateCrossingTestInitialSupportReported = false; + bool bHeadlessStrateCrossingTestPendingFloorOverlap = false; int32 HeadlessStrateCrossingTestRequestedPassageIndex = INDEX_NONE; int32 HeadlessStrateCrossingTestPassageIndex = INDEX_NONE; int32 HeadlessStrateCrossingTestUpperStrateIndex = INDEX_NONE; @@ -1068,22 +1105,22 @@ public: FString HeadlessStrateCrossingTestFailureReason; TArray HeadlessStrateCrossingTestRoute; - // PLAYER COLLISION GATE — the pawn is allowed to enter/leave a tile only when the level-0 - // collision body covering its feet has completed the RMC cook. The movement component state is - // saved so a temporary stream wait does not permanently change a character's movement mode. + // PLAYER COLLISION GATE — only the downward component is held while the level-0 collision body + // beneath the pawn's feet is unresolved. Horizontal movement and player control remain live. TWeakObjectPtr PawnTickPrerequisite; TWeakObjectPtr PawnMovementTickPrerequisite; TWeakObjectPtr CollisionGatedPawn; TWeakObjectPtr CollisionGatedPawnMovement; TWeakObjectPtr CollisionGatedCharacterMovement; - uint8 SavedCharacterMovementMode = 0; - uint8 SavedCharacterCustomMovementMode = 0; - bool bSavedCharacterMovementMode = false; - bool bSavedPawnMovementActive = false; + bool bCollisionGateEnabled = true; + float SavedCharacterGravityScale = 1.0f; + bool bSavedCharacterGravityScale = false; bool bPawnCollisionGateEngaged = false; double PawnGateEngagedAtSeconds = 0.0; bool bPawnGateUnavailableReported = false; bool bPawnGateUnsupportedReported = false; + FVoxelTileKey PawnGateRequestedSupportTile; + bool bPawnGateSupportRequestReported = false; // 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 61cc27a..1e2293e 100644 --- a/Tools/VoxelForgeTest.ps1 +++ b/Tools/VoxelForgeTest.ps1 @@ -1,6 +1,6 @@ [CmdletBinding()] param( - [ValidateSet('canonical', 'owner', 'probe', 'perf', 'tests', 'surface-fall', 'crossing', 'parity')] + [ValidateSet('canonical', 'owner', 'probe', 'perf', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'parity')] [string]$Scenario = 'canonical', [switch]$Build, [ValidateSet('owner', 'default')] @@ -45,6 +45,7 @@ $CommandletMetrics = [ordered]@{} $GameMetrics = [ordered]@{} $SurfaceFallMetrics = [ordered]@{ requested = $Scenario -in @('tests', 'surface-fall'); status = 'not_run' } $StrateCrossingMetrics = [ordered]@{ requested = $Scenario -eq 'crossing'; status = 'not_run' } +$CollisionGateStressMetrics = [ordered]@{ requested = $Scenario -in @('tests', 'gate-stress'); status = 'not_run' } $TestMetrics = @() $ParityEvidence = [ordered]@{ requested = $Scenario -eq 'parity'; status = 'not_run' } $DllEvidence = [ordered]@{ @@ -426,7 +427,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', 'tests', 'surface-fall', 'crossing') + used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing', 'gate-stress') source_path = $source source_sha256 = Get-Sha256 $source staged_path = $target @@ -474,7 +475,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', 'tests', 'surface-fall', 'crossing') + used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing', 'gate-stress') source_path = $source source_sha256 = Get-Sha256 $source staged_path = $target @@ -691,6 +692,23 @@ function Get-StrateCrossingMetrics([string]$LogPath) { 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=(?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', + 'gate_holds', 'gate_total_s', 'max_gate_s', 'full_stops', + 'full_stop_total_s', 'collision_gate')) { + $match = [regex]::Match($line, ('{0}=(?[^ ]+)' -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 { @@ -980,6 +998,20 @@ try { Add-Failure "strate-crossing test did not pass: $($StrateCrossingMetrics.status) $($StrateCrossingMetrics.reason)" } } + 'gate-stress' { + $baseCvars['voxel.TestCollisionGateStress'] = 1 + $baseCvars['voxel.TestCollisionGateStressSpeedCmPerSecond'] = 6000 + $baseCvars['voxel.TestCollisionGateStressStartDelaySeconds'] = 0.1 + $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') { + Add-Failure "collision-gate stress repro did not pass: $($CollisionGateStressMetrics.status) $($CollisionGateStressMetrics.reason)" + } + } 'tests' { $testPath = if ([string]::IsNullOrWhiteSpace($TestFilter)) { 'VoxelForge' } else { $TestFilter } $baseCvars['ExecCmds'] = "Automation RunTests $testPath; Quit" @@ -1018,6 +1050,21 @@ try { if ($SurfaceFallMetrics.status -ne 'passed') { Add-Failure "surface-fall repro did not pass: $($SurfaceFallMetrics.status) $($SurfaceFallMetrics.reason)" } + + $stressBaseCvars = [ordered]@{ + 'voxel.TestCollisionGateStress' = 1 + 'voxel.TestCollisionGateStressSpeedCmPerSecond' = 6000 + 'voxel.TestCollisionGateStressStartDelaySeconds' = 0.1 + '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') { + Add-Failure "collision-gate stress repro did not pass: $($CollisionGateStressMetrics.status) $($CollisionGateStressMetrics.reason)" + } } 'parity' { $parityArgs = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\parity-commandlet.log') (Merge-Cvars $baseCvars) @@ -1110,6 +1157,7 @@ $result = [ordered]@{ game = $GameMetrics surface_fall = $SurfaceFallMetrics strate_crossing = $StrateCrossingMetrics + 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 } @@ -1141,6 +1189,7 @@ try { [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("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("p50_p95=$(ConvertTo-CompactJson $result.p50_p95)")