diff --git a/CODEMAP.md b/CODEMAP.md index cb91781..8d4cdd9 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -433,7 +433,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `VoxelForgeCrossPlatformTest.cpp` | `VoxelForge.Determinism.CrossPlatformDigest` | SHAPE digest (sign of density = the world) + FIELD digest (bit-for-bit) over a fixed integer grid, plus `NearIso` bounding how many samples could flip sign. Reports rather than asserts until pinned. Run on Windows and Linux and compare. | | `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 5-op slab stack vs `GetSlabDensity` over 20k points, run twice — FlatPlain **and** CrystalChamber — which is what demonstrates the two archetypes really are one op. Plus window-invariance and box-verdict brute force. Compares against the reference **as it is now** (post Z-term removal), so green = pure refactor and any visual delta is attributable to §3.1 alone. | | `VoxelForgeOpStackTunnelTest.cpp` | `VoxelForge.OpStack.TunnelNetworkSpineEquivalence` | **Stage A of the last port.** Zeroes the 13 detail-op amplitudes so the *original* takes the path stage A ported — that is what makes an incomplete stack verifiable now. Samples in **clusters** (24 chunks × 250 points), because the SDF cache rebuilds when a query leaves its box and uniform sampling would rebuild per point on both paths. Check 3 (two param sets, A/B interleaved) compares each stack **to itself alone, never to the original** — the original would fail it, see AUDIT §C2. Asserts **zero** box verdicts, which is the honest stage-A result. | -| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. Known-pessimistic: proves **0 of 60** tiles (its `EffectOverBox` rejects on a `Spacing*1.6` halo instead of real connector capsules — lost CPU, never a hole). | +| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. **Fixed 2026-07-29:** its `EffectOverBox` used to return `CarveOnly` because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at `ShaftSpacing 55 / ShaftDensity 0.6`, hence **0 of 60** tiles. It now rebuilds the connectors the way `GetCells` does (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed; sweeping `[box cells] ± 1` is a superset of any 3×3's pairs) and tests the real capsule, with **Z exact** (horizontal capsule at `Zc`) and XY conservative. The sampler was also widened from ±48 voxels to ±440 — it was under one `ShaftSpacing`, the same trap as the tunnel test's ±32-vs-80. Every proved tile is brute-forced over its full lattice. | | `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. | | `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. | diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index 2c5b6d7..6d2a3dd 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -3196,3 +3196,73 @@ appetite, not a technical unknown, so it goes to Jahni rather than getting done **11 of 40 tiles (27.5 %) proved AllSolid at production defaults, 14641 voxels brute-forced, 0 violations.** The dense fixture correctly proves nothing. Every verdict is checked voxel by voxel, so the risk of the whole feature is bounded by a test that runs on every build. + +## 2026-07-29 — T1.d banked at 11/40. VerticalShafts: the connector branch never tested a connector. + +Jahni's call: bank the tunnel result and take VerticalShafts rather than squeeze the Perlin sup. +Right call — the shaft fix is a clearly-scoped defect with no hole-risk maths, on an archetype that +was getting nothing. + +### The defect, and it is stated in its own comment + +`FShaftFieldSource::EffectOverBox` ends with a "connectors" branch that never looks at a connector: + +```cpp +const FBox ConnBox = VoxelBox.ExpandBy(Spacing * 1.6f + Pad); +for (cells in ConnBox) + if (RollShaft(cx, cy, Sh)) { return EVoxelOpEffect::CarveOnly; } // prudent +``` + +It returns `CarveOnly` because a shaft **exists** somewhere in the neighbourhood. Against the +defaults — `ShaftSpacing = 55`, `ShaftDensity = 0.6`, `Pad ≈ 11 + ExtraReach` — that expanded box +spans roughly 4×4 cells and therefore ~10 shafts. **The condition is true essentially everywhere**, +which is exactly the reported `0 proved of 60`. Conservative, never wrong, and completely sterile — +the same shape as the worm's unconditional `CarveOnly`, one archetype over. + +### The fix: rebuild the connectors the way `GetCells` does, and test the real capsule + +Two things had to be right, and both were checked in the source rather than assumed: + +- **The enumeration is a superset.** `Eval` reads connectors from the 3×3 neighbourhood of *its + query's* cell. So any pair visible from a point in the box has both shafts inside the 3×3 of some + cell the box touches ⇒ both lie in `[box cells] ± 1`, which is precisely the range swept. Pairs + that no query ever sees may be produced — extra `CarveOnly`, never a hole. +- **The pair order matches, so the hash matches.** `VoxelHash::Pair(A…, B…)` is fed in insertion + order, and `GetCells` inserts over `(dy, dx)` — row-major. The sweep here is `(cy, cx)`, the same + order, and row-major order restricted to a sub-grid preserves the relative order of any two cells. + So the same pair gets the same hash **without assuming `Pair()` is symmetric** — which was never + verified and now does not need to be. + +The capsule test splits the axes because a connector is a **horizontal** capsule at height `Zc`: +Z is exact (`RMinZ > Zc + Reach || RMaxZ < Zc - Reach` ⇒ skip), XY uses point-to-segment from the box +centre minus the XY half-diagonal. Far tighter than a 3-D half-diagonal, which is what the tunnel +version had to settle for. + +### And the same sampler trap, caught this time before the build + +The shaft test drew tile XY from `RandRange(-6,6)*8` = **±48 voxels, against `ShaftSpacing = 55`** — +less than one period of the pattern. Identical in kind to the ±32-vs-80 bug that cost the tunnel test +three runs. Widened to ±440 (8 periods), and the report now prints its own extent in units of +`ShaftSpacing`, so it cannot go unnoticed again. + +**The safety net was already there and is untouched:** this test brute-forces the full lattice of +every proved tile against `EvalMC`, both hypotheses, and `AddError`s on the first violation. So the +fix is checked by construction — if the superset argument or the hash-order argument is wrong, the +existing assertion fails rather than a player falling through the floor. + +### Ready to build. Compile-error spots + +1. `VF_DistPointSegment2D` — new helper next to `VF_NearCaveSurface` in the anonymous namespace. +2. Both new helpers now use `KINDA_SMALL_NUMBER`, matching the ~6 existing uses in the plugin; they + were briefly written with the `UE_`-prefixed spelling, which nothing else here uses. +3. The shaft test hoists `SpanCells` / `SpanVoxels` **outside** the tile loop — the report needs + them, and `Extent` is loop-local. (That exact scoping slip happened in the tunnel test two rounds + ago; caught here before the build rather than after.) +4. Format string is 6 specifiers / 6 arguments — counted. + +### What to read + +`Box verdicts over 60 VerticalShafts tiles` — **0 is the number to beat**, and `%d violations` must +stay 0. If it is still 0 proved, the warning now says what to check *first*: `ExtraReach` inflates +both remaining tests, so its value against `ShaftMaxRadius` is the thing to look at before touching +either test — not a re-derivation from scratch. diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp index a1fb92a..6345df1 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp @@ -207,14 +207,24 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters) { int32 NumProved = 0, NumMixed = 0, NumUnsound = 0; FRandomStream Rng(13579); + // Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on + // ne peut pas citer dans le rapport est une étendue que personne ne surveille. + const int32 SpanCells = 55; + const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8 for (int32 t = 0; t < 60; ++t) { const int32 Step = 1, Cells = 8; const int32 Extent = Step * Cells; + // ⚠️ L'ÉTENDUE XY ÉTAIT ±48 VOXELS, POUR UN `ShaftSpacing` DE 55 : moins d'UNE cellule + // de puits. C'est le même piège que celui qui a coûté trois runs au test TunnelNetwork — + // un échantillonneur qui ne couvre pas une période du motif ne mesure pas le monde, il + // mesure un point du motif. ±440 = 8 périodes. + // The XY extent was ±48 voxels for a ShaftSpacing of 55 — less than one shaft cell, the + // same trap that cost the TunnelNetwork test three runs. ±440 covers 8 periods. const FIntVector Origin( - Rng.RandRange(-6, 6) * Extent, - Rng.RandRange(-6, 6) * Extent, + Rng.RandRange(-SpanCells, SpanCells) * Extent, + Rng.RandRange(-SpanCells, SpanCells) * Extent, FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent); const int32 GridDim = Cells + 1; @@ -259,10 +269,24 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters) TestEqual(TEXT("every box verdict the shaft stack emits survives brute force"), NumUnsound, 0); AddInfo(FString::Printf( - TEXT("Box verdicts over 60 VerticalShafts tiles: %d proved uniform, %d Mixed. Today's ") - TEXT("ClassifyTile proves ZERO of these -- every cave archetype falls through to \"pas ") - TEXT("prouvable en v1\"."), - NumProved, NumMixed)); + TEXT("Box verdicts over 60 VerticalShafts tiles (XY sampled from +/-%d voxels = %.1f x ") + TEXT("ShaftSpacing %.0f): %d proved uniform, %d Mixed, brute-forced with %d violations. ") + TEXT("This was 0 proved for as long as the connector branch bailed on mere shaft ") + TEXT("EXISTENCE within Spacing*1.6 -- true almost everywhere at ShaftDensity 0.6, so it ") + TEXT("was conservative AND sterile. It now tests the real connector capsules. Read the ") + TEXT("proved count as a measurement; what is ASSERTED is that none of them is wrong, ") + TEXT("because a false verdict here leaves no geometry and no collision."), + SpanVoxels, (float)SpanVoxels / FMath::Max(P.ShaftSpacing, 1.0f), P.ShaftSpacing, + NumProved, NumMixed, NumUnsound)); + + if (NumProved == 0) + { + AddWarning(TEXT("No VerticalShafts tile was proved, so the brute force above verified ") + TEXT("nothing. Before hypothesising: the shaft CIRCLE test and the connector ") + TEXT("CAPSULE test are the only two things that can return CarveOnly here, ") + TEXT("and ExtraReach inflates both -- check its value against ShaftMaxRadius ") + TEXT("before touching either test.")); + } } return true; diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 4fc4d40..cff8252 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -86,12 +86,26 @@ namespace { const FVector AB = B - A; const double LenSq = FVector::DotProduct(AB, AB); - const double T = (LenSq > UE_KINDA_SMALL_NUMBER) + const double T = (LenSq > KINDA_SMALL_NUMBER) ? FMath::Clamp(FVector::DotProduct(P - A, AB) / LenSq, 0.0, 1.0) : 0.0; return (float)FVector::Dist(P, A + AB * T); } + /** La même chose en 2D, pour les connecteurs de puits : ce sont des capsules HORIZONTALES, donc + * Z se teste exactement et seul XY demande une distance point-segment. */ + FORCEINLINE float VF_DistPointSegment2D(const FVector2D& P, const FVector2D& A, const FVector2D& B) + { + const FVector2D AB = B - A; + const double LenSq = (double)AB.X * AB.X + (double)AB.Y * AB.Y; + const double T = (LenSq > KINDA_SMALL_NUMBER) + ? FMath::Clamp(((double)(P.X - A.X) * AB.X + (double)(P.Y - A.Y) * AB.Y) / LenSq, 0.0, 1.0) + : 0.0; + const double DX = (double)P.X - ((double)A.X + AB.X * T); + const double DY = (double)P.Y - ((double)A.Y + AB.Y * T); + return (float)FMath::Sqrt(DX * DX + DY * DY); + } + FORCEINLINE bool VF_NearCaveSurface(float Sdf, float SDFBlendRadius) { // Transcrit tel quel, ordre des comparaisons compris : @@ -1384,20 +1398,88 @@ namespace if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::CarveOnly; } } - // ⚠️ Les connecteurs ne sont PAS testés ici, et c'est délibérément conservatif dans le - // mauvais sens si on n'y prend pas garde : un connecteur ne peut exister qu'entre deux - // puits d'un voisinage, donc si AUCUN puits n'atteint la boîte élargie de `Spacing*1.6` - // (la portée max d'une paire), aucun connecteur ne peut l'atteindre non plus. - const FBox ConnBox = VoxelBox.ExpandBy(Spacing * 1.6f + Pad); - const int32 KX0 = FMath::FloorToInt((float)ConnBox.Min.X / Spacing); - const int32 KX1 = FMath::FloorToInt((float)ConnBox.Max.X / Spacing); - const int32 KY0 = FMath::FloorToInt((float)ConnBox.Min.Y / Spacing); - const int32 KY1 = FMath::FloorToInt((float)ConnBox.Max.Y / Spacing); - for (int32 cy = KY0; cy <= KY1; ++cy) - for (int32 cx = KX0; cx <= KX1; ++cx) + //----------------------------------------------------------------- + // LES CONNECTEURS — LES VRAIES CAPSULES, PLUS « un puits existe dans le coin » + //----------------------------------------------------------------- + // ⚠️ CE BLOC RENDAIT `CarveOnly` DÈS QU'UN PUITS **EXISTAIT** dans la boîte élargie de + // `Spacing·1.6 + Pad`, sans jamais regarder un connecteur. Avec les défauts + // (`ShaftSpacing = 55`, `ShaftDensity = 0.6`) cette boîte élargie couvre ~4×4 cellules, + // donc une dizaine de puits : la condition était vraie PARTOUT et l'archétype prouvait + // 0 tuile sur 60. Conservatif, jamais faux — et totalement stérile. + // + // Ce qu'on fait à la place : reconstruire les connecteurs comme `GetCells` les + // construit, et tester la capsule réelle. + // + // ⚠️ POURQUOI L'ÉNUMÉRATION EST UN SUR-ENSEMBLE (donc sûre). `Eval` lit les connecteurs + // du voisinage 3×3 de la cellule DE SA REQUÊTE. Une paire visible depuis un point de la + // boîte a donc ses deux puits dans un même 3×3 centré sur une cellule que la boîte + // touche ⇒ les deux sont dans [cellules de la boîte] ± 1, qui est exactement la plage + // balayée ici. On peut produire des paires que personne ne voit jamais : c'est du + // `CarveOnly` en trop, pas un trou. + // + // ⚠️ ET POURQUOI L'ORDRE (A,B) EST LE MÊME QUE CELUI DE `GetCells`. Le hash de paire est + // pris sur (A puis B) dans l'ordre d'insertion, et `GetCells` insère en `(dy, dx)`, + // c'est-à-dire en balayage ligne par ligne. On balaie ici `(cy, cx)`, le même ordre — et + // un ordre ligne par ligne restreint à une sous-grille garde l'ordre relatif de deux + // cellules. Donc la même paire reçoit le même `VoxelHash::Pair`, sans supposer que + // celui-ci soit symétrique. + // + // Was: return CarveOnly as soon as any shaft EXISTED within Spacing*1.6 + Pad, which at + // ShaftSpacing 55 / ShaftDensity 0.6 is true everywhere -- 0 of 60 tiles proved. Now it + // rebuilds the connectors the way GetCells does and tests the real capsule. The pair + // enumeration is a superset (safe), and the row-major cell order reproduces GetCells' + // insertion order, so each pair gets the same hash without assuming Pair() is symmetric. + if (P.CrossConnectChance > 0.0f) { - FShaft Sh; - if (RollShaft(cx, cy, Sh)) { return EVoxelOpEffect::CarveOnly; } // prudent + const int32 QX0 = FMath::FloorToInt((float)VoxelBox.Min.X / Spacing) - 1; + const int32 QX1 = FMath::FloorToInt((float)VoxelBox.Max.X / Spacing) + 1; + const int32 QY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / Spacing) - 1; + const int32 QY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / Spacing) + 1; + + TArray> Near; + for (int32 cy = QY0; cy <= QY1; ++cy) + for (int32 cx = QX0; cx <= QX1; ++cx) + { + FShaft Sh; + if (RollShaft(cx, cy, Sh)) { Near.Add(Sh); } + } + + const float ConnReach = P.ConnectorRadius + ExtraReach; + const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness; + const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness; + + // Z est traité EXACTEMENT (le connecteur est une capsule horizontale à `Zc`), XY de + // façon conservative. Séparer les deux est bien plus serré qu'une demi-diagonale 3D. + const float RMinZ = (float)VoxelBox.Min.Z, RMaxZ = (float)VoxelBox.Max.Z; + const FVector2D CtrXY(0.5f * (float)(VoxelBox.Min.X + VoxelBox.Max.X), + 0.5f * (float)(VoxelBox.Min.Y + VoxelBox.Max.Y)); + const float HalfDiagXY = 0.5f * FMath::Sqrt( + FMath::Square((float)(VoxelBox.Max.X - VoxelBox.Min.X)) + + FMath::Square((float)(VoxelBox.Max.Y - VoxelBox.Min.Y))); + + for (int32 i = 0; i < Near.Num(); ++i) + for (int32 j = i + 1; j < Near.Num(); ++j) + { + const FShaft& A = Near[i]; + const FShaft& B = Near[j]; + const float DSq = FMath::Square(A.X - B.X) + FMath::Square(A.Y - B.Y); + if (DSq > FMath::Square(Spacing * 1.6f)) { continue; } + + const uint32 PH = VoxelHash::Pair( + FMath::RoundToInt(A.X), FMath::RoundToInt(A.Y), + FMath::RoundToInt(B.X), FMath::RoundToInt(B.Y), Salt ^ 0xC04Eu); + if (VoxelHash::ToFloat01(PH) >= P.CrossConnectChance) { continue; } + + const float Zc = FMath::Lerp(BottomZ, TopZ, + VoxelHash::ToFloat01(VoxelHash::Mix(PH))); + if (RMinZ > Zc + ConnReach || RMaxZ < Zc - ConnReach) { continue; } + + const float DistXY = VF_DistPointSegment2D( + CtrXY, FVector2D(A.X, A.Y), FVector2D(B.X, B.Y)); + if (DistXY - HalfDiagXY >= ConnReach) { continue; } + + return EVoxelOpEffect::CarveOnly; + } } return EVoxelOpEffect::Identity;