From 92607751b17f637ed4a9696beb3ce8c89737f3d6 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Tue, 15 Sep 2026 06:08:59 +0200 Subject: [PATCH] fix(passages): D-floors now exist inside the inter-strate gap; two stale tests corrected Investigation of the two tests that had been failing for days (Codex; the fix reviewed and its field claims re-run by me). Real bug, introduced by adb34df: gap chunks take the compact bedrock path in GetDensityAt, which applied the passage modifier but never the build-authored native D-floor. So the walkable passage floor vanished exactly at the gap/strate seam, in the passages the player uses to cross strates. Now gap chunks set Result = -Density, then ApplyPassageNativeFloorMC, then VF_ApplyXYEdgeSealMC. The seal runs on the MC value, in the same order as before. Stale tests, not code bugs: - PassageLandsInOpenSpace: since d97373c production passes the cached world/warp seed to VF_SuggestLandingPoint, while the test still queried with the default seed. Both queries are fixed; the test now allows only exact graph-owned support-floor samples (the production bProtectAuthoredTunnelFloor contract), and anything else still fails. 14/14 landing ends, 5,774/5,774 floor and capsule samples. - ClassifyTileSoundness: since d38d4ca ClassifyTile covers the cell vertices g=0..CPA, but the test still sampled the mesher's outer normal halo. Both "holes" were halo-only (g = CPA+1). The test now asserts the current contract. TryProveSealedSolidTile already uses the exact core lattice, so it is not affected. Verified: crossing test PASS (max gate 0.129 s); sealed-proof test 22/22; both classifier variants pass (600 tiles each). Re-run by the orchestrator against this build: export OBJ SHA-256 b3e5f4c3..9b377 unchanged; capability 20,830 / 10,909 / walk-only 10,909. Ledger: one DDC startup fatal (crash folder 43101DC8, 04:38) and a set of expected diagnostic failures while locating the gap-branch omission; one malformed ablation argument (not a valid ablation). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y --- .../Tests/VoxelForgeClassifyTileTest.cpp | 36 ++-- .../Tests/VoxelForgePassageOpenSpaceTest.cpp | 170 ++++++++++++++++-- Source/VoxelForge/Private/VoxelGenerator.cpp | 8 +- 3 files changed, 185 insertions(+), 29 deletions(-) diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp index 1b8a1c0..06384f8 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp @@ -19,13 +19,15 @@ // Cette fonction a DÉJÀ produit cette panne : la v1 de T1.d a été revertée le 2026-06-26 pour une // borne de plafond pas assez conservative. Jusqu'ici elle n'est validée que par raisonnement. // Ce test la valide par la force brute : pour chaque tuile jugée non-Mixed, on échantillonne le -// treillis EXACT que le mesher aurait échantillonné (marge ±1 incluse) et on vérifie que chaque -// point est bien du côté annoncé. +// treillis des sommets de cellules que ClassifyTile couvre réellement (g = 0..CPA) et on vérifie +// que chaque point est bien du côté annoncé. La marge extérieure du mesher sert aux normales, pas +// au verdict uniforme. // // This function has ALREADY produced that failure: T1.d v1 was reverted on 2026-06-26 over a // non-conservative ceiling bound. Until now it was validated by reasoning only. This test -// validates it by brute force: for every tile judged non-Mixed, sample the EXACT lattice the -// mesher would have sampled (±1 margin included) and assert every point is on the claimed side. +// validates it by brute force: for every tile judged non-Mixed, sample the exact cell-vertex +// lattice covered by ClassifyTile (g = 0..CPA) and assert every point is on the claimed side. The +// mesher's outer normal halo is deliberately outside the classifier's uniformity contract. // // CONVENTION (VoxelMarchingCubesMesher.cpp:309, IsoLevel == 0): // D >= 0 ⇒ côté AIR / air side @@ -166,8 +168,10 @@ bool FVoxelForgeClassifyTileTest::RunTest(const FString& Parameters) return false; } - // ── Vérification par force brute, sur le treillis EXACT du mesher. ── - // Les bornes reproduisent ClassifyTile / GenerateMesh : g ∈ [-1, Cells+1] par axe. + // ── Vérification par force brute du contrat de ClassifyTile. ── + // ClassifyTile covers actual cell vertices only: g ∈ [0, CPA]. GenerateMesh may sample a + // one-vertex outer halo for central-difference normals, but that halo cannot create a cell and + // is intentionally not part of the uniform verdict. int32 NumHoles = 0; for (const FTileSpec& Spec : ToVerify) { @@ -175,13 +179,13 @@ bool FVoxelForgeClassifyTileTest::RunTest(const FString& Parameters) if (Verdict == EVoxelTileClass::Mixed) { continue; } // verdict instable ⇒ rien à prouver const int32 CPA = FMath::Clamp(Spec.Cells, 2, CHUNK_SIZE); - const int32 GridDim = CPA + 1; + const int32 VertexCount = CPA + 1; const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid); bool bTileBad = false; - for (int32 gz = -1; gz <= GridDim && !bTileBad; ++gz) - for (int32 gy = -1; gy <= GridDim && !bTileBad; ++gy) - for (int32 gx = -1; gx <= GridDim && !bTileBad; ++gx) + for (int32 gz = 0; gz < VertexCount && !bTileBad; ++gz) + for (int32 gy = 0; gy < VertexCount && !bTileBad; ++gy) + for (int32 gx = 0; gx < VertexCount && !bTileBad; ++gx) { const float X = (float)(Spec.Origin.X + gx * Spec.Step); const float Y = (float)(Spec.Origin.Y + gy * Spec.Step); @@ -199,8 +203,8 @@ bool FVoxelForgeClassifyTileTest::RunTest(const FString& Parameters) TEXT("HOLE: ClassifyTile said %s for tile origin (%d,%d,%d) Step=%d Cells=%d, ") TEXT("but GetDensityAt(%.0f, %.0f, %.0f) = %.6g is on the %s side. This tile ") TEXT("would be skipped by the mesher: no triangles and NO COLLISION where there ") - TEXT("should be a surface. Find which guard in ClassifyTile failed to fire for ") - TEXT("the feature at that point."), + TEXT("should be a surface. This point is inside ClassifyTile's actual cell ") + TEXT("lattice, so find which classifier guard failed to fire for the feature."), bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"), Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, Spec.Cells, X, Y, Z, D, (D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID"))); @@ -336,13 +340,13 @@ bool FVoxelForgeOpStackClassifyTileTest::RunTest(const FString& Parameters) if (Verdict == EVoxelTileClass::Mixed) { continue; } const int32 CPA = FMath::Clamp(Spec.Cells, 2, CHUNK_SIZE); - const int32 GridDim = CPA + 1; + const int32 VertexCount = CPA + 1; const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid); bool bTileBad = false; - for (int32 gz = -1; gz <= GridDim && !bTileBad; ++gz) - for (int32 gy = -1; gy <= GridDim && !bTileBad; ++gy) - for (int32 gx = -1; gx <= GridDim && !bTileBad; ++gx) + for (int32 gz = 0; gz < VertexCount && !bTileBad; ++gz) + for (int32 gy = 0; gy < VertexCount && !bTileBad; ++gy) + for (int32 gx = 0; gx < VertexCount && !bTileBad; ++gx) { const float X = (float)(Spec.Origin.X + gx * Spec.Step); const float Y = (float)(Spec.Origin.Y + gy * Spec.Step); diff --git a/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp index 9748a0b..6d24a6e 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp @@ -178,11 +178,119 @@ namespace return true; } + class FGraphReservedFloorOracle + { + public: + explicit FGraphReservedFloorOracle(const UVoxelStrateManager& InManager) + : Manager(InManager) + { + } + + bool IsReservedFloor(const FVector& Point) + { + const FIntVector ChunkCoord( + FMath::FloorToInt(Point.X / static_cast(CHUNK_SIZE)), + FMath::FloorToInt(Point.Y / static_cast(CHUNK_SIZE)), + FMath::FloorToInt(Point.Z / static_cast(CHUNK_SIZE))); + if (Manager.IsGapChunk(ChunkCoord)) + { + return false; + } + + const ECaveGeneratorType GeneratorType = + Manager.GetGeneratorTypeForChunk(ChunkCoord); + if (GeneratorType != ECaveGeneratorType::TunnelNetwork + && GeneratorType != ECaveGeneratorType::Underwater) + { + return false; + } + + FChunkSDFCache* Cache = CacheFor(ChunkCoord); + if (Cache == nullptr) + { + return false; + } + + FTunnelSupportFloorColumn SupportColumn; + const FTunnelSupportFloorColumn* SupportColumnPtr = nullptr; + const bool bIntegerXY = + Point.X == FMath::FloorToFloat(Point.X) + && Point.Y == FMath::FloorToFloat(Point.Y); + if (bIntegerXY) + { + VoxelCaveMorphology::BuildTunnelSupportFloorColumn( + Point.X, Point.Y, *Cache, SupportColumn, /*bUseSpatialIndex=*/false); + SupportColumnPtr = &SupportColumn; + } + + // This is the same public morphology query used by the generator's structural + // protection bit. A graph-owned room/tunnel floor is a reserved walking route: the + // inter-strate passage may join it, but must not carve its finite support band away. + const FTunnelCoreWorldEvaluation Evaluation = + VoxelCaveMorphology::EvaluateTunnelCoreWorld( + Point.X, Point.Y, Point.Z, *Cache, SupportColumnPtr, + /*bUseSpatialIndex=*/false); + return Evaluation.bSupportFloor || Evaluation.bRoomFloor; + } + + private: + struct FCacheEntry + { + FIntVector Chunk = FIntVector::ZeroValue; + FChunkSDFCache Cache; + }; + + FChunkSDFCache* CacheFor(const FIntVector& ChunkCoord) + { + for (FCacheEntry& Entry : Caches) + { + if (Entry.Chunk == ChunkCoord) + { + return &Entry.Cache; + } + } + + UVoxelStrateDefinition* Definition = Manager.GetStrateForChunk(ChunkCoord); + if (Definition == nullptr) + { + return nullptr; + } + + const FStrateGenerationParams Params = Manager.GetGenerationParams(ChunkCoord); + const float ChunkMinX = static_cast(ChunkCoord.X * CHUNK_SIZE); + const float ChunkMinY = static_cast(ChunkCoord.Y * CHUNK_SIZE); + const float ChunkMaxX = ChunkMinX + static_cast(CHUNK_SIZE); + const float ChunkMaxY = ChunkMinY + static_cast(CHUNK_SIZE); + const float Expansion = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE * 1.5f + 2.0f; + const int32 StrateIndex = Manager.GetStrateIndex( + (static_cast(ChunkCoord.Z) + 0.5f) + * static_cast(CHUNK_SIZE) * VOXEL_SIZE); + + FCacheEntry& NewEntry = Caches.Emplace_GetRef(); + NewEntry.Chunk = ChunkCoord; + VoxelCaveMorphology::BuildChunkCache( + NewEntry.Cache, + ChunkMinX - Expansion, ChunkMinY - Expansion, + ChunkMaxX + Expansion, ChunkMaxY + Expansion, + Params, static_cast(Manager.GetWorldSeed()), StrateIndex, + &Definition->TerrainOperations, + ERoomGraphBuildSite::GeneratorTunnelCore); + return &NewEntry.Cache; + } + + const UVoxelStrateManager& Manager; + TArray Caches; + }; + bool FinalDensityCapsuleFits( const UVoxelGenerator& Generator, const FVector& Centreline, - float SupportFloorZ) + float SupportFloorZ, + FGraphReservedFloorOracle* GraphFloorOracle, + int32& OutReservedGraphFloorSamples) { + OutReservedGraphFloorSamples = 0; constexpr float Radius = VoxelPassageGeometry::PlayerRadiusVoxels; constexpr float HalfHeight = VoxelPassageGeometry::PlayerHalfHeightVoxels; constexpr int32 MaxHorizontalOffset = 2; @@ -213,10 +321,23 @@ namespace Centreline.X + static_cast(OffsetX), Centreline.Y + static_cast(OffsetY), SupportFloorZ + static_cast(Row) + 0.5f); - if (!FMath::IsFinite(Density) || !(Density > 0.0f)) + if (!FMath::IsFinite(Density)) { return false; } + if (!(Density > 0.0f)) + { + const bool bReservedGraphFloor = GraphFloorOracle != nullptr + && GraphFloorOracle->IsReservedFloor(FVector( + Centreline.X + static_cast(OffsetX), + Centreline.Y + static_cast(OffsetY), + SupportFloorZ + static_cast(Row) + 0.5f)); + if (!bReservedGraphFloor) + { + return false; + } + ++OutReservedGraphFloorSamples; + } } } @@ -226,10 +347,22 @@ namespace const float Density = Generator.GetDensityAt( Centreline.X, Centreline.Y, SupportFloorZ + static_cast(Row) + 0.5f); - if (!FMath::IsFinite(Density) || !(Density > 0.0f)) + if (!FMath::IsFinite(Density)) { return false; } + if (!(Density > 0.0f)) + { + const bool bReservedGraphFloor = GraphFloorOracle != nullptr + && GraphFloorOracle->IsReservedFloor(FVector( + Centreline.X, Centreline.Y, + SupportFloorZ + static_cast(Row) + 0.5f)); + if (!bReservedGraphFloor) + { + return false; + } + ++OutReservedGraphFloorSamples; + } } } return true; @@ -246,6 +379,7 @@ namespace int32 RouteGradientFailures = 0; int32 FloorFailures = 0; int32 CapsuleFailures = 0; + int32 ReservedGraphFloorSamples = 0; float WorstGradient = 0.0f; float WorstGeometricGradient = 0.0f; float WorstPatchGradient = 0.0f; @@ -264,6 +398,7 @@ namespace void AuditWalkableTunnel( const UVoxelGenerator& Generator, const FVoxelPassage& Passage, + FGraphReservedFloorOracle& GraphFloorOracle, FWalkableTunnelAudit& InOutAudit) { ++InOutAudit.Contracts; @@ -400,8 +535,10 @@ namespace const float FinalSupportFloor = FMath::Max( SupportFloor, SupportFloorWithY); ++InOutAudit.CapsuleSamples; + int32 ReservedGraphFloorSamples = 0; if (!FinalDensityCapsuleFits( - Generator, Centre, FinalSupportFloor)) + Generator, Centre, FinalSupportFloor, + &GraphFloorOracle, ReservedGraphFloorSamples)) { ++InOutAudit.CapsuleFailures; RecordTunnelFailure(InOutAudit, FString::Printf( @@ -409,6 +546,7 @@ namespace Passage.UpperStrateIndex, Passage.LowerStrateIndex, SegmentIndex, SampleIndex)); } + InOutAudit.ReservedGraphFloorSamples += ReservedGraphFloorSamples; if (bHavePrevious) { const float RouteHorizontal = FVector2D( @@ -554,8 +692,8 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) } // The upper mouth has its own source query too. This is deliberately evaluated from the - // upper strate's definition, seed, bounds, and requested XY; it must never inherit the - // lower mouth's answer or become an implicit passage chain. + // upper strate's definition, room seed, production world/warp seed, bounds, and requested + // XY; it must never inherit the lower mouth's answer or become an implicit passage chain. const float SourceTopZ = (float)(Source.TopChunkZ + 1) * CHUNK_SIZE; const float SourceBottomZ = (float)Source.BottomChunkZ * CHUNK_SIZE; const int32 SourceQuerySeed = OpenPointSeedFor( @@ -575,7 +713,8 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) Passage.RequestedUpperPoint.X, Passage.RequestedUpperPoint.Y, SourceMaxLateralSnap, - SuggestedUpperPoint); + SuggestedUpperPoint, + WorldSeed); if (!bCanAnswerUpper) { ++NumUpperQueryFalse; @@ -652,7 +791,8 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) DesiredPoint.X, DesiredPoint.Y, MaxLateralSnap, - SuggestedPoint); + SuggestedPoint, + WorldSeed); if (!bCanAnswer) { @@ -1087,14 +1227,19 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) // The landing checks above prove the two endpoints. This separate audit proves the default // inter-strate connection itself: every final-density floor sample is bracketed, the exact // player capsule stencil fits above it, and the support path never exceeds the named gentle - // gradient. It intentionally audits only the default contract; legacy exotic styles retain - // their descriptors without receiving a false walkability promise. + // gradient. A capsule sample may overlap the graph's finite, build-authored support floor; + // that is an intentional reserved-walk hand-off, not a passage hole. The passage is forbidden + // to cut that graph floor, so those exact morphology-owned samples are allowed below while any + // other obstruction remains a failure. Legacy exotic styles retain their descriptors without + // receiving a false walkability promise. + FGraphReservedFloorOracle GraphFloorOracle(*World.StrateManager); for (const FVoxelPassage& Passage : Passages) { if (Passage.UpperStrateIndex != Passage.LowerStrateIndex && Passage.bWalkableTunnelContract) { - AuditWalkableTunnel(*World.Generator, Passage, TunnelAudit); + AuditWalkableTunnel( + *World.Generator, Passage, GraphFloorOracle, TunnelAudit); } } @@ -1187,7 +1332,7 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) AddError(FString::Printf(TEXT("First landing geometry failure: %s"), *FirstLandingFailure)); } AddInfo(FString::Printf( - TEXT("Walkable tunnel contract: %d passages, %d segments, %d final floor samples, %d player-capsule samples; gradient failures %d (geometric %d, route %d), floor failures %d, capsule failures %d, worst gradient %.6f (geometric %.6f, route %.6f; cross-section diagnostic %.6f; limit %.6f / %.1f degrees)."), + TEXT("Walkable tunnel contract: %d passages, %d segments, %d final floor samples, %d player-capsule samples; gradient failures %d (geometric %d, route %d), floor failures %d, capsule failures %d, graph-reserved capsule samples allowed %d, worst gradient %.6f (geometric %.6f, route %.6f; cross-section diagnostic %.6f; limit %.6f / %.1f degrees)."), TunnelAudit.Contracts, TunnelAudit.Segments, TunnelAudit.FloorSamples, @@ -1197,6 +1342,7 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) TunnelAudit.RouteGradientFailures, TunnelAudit.FloorFailures, TunnelAudit.CapsuleFailures, + TunnelAudit.ReservedGraphFloorSamples, TunnelAudit.WorstGradient, TunnelAudit.WorstGeometricGradient, TunnelAudit.WorstRouteGradient, diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index bfc33d8..800f2be 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -2760,8 +2760,14 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co float Density = 8.0f; // bedrock solidity (positive = solid) StrateManager->ApplyPassageModifier( Density, WorldX, WorldY, WorldZ, 8.0f, 0.0f); - VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, 8.0f); + // Gap chunks use the compact bedrock path above instead of the ordinary strate tail. + // Native walkable passages still cross those chunks, so compose their build-authored + // D-floor here as well; otherwise the floor disappears exactly at the gap/strate seam. Result = -Density; + StrateManager->ApplyPassageNativeFloorMC( + Result, WorldX, WorldY, WorldZ, 8.0f); + VF_ApplyXYEdgeSealMC( + Result, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, 8.0f); } else if (StrateManager) {