diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index 473cc24..1ee9403 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -773,3 +773,41 @@ brute force. opt-in, so a Maze strate can be A/B-switched in the editor and judged on a screenshot (§2.6's bar). --- + +## 2026-07-27 — Phase 1 step 3: the stack is WIRED IN, behind a per-strate opt-in. + +**`OPSTACK-PLAN §4` Phase 1 step 3 done.** `GetDensityAt` gains exactly one branch, as the plan +specified, and both systems now coexist. + +**Files:** +- `VoxelStrateDefinition.h` — `bool bUseOperatorStack` (EditAnywhere, "Use Operator Stack + (experimental)"). The A/B switch §2.6's acceptance bar needs: flip it, regenerate, judge the + screenshot. +- `VoxelStrateManager.{h,cpp}` — `UsesOperatorStackForChunk()`. **The ported-archetype list lives + here and nowhere else**, so an unported archetype ignores the flag and falls back to the switch. + Ticking the box on any strate is therefore harmless today; only `Maze` changes behaviour. +- `VoxelGenerator.cpp` — `CP_OpStack` / `CP_UseOpStack` built in the SAME refetch block as the + params (so the chunk+`LayoutVersion` key already covers it, no new invalidation logic), plus one + `if (CP_UseOpStack)` on the dispatch. + +**Cost on the hot path: one bool test per voxel.** The stack is built per chunk, never per voxel — +the same cadence as the existing param refetch. `ApplyDisturbances` and the diff layer are +deliberately left OUTSIDE the stack and run once for both paths, exactly as before, so the tail of +the pipeline is untouched. + +**Defensive choice worth noting:** if `UsesOperatorStackForChunk` ever returns true for an archetype +with no builder, the code clears the flag and falls back to the switch rather than generating an +empty stack. A world that is *unported* is recoverable; a world that is *wrong* is not. + +**UNVERIFIED:** not compiled. Likely error spots: the `else switch` form in `GetDensityAt`, +`FVoxelOpStack` as a `thread_local` (it is move-only — move-assign from a temporary is used to +reset it), and the new include in `VoxelGenerator.cpp`. + +**What to look at in the editor:** set a Maze strate's `bUseOperatorStack`, regenerate, and compare +against the same seed with it off. **Pass = recognisably the same maze** — same corridor scale, same +connectivity, same feel. That is §2.6's bar, and it is the last thing Phase 1 needs. + +**Next single action:** build, then the visual A/B. After that, Phase 2 — the port order in +`OPSTACK-DECOMPOSITION §10.4` starts with FlatPlain + CrystalChamber collapsing into one op. + +--- diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 91243ce..14c5e4e 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -14,6 +14,7 @@ #include "VoxelBiomeDefinition.h" #include "VoxelNoise.h" // T2.a: float, SIMD-batched gradient-noise core #include "VoxelDensityPrimitives.h" // spine / seal / passage — shared with the operator stack +#include "VoxelDensityOpStack.h" // OPSTACK Phase 1: the opt-in per-strate operator stack //============================================================================= // SURFACE COLUMN CACHE (T1.a) — kill the per-Z heightfield redundancy @@ -493,6 +494,11 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // The key MUST include the layout version, not just the chunk coord. Symptom without it: // "I tweaked the strate asset, regenerated, and one patch kept the old shape." thread_local uint32 CP_Version = 0xFFFFFFFFu; + // OPSTACK Phase 1 — la pile d'opérateurs, construite dans le MÊME bloc de refetch que les + // params (donc même clé chunk+version, aucune logique d'invalidation en plus). Vide tant que + // la strate n'a pas coché `bUseOperatorStack` ET que son archétype n'est pas porté. + thread_local FVoxelOpStack CP_OpStack; + thread_local bool CP_UseOpStack = false; const uint32 LayoutVersion = StrateManager->GetLayoutVersion(); if (ChunkCoord != CP_Chunk || LayoutVersion != CP_Version) @@ -523,9 +529,38 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co CP_Tunnel = StrateManager->GetGenerationParams(ChunkCoord); break; } CP_Dist = StrateManager->GetDisturbanceParamsForChunk(ChunkCoord); + + // ── OPSTACK Phase 1 : (re)construire la pile si cette strate l'a demandée. ── + // Une seule branche ajoutée au chemin densité, et elle est FROIDE : la construction est + // par chunk (comme le refetch de params juste au-dessus), jamais par voxel. + CP_UseOpStack = StrateManager->UsesOperatorStackForChunk(ChunkCoord); + if (CP_UseOpStack) + { + CP_OpStack = FVoxelOpStack(); // move-assign : libère l'ancienne pile + switch (CP_GenType) + { + case ECaveGeneratorType::Maze: + VoxelDensityOps::BuildMazeStack(CP_OpStack, CP_Maze, Seed, + OriginSpineRadius, StrateManager); + break; + default: + // UsesOperatorStackForChunk ne rend true que pour les archétypes portés, donc + // on ne devrait jamais arriver ici. Si ça arrive, retomber sur le `switch` + // plutôt que générer du vide — un monde faux est pire qu'un monde non porté. + CP_UseOpStack = false; + break; + } + } } - switch (CP_GenType) + // Le seul point d'entrée de la pile dans le chemin de production. Elle rend la convention + // MC (négatif = solide) comme les fonctions d'archétype, donc les disturbances et la couche + // de diff qui suivent ne voient aucune différence. + if (CP_UseOpStack) + { + Result = CP_OpStack.EvalMC(WorldX, WorldY, WorldZ); + } + else switch (CP_GenType) { case ECaveGeneratorType::FlatPlain: case ECaveGeneratorType::CrystalChamber: diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index dd5ddb3..879d3d1 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -556,6 +556,26 @@ ECaveGeneratorType UVoxelStrateManager::GetGeneratorTypeForChunk(const FIntVecto return StrateLayout[SlotIdx].Definition->GeneratorType; } +bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord) const +{ + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) { return false; } + + const UVoxelStrateDefinition* Def = StrateLayout[SlotIdx].Definition; + if (!Def->bUseOperatorStack) { return false; } + + // LA LISTE DES ARCHÉTYPES PORTÉS — le seul endroit où elle est écrite. Un archétype non porté + // ignore le drapeau et retombe sur le `switch`, pour qu'on puisse cocher la case sur n'importe + // quelle strate sans rien casser en attendant son portage. + // THE PORTED-ARCHETYPE LIST, written down exactly once. An unported archetype ignores the flag + // and falls back to the switch, so the box can be ticked anywhere without breaking anything. + switch (Def->GeneratorType) + { + case ECaveGeneratorType::Maze: return true; + default: return false; + } +} + bool UVoxelStrateManager::IsGapChunk(const FIntVector& ChunkCoord) const { if (StrateLayout.Num() == 0) return false; diff --git a/Source/VoxelForge/Public/VoxelStrateDefinition.h b/Source/VoxelForge/Public/VoxelStrateDefinition.h index 9f972c5..7a01210 100644 --- a/Source/VoxelForge/Public/VoxelStrateDefinition.h +++ b/Source/VoxelForge/Public/VoxelStrateDefinition.h @@ -106,6 +106,29 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation") ECaveGeneratorType GeneratorType = ECaveGeneratorType::TunnelNetwork; + /** + * OPERATOR STACK (experimental) — generate this strate through the composable density operator + * stack instead of the hardcoded archetype switch. Same world, different machinery. + * + * This is the A/B switch for `OPSTACK-PLAN §2.6`'s acceptance bar: flip it, regenerate, and + * judge on a screenshot that it is recognisably the same place. Both systems coexist + * indefinitely — the switch is not going away until every archetype is ported. + * + * ⚠️ ONLY `Maze` IS PORTED SO FAR. On any other GeneratorType this flag is ignored and the + * switch runs as before, so setting it is harmless but does nothing yet. + * + * ⚠️ Do NOT flip this on a strate mid-session and expect the old and new geometry to agree to + * the bit — they differ by ~1-2 ULP with ZERO isosurface crossings, so the shape is identical + * but the floats are not (`AUDIT-2026-07.md §C10`). Regenerate the world after changing it + * rather than letting old and new tiles sit side by side. + * + * Pile d'opérateurs (expérimental) : génère cette strate via la pile composable au lieu du + * `switch` d'archétype. Seul `Maze` est porté ; ailleurs le drapeau est ignoré. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation", + meta = (DisplayName = "Use Operator Stack (experimental)")) + bool bUseOperatorStack = false; + //========================================================================= // TUNNEL NETWORK PARAMS (shown only for TunnelNetwork generator type) //========================================================================= diff --git a/Source/VoxelForge/Public/VoxelStrateManager.h b/Source/VoxelForge/Public/VoxelStrateManager.h index 9bac9da..bbe1332 100644 --- a/Source/VoxelForge/Public/VoxelStrateManager.h +++ b/Source/VoxelForge/Public/VoxelStrateManager.h @@ -201,6 +201,16 @@ public: */ ECaveGeneratorType GetGeneratorTypeForChunk(const FIntVector& ChunkCoord) const; + /** + * True when this chunk's strate opts into the density OPERATOR STACK instead of the hardcoded + * archetype switch (`UVoxelStrateDefinition::bUseOperatorStack`). + * + * Returns false for archetypes that have no port yet, so the flag can be set on any strate + * without changing its output until that archetype lands. Only `Maze` is ported today — this + * predicate is where that list grows, and it is deliberately the ONLY place it is written down. + */ + bool UsesOperatorStackForChunk(const FIntVector& ChunkCoord) const; + /** * True if this chunk is in the solid-bedrock GAP between two strates (inside the * overall stack's Z range but not in any strate slot). Chunks above the top strate