diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 5479f83..d4b67c7 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -1514,6 +1514,52 @@ void AVoxelWorld::BeginPlay() return; } + // The owner's current DA_Settings is intentionally a one-strate surface layout, so it cannot + // exercise the separate inter-strate traversal regression. The explicit headless crossing + // switch uses a transient copy of the authored four-definition corpus; no package is modified + // or saved, and ordinary gameplay keeps the assigned settings untouched. + if (bHeadlessStrateCrossingTest) + { + UVoxelSettings* CrossingSettings = DuplicateObject(Settings, this); + TArray CrossingDefinitions; + for (int32 DefinitionIndex = 1; DefinitionIndex <= 4; ++DefinitionIndex) + { + const FString AssetPath = FString::Printf( + TEXT("/Game/VoxelForge/DA_Strate%d.DA_Strate%d"), + DefinitionIndex, DefinitionIndex); + if (UVoxelStrateDefinition* Definition = + LoadObject(nullptr, *AssetPath)) + { + CrossingDefinitions.Add(Definition); + } + } + if (CrossingSettings != nullptr && CrossingDefinitions.Num() == 4) + { + CrossingSettings->Season.Reset(); + CrossingSettings->StratePool.Reset(); + CrossingSettings->FixedStrates.Reset(); + CrossingSettings->TotalStrates = CrossingDefinitions.Num(); + for (int32 DefinitionIndex = 0; DefinitionIndex < CrossingDefinitions.Num(); + ++DefinitionIndex) + { + CrossingSettings->FixedStrates.Add( + DefinitionIndex, CrossingDefinitions[DefinitionIndex]); + } + Settings = CrossingSettings; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeStrateCrossingTest] fixture=transient_authored_corpus " + "strates=%d assets_not_saved=1"), + CrossingDefinitions.Num()); + } + else + { + UE_LOG(LogTemp, Warning, + TEXT("[VoxelForgeStrateCrossingTest] fixture=transient_authored_corpus " + "unavailable definitions=%d; using assigned settings"), + CrossingDefinitions.Num()); + } + } + if (VoxelForgeStartupTrace::IsActive()) { FString SettingsPath = Settings->GetPathName(); @@ -1698,6 +1744,26 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest() HeadlessStreamingTestLastActualPosition = FVector::ZeroVector; HeadlessStreamingTestBeginSeconds = 0.0; HeadlessStreamingTestLastElapsedSeconds = 0.0; + bHeadlessSurfaceFallTest = false; + bHeadlessSurfaceFallTestStartPlaced = false; + bHeadlessSurfaceFallTestPassed = false; + bHeadlessSurfaceFallTestFailed = false; + bHeadlessSurfaceFallTestExitRequested = false; + bHeadlessSurfaceFallTestPredictionProbeInjected = false; + bHeadlessSurfaceFallTestPredictionProbeCleared = false; + HeadlessSurfaceFallTestSpawnHeightVoxels = 20.0f; + HeadlessSurfaceFallTestGroundToleranceVoxels = 4.0f; + HeadlessSurfaceFallTestTimeoutSeconds = 8.0f; + HeadlessSurfaceFallTestPlayerHalfHeightVoxels = + VoxelPassageGeometry::PlayerHalfHeightVoxels; + HeadlessSurfaceFallTestTerrainZ = 0.0f; + HeadlessSurfaceFallTestBeginSeconds = 0.0; + HeadlessSurfaceFallTestStartSeconds = 0.0; + HeadlessSurfaceFallTestDistanceCm = 0.0; + HeadlessSurfaceFallTestStartPosition = FVector::ZeroVector; + HeadlessSurfaceFallTestLastActualPosition = FVector::ZeroVector; + HeadlessSurfaceFallTestPredictionProbePosition = FVector::ZeroVector; + HeadlessSurfaceFallTestFailureReason.Reset(); bTileHashDumpEnabled = false; TileHashDumpPath.Reset(); TileHashDumpRecords.Reset(); @@ -1709,6 +1775,7 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest() bHeadlessStrateCrossingTestFailed = false; bHeadlessStrateCrossingTestExitRequested = false; bHeadlessStrateCrossingTestGateWasEngaged = false; + bHeadlessStrateCrossingTestInitialSupportReported = false; HeadlessStrateCrossingTestRequestedPassageIndex = INDEX_NONE; HeadlessStrateCrossingTestPassageIndex = INDEX_NONE; HeadlessStrateCrossingTestUpperStrateIndex = INDEX_NONE; @@ -1806,6 +1873,41 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest() HeadlessStrateCrossingTestRequestedPassageIndex); } + int32 SurfaceFallValue = 0; + bHeadlessSurfaceFallTest = + (FParse::Value(CommandLine, TEXT("voxel.TestSurfaceFall="), SurfaceFallValue) + && SurfaceFallValue != 0) + || FParse::Param(CommandLine, TEXT("voxel.TestSurfaceFall")); + if (bHeadlessSurfaceFallTest) + { + if (bHeadlessStrateCrossingTest) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=conflicting_headless_tests")); + bHeadlessSurfaceFallTestFailed = true; + HeadlessSurfaceFallTestFailureReason = TEXT("conflicting_headless_tests"); + } + FParse::Value(CommandLine, TEXT("voxel.TestSurfaceFallSpawnHeightVoxels="), + HeadlessSurfaceFallTestSpawnHeightVoxels); + FParse::Value(CommandLine, TEXT("voxel.TestSurfaceFallGroundToleranceVoxels="), + HeadlessSurfaceFallTestGroundToleranceVoxels); + FParse::Value(CommandLine, TEXT("voxel.TestSurfaceFallTimeoutSeconds="), + HeadlessSurfaceFallTestTimeoutSeconds); + HeadlessSurfaceFallTestSpawnHeightVoxels = FMath::Max( + HeadlessSurfaceFallTestSpawnHeightVoxels, 1.0f); + HeadlessSurfaceFallTestGroundToleranceVoxels = FMath::Max( + HeadlessSurfaceFallTestGroundToleranceVoxels, 0.5f); + HeadlessSurfaceFallTestTimeoutSeconds = FMath::Max( + HeadlessSurfaceFallTestTimeoutSeconds, 1.0f); + HeadlessSurfaceFallTestBeginSeconds = FPlatformTime::Seconds(); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeSurfaceFallTest] configured=1 spawn_height_vox=%.2f " + "ground_tolerance_vox=%.2f timeout_s=%.3f"), + HeadlessSurfaceFallTestSpawnHeightVoxels, + HeadlessSurfaceFallTestGroundToleranceVoxels, + HeadlessSurfaceFallTestTimeoutSeconds); + } + int32 TraceThroughCrossingValue = 0; if (FParse::Value( CommandLine, @@ -2151,6 +2253,27 @@ void AVoxelWorld::AdvanceHeadlessStrateCrossingTest( StrateManager->GetPassages()[HeadlessStrateCrossingTestPassageIndex]; if (!bHeadlessStrateCrossingTestStartPlaced) { + FVoxelTileKey InitialSupportTile; + const TCHAR* InitialSupportUnavailableReason = nullptr; + if (!IsPlayerSupportCollisionReady( + PlayerPawn, InOutPlayerPosition, &InitialSupportTile, + &InitialSupportUnavailableReason)) + { + if (!bHeadlessStrateCrossingTestInitialSupportReported) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeStrateCrossingTest] waiting_for_initial_support " + "tile=(%d,%d,%d) reason=%s"), + InitialSupportTile.Coord.X, InitialSupportTile.Coord.Y, + InitialSupportTile.Coord.Z, + InitialSupportUnavailableReason != nullptr + ? InitialSupportUnavailableReason : TEXT("collision_pending")); + bHeadlessStrateCrossingTestInitialSupportReported = true; + } + return; + } + bHeadlessStrateCrossingTestInitialSupportReported = false; + if (const ACharacter* Character = Cast(PlayerPawn)) { if (const UCapsuleComponent* Capsule = Character->GetCapsuleComponent()) @@ -2529,6 +2652,319 @@ void AVoxelWorld::AdvanceHeadlessStreamingTest( } } +void AVoxelWorld::AdvanceHeadlessSurfaceFallTest( + FVector& InOutPlayerPosition, FVector& InOutPlayerHeading, APawn* PlayerPawn) +{ + if (!bHeadlessSurfaceFallTest || bHeadlessSurfaceFallTestFailed + || bHeadlessSurfaceFallTestPassed || !IsValid(PlayerPawn) + || HeadlessSurfaceFallTestBeginSeconds <= 0.0 || !Generator || !StrateManager) + { + return; + } + + const double Now = FPlatformTime::Seconds(); + if (!bHeadlessSurfaceFallTestStartPlaced) + { + if (StrateManager->GetNumStrates() != 1 + || StrateManager->GetGeneratorTypeForChunk(FIntVector(0, 0, 0)) + != ECaveGeneratorType::SurfaceWorld) + { + bHeadlessSurfaceFallTestFailed = true; + HeadlessSurfaceFallTestFailureReason = TEXT("layout_is_not_one_surface_strate"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=%s strates=%d"), + *HeadlessSurfaceFallTestFailureReason, StrateManager->GetNumStrates()); + return; + } + + float HeightfieldTerrainZ = 0.0f; + float CeilingZ = 0.0f; + if (!Generator->GetSurfaceHeightAt( + 0.0f, 0.0f, 0, HeightfieldTerrainZ, CeilingZ)) + { + bHeadlessSurfaceFallTestFailed = true; + HeadlessSurfaceFallTestFailureReason = TEXT("surface_height_oracle_unavailable"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=%s"), + *HeadlessSurfaceFallTestFailureReason); + return; + } + + if (const ACharacter* Character = Cast(PlayerPawn)) + { + if (const UCapsuleComponent* Capsule = Character->GetCapsuleComponent()) + { + const float HalfHeightCm = Capsule->GetScaledCapsuleHalfHeight(); + if (VoxelMath::IsFinite(HalfHeightCm) && HalfHeightCm > 0.0f) + { + HeadlessSurfaceFallTestPlayerHalfHeightVoxels = + HalfHeightCm / VOXEL_SIZE; + } + } + } + + int32 TopChunkZ = 0; + int32 BottomChunkZ = 0; + if (!StrateManager->GetStrateChunkZBounds(0, TopChunkZ, BottomChunkZ)) + { + bHeadlessSurfaceFallTestFailed = true; + HeadlessSurfaceFallTestFailureReason = TEXT("surface_strate_bounds_unavailable"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=%s"), + *HeadlessSurfaceFallTestFailureReason); + return; + } + + // The authored surface entry is at the top of the one-strate band. The procedural + // heightfield can be far below that opening, so use the higher of the two as the spawn + // floor; this reproduces a character falling through the surface-only world instead of + // accidentally starting next to the eventual terrain. + const float SurfaceEntryTopZ = static_cast(TopChunkZ + 1) * CHUNK_SIZE; + if (!StrateManager->GetLayout().IsValidIndex(0) + || StrateManager->GetLayout()[0].Definition == nullptr + || Settings == nullptr) + { + bHeadlessSurfaceFallTestFailed = true; + HeadlessSurfaceFallTestFailureReason = TEXT("surface_entry_geometry_unavailable"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=%s"), + *HeadlessSurfaceFallTestFailureReason); + return; + } + + const UVoxelStrateDefinition* TopDefinition = + StrateManager->GetLayout()[0].Definition; + const float TopSeal = TopDefinition->SurfaceParams.BoundarySealThickness; + const float OriginRadius = Settings->GetEffectiveOriginSpineRadius(); + const VoxelPassageGeometry::FOriginLandingGeometry OriginLanding = + VoxelPassageGeometry::BuildOriginLandingGeometry( + SurfaceEntryTopZ, + static_cast(BottomChunkZ) * CHUNK_SIZE, + TopSeal, + OriginRadius); + if (!OriginLanding.bValid) + { + bHeadlessSurfaceFallTestFailed = true; + HeadlessSurfaceFallTestFailureReason = TEXT("surface_entry_landing_unavailable"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=%s"), + *HeadlessSurfaceFallTestFailureReason); + return; + } + HeadlessSurfaceFallTestTerrainZ = OriginLanding.FloorZ; + const float GroundHeightfieldZ = HeightfieldTerrainZ; + const float SpawnBaseZ = FMath::Max(HeightfieldTerrainZ, SurfaceEntryTopZ); + const FVector StartVoxel( + 0.0f, + 0.0f, + SpawnBaseZ + + HeadlessSurfaceFallTestPlayerHalfHeightVoxels + + HeadlessSurfaceFallTestSpawnHeightVoxels); + HeadlessSurfaceFallTestStartPosition = LocalVoxelToWorld(StartVoxel); + const bool bPlaced = PlayerPawn->SetActorLocation( + HeadlessSurfaceFallTestStartPosition, + false, + nullptr, + ETeleportType::TeleportPhysics); + const FVector ActualPosition = PlayerPawn->GetActorLocation(); + InOutPlayerPosition = ActualPosition; + InOutPlayerHeading = FVector::ZeroVector; + HeadlessSurfaceFallTestStartPosition = ActualPosition; + HeadlessSurfaceFallTestLastActualPosition = ActualPosition; + HeadlessSurfaceFallTestDistanceCm = 0.0; + bHeadlessSurfaceFallTestStartPlaced = true; + HeadlessSurfaceFallTestStartSeconds = Now; + + if (ACharacter* Character = Cast(PlayerPawn)) + { + if (UCharacterMovementComponent* CharacterMovement = Character->GetCharacterMovement()) + { + CharacterMovement->StopMovementImmediately(); + CharacterMovement->SetMovementMode(MOVE_Falling); + } + } + + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeSurfaceFallTest] start_placed moved=%d ground_vox=%.3f " + "heightfield_vox=%.3f entry_top_vox=%.3f " + "spawn_vox=(%.2f,%.2f,%.2f) world=(%.1f,%.1f,%.1f)"), + bPlaced ? 1 : 0, + HeadlessSurfaceFallTestTerrainZ, + GroundHeightfieldZ, + SurfaceEntryTopZ, + StartVoxel.X, StartVoxel.Y, StartVoxel.Z, + ActualPosition.X, ActualPosition.Y, ActualPosition.Z); + return; + } + + // Reproduce the gate's original stale-prediction path while the pawn is still above the + // landing: the current support is valid, but a deliberately fast one-frame horizontal probe + // points at a tile outside the current desired set. The old gate remembers that probe after + // stopping the pawn and never re-derives it. The probe is cleared on the next tick only when + // the gate has correctly released, after which ordinary gravity continues the fall. + if (!bHeadlessSurfaceFallTestPredictionProbeInjected + && Now - HeadlessSurfaceFallTestStartSeconds >= 0.25) + { + FVoxelTileKey ProbeSupportTile; + const FVector LocalPosition = WorldToLocalVoxel(InOutPlayerPosition); + const bool bProbeSupportReady = + IsPlayerSupportCollisionReady(PlayerPawn, InOutPlayerPosition, &ProbeSupportTile); + int32 ProbeTopChunkZ = 0; + int32 ProbeBottomChunkZ = 0; + const bool bProbeSupportInLayout = bProbeSupportReady + && StrateManager->GetStrateChunkZBounds( + ProbeSupportTile.Coord.Z, ProbeTopChunkZ, ProbeBottomChunkZ); + if (bProbeSupportReady + && bProbeSupportInLayout + && LocalPosition.Z - HeadlessSurfaceFallTestPlayerHalfHeightVoxels + > HeadlessSurfaceFallTestTerrainZ + 2.0f) + { + bHeadlessSurfaceFallTestPredictionProbeInjected = true; + HeadlessSurfaceFallTestPredictionProbePosition = InOutPlayerPosition; + if (ACharacter* Character = Cast(PlayerPawn)) + { + if (UCharacterMovementComponent* CharacterMovement = + Character->GetCharacterMovement()) + { + CharacterMovement->Velocity = FVector(1000000.0f, 0.0f, 0.0f); + CharacterMovement->SetMovementMode(MOVE_Falling); + CharacterMovement->UpdateComponentVelocity(); + } + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeSurfaceFallTest] prediction_probe injected=1 " + "current_support=(%d,%d,%d) local=(%.2f,%.2f,%.2f) " + "velocity_cm_s=1000000 pawn_velocity=(%.1f,%.1f,%.1f)"), + ProbeSupportTile.Coord.X, ProbeSupportTile.Coord.Y, ProbeSupportTile.Coord.Z, + LocalPosition.X, LocalPosition.Y, LocalPosition.Z, + PlayerPawn->GetVelocity().X, PlayerPawn->GetVelocity().Y, + PlayerPawn->GetVelocity().Z); + // Use a stable test-frame delta so this command-line probe exercises the same + // prediction branch even when an uncapped -nullrhi tick reports a near-zero delta. + UpdatePawnCollisionGate(PlayerPawn, InOutPlayerPosition, 1.0f / 60.0f); + } + } + else if (bHeadlessSurfaceFallTestPredictionProbeInjected + && !bHeadlessSurfaceFallTestPredictionProbeCleared + && !bPawnCollisionGateEngaged) + { + const bool bRestored = PlayerPawn->SetActorLocation( + HeadlessSurfaceFallTestPredictionProbePosition, + false, + nullptr, + ETeleportType::TeleportPhysics); + InOutPlayerPosition = PlayerPawn->GetActorLocation(); + InOutPlayerHeading = FVector::ZeroVector; + bHeadlessSurfaceFallTestPredictionProbeCleared = true; + if (ACharacter* Character = Cast(PlayerPawn)) + { + if (UCharacterMovementComponent* CharacterMovement = + Character->GetCharacterMovement()) + { + CharacterMovement->StopMovementImmediately(); + CharacterMovement->SetMovementMode(MOVE_Falling); + } + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeSurfaceFallTest] prediction_probe cleared=1 restored=%d"), + bRestored ? 1 : 0); + } + + HeadlessSurfaceFallTestDistanceCm += FVector::Dist( + HeadlessSurfaceFallTestLastActualPosition, InOutPlayerPosition); + HeadlessSurfaceFallTestLastActualPosition = InOutPlayerPosition; + + const FVector LocalPosition = WorldToLocalVoxel(InOutPlayerPosition); + const float FeetZ = LocalPosition.Z - HeadlessSurfaceFallTestPlayerHalfHeightVoxels; + bool bOnGround = false; + if (const ACharacter* Character = Cast(PlayerPawn)) + { + if (const UCharacterMovementComponent* CharacterMovement = + Character->GetCharacterMovement()) + { + bOnGround = CharacterMovement->IsMovingOnGround(); + } + } + if (bOnGround + && bHeadlessSurfaceFallTestPredictionProbeCleared + && FeetZ <= HeadlessSurfaceFallTestTerrainZ + + HeadlessSurfaceFallTestGroundToleranceVoxels + && FeetZ >= HeadlessSurfaceFallTestTerrainZ + - HeadlessSurfaceFallTestGroundToleranceVoxels) + { + bHeadlessSurfaceFallTestPassed = true; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeSurfaceFallTest] reached_ground elapsed_s=%.6f " + "distance_m=%.6f feet_vox=%.3f terrain_vox=%.3f gate=%d probe=1"), + Now - HeadlessSurfaceFallTestStartSeconds, + HeadlessSurfaceFallTestDistanceCm / 100.0, + FeetZ, + HeadlessSurfaceFallTestTerrainZ, + bPawnCollisionGateEngaged ? 1 : 0); + } +} + +void AVoxelWorld::MaybeFinishHeadlessSurfaceFallTest() +{ + if (!bHeadlessSurfaceFallTest || bHeadlessSurfaceFallTestExitRequested + || HeadlessSurfaceFallTestBeginSeconds <= 0.0) + { + return; + } + + const double Now = FPlatformTime::Seconds(); + if (!bHeadlessSurfaceFallTestStartPlaced + && Now - HeadlessSurfaceFallTestBeginSeconds + >= static_cast(HeadlessSurfaceFallTestTimeoutSeconds)) + { + bHeadlessSurfaceFallTestFailed = true; + HeadlessSurfaceFallTestFailureReason = TEXT("pawn_start_not_observed"); + } + else if (!bHeadlessSurfaceFallTestPredictionProbeInjected + && !bHeadlessSurfaceFallTestPassed && !bHeadlessSurfaceFallTestFailed + && Now - HeadlessSurfaceFallTestStartSeconds + >= static_cast(HeadlessSurfaceFallTestTimeoutSeconds)) + { + bHeadlessSurfaceFallTestFailed = true; + HeadlessSurfaceFallTestFailureReason = TEXT("surface_prediction_probe_not_exercised"); + } + else if (bHeadlessSurfaceFallTestPredictionProbeInjected + && !bHeadlessSurfaceFallTestPredictionProbeCleared + && !bHeadlessSurfaceFallTestPassed && !bHeadlessSurfaceFallTestFailed + && Now - HeadlessSurfaceFallTestStartSeconds + >= static_cast(HeadlessSurfaceFallTestTimeoutSeconds)) + { + bHeadlessSurfaceFallTestFailed = true; + HeadlessSurfaceFallTestFailureReason = TEXT("collision_gate_prediction_deadlock"); + } + else if (!bHeadlessSurfaceFallTestPassed && !bHeadlessSurfaceFallTestFailed + && Now - HeadlessSurfaceFallTestStartSeconds + >= static_cast(HeadlessSurfaceFallTestTimeoutSeconds)) + { + bHeadlessSurfaceFallTestFailed = true; + HeadlessSurfaceFallTestFailureReason = TEXT("surface_ground_timeout"); + } + if (!bHeadlessSurfaceFallTestPassed && !bHeadlessSurfaceFallTestFailed) + { + return; + } + + bHeadlessSurfaceFallTestExitRequested = true; + const FVector FinalPosition = HeadlessSurfaceFallTestLastActualPosition; + const bool bPassed = bHeadlessSurfaceFallTestPassed && !bHeadlessSurfaceFallTestFailed; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeSurfaceFallTest] result=%s reason=%s elapsed_s=%.6f " + "distance_m=%.6f final_position=(%.1f,%.1f,%.1f) terrain_vox=%.3f"), + bPassed ? TEXT("PASS") : TEXT("FAIL"), + bPassed ? TEXT("reached_surface_ground") : *HeadlessSurfaceFallTestFailureReason, + Now - HeadlessSurfaceFallTestBeginSeconds, + HeadlessSurfaceFallTestDistanceCm / 100.0, + FinalPosition.X, FinalPosition.Y, FinalPosition.Z, + HeadlessSurfaceFallTestTerrainZ); + FPlatformMisc::RequestExitWithStatus( + false, bPassed ? 0 : 1, TEXT("VoxelForge surface fall test complete")); +} + void AVoxelWorld::MaybeFinishHeadlessStreamingTest() { if (bHeadlessStrateCrossingTest) @@ -2537,6 +2973,12 @@ void AVoxelWorld::MaybeFinishHeadlessStreamingTest() return; } + if (bHeadlessSurfaceFallTest) + { + MaybeFinishHeadlessSurfaceFallTest(); + return; + } + if (bHeadlessStreamingTestExitRequested || HeadlessStreamingTestExitSeconds <= 0.0f || HeadlessStreamingTestBeginSeconds <= 0.0) { @@ -2575,6 +3017,7 @@ void AVoxelWorld::Tick(float DeltaTime) FVector PlayerHeading = PlayerPawn ? PlayerPawn->GetVelocity() : FVector::ZeroVector; AdvanceHeadlessStreamingTest(PlayerLastPos, PlayerHeading, PlayerPawn); AdvanceHeadlessStrateCrossingTest(PlayerLastPos, PlayerHeading, PlayerPawn); + AdvanceHeadlessSurfaceFallTest(PlayerLastPos, PlayerHeading, PlayerPawn); PeakObservedPawnSpeedCmPerSecond = FMath::Max( PeakObservedPawnSpeedCmPerSecond, PlayerHeading.Size2D()); UpdatePawnCollisionGate(PlayerPawn, PlayerLastPos, DeltaTime); @@ -2955,9 +3398,49 @@ bool AVoxelWorld::IsTileCollisionReady(const FVoxelTileKey& Tile) const return Mesh != nullptr && Mesh->GetBodySetup() != nullptr; } -bool AVoxelWorld::IsCollisionReadyFromSupportTile( - const FVoxelTileKey& InitialTile, FVoxelTileKey* OutSupportTile) const +bool AVoxelWorld::IsCollisionTileUnavailable(const FVoxelTileKey& Tile, + const TCHAR*& OutReason) const { + OutReason = nullptr; + + // A tile outside every authored strate is open air. The streamer deliberately does not + // request it, so treating its absent collision as pending would make a one-strate world an + // impossible gate target. Inter-strate gap chunks are different: they are authored solid + // bedrock and must remain blocking until their collision is ready. + if (StrateManager != nullptr) + { + int32 TopChunkZ = 0; + int32 BottomChunkZ = 0; + const bool bInStrate = StrateManager->GetStrateChunkZBounds( + Tile.Coord.Z, TopChunkZ, BottomChunkZ); + if (!bInStrate && !StrateManager->IsGapChunk(Tile.Coord)) + { + OutReason = TEXT("out_of_layout"); + return true; + } + } + + // 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; +} + +bool AVoxelWorld::IsCollisionReadyFromSupportTile( + const FVoxelTileKey& InitialTile, FVoxelTileKey* OutSupportTile, + const TCHAR** OutUnavailableReason) const +{ + if (OutUnavailableReason != nullptr) + { + *OutUnavailableReason = nullptr; + } + FVoxelTileKey CandidateTile = InitialTile; const int32 Direction = CollisionSolidTiles.Contains(InitialTile) ? 1 : -1; @@ -2975,6 +3458,19 @@ bool AVoxelWorld::IsCollisionReadyFromSupportTile( } return true; } + const TCHAR* UnavailableReason = nullptr; + if (IsCollisionTileUnavailable(CandidateTile, UnavailableReason)) + { + if (OutSupportTile != nullptr) + { + *OutSupportTile = CandidateTile; + } + if (OutUnavailableReason != nullptr) + { + *OutUnavailableReason = UnavailableReason; + } + return true; + } if (!CollisionNotRequiredTiles.Contains(CandidateTile)) { if (OutSupportTile != nullptr) @@ -2993,15 +3489,22 @@ bool AVoxelWorld::IsCollisionReadyFromSupportTile( } bool AVoxelWorld::IsPlayerSupportCollisionReady(APawn* Pawn, const FVector& PlayerPosition, - FVoxelTileKey* OutSupportTile) const + FVoxelTileKey* OutSupportTile, + const TCHAR** OutUnavailableReason) const { + if (OutUnavailableReason != nullptr) + { + *OutUnavailableReason = nullptr; + } + FVoxelTileKey CandidateTile; if (!GetPlayerSupportTile(Pawn, PlayerPosition, CandidateTile)) { return false; } - return IsCollisionReadyFromSupportTile(CandidateTile, OutSupportTile); + return IsCollisionReadyFromSupportTile( + CandidateTile, OutSupportTile, OutUnavailableReason); } void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosition, @@ -3027,6 +3530,7 @@ void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosi PawnTickPrerequisite = Pawn; Pawn->AddTickPrerequisiteActor(this); bPawnGateUnsupportedReported = false; + bPawnGateUnavailableReported = false; } // The actor prerequisite covers the pawn tick; attach the same prerequisite directly to its @@ -3062,11 +3566,13 @@ void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosi } FVoxelTileKey CurrentSupportTile; + const TCHAR* CurrentSupportUnavailableReason = nullptr; const bool bCurrentSupportReady = IsPlayerSupportCollisionReady( - Pawn, PlayerPosition, &CurrentSupportTile); + 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. @@ -3085,48 +3591,74 @@ void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosi const float Alpha = static_cast(ProbeIndex) / static_cast(NumPredictedProbes); const FVector PredictedPosition = PlayerPosition + Velocity * DeltaTime * Alpha; - const bool bHavePredictedSupport = GetPlayerSupportTile( - Pawn, PredictedPosition, PredictedSupportTile); - const bool bPredictedSupportReady = bHavePredictedSupport - && IsPlayerSupportCollisionReady( - Pawn, PredictedPosition, &PredictedSupportTile); + const bool bPredictedSupportReady = IsPlayerSupportCollisionReady( + Pawn, PredictedPosition, &PredictedSupportTile, + &PredictedSupportUnavailableReason); if (!bPredictedSupportReady) { bCanRelease = false; - bWaitingForPredictedSupport = bHavePredictedSupport; + bWaitingForPredictedSupport = true; break; } } } } - // Engage() stops velocity, so the blocked tile must be remembered across subsequent ticks; - // otherwise a gate caused by a predicted crossing would release immediately on the next tick. - if (bPawnCollisionGateEngaged && bPawnGateWaitingForPredictedSupport) + const TCHAR* NonBlockingReason = CurrentSupportUnavailableReason != nullptr + ? CurrentSupportUnavailableReason : PredictedSupportUnavailableReason; + if (NonBlockingReason != nullptr) { - if (!IsCollisionReadyFromSupportTile(PawnGatePredictedSupportTile, - &PredictedSupportTile)) + if (!bPawnGateUnavailableReported) { - bCanRelease = false; - bWaitingForPredictedSupport = true; + 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"), + *Pawn->GetName(), + NonBlockingReason, + CurrentSupportUnavailableReason != nullptr + ? CurrentSupportUnavailableReason : TEXT("none"), + PredictedSupportUnavailableReason != nullptr + ? PredictedSupportUnavailableReason : TEXT("none"), + CurrentSupportTile.Coord.X, CurrentSupportTile.Coord.Y, + CurrentSupportTile.Coord.Z, + PredictedSupportTile.Coord.X, PredictedSupportTile.Coord.Y, + PredictedSupportTile.Coord.Z); } + bPawnGateUnavailableReported = true; + } + else + { + bPawnGateUnavailableReported = false; } if (bCanRelease) { - bPawnGateWaitingForPredictedSupport = false; ReleasePawnCollisionGate(); return; } - if (bWaitingForPredictedSupport) + constexpr double MaxCollisionGateWaitSeconds = 2.0; + if (bPawnCollisionGateEngaged && PawnGateEngagedAtSeconds > 0.0) { - bPawnGateWaitingForPredictedSupport = true; - PawnGatePredictedSupportTile = PredictedSupportTile; - } - else if (!bCurrentSupportReady) - { - bPawnGateWaitingForPredictedSupport = false; + 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 predicted_wait=%d current_support=(%d,%d,%d) " + "predicted_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); + ReleasePawnCollisionGate(); + return; + } } if (!bPawnCollisionGateEngaged) @@ -3167,6 +3699,7 @@ void AVoxelWorld::EngagePawnCollisionGate(APawn* Pawn) CharacterMovement->StopMovementImmediately(); CharacterMovement->SetMovementMode(MOVE_None); bPawnCollisionGateEngaged = true; + PawnGateEngagedAtSeconds = FPlatformTime::Seconds(); return; } } @@ -3178,6 +3711,7 @@ void AVoxelWorld::EngagePawnCollisionGate(APawn* Pawn) PawnMovement->StopMovementImmediately(); PawnMovement->Deactivate(); bPawnCollisionGateEngaged = true; + PawnGateEngagedAtSeconds = FPlatformTime::Seconds(); return; } @@ -3200,7 +3734,7 @@ void AVoxelWorld::ReleasePawnCollisionGate() CollisionGatedCharacterMovement.Reset(); bSavedCharacterMovementMode = false; bSavedPawnMovementActive = false; - bPawnGateWaitingForPredictedSupport = false; + PawnGateEngagedAtSeconds = 0.0; return; } @@ -3235,7 +3769,7 @@ void AVoxelWorld::ReleasePawnCollisionGate() bSavedCharacterMovementMode = false; bSavedPawnMovementActive = false; bPawnCollisionGateEngaged = false; - bPawnGateWaitingForPredictedSupport = false; + PawnGateEngagedAtSeconds = 0.0; } void AVoxelWorld::BindRealtimeMeshCollisionEvent(URealtimeMesh* Mesh) diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index 2b0aba7..a644852 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -522,11 +522,14 @@ private: void ConfigureHeadlessStreamingTest(); void AdvanceHeadlessStreamingTest(FVector& InOutPlayerPosition, FVector& InOutPlayerHeading, APawn* PlayerPawn); + void AdvanceHeadlessSurfaceFallTest(FVector& InOutPlayerPosition, + FVector& InOutPlayerHeading, APawn* PlayerPawn); void InitializeHeadlessStrateCrossingTest(); void AdvanceHeadlessStrateCrossingTest(FVector& InOutPlayerPosition, FVector& InOutPlayerHeading, APawn* PlayerPawn); void ObserveHeadlessStrateCrossingTest(const FVector& PlayerPosition, APawn* PlayerPawn); void MaybeFinishHeadlessStrateCrossingTest(); + void MaybeFinishHeadlessSurfaceFallTest(); void MaybeFinishHeadlessStreamingTest(); void RecordTileHash(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels, int32 Step, int32 Cells, int32 BandChunkLo, int32 BandChunkHi, @@ -824,10 +827,14 @@ public: FVoxelTileKey& OutSupportTile) const; bool IsTileCollisionReady(const FVoxelTileKey& Tile) const; + bool IsCollisionTileUnavailable(const FVoxelTileKey& Tile, + const TCHAR*& OutReason) const; bool IsCollisionReadyFromSupportTile(const FVoxelTileKey& CandidateTile, - FVoxelTileKey* OutSupportTile = nullptr) const; + FVoxelTileKey* OutSupportTile = nullptr, + const TCHAR** OutUnavailableReason = nullptr) const; bool IsPlayerSupportCollisionReady(APawn* Pawn, const FVector& PlayerPosition, - FVoxelTileKey* OutSupportTile = nullptr) const; + FVoxelTileKey* OutSupportTile = nullptr, + const TCHAR** OutUnavailableReason = nullptr) const; /** 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 @@ -997,6 +1004,29 @@ public: double HeadlessStreamingTestBeginSeconds = 0.0; double HeadlessStreamingTestLastElapsedSeconds = 0.0; + // 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. + bool bHeadlessSurfaceFallTest = false; + bool bHeadlessSurfaceFallTestStartPlaced = false; + bool bHeadlessSurfaceFallTestPassed = false; + bool bHeadlessSurfaceFallTestFailed = false; + bool bHeadlessSurfaceFallTestExitRequested = false; + bool bHeadlessSurfaceFallTestPredictionProbeInjected = false; + bool bHeadlessSurfaceFallTestPredictionProbeCleared = false; + float HeadlessSurfaceFallTestSpawnHeightVoxels = 20.0f; + float HeadlessSurfaceFallTestGroundToleranceVoxels = 4.0f; + float HeadlessSurfaceFallTestTimeoutSeconds = 8.0f; + float HeadlessSurfaceFallTestPlayerHalfHeightVoxels = 3.52f; + float HeadlessSurfaceFallTestTerrainZ = 0.0f; + double HeadlessSurfaceFallTestBeginSeconds = 0.0; + double HeadlessSurfaceFallTestStartSeconds = 0.0; + double HeadlessSurfaceFallTestDistanceCm = 0.0; + FVector HeadlessSurfaceFallTestStartPosition = FVector::ZeroVector; + FVector HeadlessSurfaceFallTestLastActualPosition = FVector::ZeroVector; + FVector HeadlessSurfaceFallTestPredictionProbePosition = FVector::ZeroVector; + FString HeadlessSurfaceFallTestFailureReason; + // Explicit parity evidence. Workers append under the lock; EndPlay writes the compact JSON // after ActiveTaskCount has reached zero, so the dump is a complete worker-side snapshot. bool bTileHashDumpEnabled = false; @@ -1015,6 +1045,7 @@ public: bool bHeadlessStrateCrossingTestFailed = false; bool bHeadlessStrateCrossingTestExitRequested = false; bool bHeadlessStrateCrossingTestGateWasEngaged = false; + bool bHeadlessStrateCrossingTestInitialSupportReported = false; int32 HeadlessStrateCrossingTestRequestedPassageIndex = INDEX_NONE; int32 HeadlessStrateCrossingTestPassageIndex = INDEX_NONE; int32 HeadlessStrateCrossingTestUpperStrateIndex = INDEX_NONE; @@ -1050,8 +1081,8 @@ public: bool bSavedCharacterMovementMode = false; bool bSavedPawnMovementActive = false; bool bPawnCollisionGateEngaged = false; - bool bPawnGateWaitingForPredictedSupport = false; - FVoxelTileKey PawnGatePredictedSupportTile; + double PawnGateEngagedAtSeconds = 0.0; + bool bPawnGateUnavailableReported = false; bool bPawnGateUnsupportedReported = false; // Desired-set membership STAMPÉE : clé → numéro du dernier crossing où la tuile était désirée. diff --git a/Tools/VoxelForgeTest.ps1 b/Tools/VoxelForgeTest.ps1 index 63c6f26..61cc27a 100644 --- a/Tools/VoxelForgeTest.ps1 +++ b/Tools/VoxelForgeTest.ps1 @@ -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=(?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}=(?[^ ]+)' -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=(?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}=(?[^ ]+)' -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)")