diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp index 3877e42..707aa5b 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp @@ -1387,6 +1387,12 @@ bool FVoxelForgeStrateConnectivityTest::RunTest(const FString& Parameters) return false; } + // The two density-sign/coarse-lie controls below deliberately use boxes up to 256 voxels + // wide. That is larger than the player-facing DiffLayer default radius cap; this test is + // validating the measurement field, not the gameplay brush budget. Keep the fixture budget + // unlimited so a rejected control cannot masquerade as a density or connectivity failure. + World.DiffLayer->SetBudget(/*MaxMods=*/0, /*MaxRadius=*/0.0f, /*MaxVolume=*/0.0f); + FVoxelStrateMeasureSettings Settings; Settings.SampleStep = 4; Settings.RadiusInVoxels = 256; diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeTilePostReachTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeTilePostReachTest.cpp new file mode 100644 index 0000000..d909cf2 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeTilePostReachTest.cpp @@ -0,0 +1,226 @@ +// The tile-reach proof must fail loudly when its envelope is deliberately made too small. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" +#include "HAL/IConsoleManager.h" + +#include "VoxelForgeTestFixture.h" +#include "VoxelMarchingCubesMesher.h" +#include "../../Public/VoxelTilePostReach.h" + +namespace +{ + using namespace VoxelForgeTest; + + struct FScopedReachTestCVars + { + IConsoleVariable* Debug = nullptr; + IConsoleVariable* Scale = nullptr; + int32 OldDebug = 0; + float OldScale = 1.0f; + bool bValid = false; + + FScopedReachTestCVars() + { + IConsoleManager& Console = IConsoleManager::Get(); + Debug = Console.FindConsoleVariable(TEXT("voxel.TilePostReachDebug")); + Scale = Console.FindConsoleVariable(TEXT("voxel.TilePostReachScale")); + if (Debug == nullptr || Scale == nullptr) + { + return; + } + + OldDebug = Debug->GetInt(); + OldScale = Scale->GetFloat(); + bValid = true; + Debug->Set(1, ECVF_SetByCode); + } + + ~FScopedReachTestCVars() + { + if (!bValid) + { + return; + } + Debug->Set(OldDebug, ECVF_SetByCode); + Scale->Set(OldScale, ECVF_SetByCode); + } + + void SetScale(float InScale) + { + if (Scale != nullptr) + { + Scale->Set(InScale, ECVF_SetByCode); + } + } + }; + + struct FReachSnapshot + { + uint64 Tiles = 0; + uint64 FinalComparisons = 0; + uint64 FinalDifferences = 0; + uint64 AllComparisons = 0; + uint64 AllDifferences = 0; + int64 DensitySamples = 0; + int32 MeshedTiles = 0; + }; + + FReachSnapshot RunReachTiles( + UVoxelMarchingCubesMesher& Mesher, + const TArray& Origins) + { + FReachSnapshot Out; + const uint64 TileCountBefore = + VoxelGenLOD::GTilePostReachTileCount.load(std::memory_order_relaxed); + for (const FIntVector& Origin : Origins) + { + int64 Samples = 0; + Mesher.GenerateMesh( + Origin, 8, 16, nullptr, INT32_MIN, INT32_MAX, &Samples); + Out.DensitySamples += Samples; + Out.MeshedTiles += Samples > 0 ? 1 : 0; + } + + const uint64 TileCountAfter = + VoxelGenLOD::GTilePostReachTileCount.load(std::memory_order_relaxed); + Out.Tiles = TileCountAfter - TileCountBefore; + Out.FinalComparisons = VoxelGenLOD::GSkippedPostComparisonsByKind[ + static_cast(VoxelGenLOD::ETilePostComparisonKind::FinalField)] + .load(std::memory_order_relaxed); + Out.FinalDifferences = VoxelGenLOD::GSkippedPostDifferencesByKind[ + static_cast(VoxelGenLOD::ETilePostComparisonKind::FinalField)] + .load(std::memory_order_relaxed); + Out.AllComparisons = VoxelGenLOD::GSkippedPostComparisons.load( + std::memory_order_relaxed); + Out.AllDifferences = VoxelGenLOD::GSkippedPostDifferences.load( + std::memory_order_relaxed); + return Out; + } + + int32 FloorAlign(int32 Value, int32 Alignment) + { + return FMath::FloorToInt(static_cast(Value) / static_cast(Alignment)) + * Alignment; + } + + void BuildReachTileOrigins( + const FTestWorld& World, + int32 Slot, + TArray& OutOrigins) + { + int32 SlotTop = 0; + int32 SlotBottom = 0; + if (!World.GetSlotVoxelZRange(Slot, SlotTop, SlotBottom)) + { + return; + } + const int32 TileExtent = 16 * 8; + const int32 TileZ = FloorAlign(SlotBottom, TileExtent); + // Nine XY tiles around the origin cover the guaranteed spine, winding tunnel mouths, + // and feature-free graph regions. Include one distant tile to prove the skip side is + // exercised rather than relying only on near-tunnel tiles. + for (int32 Y = -TileExtent; Y <= TileExtent; Y += TileExtent) + { + for (int32 X = -TileExtent; X <= TileExtent; X += TileExtent) + { + OutOrigins.Add(FIntVector(X, Y, TileZ)); + } + } + OutOrigins.Add(FIntVector(4 * TileExtent, -3 * TileExtent, TileZ)); + } + + void AppendWorldReachTiles( + const FTestWorld& World, + TArray& OutOrigins) + { + BuildReachTileOrigins(World, FTestWorld::SlotTunnelNetwork, OutOrigins); + BuildReachTileOrigins(World, FTestWorld::SlotUnderwater, OutOrigins); + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeTilePostReachProofTest, + "VoxelForge.Correctness.TilePostReachProof", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeTilePostReachProofTest::RunTest(const FString& Parameters) +{ + FScopedReachTestCVars CVarGuard; + if (!CVarGuard.bValid) + { + AddError(TEXT("Tile-post reach test could not find its two console variables.")); + return false; + } + + FTestWorld NativeWorld; + NativeWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false); + FTestWorld FusedWorld; + FusedWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true); + if (!NativeWorld.IsValid() || !FusedWorld.IsValid()) + { + AddError(NativeWorld.IsValid() && FusedWorld.IsValid() + ? TEXT("Unexpected invalid reach-proof fixture") + : (!NativeWorld.IsValid() ? NativeWorld.WhyInvalid() : FusedWorld.WhyInvalid())); + return false; + } + + TArray NativeOrigins; + TArray FusedOrigins; + AppendWorldReachTiles(NativeWorld, NativeOrigins); + AppendWorldReachTiles(FusedWorld, FusedOrigins); + TestTrue(TEXT("the representative native tile set is non-empty"), NativeOrigins.Num() > 0); + TestTrue(TEXT("the representative fused tile set is non-empty"), FusedOrigins.Num() > 0); + + TStrongObjectPtr NativeMesher( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + TStrongObjectPtr FusedMesher( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + NativeMesher->SetGenerator(NativeWorld.Generator.Get()); + FusedMesher->SetGenerator(FusedWorld.Generator.Get()); + + VoxelGenLOD::ResetTilePostReachDiagnostics(); + CVarGuard.SetScale(1.0f); + const FReachSnapshot NativeFull = RunReachTiles(*NativeMesher, NativeOrigins); + const FReachSnapshot FusedFull = RunReachTiles(*FusedMesher, FusedOrigins); + const uint64 FullFinalComparisons = NativeFull.FinalComparisons + FusedFull.FinalComparisons; + const uint64 FullFinalDifferences = NativeFull.FinalDifferences + FusedFull.FinalDifferences; + TestTrue(TEXT("normal reach compares final fields on the representative tiles"), + FullFinalComparisons > 0); + TestEqual(TEXT("normal reach has no final-field differences"), FullFinalDifferences, 0ull); + TestEqual(TEXT("normal reach has no skipped-post differences"), + NativeFull.AllDifferences + FusedFull.AllDifferences, 0ull); + + VoxelGenLOD::ResetTilePostReachDiagnostics(); + // Use a deliberately tiny but valid scale so at least one envelope is rejected while the same + // final-field samples remain exercised; this is the negative control, not a supported setting. + CVarGuard.SetScale(0.01f); + const FReachSnapshot NativeShrunk = RunReachTiles(*NativeMesher, NativeOrigins); + const FReachSnapshot FusedShrunk = RunReachTiles(*FusedMesher, FusedOrigins); + const uint64 ShrunkFinalComparisons = + NativeShrunk.FinalComparisons + FusedShrunk.FinalComparisons; + const uint64 ShrunkFinalDifferences = + NativeShrunk.FinalDifferences + FusedShrunk.FinalDifferences; + TestTrue(TEXT("shrunk reach still executes final-field comparisons"), + ShrunkFinalComparisons > 0); + TestTrue(TEXT("shrunk reach demonstrably fails the final-field proof"), + ShrunkFinalDifferences > 0); + + AddInfo(FString::Printf( + TEXT("tile_post_reach_proof: native_tiles=%llu fused_tiles=%llu full_final=%llu/%llu " + "shrunk_final=%llu/%llu native_samples=%lld/%lld fused_samples=%lld/%lld."), + static_cast(NativeFull.Tiles), + static_cast(FusedFull.Tiles), + static_cast(FullFinalComparisons), + static_cast(FullFinalDifferences), + static_cast(ShrunkFinalComparisons), + static_cast(ShrunkFinalDifferences), + static_cast(NativeFull.DensitySamples), + static_cast(NativeShrunk.DensitySamples), + static_cast(FusedFull.DensitySamples), + static_cast(FusedShrunk.DensitySamples))); + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Public/VoxelTilePostReach.h b/Source/VoxelForge/Public/VoxelTilePostReach.h index c2d0e60..f2acbe9 100644 --- a/Source/VoxelForge/Public/VoxelTilePostReach.h +++ b/Source/VoxelForge/Public/VoxelTilePostReach.h @@ -129,6 +129,44 @@ namespace VoxelGenLOD inline std::atomic GTileReachBlockCostCycles[ static_cast(ETileReachCostKind::Count)][2]{}; + // Automation tests deliberately run the real mesher twice (full reach and a shrunken reach) + // in one process. Keep the reset in the reach owner so a test cannot accidentally omit one + // of the per-kind counters and accept a vacuous proof. + FORCEINLINE void ResetTilePostReachDiagnostics() + { + GTilePostReachDebugEnabled.store(false, std::memory_order_relaxed); + GTilePostReachTileCount.store(0, std::memory_order_relaxed); + GOriginLandingReachableTiles.store(0, std::memory_order_relaxed); + GPassageLandingReachableTiles.store(0, std::memory_order_relaxed); + GPassageStructuralReachableTiles.store(0, std::memory_order_relaxed); + GOriginLandingSkippedTiles.store(0, std::memory_order_relaxed); + GPassageLandingSkippedTiles.store(0, std::memory_order_relaxed); + GPassageStructuralSkippedTiles.store(0, std::memory_order_relaxed); + GTunnelCoreReachableTiles.store(0, std::memory_order_relaxed); + GTunnelCoreSkippedTiles.store(0, std::memory_order_relaxed); + GPassageCarvingReachableTiles.store(0, std::memory_order_relaxed); + GPassageCarvingSkippedTiles.store(0, std::memory_order_relaxed); + GSkippedPostComparisons.store(0, std::memory_order_relaxed); + GSkippedPostDifferences.store(0, std::memory_order_relaxed); + for (uint8 Index = 0; + Index < static_cast(ETilePostComparisonKind::Count); ++Index) + { + GSkippedPostComparisonsByKind[Index].store(0, std::memory_order_relaxed); + GSkippedPostDifferencesByKind[Index].store(0, std::memory_order_relaxed); + } + GTileReachCostDiagnosticsEnabled.store(false, std::memory_order_relaxed); + for (uint8 Kind = 0; Kind < static_cast(ETileReachCostKind::Count); ++Kind) + { + for (uint8 Reach = 0; Reach < 2; ++Reach) + { + GTileReachCostCalls[Kind][Reach].store(0, std::memory_order_relaxed); + GTileReachCostCycles[Kind][Reach].store(0, std::memory_order_relaxed); + GTileReachBlockCostCalls[Kind][Reach].store(0, std::memory_order_relaxed); + GTileReachBlockCostCycles[Kind][Reach].store(0, std::memory_order_relaxed); + } + } + } + FORCEINLINE bool IsTilePostReachable(uint8 Bit) { return (TilePostReachFlags & Bit) != 0;