Compare commits

...

15 Commits

Author SHA1 Message Date
Fr0zka 451ebcb776 docs: close the operator stack -- handoff rewritten in plain language
Jahni built the last two commits and the game looks fine. Closing the thread.

OPSTACK-HANDOFF.md fully rewritten. The old one had become a private dialect --
"you've started using very unique terminology, i have NO idea what you've been
doing" -- which is a failure of the document, not the reader. The new version
leads with what the op stack was for, says plainly that Phase 3 was never built
and is not being built, carries the honest three-week ledger (120 commits, every
feat: a port, the world unchanged by design), and translates the jargon.

Final state: 8/8 archetypes live and bit-identical, 14/14 tests green through
871ca19, violations 0, and 39% of tiles skipped in the running game against 0%
that morning.

Flagged in the handoff: 4d33321 and 91585ea are built but not re-verified -- the
suite has not been re-run since. Two named checks, and revert 4d33321 if either
fails; the 39% does not depend on it.

Direction agreed: no Phase 3, no further refactoring, old switch stays as the
oracle, next work is visible content from fable-idea.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 03:23:31 +02:00
Fr0zka 91585ea493 test(tunnel): a warning that always means "this is fine" is noise -- demote it
Jahni asked whether a "warning stage" he had been seeing for several sessions
meant something was broken. It did not: the only Warning in that run is the
tunnel test's "[dense fixture] No tile was proved", whose own text explains that
0 proved is the EXPECTED and correct result there -- cull spheres cover the
dense fixture ~3.6x, so no box can be outside all of them. Test passes, density
bit-identical, all coverage non-zero, violations 0, and production defaults
reports 11 proved / 14641 voxels / 0 violations right below it.

But a warning that fires every run and always means "fine" trains the reader to
ignore warnings, and this one quietly worried him across several sessions.

RunTileScan now takes bZeroProvedIsExpected: AddInfo on the dense fixture where
zero is the only possible answer, AddWarning on production defaults where zero
would be a real regression from 11. Same message, severity now carries meaning.

Same defect as the lumped bail counter one layer out: a signal that always says
the same thing measures nothing.

Test-only. Not built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 03:20:07 +02:00
Fr0zka 4d33321bfa feat(classify): fold cave boundary categories safely 2026-08-17 01:35:58 +02:00
Fr0zka 2303442d8f docs: T1.d fires in production -- 39% of tiles skipped, measured in game
Classified 1.88 / Meshed 1.15 / SkippedAllAir 0.73 (1.15 + 0.73 = 1.88, exact).
Was Classified 1.90 / Meshed 1.90 / nothing skipped. Cave Bail No Layout went
1.58 -> gone.

39% of classified tiles now skipped against 0% this morning, each avoiding
~43000 density evaluations plus marching cubes. First observation of
tile-skipping in the running game rather than an automation harness.

Not yet firing, stated plainly: TilesSkippedAllSolid and the two op-stack
counters remain absent, so the cave branch still produces no verdicts in game --
its attempts land in Cave Bail Mixed Content (0.77) and Stack Verdict (0.32).
The win here is the open-air class proved by the classifier's own Z categories,
not by ClassifyBox. That is the next thread and the counters now name it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 01:23:41 +02:00
Fr0zka 871ca190af fix(classify): use the public GetStrateChunkZBounds; the index accessor is protected
VoxelGenerator.cpp(2796): C2248 -- FindSlotIndexForChunkZ is protected.

I claimed it was public "verified" in the previous commit. The grep printed the
declaration line, not the access specifier above it. Same shape as the partial
read that produced a false accusation earlier today.

GetStrateChunkZBounds is public (VoxelStrateManager.h:177), returns false for
exactly the no-slot case, and ClassifyTile already calls it three times for the
same question. Wrapped in a VF_ChunkZHasSlot lambda so the condition reads as
the predicate it is. No accessor promoted, no API widened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 01:22:12 +02:00
Fr0zka a2c5e02713 fix(classify): out-of-layout chunks are constant AIR, not TunnelNetwork -- unblocks T1.d
Measured in game: Cave Bail Not Op Stack No Layout = 1.58 of 1.90 classified
tiles (83%), with SoleSlot and BoundaryTile never firing. The assets were
correctly ticked all along.

Cause: GetGeneratorTypeForChunk returns TunnelNetwork for any chunk outside the
strate stack, commenting "the fallback density path will produce solid rock
anyway". That comment is FALSE. GetGenerationParams for SlotIdx < 0 returns
BaseDensity = -1, RoomDensity = 0, WormStrength = 0 -- a CONSTANT AIR field.
IsGapChunk's "open air, NOT a gap" was the correct description.

So every tile touching the open air above the world looked like a cave
archetype, entered the cave branch, found no layout slot, and bailed. T1.d was
never failing -- it was unreachable, behind a routing mistake in the archetype
lookup that had nothing to do with the operator stack, the box verdicts, or the
flags.

Fix: ClassifyTile gains a fourth Z category for no-slot chunks. It sets
bAnyNonCave (never enters the cave branch) and bCanSolid = false (AllAir
survives), and bails to Mixed if a disturbance could add rock there -- chasms
only carve, so they cannot threaten an air verdict.

A tile entirely above the stack now resolves AllAir and is skipped: the
majority of a surface flight, a class T1.d has never been able to prove.

Classifier only -- no density value changes. Equivalences must stay
bit-identical and violations must stay 0.

Not built -- Jahni builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 01:20:06 +02:00
Fr0zka 05986bd875 docs: T1.d blocker FOUND -- out-of-layout chunks misroute into the cave branch
In-game with Sol's four-way split: NotOpStackNoLayout = 1.58 of 1.90 classified
(83%), while SoleSlot, BoundaryTile and MixedContent never fire. So the assets
ARE ticked -- Jahni was right -- and the neighbour theory is dead. The split
earned itself on its first flight; the old lumped counter would have sent us at
the assets again.

Mechanism: GetGeneratorTypeForChunk returns TunnelNetwork for any chunk outside
the strate stack ("fallback produces solid rock anyway"), while IsGapChunk
returns false for above-stack chunks ("open air, NOT a gap"). So every chunk
above TopChunkZ = 0 -- all the open air over the world -- looks to ClassifyTile
like a cave archetype, enters the cave branch, finds no layout slot, and bails.

T1.d was never failing. It was unreachable, behind a routing mistake in the
archetype lookup that has nothing to do with the operator stack, the box
verdicts, or the flags -- all of which are correct and tested.

Not fixed: the two comments disagree about whether out-of-layout is solid rock
or open air, and the right verdict depends on which. Read GetDensityAt's
no-slot path before choosing; guessing there writes a hole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 01:16:12 +02:00
Fr0zka 4ae9e6e72f fix(strate): opt-in diagnostic goes Verbose under automation, not Log
The verbosity fix stopped the diagnostic failing the suite, but it still printed
at Log on every Initialize -- which the fixture calls dozens of times per run,
burying the actual test output under hundreds of identical lines.

Under GIsAutomationTesting it now logs at Verbose (off by default, still
available with -LogCmds). Editor and game keep Warning, where it is actionable
and fires once per rebuild.

The information was worthless in tests anyway: the fixture's opt-in state is
known by construction, and the one test that needs the opted-in variant
(OpStack.ClassifyTileSoundness) already reports "all 7 cave layout slots have
Use Operator Stack enabled" on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 01:09:22 +02:00
Fr0zka b06c39c077 docs: suite green after the verbosity fix; FIELD digest proves nothing moved
14/14 pass with no StrateManager warnings. Every reported number is identical
to the pre-change run, including the CrossPlatform FIELD digest
(0xF62F3D355B1C0BDB) -- a bit-for-bit hash over 115,000 samples of the whole
field.

That digest is the strongest evidence available: the six-box LRU, the
DensityCacheOwnerId key, the bail re-attribution and the scope fix did not move
a single voxel between them. Four changes to caching, keying and diagnostics,
and the world is byte-for-byte identical -- exactly the contract each claimed,
now tested rather than asserted.

Still unmeasured: the performance benefit of those caching changes. "Quite a
fraction of what it used to be" is a real observation, not a same-seed/
same-route number.

Still open: read `Operator-stack opt-in` in the GAME log. That line is the
ground truth for T1.d and supersedes both the bail-counter inference and the
recorded belief that the assets have the switch on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 01:04:46 +02:00
Fr0zka 9740d117e0 fix(strate): opt-in diagnostic must not fail the automation suite
Sol's opt-in warning logs at Warning. UE's automation framework counts a
Warning as a failure, and the Determinism.* tests deliberately build a
NON-opted-in world -- that world is their comparison oracle, the baseline every
OpStack.* number is measured against. The diagnostic was failing the suite by
correctly reporting an intentional configuration.

Verbosity is now Log under GIsAutomationTesting and Warning otherwise. The
message is written once and only the verbosity branches (Printf then log %s):
four copies of a long format string across two sites would have been exactly
the "one definition, not two kept in sync" failure this project has rules
about. CoreGlobals.h included explicitly for GIsAutomationTesting -- IWYU, no
shared PCH here.

Note for the record: the tests must NOT be switched to the op stack. The
non-opted-in fixture is the oracle, not an oversight.

The diagnostic also did its job: 7/7 cave slots report disabled in the fixture
(slot 4 absent = the SurfaceWorld narrowing working). The same line read from
the GAME log is now the ground truth for whether T1.d has ever been enabled in
production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 01:01:55 +02:00
Fr0zka 45c61dd00e fix(opstack): hoist the acquired column box out of the integer-XY scope
VoxelDensityOpStack.cpp(832): C2065 'Box' undeclared.

The six-box LRU refactor moved acquisition to `FColumnBox& Box = Cache.Acquire(...)`
inside `if (bIntegerXY)`, but the Computed flag is set after the column is
computed, outside that block. The previous single-box version had Box at
function scope so the write-back compiled.

Hoists `FColumnBox* AcquiredBox` beside MemoColumn and writes back through it.
The guard becomes `if (AcquiredBox)` instead of `if (bIntegerXY)`: non-null
implies the integer path, so the pointer proves its own safety rather than
relying on two conditions staying in agreement.

No caching or logic change. Other Box. uses (761-763) are in scope, verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:50:34 +02:00
Fr0zka 6e7ea7038a docs(opstack): record unbuilt experimental landing
Append the exact commits, Approach A memory decision, cave-only warning caveat, diagnostic semantics, CP owner scope, compile watchpoints, deliberately open measurements, and recoverable worktree cleanup. No build or runtime result is claimed.
2026-08-17 00:44:27 +02:00
Fr0zka f90c4e56c3 fix(generator): key CP cache by world owner
Function-static thread_local CP state previously trusted only chunk coordinates and each manager's locally-reused layout version, allowing a worker to carry params, biome context, CP_UseOpStack, and the built stack into another world. Allocate each generator a monotonic owner ID and add one uint64 comparison to the hot key. This intentionally fixes only the proved CP_* path; other TLS caches remain outside this change.
2026-08-17 00:41:51 +02:00
Fr0zka 1e02c6314f diagnostics(opstack): separate disabled-slot bail causes
The old Not Op Stack counter fired before slot attribution, so interior disabled strates and boundary encounters produced the same number. Split the instrumentation into sole-slot, boundary-tile, unresolved-layout, and late-recheck counters while preserving every ClassifyTile condition, return point, and verdict.
2026-08-17 00:39:47 +02:00
Fr0zka 8295f6e76b perf(opstack): retain six surface column regions
The single direct-indexed box discarded all 6,561 computed columns whenever spatial or column identity moved. Port the reference six-box LRU so interleaved regions keep five warm working sets, accepting the documented ~0.79 MiB TLS cost per worker to preserve the recommended and measurable A/B path. Also report disabled cave opt-ins at layout initialization; SurfaceWorld is excluded because its exact-lattice tile proof does not use that flag.
2026-08-17 00:37:56 +02:00
12 changed files with 936 additions and 426 deletions
+3 -1
View File
@@ -306,7 +306,9 @@ driven by `EditorBrush*` props.
- **SDF cache** (`GetDensityWithParams`): search-BOX validity, not chunk-key — gradient ±1
sampling must not thrash the (expensive) rebuild.
- **Per-chunk param cache** in `GetDensityAt`: GenType + param struct + disturbance cached
thread-locally per chunk; don't move the fetch/blend back to per-voxel.
thread-locally by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; the process-unique owner ID
prevents cross-world reuse while adding only one `uint64` compare per voxel. Don't remove the owner
or layout key, and don't move the fetch/blend back to per-voxel.
- **Biome cache** (`ResolveBiomeSampleAt`/`FChunkBiomeCache`, §8.14): validity is a world-XY BOX +
ChunkZ + Seed, NOT a chunk key — same reason as the SDF cache. The cell classification is
noise-heavy; a chunk-key would thrash it on gradient-normal / +X/+Y boundary samples. Keep
+9 -7
View File
@@ -79,7 +79,7 @@ Paths relative to `Source/VoxelForge/`. `Public/` = headers, `Private/` = impl.
| `../../VoxelForge.uplugin` | Plugin manifest. One Runtime module `VoxelForge`. Beta. |
| `VoxelForge.Build.cs` | Deps: Core, CoreUObject, Engine, **GameplayTags**, **RealtimeMeshComponent**. |
| `Public/VoxelForgeModule.h` / `Private/VoxelForgeModule.cpp` | `FVoxelForgeModule` boilerplate (Startup/Shutdown just log). |
| `Public/VoxelStats.h` / `Private/VoxelStats.cpp` | `stat VoxelForge` DWORD counters for tile classification, skipping, meshing, operator-stack verdicts, and cave-bail diagnosis. |
| `Public/VoxelStats.h` / `Private/VoxelStats.cpp` | `stat VoxelForge` DWORD counters for tile classification, skipping, meshing, operator-stack verdicts, and cave-bail diagnosis. The former ambiguous `Cave Bail Not Op Stack` is split into `Sole Slot`, `Boundary Tile`, `No Layout`, and late `Recheck` counters, so each increment names one guard/context. |
### 3.2 Foundational types — `Public/VoxelTypes.h` (no UClass, everyone includes it)
| Symbol | Line | Notes |
@@ -152,7 +152,7 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar
| `VoxelDensityOps::MakeSlabVoidSource` | 1 | Floor surface + ceiling surface → void field. **XY-pure** since §3.1, which is what gives it an **exact `ClassifyBox` with no sampling**: FBM's `[-1,1]` contract bounds both surfaces into known Z bands. Serves FlatPlain **and** CrystalChamber. |
| `VoxelDensityOps::MakeGridColumnMod` | 3 | Infinite-height cylinders on a world grid, 3×3 cell memo. Adds solid only ⇒ `FillOnly` when a column reaches the box, `Identity` otherwise — and that `Identity` is what lets the source's `AllAir` verdict survive. |
| `VoxelDensityOps::BuildSlabStack` | — | 5 ops, **no branch on archetype**: FlatPlain and CrystalChamber differ only in defaults, exactly as `GetSlabDensity` already had it. 8 archetypes → 7. |
| `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns the per-column memo, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. |
| `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns a **six-box spatial LRU** of direct-indexed per-column cells, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed, ParamsFingerprint)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. Six 81×81 boxes preserve hot columns across interleaved regions at roughly 0.79 MiB TLS before padding (more memory, fewer whole-cache recenter/recompute misses). Fractional XY remains direct-compute. |
| `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 3 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. |
| `VoxelDensityOps::BuildVerticalShaftStack` | — | 8 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. |
| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion**; the original lacked them until AUDIT §C2 was fixed (2026-07-28) and now carries them too. **`EffectOverBox` ANSWERS SPATIALLY** since 2026-07-28 — this is the T1.d switch (measured: **6 of 40 tiles proved AllSolid at production defaults, 7986 voxels brute-forced, 0 violations**). It builds the cache for the queried box into a *second* per-worker cache (never `FState::Cache`), then applies a **disjunction** per primitive: it doesn't matter if it **fails its cull** *or* if **its own SDF stays ≥ `T+K`** over the box. Cull wins for rooms (`Rmax+3K` < `Rmax+T+K`); the threshold wins hugely for tunnels, whose cull is a capsule *bounding sphere* (~107 radius for a 200-long tube of radius 7). ⚠️ **`Identity` therefore means `Sdf ≥ T`, not `Sdf == FLT_MAX`**, with `T = max(3·SDFBlendRadius, WormNetworkRange)`**any new consumer of the `Sdf` channel must have a threshold ≤ T or be added to that max**, or it gets tiles with no geometry and no collision. The `K` slack covers any number of primitives because `SmoothMin`'s penalty is exactly 0 once `\|AB\| ≥ K`. Pits/chimneys use the cull only; columns are **not** tested (their sole consumer gates on `Sdf`, so the test was redundant). Verdict memoised per box; warp dilation uses a **provable** `\|Perlin3D\| ≤ 2`. |
@@ -251,24 +251,26 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
> **Game-thread profiling (Perf):** `AVoxelWorld::Tick` and its sub-steps are wrapped in `TRACE_CPUPROFILER_EVENT_SCOPE` — `VoxelForge_Tick / UpdateChunks / BuildDesiredTiles / CullTiles / SubmitTiles / ProcessPending / ProcessUnload / UpdateDecorations / UpdateWater`. Capture a `Count/Incl/Excl` Insights timer export and read the `Excl` column to see which step owns the per-frame cost (the actor tick shows as `BP_VoxelWorld_C` if subclassed in BP). `VoxelForge_ClassifyTile` (T1.d) / `VoxelForge_GenerateMesh` + `VoxelForge_BuildStreams` are worker-side (off the frame): the RMC `FRealtimeMeshStreamSet` is now built on the gen worker (`BuildTileStreamSet`) and carried on `FChunkResult::Streams` (TSharedPtr), so `ApplyMeshToTile` is game-thread-cheap — just material/ceiling resolve + `CreateSectionGroup(MoveTemp)`. See ARCHITECTURE §8.10 "Worker-built StreamSet (T1.f)".
### 3.6 Density generator — `Public/VoxelGenerator.h` + `Private/VoxelGenerator.cpp`
`UVoxelGenerator : UObject` — lightweight; holds `Seed`, and injected services
`StrateManager` + `DiffLayer` (both nullable). This is **where terrain shape lives.**
`UVoxelGenerator : UObject` — lightweight; holds `Seed`, a process-unique
`DensityCacheOwnerId`, and injected services `StrateManager` + `DiffLayer` (both nullable).
This is **where terrain shape lives.**
| Symbol | .cpp line | Role |
|--------|-----------|------|
| `UVoxelGenerator` / `DensityCacheOwnerId` | — | Constructor allocates a process-unique integer identity (relaxed atomic, once per object). `GetDensityAt` includes it in the `CP_*` thread-local key, preventing a worker from serving another generator/world's params, biome context, `CP_UseOpStack`, or stack when `(ChunkCoord, LayoutVersion)` happens to match. Hot-path cost: one `uint64` compare per voxel. Scope is deliberately only the proved `CP_*` path. |
| `FractalNoise3D` (static) | 25 | fBM (layered Perlin). |
| `RidgedNoise3D` (static) | 55 | Ridged multifractal — craggy. |
| `CellularNoise3D` (static) | 101 | Worley/cellular — grotto/scallop. |
| `ApplyBoundarySeal` (static) | 170 | Solidifies strate top/bottom shells. |
| `ApplyPassageCarving` (static) | 197 | Punches passages/elevator through the seal. |
| `InitializeSettings` | 211 | Copies seed from settings. |
| **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, dispatches, adds diff offset. |
| **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, dispatches, adds diff offset. Its `CP_*` per-chunk state is keyed by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; every key component is an integer compare and a different generator/world cannot inherit the previous owner's cached params or op stack. |
| **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. ⚠️ Takes **required** `ParamsFingerprint` + `LayoutVersion` since the AUDIT §C2 fix (2026-07-28) — they go into the SDF cache key so a chunk can no longer be evaluated against a neighbour's rooms. Callers compute the CRC **once per chunk** (`CP_TunnelFP`), never per voxel. |
| **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. |
| `SampleSurfaceStructuralZ` | — | **F20:** the RAW SurfaceWorld heightfield (continents+mountains+detail), BEFORE any terrain op; returns terrain Z + relief M. Cliff re-samples it at an XY offset for a cheap analytic slope. |
| `ComputeSurfaceTerrainZ` / `GetSurfaceDensity` | — | SurfaceWorld heightfield → terrain Z, then density; biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). **F20 surface ops** (`FSurfaceGenerationParams`, biome-selected + slope/relief-conditioned, all default off): Cliff (slope-gated STEEPENING — push height from local mean where steep ⇒ sheer walls; 4 structural resamples only when on), Terrace (relief-gated + `TerraceHardness`), LayerLines (sedimentary shelves) — pure per-column height REMAPS applied here so the single height oracle stays consistent (MC/sheets/ClassifyTile/deco/BP bridge). **Phase 2 OVERHANG** (volumetric — real jutting shelves): in `SurfaceDensityFromColumn`, for AIR voxels in a window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height (tiny low ⇒ air over the void, full high ⇒ borrows the far cliff rock) and unioned in ⇒ a shelf attached to the cliff, tapering out over the void with air beneath (the sketch). Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill `(DirX,DirY)` resolved once in `ComputeSurfaceColumn` (gradient sampled at the REACH scale so a spot over the void can see the cliff), cached on `FSurfaceColumn`. Genuine 3D (per-voxel structural re-eval, gated to steep overhang columns). Off ⇒ byte-identical. §8.14. |
| `VF_BuildOpStackForChunk` (file-static) | — | **The archetype → stack mapping, written down once.** `GetDensityAt` and `ClassifyTile` both call it; params are passed in, never fetched here. A second copy would be the worst bug available in this file — a tile skipped on the verdict of a stack that is not the one producing its density is a hole. Returns false (⇒ caller falls back to the `switch`) for an unported archetype, missing params, or a **degenerate strate**, since five archetype functions early-out to air there and the stack deliberately has no such early-out. `Refs.Surface == nullptr` makes it refuse SurfaceWorld, which is how `ClassifyTile` keeps its own exact-lattice proof. |
| `ClassifyTile` | — | **T1.d trivial-tile reject** (worker, called by `LoadTile` before `GenerateMesh`): proves a tile AllSolid/AllAir on the mesher's exact lattice (gap chunks + SurfaceWorld columns via the SHARED `GSurfColCache`; seal bands; **cave archetypes via `FVoxelOpStack::ClassifyBox` when the strate opted in** — see §3.2d for the six guards, all failing to `Mixed`; guards: diff mods, passages, spine, disturbances, **F20 overhang** — a column point in `(TerrainZ, TerrainZ+OverhangMargin]` (margin = max `OverhangHeight`) is unprovable ⇒ Mixed, UPWARD only since the shelf union only ADDS rock above ground, so an overhang shelf never holes a trivially-skipped tile) → skip gen. Mixed = generate normally. §8.10. |
| `ClassifyTile` | — | **T1.d trivial-tile reject** (worker, called by `LoadTile` before `GenerateMesh`): proves a tile AllSolid/AllAir on the mesher's exact lattice (gap chunks + SurfaceWorld columns via the SHARED `GSurfColCache`; seal bands; **cave archetypes via `FVoxelOpStack::ClassifyBox` when the strate opted in** — see §3.2d for the six guards, all failing to `Mixed`; guards: diff mods, passages, spine, disturbances, **F20 overhang** — a column point in `(TerrainZ, TerrainZ+OverhangMargin]` (margin = max `OverhangHeight`) is unprovable ⇒ Mixed, UPWARD only since the shelf union only ADDS rock above ground, so an overhang shelf never holes a trivially-skipped tile) → skip gen. Mixed = generate normally. Its diagnostic-only not-op-stack bail attribution distinguishes a tile wholly inside the disabled slot, a boundary tile, and an unresolved layout; the classifier's conditions/returns are unchanged. §8.10. |
| `SampleRelief` / `SampleMoisture` | — | Climate fields (pure XY, [0,1]). Relief = shared source of truth for the relief map M. §8.14. |
| `SampleBiomeAt` | — | Warped-Voronoi + climate biome query (dominant + neighbour + weight). Reference used by the preview bake + `GetDominantBiomeAt`. §8.14. |
| `ResolveBiomeSampleAt` / `RebuildBiomeGrid` | — | Hot-path biome resolve (FBiomeSample) via a box-validated per-chunk cell-grid cache. Bit-identical to `SampleBiomeAt`. §8.14, §8.10. |
@@ -338,7 +340,7 @@ Maps depth→strate at runtime; owns passages.
- `FStrateSlot` (h:84): definition + chunk-Z range + index.
| Method | .cpp line | Role |
|--------|-----------|------|
| `Initialize` | 10 | Builds the stacked layout from settings+seed (fixed slots + shuffled pool), then `GeneratePassages`. |
| `Initialize` | 10 | Builds the stacked layout from settings+seed (fixed slots + shuffled pool), logs every **cave** slot whose operator-stack opt-in is disabled, then `GeneratePassages`. SurfaceWorld is deliberately excluded from that diagnostic because its exact-lattice T1.d path does not depend on the flag. |
| `GeneratePassages` | 146 | Deterministic passages between consecutive strates (per-type control points). |
| `EvaluateModifierSDF` | 357 | SDF of passages at a point (for carving). Per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`. §8.10. |
| `AnyPassageNearBox` | — | Conservative sphere-vs-AABB test of every passage's bound against a voxel box (+carve blend pad). Per TILE (ClassifyTile guard), never per voxel. |
+77 -313
View File
@@ -1,339 +1,103 @@
# Handoff — VoxelForge operator stack, updated 2026-08-16 (four things queued on ONE build)
# VoxelForge handoff — the operator stack is DONE. Read this, not the old jargon.
> Paste the block below into a fresh session. Everything it refers to is on disk and in git.
>
> **State:** 8 of 8 archetypes ported and green. Tile-skipping is measured **in the automation
> harness** (11 of 40 tiles proved `AllSolid` at production defaults, 14641 voxels brute-forced,
> 0 violations) and **still unobserved in the running game**. `AUDIT §C2` is **fully closed** (both
> halves — verified 2026-08-16, don't reopen). `experimental` is pushed.
>
> ## ⛔ FOUR unbuilt things are stacked on `experimental`. Build once, read four numbers.
>
> | # | commit | what to read |
> |---|---|---|
> | 1 | `e002bd4` VerticalShafts connector capsules | `Box verdicts over 60 VerticalShafts tiles` — **0 has been the number for the project's whole life**; `violations` must stay 0 |
> | 2 | `eb317d9` `stat VoxelForge`, 8 counters | baseline `TilesOpStackSolid` = 0 → tick one `TunnelNetwork` strate → **non-zero**. That is the production proof of T1.d, which has never existed |
> | 3 | `7dbdf51` `ExtraReach` × `VF_PerlinAbsBound` | Maze/VerticalShafts may prove **FEWER** tiles. **That is correct, not a regression** |
> | 4 | `eaa44bf` `Max3` radius envelope | **a NO-OP at shipped defaults is the correct result** — any moved number means the diff did more than intended |
>
> In all four: the eight equivalence tests must stay green, and `violations` must stay 0.
>
> **⚠️ 3 and 4 are CORRECTNESS fixes to box verdicts, found by auditing all 28 `EffectOverBox`
> implementations.** Both were the same mistake: *a bound taken from the parameter that reads like
> the maximum instead of the supremum of what `Eval` actually produces* — and both times a correct
> instance of the same reasoning already existed elsewhere in the same file. See
> `OPSTACK-PROGRESS.md` 2026-08-16 (e) and (f); the sound-and-checked ops are listed there so they
> are not re-audited.
> Paste this into a fresh session. Written 2026-08-16, deliberately in plain language: the previous
> version of this file had become a private dialect that Jahni could not read, which is a failure of
> the document, not of the reader.
---
You're picking up the VoxelForge UE5 voxel plugin on branch `experimental` (already checked out —
do not create another). I'm Jahni. The design and the history are written down so you don't
re-derive them.
## 1. What the operator stack was, in one paragraph
## Read first, in this order
Cave generation used to be one big `switch`: each cave type (tunnels, maze, shafts, floating
islands, surface…) was its own hardcoded ~2001000 line C++ function. The refactor replaced that with
small composable pieces ("operators") that stack up to produce the same terrain. **The promise was
that you could eventually invent new world types by combining pieces in the editor instead of asking
for another thousand-line function.**
1. **`CLAUDE.md`** — project rules. **Rule #1 is absolute: never build, compile, or run the editor.**
I build everything myself. When code is done, stop, say "ready to build", list the likely
compile-error spots, and wait.
2. **`OPSTACK-PROGRESS.md` — THE LAST ENTRY FIRST.** Append-only log; the resume point.
3. **`OPSTACK-PLAN.md`** — the plan. **§2.6.1 is the acceptance bar** and supersedes §2.6.
4. **`OPSTACK-DECOMPOSITION.md`** — per-archetype breakdown. **§0.2** (the amplitude bound) is now
*implemented*, not pending; §2 TunnelNetwork and §8 Underwater are history, not instructions.
5. **`AUDIT-2026-07.md`** — **§C2's SDF-cache half is FIXED (2026-07-28)**, its live-edit half
(`OC_Chunk` / `BM_Chunk` / `FChunkBiomeCache`) is still open; §C10 is SOLVED, don't reopen;
§C9's library half is the top open theoretical risk with 0 measured exposure.
6. **`CODEMAP.md`** — navigation. Trust symbol names over line numbers.
**That promise — "Phase 3", ops as data assets — was never built, and is NOT being built now.**
## How we work now — Codex writes, you orchestrate
## 2. Status: done. Stop refactoring.
From 2026-07-29 this project runs **in tandem with Codex (Model Luna, xHigh)**. **Codex handles most
of the coding; you orchestrate.** Concretely:
- **8 of 8 archetypes ported**, running in production (`bUseOperatorStack` is ticked on the game's
strate assets), and **bit-for-bit identical** to the old path. Verified by a 115 000-sample field
digest plus eight per-archetype equivalence tests.
- **14/14 automation tests green, 0 violations anywhere.**
- **39 % of tiles are skipped in the running game** (`Tiles Meshed` 1.15 vs `Tiles Classified` 1.88).
It was 0 % on the morning of 2026-08-16. Each skipped tile avoids ~43 000 density evaluations plus
marching cubes.
- You read the code and decide *what* to do; you write **precise specs** Codex executes; you **review
what comes back against the real code, not against its description**; you own the docs
(`OPSTACK-PROGRESS.md`, `CODEMAP §3`, this file) and the measurements.
- **Hand Codex the INVARIANT, not just the task.** This codebase's traps are invisible in a diff —
density sign, `Identity` meaning `Sdf ≥ T` (below), cache keys needing params + `LayoutVersion`,
inserting classes above the anonymous-namespace end marker. A spec that omits these gets code that
compiles and deletes collision.
- `CODEX-TASK-*.md` at the plugin root are the specs. Each carries a **Why**, the **exact site**, the
**invariants**, an **acceptance** section, and **notes for the reviewer**. Write the next one the
same way.
- Unchanged: **never build** (Jahni does), and a plausible patch is not a verified one until a
measurement says so.
**Decision taken 2026-08-16, with Jahni:** the op stack is finished. **Do not start Phase 3. Do not
start another refactor.** The old `switch` stays in place as the correctness oracle — deleting it
buys nothing today. Next work should be things Jahni can *see*: see `fable-idea.md` (F7 set-pieces,
F9 audio were queued before this started).
## Where things stand
### The honest ledger, so nobody re-litigates it
All 8 archetypes have an operator-stack twin, per-strate opt-in, each equivalence-tested **bit for
bit** against its original density function. The `switch` and the stack are two complete,
interchangeable implementations.
Three weeks, 120 commits, from 2026-07-27. Every `feat:` commit in that window is a *port* of
something that already worked. **The world did not change by a single voxel — that was the
acceptance criterion.** What Jahni actually got: the 39 % perf win, two genuine pre-existing bugs
found (a use-after-free on every strate-asset edit while streaming, and an under-bounded room
collection that could make two multiplayer peers generate different geometry), and a number of fixes
to bugs the refactor itself introduced. That is a thin return for three weeks, and it is why the
direction changed.
Everything sits behind `UVoxelStrateDefinition::bUseOperatorStack`; the ported list lives **only** in
`UVoxelStrateManager::UsesOperatorStackForChunk` (all 8). `GetDensityAt` and `ClassifyTile` build the
stack through the **same** factory, `VF_BuildOpStackForChunk` — a second copy would be a hole.
## 3. The jargon, translated
> ### ⚠️⚠️ CORRECTED 2026-08-16 — **THE FLAG IS ON IN THE GAME'S DATA ASSETS.**
>
> This section used to say *"No strate asset has the box ticked — that is my call and I still
> haven't made it."* **That is false and was believed for a whole session.** Jahni: *"the data assets
> in game have the switch on."*
>
> **Everything downstream of that premise flips:**
> - The operator stack is **the production density path**, not a dormant twin. The measured perf
> regression is a regression players feel, not a lab result.
> - Any unsoundness in an op's `EffectOverBox` is a **live** hole, not a latent one. Two were found
> and fixed on 2026-08-16 (`7dbdf51`, `eaa44bf`) and **both of those commit messages say "nothing
> in the running game was affected" — that sentence is WRONG, for this reason.** See
> `OPSTACK-PROGRESS.md` 2026-08-16 (h) for the corrected severity.
> - The T1.d prize is already being collected in-game; `CODEX-TASK-001`'s counters measure how much.
>
> **Never state the flag's state from memory again — it lives in `.uasset` data, which is not
> greppable from here. Ask, or read it in the editor.**
Almost all of it means one thing: **can we prove a chunk of world is entirely rock or entirely air
without checking every point in it, so we can skip the expensive work?**
### ✅ T1.d — the tile-skipping prize — is real and measured **in the harness** (not yet in the game)
`FRoomGraphSource::EffectOverBox` answers **spatially**. The result, brute-forced voxel by voxel:
```
[production defaults] 11 of 40 tiles proved AllSolid — 14641 voxels checked, 0 violations
[dense fixture] 0 of 40 — correct, and structurally inevitable
```
One function's verdict is inherited by `FSdfConvertOp`, the twelve detail modifiers (via
`VF_NoCaveOverBox`) **and** `FWormFieldSource` — fourteen operators from one place. That is what the
C1 wiring was built for.
### ⚠️⚠️ THE ONE INVARIANT THAT CAN DELETE COLLISION — read before touching any op
**`FRoomGraphSource::EffectOverBox` returning `Identity` now means `Sdf ≥ T`, NOT `Sdf == FLT_MAX`**,
where `T = max(3·SDFBlendRadius, WormNetworkRange)`. That is sound only because all three consumers
of the SDF channel were read one by one:
| consumer | threshold |
| term | plain meaning |
|---|---|
| `FSdfConvertOp::Eval` | `Sdf >= Blend`, and the tunnel stack passes `MakeSdfCarve(P.SDFBlendRadius, …)`**K** |
| the twelve modifiers | `VF_NearCaveSurface`**3K** |
| `FWormFieldSource::Eval` | `CaveSDF >= WormNetworkRange`**WormNetworkRange** |
| **T1.d / tile skipping** | that idea. The single biggest perf item in the plan. |
| **box verdict / `ClassifyBox`** | "is this whole box uniform?" → `AllSolid`, `AllAir`, or `Mixed` (don't know) |
| **`Mixed`** | "can't prove it" — always safe, just means we do the work |
| **`ClassifyTile`** | the function that decides, per tile, whether to skip meshing |
| **operator / op stack** | one generation step (rock, carve, roughness…) and the list of them |
| **equivalence test** | proof the new path produces byte-identical terrain to the old one |
| **`violations`** | ⚠️ **the only number that means danger.** A tile wrongly proved uniform has *no geometry and no collision* — a player falls through the floor. Must always be 0. |
**Any new consumer of `InOut.Sdf` must have a threshold ≤ `T`, or be added to that `max`.** An op
reading `Sdf < 100` would see false `Identity` verdicts and produce tiles with no geometry **and no
collision**. The warning is written at the site you land on when you add one.
## 4. What is verified, and what is not
(The `K` slack covers *any* number of primitives because `SmoothMin`'s penalty is exactly zero once
`|AB| ≥ K`, so the running minimum saturates at `K` below the smallest term. Without that
observation the slack would scale with the ~88 tunnels in a cache and the criterion would be dead.)
**Verified:** everything through commit `871ca19` — tests green, digests unchanged, 39 % measured
in game.
## First actions — one build to read, two tasks to hand Codex
⚠️ **Built but NOT re-verified:** `4d33321` (Sol's boundary fold — lets a tile that straddles cave and
open air still resolve) and `91585ea` (a test-only warning demotion). Jahni built these and says the
game *looks* fine, but **the test suite has not been re-run and the counters have not been re-read
since.** Before trusting them:
### (a) Hand Codex `CODEX-TASK-001-tile-skip-stats.md`, then `-002-` — 001 first, they chain
1. run the `VoxelForge` automation filter — **`violations` must be 0 and all eight equivalences
bit-identical**;
2. `stat VoxelForge` in game — the accounting must close:
`Tiles Meshed + Skipped All Air + Skipped All Solid = Tiles Classified`.
Everything in this refactor has been proved in an automation harness on 40 sampled tiles, and
**nothing has ever been observed in the running game.** Task 001 adds a `stat VoxelForge` group with
`TilesClassified / TilesSkippedAllSolid / TilesSkippedAllAir / TilesMeshed`, **plus
`TilesOpStackSolid / TilesOpStackAir` at a second site**. Task 002 adds `ColumnMemoHit / Miss` to the
same group; it needs 001's header to exist, and both should land in one build.
If either fails, `git revert 4d33321` — the 39 % win does not depend on it.
Its deliverable is a **before/after that constitutes the production proof of T1.d**: after ticking
`bUseOperatorStack` on one `TunnelNetwork` strate and flying the same route, **`TilesOpStackSolid`
must go non-zero**. The spec carries the invariants — most importantly that `bTrivialEmpty` decides
whether a tile has **collision**, and that `GenerateTileResult` runs on **worker threads** so a plain
`static int32++` is a data race.
## 5. Rules that still prevent real bugs
⚠️ **Corrected 2026-08-16 — the earlier version of this bar was unmeasurable.** It said
`TilesSkippedAllSolid` would read 0 underground with no strate opted in. It will not: `ClassifyTile`
also proves `AllSolid` on its **hand-written** path (a bedrock-gap chunk sets `bCanAir = false`,
`VoxelGenerator.cpp` ~2835), which fires with nothing ticked at all. That is the same "~84 %" caveat
below, which the old bar quoted and then contradicted. The op-stack-only counters are zero **by
construction** — the cave branch returns `Mixed` at `UsesOperatorStackForChunk` — so they are the
ones that prove anything.
- **Never build.** Jahni builds; he has the editor open and it costs him real time. Say "ready to
build" and list likely compile-error spots.
- **`violations` 0 and the eight equivalences bit-identical** — the only non-negotiable results.
- **A bound used to skip work must be PROVED, not observed.** Use `VF_PerlinAbsBound` (= 1.5);
`FMath::Lerp(A,B,t)` spans `[min(A,B), max(A,B)]`, so a radius envelope is `max(Min,Max)`.
- **Never change `ClassifyTile`'s conditions or return values casually.** Every `return` there fails
safe to `Mixed`.
- **Never state a `.uasset` value from memory** (like `bUseOperatorStack`). Ask, or read it in the
editor. This sent a full day sideways.
- `FindSlotIndexForChunkZ` is **protected**; `GetStrateChunkZBounds` is the public equivalent.
- Push `experimental` freely. **Never push `main`.**
Interim answer if Jahni wants it before that lands: **Unreal Insights already shows this.** The trace
scopes `VoxelForge_ClassifyTile` and `VoxelForge_GenerateMesh` exist at the site; a skipped tile is a
`ClassifyTile` with no `GenerateMesh` after it. ⚠️ But ~84 % of tiles were *already* being rejected by
the hand-written SurfaceWorld/bedrock paths long before this work, so surface skips will drown the
cave ones — you must be **underground in an opted-in `TunnelNetwork` strate** for the number to mean
anything.
## 6. Two lessons that generalise beyond this plugin
### (b) Build `e002bd4` (VerticalShafts) and read ONE line
- **A signal that always says the same thing measures nothing.** A counter that can fire for two
reasons is not a measurement — splitting one such counter is what finally cracked T1.d after a day
of wrong inference. A warning that fires every run and always means "this is fine" is noise that
trains the reader to ignore warnings; one of those quietly worried Jahni for several sessions.
- **Verify the premise, and verify it completely.** Multiple confident chains reversed on checking
this month. Twice the failure was a *partial* read — grepping a symbol and reporting it as checked
for something else. A grep that finds a declaration has not checked its access specifier.
Everything before it is built and green.
## 7. The open question, which matters more than any of the above
> Build, run the `VoxelForge` filter, and read
> **`Box verdicts over 60 VerticalShafts tiles`**.
>
> **0 was the number for the whole project's life.** 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`. It now rebuilds the connectors the way `GetCells` does and
> tests the real capsules, with **Z exact** and XY conservative.
>
> - **Non-zero, and `violations` still 0** ⇒ it worked; record it and move on.
> - **Still 0** ⇒ the warning in that test names what to check **first**: `ExtraReach` inflates both
> remaining tests, so compare it against `ShaftMaxRadius` before touching either test. **Do not
> re-derive from scratch** — that is exactly what cost three rounds on TunnelNetwork.
## Then, in order
1. **PERF — the biggest open item, and now AIMED (2026-08-16). Read this before touching it.**
The op path is measurably slower; one cause was already found and fixed (the column memo
discarded itself every chunk). Three things were worked out since, all still **unmeasured**:
- **The A/B needs no new code.** `VoxelForge_ClassifyTile` and `VoxelForge_GenerateMesh` already
exist, and the world is deterministic, so two Insights traces — flag off, then on, same seed and
route — are a clean before/after.
- **But it is unreadable without task 001.** With the stack on, tiles get *skipped*, so
`GenerateMesh` runs fewer times; a total conflates "cheaper per tile" with "fewer tiles" and
those pull opposite ways. `TilesMeshed` is the denominator. **⇒ 001 is a PREREQUISITE here, not
a parallel item.** Order: 001 → traces → attribution → fix.
- **The suspects don't share an archetype**, so measure one ticked strate at a time.
*SurfaceWorld* = the hashed column memo. *TunnelNetwork* = **19 virtual calls per voxel**
(16 from `BuildTunnelNetworkStack` + 3 from `AppendStructuralPost`), plus the known 12× gate
re-test (stage B5's deliberate trade).
`CODEX-TASK-002` tests the SurfaceWorld suspect and **fixes nothing on purpose** — the derivation
says the direct-mapped 4096-slot table evicts ~25 % of columns *every Z plane* (the mesher
pre-samples **Z-outermost**), for a derived ~9× on column work. **Derived, not measured.**
**Measure before optimising** — the §C10 lesson, re-learned the hard way last session.
2. **The warp squeeze — PARKED with its ceiling measured, my recommendation is leave it.** The
`WARP SHARE` line says over half the remaining blocking is the query-box dilation, not geometry
(production: rooms 0.9 → 0.4, tunnels 2.4 → 1.1 with the dilation zeroed). The only remaining
route is proving `sup|Perlin3D|` down from the proved **1.5** toward its apparent ~1.01.1, worth
~27 % of the dilation. Spot-checking a grid is **not** a proof and a wrong sup is a hole.
**A negative result is already recorded so nobody repeats it:** bounding the warp *locally*
(evaluate at the box centre, shift, dilate by the variation) is **worse** — a rigorous per-axis
Lipschitz bound is `4·1.875 + 1 = 8.5` per unit cell, and `8.5 × 0.206` (the half-box in noise
units) `= 1.75` exceeds the global range bound of 1.5.
3. **`AUDIT §C9` library half** — `sinf`/`cosf` are not IEEE-754 specified, so MSVC's CRT and glibc's
libm can differ. Currently **0 samples within 1e-6 of the isosurface**, i.e. no measured risk. Run
`CrossPlatformDigest` on Linux, compare the SHAPE digest, pin it. The real fix if ever needed is a
deterministic in-house sin/cos.
4. ~~**`AUDIT §C2`'s remaining half**~~**✅ CLOSED, verified 2026-08-16. Do not re-open, and do
not spec a fix for it — I nearly did.** `OC_Chunk`, `BM_Chunk` and `TC_BiomeCache` all carry a
layout-version guard (`OC_Version` / `BM_Version` / `TC_SeenVersion`), `FChunkBiomeCache` has an
explicit `Invalidate()` that all four `thread_local` instances call on a version change, and the
only other two instances in the tree are **function-local**, so they cannot go stale. Recorded in
`AUDIT-2026-07.md §C2`. The live-edit half was fixed at the same time as the determinism half;
only this list was stale.
5. **Phase 3 — ops as data assets.** A design conversation, not a transcription. Don't start it
unprompted. What makes it possible is already in place: ops depend on capabilities
(`IVoxelBiomeField`), never on `UVoxelGenerator`.
## Debts — status changed, read this before acting on the old text
1. **"Box bounds read STRATE params but a per-room op can raise them" — DORMANT, not urgent.**
Checked rather than paid, and the check reversed the premise: when the source proves `Identity`
the twelve modifiers are `Identity` **soundly** (their `bNearCaveSurface` gate never opens, so no
room op can enable anything), and when it answers `Both` it supplies no `MaxCarveOverBox`, so the
default `FLT_MAX` kills every hypothesis regardless of what the modifiers claim. **It goes live
the day `FRoomGraphSource` gains a `MaxCarveOverBox`** — bounding the converter's `2·BaseDensity`
would make the modifiers' own numbers matter for the first time. Written at the site.
2. **`AUDIT §C2` — FIXED on the `switch` path.** `GetDensityWithParams` now takes **required**
`ParamsFingerprint` + `LayoutVersion`. Required, not defaulted, so a caller that forgets fails to
compile. The CRC is taken **once per chunk** where the params memo already lives (`CP_TunnelFP`) —
a `MemCrc32` per voxel on the hottest path would have been a real regression. Note the audit's own
suggested alternative ("add chunk Z to the key") is both insufficient (`Interleaved` makes `Alpha`
depend on chunk **XY** too) and destructive (chunk XY is deliberately absent so `WorldX ± 1`
gradient probes don't thrash the box — `ARCHITECTURE §8.10`).
## Hard rules that prevent real bugs
- **Density sign:** negative = solid at the mesher. Inside the op stack the convention is INTERNAL
(**positive = solid**), negated once by the caller. The SDF channel uses standard SDF convention.
- **`Identity` from the room source means `Sdf ≥ T`.** See the boxed invariant above. This is the
single most dangerous thing in the current code.
- **Never run both density paths in one world.** **Comparing them is legitimate** — §C10 is closed
since `FPSemantics = Precise`, and all eight equivalence tests compare bit for bit. They are
**port-correctness oracles**, not fidelity checks: §2.6.1 requires *same seed ⇒ same world on every
peer*, not resemblance to the pre-refactor world.
- **Every cache key includes `LayoutVersion` AND the params.** See §C2 and the overhang regression of
2026-07-27, where omitting the params silently deleted the overhang and only 1 sample in 20 000
crossed the isosurface.
- **A bound in a box verdict must be PROVED, not observed.** `|Perlin3D| ≤ 1.5` is derived from
`GradDot`'s two-distinct-axes form and the per-axis weighted bound of 0.5 — *not* from the header's
"~[-1,1]". Over-estimating costs CPU; under-estimating deletes collision.
**⚠️ USE `VF_PerlinAbsBound` — it is file-scope in `VoxelDensityOpStack.cpp` and it is the ONLY
copy. Never write a bare `1.0` for a noise amplitude in a reach.** `VoxelNoise::FBM` **normalises**
(`return Total / MaxValue`), so `sup|FBM| = sup|Perlin3D|` **exactly** — the octave sum neither
amplifies nor attenuates it, and an `FBM`-driven reach needs the same 1.5. This rule was written
*before* three `ExtraReach` formulas were found violating it (2026-08-16, fixed in `7dbdf51`):
VerticalShafts and Maze were unsound at their shipped defaults, FloatingIslands sound only because
its `SDFBlendRadius` happens to be large. **A rule stated in a doc is not a rule enforced in code**
when you add a reach, grep for `VF_PerlinAbsBound` and use it.
- `ProcessQueue` stays `EQueueMode::Mpsc`; `Epoch` carries through every async path; don't "optimize"
the `ARCHITECTURE §8.10` invariants.
- Commit per coherent unit with a real message. **`experimental` is pushed and tracked
(`origin/experimental`, since 2026-07-29) — keep it in sync. NEVER push `main`**, which stays the
known-good fallback at the commit it has always been. ⚠️ A pushed commit here is **not** a
"verified green" marker: the branch carries unbuilt work by design, and only `OPSTACK-PROGRESS.md`
says what was actually built.
- Update `CODEMAP §3`, `ARCHITECTURE §8`, tick `OPSTACK-PLAN`, append to `OPSTACK-PROGRESS.md`.
- **When inserting a class into `VoxelDensityOpStack.cpp` / `VoxelHeightOpStack.cpp`, put it ABOVE
the labelled end of the anonymous namespace.** Anchoring on the FACTORIES banner puts it outside,
and the brace added with it closes nothing. Made that mistake twice; both files say so.
- **Match the codebase's spelling of engine macros.** `KINDA_SMALL_NUMBER`, not
`UE_KINDA_SMALL_NUMBER` — the plugin uses the unprefixed form everywhere.
## Method lessons this refactor actually paid for
Ordered by how much they cost.
- **⭐ Instrument what you ASSUMED, not just what you changed.** This is the expensive one, learned
over four rounds in one session. The warp dilation — `CaveWarpStrength · VOXEL_NOISE_SCALE ·
PerlinAbsBound`, a constant chosen in the first commit — inflated a 10-voxel tile into a 50-voxel
query box, **125× the volume**. Four separate tightenings (the worm, the columns, the sampler, the
tunnel disjunction) were each individually correct and each landed *around* that untouched term.
The tunnel fix, predicted "an order of magnitude", delivered 25 % — **and the instrument said so,
and I credited the tunnels.** *When a fix under-delivers against its predicted size, suspect the
constant you never measured.*
- **Instrument before hypothesising.** §C10 cost six builds and five refuted hypotheses. In this
session the attribution line (`AllSolid killed by: …`) was written after *two* wrong guesses and
immediately named a third operator nobody had looked at. **A diagnostic that lists candidate causes
without measuring them is still a guess wearing rigour** — my "either the tiles straddle cave or
the source isn't reaching Identity" warning offered two causes and both were wrong.
- **Verify the premise before reasoning from it.** Six times now a confident chain rested on an
unchecked assumption and the check reversed it. Latest three: `RoomSpacing` was **42** (the fixture
overrides it) while I did three rounds of arithmetic with the header default of 80 — *the number
was printing in the report I kept quoting*; "the plugin bets on `|Perlin3D| ≤ 0.8`" was wrong (the
cache **rebuilds** when the warped query leaves the box, so that expansion is a perf heuristic);
and the per-room-op debt "must be paid first" was wrong (it is dormant). **Include the premises you
are confident enough about not to look up — especially a default, when a fixture exists whose whole
job is overriding defaults.**
- **A sampler must cover at least one period of what it samples.** The tunnel test drew tile XY from
**±32 voxels** with `RoomSpacing 80` and a guaranteed origin room at (0,0) — it measured the spine
hub and called it the world. The shaft test had the identical bug (±48 against `ShaftSpacing 55`).
Both now print their own extent **in units of the pattern's period**.
- **A test fixture tuned for coverage can be antagonistic to the thing you are measuring.**
`EnableTunnelFeatures` densifies (`RoomSpacing` 80→42, `RoomDensity` 0.35→0.85) so the equivalence
check isn't comparing solid rock to solid rock — and at that density the room cull radius *equals*
the lattice spacing, so **no box can ever be proved**. `0 proved` there is the correct answer. The
box verdict is therefore measured on **both** densities, and the dense run must stay at 0.
- **Diagnostics report THIS run; history goes in the log.** The test output had accumulated hardcoded
numbers from previous runs beside live ones ("32 of 34 tiles" printed while the live figure was 21
of 28). Unreadable, and self-inflicted.
- **Read the code, not the comment.** The cliff modifier's comment promises a sampled Z±1 gradient;
the code samples nothing and uses a Z-stretched Perlin it *calls* `VertGrad`. Ported as written —
and written down, so nobody "fixes" it from the comment.
- **A perf change can be a correctness change.** The column-memo optimisation silently deleted the
overhang; the tests caught it the same day. Invisible to inspection, and it produced plausible
terrain.
- **Coverage is a number, not a boolean.** Four related traps, each producing a green run that proved
almost nothing:
- *A test that prints nothing on success is indistinguishable from one that never ran.*
- *A guard that only trips at zero notices absence, it does not measure coverage.* Use fractions.
- *A success message that **asserts** coverage instead of reporting it reads as evidence while
measuring nothing.*
- *A check can be vacuous as well as a counter.* "Nothing leaked" is worthless unless something
happened.
- **Enabling a feature is not evidence it fired — ask the structure, not the output.** Setting
`PitDensity` did nothing (wrong struct). **Prefer the check that can fail for exactly one reason**
— and when a zero has several possible causes, give each one its own number.
- **An oracle that shares the defect under test proves nothing.** The stale-cache check compares each
stack against *itself evaluated alone*. (Since §C2 was fixed, the test call sites now pass a real
params fingerprint, so the original no longer shares the defect either.)
- **One definition, not two kept in sync.** `VF_BuildOpStackForChunk` exists because a tile skipped on
the verdict of a stack that is not the one producing its density is a hole. The same reasoning is
why `GetLastRoomBoxDiagnostic` **reads back** what the operator computed instead of letting the
test re-derive the criterion, and why the two-density tile scan is one lambda called twice.
- **Don't assert a number you want to improve.** Check 4 asserted `0 proved` — honest when written,
and it would have forbidden the entire T1.d gain. What it asserts now is that **no proved tile is
wrong** (brute force, every voxel); the proved count is *reported*.
**What do you want the world to *do* that it doesn't?** Three weeks went into a pipeline instead of
that question. Start there.
+464
View File
@@ -4134,3 +4134,467 @@ or reword that first.
**Neither approach solves VF-03's owner identity, boundary params conservatism, or the memo's
unproven benefit.** Stated plainly by Sol rather than glossed.
## 2026-08-17 (o) — Approach A + unambiguous bails + CP owner key LANDED on `experimental`
Jahni explicitly authorised writes, commits, and a push to the real `experimental` tree. The three
code changes below were landed there as separate commits. **Nothing in this entry was built,
compiled, run in the editor, automation-tested, or measured.** All checks were static source/diff
inspection plus `git diff --check`. The tree is ready for Jahni's build, not claimed green.
### `8295f6e` — six-box surface-column LRU + cave-only opt-in diagnostic
- Landed Approach A's six 81×81 direct-indexed boxes in
`FSurfaceColumnSource::GetColumn`. An acquisition miss recentres and clears one LRU victim; the
other five working sets stay warm. Exact `uint64 ColumnKey`, exact XY coverage, per-cell computed
flags, and fractional-XY direct computation are retained.
- Deliberately kept the recommended six boxes despite the estimated **~0.79 MiB TLS per worker**
versus ~0.13 MiB for one box. There is no same-route measurement supporting an arbitrary smaller
count; silently choosing one would trade unknown miss behaviour for memory without evidence.
- Applied the report's caveat before landing: `UVoxelStrateManager::Initialize` counts and lists
disabled **cave** slots only. `SurfaceWorld` is explicitly excluded because its exact-lattice
T1.d proof does not depend on `bUseOperatorStack`. The flag remains a real per-asset A/B switch;
no forced cutover from Approach B was taken.
- Likely compile-error/watch spots for this commit: MSVC/UE function-local TLS for the large nested
`FColumnCache`; aggregate initialization of six `FColumnBox` values; range-for/member lookup in
the local `Acquire`; `FMemory::Memzero` on the selected `Computed` array; the new `UE_LOG` format
arguments and direct `Slot.Definition->GeneratorType` access.
### `1e02c63` — the old `Cave Bail Not Op Stack` no longer means two things
- Replaced the ambiguous counter with four named sites: `Sole Slot` means the complete sampled tile
Z range is proved inside the disabled slot; `Boundary Tile` means that range crosses its bounds;
`No Layout` means the failed chunk has no resolvable slot; `Recheck` means the later exhaustive
XYZ guard failed after the Z pass had already accepted the cave slot.
- This is instrumentation only. The existing `UsesOperatorStackForChunk` predicate is still tested
at the same point, and every original `ClassifyTile` return point and return value is unchanged.
Slot bounds are queried only after that predicate has already failed, solely to choose a counter.
- Likely compile-error/watch spots for this commit: declaration/definition spelling for all four UE
stats; the long stat display names; `FloorDivC` visibility inside the diagnostic branch; and the
`GetStrateChunkZBounds(ChunkZ, Top, Bottom)` argument order. A source diff confirms no terrain
predicate or `EVoxelTileClass` return changed, but only a build can validate the stat macros.
### `f90c4e5` — VF-03's proved `CP_*` cache now has an owner identity
- Each `UVoxelGenerator` receives a monotonic process-unique `DensityCacheOwnerId` from a relaxed
atomic at construction. `GetDensityAt` now keys its function-static `thread_local CP_*` state by
`(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`. A world/generator change therefore refetches
params, generator type, disturbances, biome context, `CP_UseOpStack`, and the built op stack, and
invalidates `CP_BiomeCache` even when chunk/version values happen to match.
- Hot-path cost is one additional `uint64` comparison per voxel; atomic work happens only once per
generator construction. A monotonic ID was chosen instead of a raw owner pointer so a later
UObject address reuse cannot resurrect stale TLS state.
- Scope was deliberately restricted to the proved `CP_*` path. No claim was made and no key was
added to `OC_*`, `BM_*`, passage, biome, diff, or op-local caches. The test fixture's process-unique
layout-version bumps remain as conservative isolation for those unaudited caches, with its stale
CP-specific explanation corrected.
- Likely compile-error/watch spots for this commit: UHT/generated-constructor compatibility with the
explicit `UVoxelGenerator()` declaration; MSVC/UE support for `<atomic>`, `std::atomic<uint64>`,
`fetch_add`, and `std::memory_order_relaxed`; and initialization/access of the new private,
non-UPROPERTY `DensityCacheOwnerId` from the `.cpp` constructor and const hot path.
### Deliberately still open
- Read the actual `.uasset` opt-in state in the editor and fly the same underground route. The new
`Sole Slot` versus `Boundary Tile` counters make that result interpretable; source cannot answer
asset state.
- Measure the six-box memo on the same seed/route/settings/warm-up and record both miss rate per tile
and process memory at the real worker count. The verified structural improvement is **not** yet a
measured performance win.
- Audit owner identity for the other TLS caches separately. This change does not promote VF-03's
unproved breadth into fact.
- Boundary slot/params conservatism and every `ClassifyTile` safety guard remain unchanged.
### Candidate worktree cleanup
The dirty candidate diffs were preserved as named, recoverable stashes before cleanup:
- `c5e067c3` (currently `stash@{1}`) — `archive VF approach A before worktree cleanup 2026-08-17`
- `3be0b558` (currently `stash@{0}`) — `archive VF approach B before worktree cleanup 2026-08-17`
`../VF-approach-A` and `../VF-approach-B` were then removed from Git's worktree list and their
directories removed. Stashing was a cleanup-safety deviation only; it did not alter the landed code.
## 2026-08-16 (o) — build break in the six-box LRU: `Box` used after its scope closed
`VoxelDensityOpStack.cpp(832): error C2065: 'Box' : identificateur non déclaré`
Sol's single-box → six-box refactor changed the acquisition to
`FColumnBox& Box = Cache.Acquire(...)` **inside** `if (bIntegerXY)`, but the `Computed` flag is only
set *after* the column is computed, further down and **outside** that block. The old single-box
version had `Box` at function scope, so the write-back compiled; the reference did not.
Fixed by hoisting `FColumnBox* AcquiredBox = nullptr;` beside `MemoColumn` and writing back through
it. The guard is now `if (AcquiredBox)` rather than `if (bIntegerXY)`**non-null ⇔ integer path**,
so the pointer proves its own safety instead of relying on two conditions staying in agreement.
Swept the file: the only other `Box.` uses (761763) are inside the `if` and in scope. Not a logic
error and not a caching change — a scope slip, invisible in review because the diff showed both
halves separately. Still unbuilt beyond this compile fix.
## 2026-08-16 (p) — the opt-in diagnostic was failing the test suite; and it answered T1.d
### The six-box LRU worked
Jahni: *"column memo miss is quite a fraction of what it used to be."* First qualitative
confirmation that the LRU port was the right call — the single-box version wiped 6561 cells on every
recenter. **Still owed a same-seed/same-route number before it counts as measured.**
### The diagnostic broke the tests, for doing its job
Sol's opt-in warning logs at `Warning`. UE's automation framework counts a `Warning` as a failure,
and the `Determinism.*` tests **deliberately build a non-opted-in world** — that world is their
comparison oracle, the baseline the `OpStack.*` numbers are measured against. So the diagnostic was
failing the suite by correctly reporting an intentional configuration.
Fixed: verbosity is now `Log` when `GIsAutomationTesting`, `Warning` otherwise. The message is
written **once** and only the verbosity branches (`FString::Printf` then log `%s`) — four copies of a
long format string across two sites would have been the "one definition, not two kept in sync"
failure this project already has rules about. `CoreGlobals.h` added explicitly for
`GIsAutomationTesting` rather than relying on transitivity, since there is no shared PCH.
**Answer to "can I set the tests to use the op stack?" — no, and it must stay that way.** The
`Determinism.*` fixture being non-opted-in is not an oversight; it is the oracle. `OpStack.*` runs the
opted-in variant. Flipping the fixture would delete the comparison that gives every T1.d number its
meaning.
### ⚠️ AND THE DIAGNOSTIC ANSWERED T1.d — pending one check in the GAME log
```
7/7 cave layout slots have Use Operator Stack disabled
cave slot=0..3, 5..7 bUseOperatorStack=false
```
Slot 4 is absent, which is Sol's SurfaceWorld narrowing working as intended.
Those slots are the **test fixture** (`name=''` ⇒ transient objects). **The line that matters is the
same log from the real game.** If it also reports slots disabled, then the belief recorded on
2026-08-16 (h) — *"the data assets in game have the switch on"* — is **wrong**, and T1.d has never
been enabled in production at all. That single log line settles what a day of counter-reading could
not, and it costs nothing to read.
⇒ **Next action for Jahni: open the game (not the tests) and search the log for
`Operator-stack opt-in`.** Whatever it says is the ground truth, and it supersedes both my earlier
inference from `Cave Bail Not Op Stack` and the recorded belief about the assets.
## 2026-08-16 (q) — suite green again, and the FIELD digest proves nothing moved
14/14 pass, no `StrateManager` warnings in the output — the verbosity fix worked and the diagnostic
no longer fails the suite it was meant to inform.
### The result that matters: every number is IDENTICAL to the pre-change run
```
SHAPE digest 0xAC0294F1C6DD354C FIELD digest 0xF62F3D355B1C0BDB (both unchanged)
ClassifyTile Mixed 472 / AllSolid 70 / AllAir 58 (unchanged)
OpStack ClassifyTile Mixed 541 / AllSolid 46 / AllAir 13 (unchanged)
Shafts 30/60 · Maze 27/60 · Slab 45,43,32/60 · Islands 9 AllAir (unchanged)
Tunnel [production] 11 proved / 14641 voxels / 0 violations (unchanged)
all eight equivalences bit-identical · 0 violations everywhere
```
**The `FIELD` digest is the strongest evidence available here** — a bit-for-bit hash over 115,000
samples of the whole field. It is unchanged, so the six-box LRU, the `DensityCacheOwnerId` key, the
bail re-attribution and the scope fix **did not move a single voxel between them.** Four changes to
caching, keying and diagnostics, and the world is byte-for-byte the same. That is precisely the
contract each of them claimed, now tested rather than asserted.
⇒ Caching changes are validated. **Their PERFORMANCE benefit is still unmeasured** — Jahni's
"quite a fraction of what it used to be" on the column memo is a real observation but not a
same-seed/same-route number.
### The one thing still open, and it costs a log search
**`Operator-stack opt-in` in the GAME's log.** The fixture reports 7/7 cave slots disabled, which is
expected there. Whether the *real* world says the same is the ground truth for T1.d, and it
supersedes both my inference from `Cave Bail Not Op Stack` and the belief recorded in (h). Nothing
else should be built or reasoned about T1.d until that line is read.
## 2026-08-16 (r) — ⛔ T1.d's BLOCKER FOUND: out-of-layout chunks are misrouted into the cave branch
In-game `stat VoxelForge`, flying at full speed, with the split bail counters:
```
Tiles Classified 1.90 Tiles Meshed 1.90 ← nothing skipped
Cave Bail Not Op Stack No Layout 1.58 (83% of classified)
Cave Bail Stack Verdict 0.27
Cave Bail Params 0.05
Cave Bail Not Op Stack SoleSlot / BoundaryTile / MixedContent ← NEVER FIRED
```
**Jahni was right and I was wrong to doubt the assets.** `SoleSlot` never fires, so every strate he
flies is genuinely opted in. `BoundaryTile` never fires either, so the neighbour theory is dead too.
Sol's four-way split earned its keep on its first flight: the old lumped counter said "not op stack"
and would have sent us at the assets again.
### The mechanism, read out of the code
Two functions disagree about what a chunk outside the strate stack *is*:
```cpp
// GetGeneratorTypeForChunk — outside all strates (above or below) →
return ECaveGeneratorType::TunnelNetwork; // "the fallback density path produces solid rock anyway"
// IsGapChunk — above the top strate or below the bottom →
return false; // "open air, NOT a gap"
```
So for any chunk **above the top strate** (`TopChunkZ = 0`, i.e. all of the open air above the
world) `ClassifyTile` sees: not a gap, not SurfaceWorld ⇒ **cave archetype**. It enters the cave
branch, calls `UsesOperatorStackForChunk`, `FindSlotIndexForChunkZ` returns 1, and it bails.
**Every tile touching open air is being classified as a TunnelNetwork cave tile whose strate has the
flag off.** That is 83 % of tiles on his route, and it is why `Tiles Classified == Tiles Meshed`.
⚠️ **Note the two comments contradict each other** — one calls out-of-layout "solid rock via the
fallback", the other calls above-stack "open air". **Do not guess which is true**; the correct verdict
for those tiles depends on it, and guessing here writes a hole. Read `GetDensityAt`'s no-slot path
before choosing.
### Why this is the whole T1.d story
The cave branch was never reached for the tiles that dominate a normal flight. T1.d has not been
failing — it has been **unreachable**, and for a reason that has nothing to do with the operator
stack, the box verdicts, or the asset flags. All of that machinery is correct and tested; it was
sitting behind a routing mistake in the classifier's archetype lookup.
**Next step (not taken — out of budget, and it needs the density question answered first):** make
out-of-layout chunks their own category in `ClassifyTile` rather than borrowing `TunnelNetwork`.
If open air above the stack is provably uniform, those tiles become an `AllAir`/`AllSolid` verdict
and T1.d finally fires on the majority of a surface flight.
## 2026-08-16 (s) — T1.d BLOCKER FIXED: out-of-layout is constant AIR, not a cave archetype
The contradictory comments are settled by reading `GetGenerationParams`:
```cpp
// If outside all strates, return negative density → guaranteed air.
if (SlotIdx < 0) {
Empty.BaseDensity = -1.0f; Empty.WormStrength = 0.0f; Empty.RoomDensity = 0.0f;
```
**`GetGeneratorTypeForChunk`'s comment ("the fallback density path will produce solid rock
anyway") is FALSE.** Out-of-layout is a **constant air field** — no rooms, no worms, nothing to
carve or fill it. `IsGapChunk`'s "open air, NOT a gap" was the correct one.
### The fix
`ClassifyTile` gains a fourth Z category (`MemoCat = 3`) for chunks with no layout slot:
- sets `bAnyNonCave = true`**never enters the cave branch**, which is where all 83 % were dying;
- sets `bCanSolid = false` ⇒ the AllSolid hypothesis dies, **AllAir survives**;
- bails to `Mixed` if a disturbance could *add* rock there (`BridgeDensity`/`RidgeDensity` > 0) —
chasms only carve, so they cannot threaten an air verdict.
A tile entirely above the stack now resolves: no cave block, no column scan (`NumSlots == 0`),
`bCanSolid = false`, `bCanAir = true`**`AllAir`, skipped.** That is the majority of a surface
flight, and it is a class of tile T1.d has never once been able to prove.
### What to read after the build
- **`Cave Bail Not Op Stack No Layout` should collapse to ~0** — those tiles no longer reach the
cave branch at all.
- **`Tiles Skipped All Air` should rise sharply**, and `Tiles Meshed` should fall below
`Tiles Classified` for the first time in the game.
- ⚠️ **`violations` must stay 0** in every test, and the eight equivalences must stay bit-identical:
this changes only the *classifier*, never a density value.
Compile-risk spots: `FindSlotIndexForChunkZ` is public (`VoxelStrateManager.h:351`, verified);
`FStrateDisturbanceParams` is already used later in the same function; the new `else if` sits before
the surface `else`, so `Slots[MemoSlotIdx]` is never dereferenced for the new category.
## 2026-08-16 (t) — build break: `FindSlotIndexForChunkZ` is protected, not public
`VoxelGenerator.cpp(2796): C2248 — cannot access protected member`.
I wrote "`FindSlotIndexForChunkZ` is public (`VoxelStrateManager.h:351`, verified)" in the previous
entry. **It is `protected`.** The grep I called verification printed the declaration line and not the
access specifier above it — I checked that the symbol existed and reported it as checked for
something else. Exactly the "assert a negative from a partial read" mistake made earlier today with
the test fixture, in the same shape.
**Fixed without widening the API:** `GetStrateChunkZBounds` is `public:` (line 177), returns false
for precisely the no-slot case, and this same function already calls it three times for the same
question. A small `VF_ChunkZHasSlot` lambda wraps it so the `else if` reads as the predicate it is.
No accessor promoted, no new surface.
## 2026-08-16 (u) — ✅✅ T1.d FIRES IN PRODUCTION. 39% of tiles skipped, measured in game.
```
BEFORE (a2c5e02^) AFTER
Tiles Classified 1.90 1.88
Tiles Meshed 1.90 1.15
Tiles Skipped All Air (absent) 0.73
Cave Bail Not Op Stack NoLayout 1.58 (GONE)
Cave Bail Mixed Content (absent) 0.77
```
`1.15 + 0.73 = 1.88` — the accounting closes exactly.
**39 % of classified tiles are now skipped, against 0 % this morning.** Each skipped tile avoids
~43 000 density evaluations plus marching cubes. This is the first time in the project's history
that tile-skipping has been observed in the running game rather than in an automation harness.
`Cave Bail Not Op Stack No Layout` is gone: those tiles now either resolve `AllAir` or attribute to
`Cave Bail Mixed Content` (0.77) — genuinely straddling open air and something else, a correct
conservative `Mixed` rather than a misrouting.
### What is NOT yet firing, stated plainly
`Tiles Skipped All Solid`, `TilesOpStackSolid` and `TilesOpStackAir` are still absent. **The cave
branch still produces no verdicts in game** — its attempts now land in `Cave Bail Mixed Content`
(0.77) and `Cave Bail Stack Verdict` (0.32). So the win measured here is the **open-air** class,
proved by the classifier's own Z categories; the operator stack's `ClassifyBox` has still never
returned a verdict in production. That remains the open item, and the counters now name where to
look: tiles straddling content boundaries, and boxes the stack legitimately cannot prove.
### How this was found, worth keeping
Every earlier attempt reasoned from a number that could mean four things. It broke open when Sol's
four-way split of `Cave Bail Not Op Stack` showed `SoleSlot` and `BoundaryTile` at **zero** — which
proved the assets were correctly ticked (Jahni said so twice; I doubted it twice) and pointed at
`NoLayout`, a cause nobody had considered. *A counter that can fire for two reasons is not a
measurement.*
## 2026-08-17 (v) — mixed-content split + guarded category fold LANDED, awaiting Jahni's build
**Nothing in this entry was built, compiled, run in the editor, automation-tested, or measured.**
The only validation was static source/diff inspection and `git diff --check`. All rates below are
instructions for the next flight, not claimed results.
### The old 0.77 bucket is split before changing its guard
`Cave Bail Mixed Content` had three call sites and four meanings. It is replaced by:
- `Cave Mix Out Of Layout` — a tile contains one cave slot plus constant-air outside the layout;
- `Cave Mix Gap` — one cave slot plus solid inter-strate bedrock;
- `Cave Mix Surface World` — one cave slot plus a SurfaceWorld slot;
- `Cave Bail Two Cave Slots` — a second cave slot was found; this remains an immediate `Mixed` bail.
The first three are deliberately **presence counters**, incremented once per present category per
tile before the later stack guards. They are not exclusive for an unusually tall tile, so they are
not expected to sum to the old 0.77; each answers one unambiguous yes/no question. This placement is
why the diagnostic survives the fold: a tile can increment `Cave Mix Out Of Layout` and later be
proved `AllAir`. `Two Cave Slots` is still a bail counter because that condition was not relaxed.
The split was applied first with the original `return Mixed` intact. Only after that
behavior-preserving split was inspected was the return deliberately replaced by the fold below.
### Decision: relax one-category, NOT one-cave-slot
Folding is safe, but only in the narrow form suggested by the code:
- Gap already contributes a proved `solid` hypothesis, out-of-layout a proved `air` hypothesis,
and SurfaceWorld classifies its exact mesher lattice through `ComputeSurfaceColumn`.
- The one cave slot records the minimum/maximum **sampled cave Z**. `ClassifyBox` receives that cave
Z sub-box with the tile's full XY extent; asking it about the previous full tile box would model
gap/surface/out-of-layout with cave params and is not valid.
- Opt-in, generator type, and bit-identical params are still checked for **every chunk intersecting
that cave sub-box in XYZ**. They were narrowed in Z only to exclude categories represented by
their own hypotheses; none was removed or weakened.
- The stack verdict folds into the existing `bCanSolid` / `bCanAir`, then SurfaceWorld's exact
column scan still runs. `Tiles Operator Stack Solid/Air` now increments only for the final
non-`Mixed` tile verdict, after all categories have agreed.
The independent **one-cave-slot requirement cannot be relaxed by this fold**. A second cave slot can
carry a different archetype, opt-in, params, and stack; proving it would require building and
guarding a second stack, not stretching the first. `Cave Bail Two Cave Slots` therefore still
returns `Mixed`. Category folding is compatible with the generator-type and bit-identical-param
guards precisely because those guards remain exhaustive over the sole cave sub-box.
`Cave Bail Fold Conflict` was added for a proved cave verdict that contradicts an already proved
category (for example cave=solid plus out-of-layout=air). That is distinct from `Cave Bail Stack
Verdict` (the stack itself could not prove its box) and `Cave Bail Disturbance` (a post-stack feature
killed the remaining hypothesis).
### Exact next-build / next-flight readout
Read these counters together on the same underground route:
1. `Cave Mix Out Of Layout`, `Cave Mix Gap`, `Cave Mix Surface World`, and
`Cave Bail Two Cave Slots` — whichever is largest names the dominant old mixed-content cause.
The first three are presence counts and may overlap; `Cave Bail Mixed Content` should be absent.
2. `Tiles Operator Stack Air` and `Tiles Operator Stack Solid` — either becoming non-zero proves a
cave stack participated in a **final** skip verdict. For the measured cave+open-air population,
the expected success signal is primarily `Tiles Operator Stack Air`.
3. `Tiles Skipped All Air`, `Tiles Skipped All Solid`, `Tiles Meshed`, and `Tiles Classified` — the
accounting must still close: `Meshed + SkippedAllAir + SkippedAllSolid = Classified`. A fold win
raises a skipped counter and lowers `Tiles Meshed`; the pre-fold baseline was 1.15 meshed,
0.73 skipped-air, 1.88 classified.
4. `Cave Bail Fold Conflict`, `Cave Bail Stack Verdict`, `Cave Bail Params`,
`Cave Bail Disturbance`, and `Cave Bail Two Cave Slots` — these explain mixed tiles that remain.
In particular, a high `Fold Conflict` means the dominant mix is genuinely heterogeneous rather
than blocked merely by the former one-category policy.
After Jahni runs the suite, every brute-force `violations` count must remain zero and all eight
operator-stack equivalences must remain bit-identical. Those are correctness requirements, not
results from this unbuilt change.
### Likely compile-error / review watch spots
- declaration/definition/use spelling for the five new UE stats, especially
`STAT_VoxelForgeCaveBailFoldConflict` and `STAT_VoxelForgeCaveBailTwoCaveSlots`;
- the `MAX_int32` / `MIN_int32` cave-extrema initializers and `FMath::Min/Max(int32)` assignments to
`CaveMinZ` / `CaveMaxZ`;
- construction of `FBox CaveBox` from the cave-only integer Z extrema;
- control flow after removing the cave branch's final return: SurfaceWorld columns must run before
the shared final verdict, and `bAnyCave` must imply initialized cave extrema (established in the
same `MemoCat == 2` iteration);
- no protected layout accessor was introduced: the code still uses public
`GetStrateChunkZBounds`, never `FindSlotIndexForChunkZ`.
## 2026-08-16 (v) — the permanent "warning" was noise, and it cost Jahni several sessions of worry
Jahni: *"[this] have been in a 'warning' stage, is this fine ? is everything fine ?"*
**Everything was fine.** The only `Warning:` in that whole run is the tunnel test's
`[dense fixture] No tile was proved`, whose own text says *"On the DENSE FIXTURE this is the EXPECTED
and correct result, not a defect."* The test reports Success; density is bit-identical; every
coverage number is non-zero; `violations` is 0; and the `[production defaults]` run directly below
reports **11 proved / 14641 voxels / 0 violations**.
**But a warning that fires on every run and always means "this is fine" is not a warning — it is
noise that trains the reader to ignore warnings.** It sat there for several sessions and made him
uneasy about a green suite. That is a real cost, and it came from writing diagnostics for a reader
who already had the context.
Fixed: `RunTileScan` takes `bZeroProvedIsExpected`. On the **dense fixture** 0 proved is the only
arithmetically possible answer (cull spheres cover that world ~3.6×) ⇒ `AddInfo`. On **production
defaults** 0 proved would be a genuine regression from today's 11 ⇒ still `AddWarning`. Same message,
same information, but the severity now carries meaning.
**General rule for this project's diagnostics:** if a line cannot distinguish a good run from a bad
one, it must not be logged at a severity that implies something is wrong. The same defect as the
lumped bail counter, one layer out: *a signal that always says the same thing measures nothing.*
## 2026-08-16 (w) — CLOSED. The operator stack is declared done; direction changes to visible work.
Jahni built `4d33321` + `91585ea` and reports the game looks fine. He asked for the handoff to be
updated and the thread closed.
**`OPSTACK-HANDOFF.md` fully rewritten in plain language.** The previous version had become a private
dialect — his words: *"you've started using very unique terminology, i have NO idea what you've been
doing."* That is a failure of the document, not of the reader. The new one leads with what the op
stack was for, states plainly that Phase 3 was never built and is not being built, carries the honest
three-week ledger, and includes a translation table for the jargon (`T1.d`, `box verdict`, `Mixed`,
`violations`).
### Final state
- 8/8 archetypes live and bit-identical (115 000-sample field digest unchanged, eight equivalences).
- 14/14 tests green through `871ca19`, `violations` 0 everywhere.
- **39 % of tiles skipped in the running game**, from 0 % that morning.
⚠️ **`4d33321` (boundary fold) and `91585ea` (warning demotion) are built but NOT re-verified** — the
suite has not been re-run and the counters not re-read since. Recorded in the handoff with the exact
two checks and the instruction to `git revert 4d33321` if either fails; the 39 % does not depend on
it.
### Direction, agreed
**No Phase 3. No further refactoring.** The old `switch` stays as the oracle. Next work is whatever
Jahni can see — `fable-idea.md`, F7 set-pieces, F9 audio. The handoff closes on the question that
should have been asked in July: *what do you want the world to do that it doesn't?*
This log stays append-only and stays the record of what was actually built. It ends here for the
operator stack.
@@ -1107,7 +1107,8 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
// seule ne dit rien de la production. Copier-coller le balayage aurait donné deux critères qui
// divergent ; c'est un paramètre, pas un doublon.
auto RunTileScan = [&](const FVoxelOpStack& S, const FStrateGenerationParams& TP,
const FVoxelOpContext& TCtx, const TCHAR* Label)
const FVoxelOpContext& TCtx, const TCHAR* Label,
bool bZeroProvedIsExpected)
{
int32 NumProved = 0, NumMixed = 0, NumSolid = 0, NumAir = 0;
int32 NumBruteSamples = 0, NumViolations = 0;
@@ -1290,7 +1291,17 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
if (NumProved == 0)
{
AddWarning(FString::Printf(
// ⚠️ UN AVERTISSEMENT QUI SE DÉCLENCHE À CHAQUE RUN ET VEUT DIRE « tout va bien »
// N'EST PAS UN AVERTISSEMENT — c'est du bruit qui apprend à ignorer les vrais.
// Sur la fixture dense, 0 prouvé est la SEULE réponse arithmétiquement possible
// (les sphères de cull couvrent ce monde 3,6x) : c'est une info. En production
// défauts, 0 prouvé serait une VRAIE régression (11 aujourd'hui) : ça reste un
// avertissement.
// A warning that fires every run and always means "this is fine" is noise that
// trains the reader to ignore warnings. Zero proved is the only possible answer on
// the dense fixture (info); on production defaults it would be a real regression
// from 11 (warning).
const FString ZeroMsg = FString::Printf(
TEXT("[%s] No tile was proved, so the brute force verified nothing -- it has no ")
TEXT("verdict to contradict. Do NOT re-derive the cause: read the two lines above, ")
TEXT("which name the operator and then the primitive class. ⚠️ On the DENSE ")
@@ -1299,7 +1310,10 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
TEXT("occupancy, so cull spheres cover that world ~3.6x over and no box can be ")
TEXT("outside all of them. It is the 'production defaults' run that answers ")
TEXT("whether real worlds have skippable rock."),
Label));
Label);
if (bZeroProvedIsExpected) { AddInfo(ZeroMsg); }
else { AddWarning(ZeroMsg); }
}
TestEqual(FString::Printf(
@@ -1336,7 +1350,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
// something, and that same densification makes tile-proving structurally impossible (room cull
// radius ~= the lattice spacing, at 85% occupancy). Both are measured and both are brute-forced;
// the dense one reporting ~0 is the correct answer, not a failure.
RunTileScan(Stack, P, Ctx, TEXT("dense fixture"));
RunTileScan(Stack, P, Ctx, TEXT("dense fixture"), /*bZeroProvedIsExpected*/ true);
{
FStrateGenerationParams SparseP = P;
@@ -1352,7 +1366,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
// et c'est exactement ce que le contrôle 3 vérifie : l'empreinte de params est dans la clé,
// donc l'une ne peut pas se servir les salles de l'autre. Le jour où ce contrôle tombe,
// cette ligne-ci devient fausse en même temps — elles se surveillent mutuellement.
RunTileScan(SparseStack, SparseP, Ctx, TEXT("production defaults"));
RunTileScan(SparseStack, SparseP, Ctx, TEXT("production defaults"), /*bZeroProvedIsExpected*/ false);
}
//=========================================================================
@@ -129,22 +129,20 @@ namespace VoxelForgeTest
// elle n'était jusqu'ici masquée que par un accident.
//
// `PassagesVersion` est PAR INSTANCE et part de 0, donc deux `FTestWorld` successifs
// rendaient tous les deux **1**. Or les caches par chunk de `GetDensityAt` sont clés sur
// `(ChunkCoord, LayoutVersion)` : deux mondes différents, même version, même chunk ⇒ le
// second se voit servir les params — ET le drapeau `CP_UseOpStack` — du premier.
// Personne ne l'a vu parce que `bUseOperatorStack` valait false partout : les deux
// mondes étaient d'accord par défaut. Le premier monde qui coche la case fait tomber
// cette coïncidence, dans les DEUX sens (il contamine, et il est contaminé).
// rendaient tous les deux **1**. Historiquement, les caches `CP_*` de `GetDensityAt`
// n'avaient que `(ChunkCoord, LayoutVersion)` et le second monde pouvait hériter les
// params — ET `CP_UseOpStack` — du premier. `DensityCacheOwnerId` ferme maintenant CE
// chemin prouvé. Les bumps restent ici comme isolation conservatrice des autres caches
// TLS que cette correction n'a volontairement pas audités ni modifiés.
//
// Un compteur de processus donne à chaque monde une version distincte, donc tout cache
// survivant d'un test à l'autre est forcément invalidé. `Initialize` est déterministe
// (le pool est mélangé par le seed, les fixed strates sont épinglées), donc le rappeler
// ne change pas le layout — seulement le compteur.
//
// Each test world gets a process-unique LayoutVersion. Two worlds both reporting 1 made
// GetDensityAt's per-chunk caches serve the previous world's params — and its
// CP_UseOpStack flag — for the same chunk coord. Invisible while every world agreed that
// the flag was false.
// Each test world still gets a process-unique LayoutVersion. DensityCacheOwnerId now
// prevents the proved CP_* cross-world reuse directly; the version bumps remain as
// conservative isolation for other TLS caches not audited or changed by that fix.
static int32 GWorldSerial = 0;
const int32 Bumps = ++GWorldSerial;
for (int32 b = 0; b < Bumps; ++b)
@@ -661,30 +661,77 @@ namespace
}
/** La colonne complète, exactement les cinq sorties de `ComputeSurfaceColumn`.
* Le mémo est une boîte à index direct, comme `FSurfaceColumnBox` : la pile évalue tous
* les Z d'une colonne au même XY, donc l'overhang lit la MÊME colonne que la source, par
* construction plutôt que par convention. */
* Le mémo est un LRU spatial de six boîtes à index direct, comme `GSurfColCache` : la
* pile évalue tous les Z d'une colonne au même XY, donc l'overhang lit la MÊME colonne
* que la source, par construction plutôt que par convention.
*
* The memo is a six-box spatial LRU with direct XY indexing, matching `GSurfColCache`.
* Six boxes retain interleaved strate regions at the cost of roughly 0.79 MiB of TLS for
* the five-float column payload plus one computed flag per cell, before compiler padding. */
struct FColumn { float TerrainZ, CeilSurf, OverhangAmp, DirX, DirY; };
const FColumn& GetColumn(float WorldX, float WorldY) const
{
// Même schéma éprouvé que `GSurfColCache` : index direct dans une boîte XY, puis un
// drapeau `Computed` par cellule. Une tuile MC pleine résolution demande
// (CHUNK_SIZE + 3)² = 35×35 = 1225 colonnes (anneau de marge inclus) ; cette boîte
// de Dim×Dim, recentrée sur le premier échantillon, les garde toutes sans collision.
// Same proven scheme as `GSurfColCache`: direct XY indexing plus one `Computed` flag per
// cell. A full-resolution MC tile needs 35×35 = 1225 columns including its margin ring;
// the box is sized so one tile fits without eviction.
// Même schéma éprouvé que `GSurfColCache` : six boîtes à index direct dans XY, chacune
// avec un drapeau `Computed` par cellule et une clé uint64 exacte. Une tuile MC pleine
// résolution demande 35×35 = 1225 colonnes (anneau de marge inclus) ; une boîte de
// Dim×Dim, recentrée sur le premier échantillon, les garde toutes sans éviction.
// Same proven scheme as `GSurfColCache`: six direct-indexed XY boxes, each with one
// `Computed` flag per cell and an exact uint64 key. A full-resolution MC tile needs
// 35×35 = 1225 columns including its margin ring; one Dim×Dim box holds that tile.
struct FColumnBox
{
enum : int32 { Halo = CHUNK_SIZE + 8, Dim = 2 * Halo + 1 };
int32 BaseX = 0, BaseY = 0;
uint64 Key = 0; // strate + layout + seed + ParamsFingerprint
uint32 LastUse = 0; // LRU stamp
bool bValid = false;
FColumn Cols[Dim * Dim];
bool Computed[Dim * Dim];
};
thread_local FColumnBox Box = {};
struct FColumnCache
{
enum : int32 { NumBoxes = 6 };
FColumnBox Boxes[NumBoxes];
uint32 Clock = 0;
// Hit exact : clé complète + couverture XY complète. En cas de miss, seul le
// victim LRU est recentré et invalidé ; les cinq autres boîtes restent chaudes.
// Exact hit: full key + full XY coverage. On a miss, only the LRU victim is
// recentered and invalidated; the other five boxes remain warm.
FColumnBox& Acquire(int32 IX, int32 IY, uint64 InColumnKey)
{
++Clock;
for (FColumnBox& B : Boxes)
{
if (B.bValid && B.Key == InColumnKey
&& IX >= B.BaseX && IX < B.BaseX + FColumnBox::Dim
&& IY >= B.BaseY && IY < B.BaseY + FColumnBox::Dim)
{
B.LastUse = Clock;
return B;
}
}
// Miss d'acquisition : évincer/recentrer une seule boîte, jamais tout le cache.
// Acquisition miss: evict/recenter one box only, never the whole cache.
FColumnBox* Victim = &Boxes[0];
for (FColumnBox& B : Boxes)
{
if (B.LastUse < Victim->LastUse) Victim = &B;
}
Victim->BaseX = IX - FColumnBox::Halo;
Victim->BaseY = IY - FColumnBox::Halo;
Victim->Key = InColumnKey;
Victim->LastUse = Clock;
Victim->bValid = true;
FMemory::Memzero(Victim->Computed, sizeof(Victim->Computed));
return *Victim;
}
};
thread_local FColumnCache Cache = {};
thread_local FColumn DirectColumn = {};
// The production mesher and the exact-lattice classifier use integer XY. Fractional
@@ -693,6 +740,11 @@ namespace
const bool bIntegerXY = WorldX == FMath::FloorToFloat(WorldX)
&& WorldY == FMath::FloorToFloat(WorldY);
FColumn* MemoColumn = &DirectColumn;
// ⚠️ La boîte acquise doit survivre au `if` : le drapeau `Computed` n'est posé qu'APRÈS
// le calcul, plus bas, hors de cette portée. Non nul ⇔ chemin XY entier.
// The acquired box must outlive the `if`: the `Computed` flag is only set AFTER the
// column is computed, further down and outside this scope. Non-null <=> integer path.
FColumnBox* AcquiredBox = nullptr;
int32 CI = 0;
bool bNeedsCompute = true;
@@ -701,18 +753,10 @@ namespace
const int32 IX = (int32)WorldX;
const int32 IY = (int32)WorldY;
// Bounds are checked exactly before deriving CI; the index itself is the XY key.
// Les bornes sont vérifiées exactement avant CI : l'index EST la clé XY.
if (!Box.bValid || Box.Key != ColumnKey
|| IX < Box.BaseX || IX >= Box.BaseX + FColumnBox::Dim
|| IY < Box.BaseY || IY >= Box.BaseY + FColumnBox::Dim)
{
Box.BaseX = IX - FColumnBox::Halo;
Box.BaseY = IY - FColumnBox::Halo;
Box.Key = ColumnKey;
Box.bValid = true;
FMemory::Memzero(Box.Computed, sizeof(Box.Computed));
}
// Acquire vérifie la clé uint64 complète et les bornes exactes avant de dériver CI.
// Acquire checks the exact uint64 key and exact bounds before deriving CI.
FColumnBox& Box = Cache.Acquire(IX, IY, ColumnKey);
AcquiredBox = &Box;
CI = (IY - Box.BaseY) * FColumnBox::Dim + (IX - Box.BaseX);
MemoColumn = &Box.Cols[CI];
@@ -791,7 +835,7 @@ namespace
if (Slope > KINDA_SMALL_NUMBER) { C.DirX = GX / Slope; C.DirY = GY / Slope; }
}
if (bIntegerXY) { Box.Computed[CI] = true; }
if (AcquiredBox) { AcquiredBox->Computed[CI] = true; }
}
return *MemoColumn;
}
@@ -830,11 +874,13 @@ namespace
* c'est-à-dire à chaque chunk. Résultat : une strate haute de 4 chunks recalculait ses
* colonnes **4 fois**, resamples du cliff compris. Le chemin d'origine ne fait pas ça
* `GSurfColCache` est clé sur `(boîte XY, StrateKey, Seed, LayoutVersion)` **SANS ChunkZ**,
* délibérément, « shared down the whole vertical strate stack ».
* délibérément, « shared down the whole vertical strate stack ». Cette pile reprend la
* même identité de strate/layout/seed, en ajoutant l'empreinte obligatoire des params pour
* protéger ses sorties propres ; son mémo est maintenant un LRU spatial de six boîtes.
*
* Donc la clé devient la même identité : ce qui rend deux colonnes interchangeables, c'est
* la STRATE et la version de layout, pas le chunk. Le mémo étant `thread_local`, il SURVIT
* à la reconstruction de la pile seule la clé l'invalidait.
* Donc la clé garde l'identité partagée : ce qui rend deux colonnes interchangeables, c'est
* la STRATE, le seed, la version de layout et les params, pas le chunk. Le mémo étant
* `thread_local`, il SURVIT à la reconstruction de la pile seule la clé l'invalidait.
*
* POURQUOI C'EST SÛR : les hauteurs sont XY-pures par construction (c'est tout l'objet de
* `VoxelHeightOp.h`, le type n'a pas de Z), et le champ de biomes est documenté
@@ -843,8 +889,9 @@ namespace
*
* The memo was keyed on InstanceId, which changes every chunk, so a 4-chunk strate recomputed
* every column 4x. GSurfColCache deliberately omits ChunkZ and shares down the whole vertical
* stack; this now keys on the same identity. Safe because heights are XY-pure by type and the
* biome field is documented Z-independent.
* stack; this now shares the same strate/layout/seed identity and adds the required params
* fingerprint for its own outputs. The six-box LRU keeps independent XY regions alive. Safe
* because heights are XY-pure by type and the biome field is documented Z-independent.
*/
void PrepareChunk(const FVoxelOpContext& Ctx) override
{
@@ -881,14 +928,14 @@ namespace
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
{
// ⚠️ PAS de mémo par colonne ICI, délibérément. Le cache T1.a existe déjà UN NIVEAU
// AU-DESSUS (`GSurfColCache` dans `GetDensityAt`), clé sur (boîte XY, StrateKey, Seed).
// En rajouter un ici demanderait une seconde clé de cache à tenir juste — et une clé de
// cache fausse dans un op partagé sur toute la pile verticale est précisément le mode de
// défaillance qu'`AUDIT §6.3` décrit. Le branchement (étape 2b) réutilise le cache
// existant plutôt que d'en inventer un second.
// No per-column memo here on purpose: T1.a already exists one level up, and a second
// cache key is a second thing to get wrong.
// Le mémo par colonne vit ici, dans six boîtes thread_local partagées par les instances
// mais séparées par la clé, et lues par les Eval de cette source et FOverhangShelfMod.
// Il est séparé de `GSurfColCache` : la pile possède ses propres sorties et sa clé
// complète (strate + layout + seed + empreinte des params), donc réutiliser le cache
// du générateur serait incorrect.
// The per-column memo lives here in six thread-local boxes shared across instances but
// separated by the key, and read by this source's Eval calls and FOverhangShelfMod.
// It is separate from `GSurfColCache`: the stack owns its own outputs and full key.
const FColumn& C = GetColumn(WorldX, WorldY);
float Density = C.TerrainZ - WorldZ;
+175 -42
View File
@@ -18,6 +18,8 @@
#include "VoxelHeightOp.h" // IVoxelBiomeField — the adapter below implements it
#include "VoxelStats.h"
#include <atomic>
//=============================================================================
// L'ADAPTATEUR DE CHAMP DE BIOMES / THE BIOME FIELD ADAPTER
//=============================================================================
@@ -445,6 +447,13 @@ static void ApplyDisturbances(float& MC, float X, float Y, float Z,
// never fetched here — both callers already have them.
namespace
{
// Une identité monotone évite qu'un worker réutilise les CP_* d'un monde détruit même si
// l'allocateur UObject recycle plus tard la même adresse. Relaxed suffit : on ne publie aucune
// donnée, on alloue seulement une valeur distincte par instance.
// A monotonic identity prevents stale CP_* reuse even if UObject allocation later recycles an
// address. Relaxed ordering is sufficient: this allocates uniqueness, it publishes no data.
std::atomic<uint64> GNextDensityCacheOwnerId { 0 };
struct FVoxelStackParamRefs
{
const FSlabGenerationParams* Slab = nullptr;
@@ -542,6 +551,11 @@ namespace
}
}
UVoxelGenerator::UVoxelGenerator()
: DensityCacheOwnerId(GNextDensityCacheOwnerId.fetch_add(1, std::memory_order_relaxed) + 1)
{
}
void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings)
{
// Seul le seed est copié ici. Tout le reste (params de cave, transitions,
@@ -579,7 +593,9 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
// The generator type, the (boundary-blended) param struct, and the disturbance
// params are identical for the whole chunk, yet resolving them re-runs a strate
// lookup + copies large structs (and a ~60-field Lerp for blended cave chunks).
// Cache them thread-locally, keyed by chunk coord — refetch only on chunk change.
// Cache them thread-locally, keyed by owner + chunk coord + layout version — refetch only
// when one of those integer identities changes.
thread_local uint64 CP_OwnerId = 0;
thread_local FIntVector CP_Chunk(INT32_MAX, INT32_MAX, INT32_MAX);
thread_local ECaveGeneratorType CP_GenType = ECaveGeneratorType::TunnelNetwork;
thread_local FStrateGenerationParams CP_Tunnel;
@@ -613,18 +629,23 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
// "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
// params (donc même clé owner+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)
const bool bOwnerChanged = DensityCacheOwnerId != CP_OwnerId;
if (bOwnerChanged || ChunkCoord != CP_Chunk || LayoutVersion != CP_Version)
{
// La grille de biome est validée par une BOÎTE XY, qui ne dit rien du FBiomeContext
// ayant servi à classer ses cellules : sur un changement de version elle est périmée
// même si la boîte couvre encore la requête.
if (LayoutVersion != CP_Version) { CP_BiomeCache.Invalidate(); }
// même si la boîte couvre encore la requête. Même invalidation quand le propriétaire
// change : deux mondes peuvent partager version et coordonnées, jamais leur contexte.
// The biome grid's XY box says nothing about its context. Owner changes invalidate it
// too: two worlds may share version and coordinates, never cached params/context.
if (bOwnerChanged || LayoutVersion != CP_Version) { CP_BiomeCache.Invalidate(); }
CP_OwnerId = DensityCacheOwnerId;
CP_Version = LayoutVersion;
CP_Chunk = ChunkCoord;
CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord);
@@ -2704,8 +2725,8 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
if (CX * CX + CY * CY <= Reach * Reach) bCanSolid = false;
}
// ── Catégorisation par Z de treillis. v1 : gap bedrock = solide ; SurfaceWorld = test
// colonne ; tout le reste (intérieurs de caves, hors layout) = Mixed immédiat. ──
// ── Catégorisation par Z du treillis : gap bedrock = solide ; hors layout = air constant ;
// SurfaceWorld = test colonne ; un slot cave opt-in = verdict de pile sur sa sous-boîte. ──
struct FSurfSlot
{
int32 BotChunkZ = INT32_MAX; // identité du slot (borne basse de la strate, en chunks)
@@ -2732,17 +2753,31 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
int32 NumSlots = 0;
// ── T1.d GÉNÉRIQUE : la pile d'opérateurs classe les archétypes de CAVE ──
// Ces trois-là suivent le slot de cave que la tuile touche. Le verdict de la pile porte sur la
// BOÎTE ENTIÈRE, pas sur un z, donc il ne peut être calculé qu'après la boucle — et il n'est
// valable que si la tuile ne touche QUE ce slot-là (voir la garde `bAnyNonCave`).
// Ces valeurs suivent l'UNIQUE slot de cave que la tuile touche. La pile classera seulement la
// sous-boîte Z de ses échantillons ; les catégories gap/surface/hors-layout plient séparément
// leurs hypothèses dans `bCanSolid` / `bCanAir`.
// These values track the ONE cave slot touched by the tile. The stack classifies only its
// sampled Z sub-box; gap/surface/out-of-layout fold their hypotheses separately.
int32 CaveBotChunkZ = INT32_MAX; // identité du slot de cave (borne basse, en chunks)
int32 CaveRepChunkZ = 0;
bool bAnyCave = false;
bool bAnyNonCave = false; // gap ou SurfaceWorld dans la même tuile ⇒ on abandonne
int32 CaveMinZ = MAX_int32;
int32 CaveMaxZ = MIN_int32;
bool bAnyCave = false;
bool bAnyGap = false;
bool bAnySurface = false;
bool bAnyOutOfLayout = false;
int32 MemoChunkZ = INT32_MAX;
int32 MemoCat = -1; // 0 = gap, 1 = surface, 2 = cave (pile d'opérateurs)
int32 MemoCat = -1; // 0 = gap, 1 = surface, 2 = cave (pile), 3 = hors layout
int32 MemoSlotIdx = -1;
// « Ce chunk Z appartient-il à une strate ? » sous forme publique : `FindSlotIndexForChunkZ`
// est `protected`, `GetStrateChunkZBounds` rend false pour exactement le même cas.
auto VF_ChunkZHasSlot = [&](int32 Z) -> bool
{
int32 UnusedTopCZ = 0, UnusedBotCZ = 0;
return StrateManager->GetStrateChunkZBounds(Z, UnusedTopCZ, UnusedBotCZ);
};
for (int32 g = -1; g <= GridDim; ++g)
{
const int32 Zi = OriginVoxels.Z + g * Step;
@@ -2754,7 +2789,43 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
if (StrateManager->IsGapChunk(CC))
{
MemoCat = 0;
bAnyNonCave = true;
bAnyGap = true;
}
//=================================================================
// ⛔ HORS LAYOUT = AIR CONSTANT. C'ÉTAIT LE BLOCAGE DE T1.d.
//=================================================================
// `GetGeneratorTypeForChunk` rend `TunnelNetwork` pour tout chunk hors de la pile de
// strates (« le chemin de repli produit de la roche de toute façon » — CE COMMENTAIRE
// EST FAUX) et `IsGapChunk` rend false au-dessus du sommet (« open air, NOT a gap »).
// Résultat : chaque tuile touchant l'air libre au-dessus du monde entrait dans la
// BRANCHE DE CAVE, n'y trouvait aucun slot, et abandonnait — mesuré en jeu à 83 % des
// tuiles classées (`Cave Bail Not Op Stack No Layout` = 1.58 / 1.90).
//
// La vérité est dans `GetGenerationParams` : hors layout il rend `BaseDensity = -1`,
// `RoomDensity = 0`, `WormStrength = 0` — un champ CONSTANT, donc de l'air, sans salle
// ni ver pour le percer. Une telle tuile est prouvable sans échantillonner.
//
// Out-of-layout is a CONSTANT AIR field, not a cave archetype. Every tile touching the
// open air above the world was being routed into the cave branch and bailing there.
// `GetStrateChunkZBounds` (PUBLIC) rend false exactement quand `FindSlotIndexForChunkZ`
// rend -1 — ce dernier est `protected`, et cette fonction l'utilise déjà deux fois pour
// la même question. Pas de nouvelle surface d'API pour un prédicat qui existe.
// GetStrateChunkZBounds is the public form of "has a layout slot"; the index accessor
// is protected and this function already uses the bounds call twice for the same test.
else if (!VF_ChunkZHasSlot(ChunkZ))
{
MemoCat = 3;
bAnyOutOfLayout = true;
// Les disturbances sont appliquées APRÈS la densité d'archétype et peuvent AJOUTER
// de la roche (ponts, arêtes). Même prudence que les branches gap et cave : si
// l'une peut agir ici, on ne prouve rien. Les chasms ne font que creuser ⇒ ils ne
// menacent pas un verdict d'air.
const FStrateDisturbanceParams DOut = StrateManager->GetDisturbanceParamsForChunk(CC);
if (DOut.BridgeDensity > 0.0f || DOut.RidgeDensity > 0.0f)
{
return EVoxelTileClass::Mixed;
}
}
else if (StrateManager->GetGeneratorTypeForChunk(CC) == ECaveGeneratorType::SurfaceWorld)
{
@@ -2796,7 +2867,7 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
if (D.BridgeDensity > 0.0f || D.RidgeDensity > 0.0f) bCanAir = false;
}
MemoCat = 1;
bAnyNonCave = true;
bAnySurface = true;
}
else
{
@@ -2809,7 +2880,33 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
// même endroit, que `GetDensityAt`.
if (!StrateManager->UsesOperatorStackForChunk(CC))
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNotOpStack);
// Attribution DIAGNOSTIQUE uniquement : l'ancien compteur mélangeait une
// strate cave entièrement désactivée avec une tuile de frontière qui avait
// rencontré un slot désactivé avant la garde « slot différent » ci-dessous.
// On résout les bornes APRÈS l'échec du même prédicat ; elles ne changent ni
// la condition, ni le point de retour, ni le verdict.
// Diagnostic attribution only: the old counter mixed a wholly disabled cave
// slot with a boundary tile that met a disabled slot before the different-slot
// guard below. Resolve bounds only after the same predicate fails; classification
// control flow and return value stay unchanged.
int32 FailedTopCZ = 0, FailedBotCZ = 0;
if (!StrateManager->GetStrateChunkZBounds(ChunkZ, FailedTopCZ, FailedBotCZ))
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNotOpStackNoLayout);
}
else
{
const int32 TileMinCZ = FloorDivC(MinZ, CHUNK_SIZE);
const int32 TileMaxCZ = FloorDivC(MaxZ, CHUNK_SIZE);
if (TileMinCZ >= FailedBotCZ && TileMaxCZ <= FailedTopCZ)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNotOpStackSoleSlot);
}
else
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNotOpStackBoundaryTile);
}
}
return EVoxelTileClass::Mixed;
}
@@ -2818,12 +2915,12 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
int32 CaveTopCZ = 0, CaveBotCZ = 0;
if (!StrateManager->GetStrateChunkZBounds(ChunkZ, CaveTopCZ, CaveBotCZ))
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailMixedContent);
INC_DWORD_STAT(STAT_VoxelForgeCaveMixOutOfLayout);
return EVoxelTileClass::Mixed; // hors layout
}
if (CaveBotChunkZ != INT32_MAX && CaveBotChunkZ != CaveBotCZ)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailMixedContent);
INC_DWORD_STAT(STAT_VoxelForgeCaveBailTwoCaveSlots);
return EVoxelTileClass::Mixed;
}
CaveBotChunkZ = CaveBotCZ;
@@ -2835,12 +2932,23 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
if (MemoCat == 2)
{
// Rien par z : la pile répond pour la boîte entière, après la boucle.
// La pile répond après la boucle, sur la sous-boîte Z contenant exactement les
// échantillons cave (XY reste la boîte complète du treillis).
CaveMinZ = FMath::Min(CaveMinZ, Zi);
CaveMaxZ = FMath::Max(CaveMaxZ, Zi);
}
else if (MemoCat == 0)
{
bCanAir = false; // bedrock du gap = solide (le carve des passages est déjà gardé)
}
else if (MemoCat == 3)
{
// Hors layout = air constant (BaseDensity = -1, aucune salle, aucun ver). L'hypothèse
// « tout solide » meurt ; « tout air » survit. Les passages et la spine ne font que
// creuser — ils sont déjà gardés plus haut et ne peuvent pas rendre ce z solide.
// Out of layout = constant air: AllSolid dies, AllAir survives.
bCanSolid = false;
}
else
{
FSurfSlot& S = Slots[MemoSlotIdx];
@@ -2871,23 +2979,27 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
// DENSITÉ de cette tuile — même fabrique, mêmes params, même drapeau.
if (bAnyCave)
{
// Une tuile mi-cave mi-surface (ou mi-gap) n'est pas classable ainsi : la pile de cave ne
// répond que pour SA strate, et sa boîte couvrirait des z appartenant à une autre.
if (bAnyNonCave)
// Diagnostic de PRÉSENCE avant les gardes : le signal reste visible même si le pliage rend
// finalement AllSolid/AllAir. Ces compteurs ne sont pas exclusifs entre eux sur une tuile
// très haute ; chacun répond exactement à « cette catégorie était-elle aussi présente ? ».
// Presence diagnostics run before the guards, so a successful fold cannot hide the mix.
// They are not mutually exclusive for a very tall tile; each answers one exact question.
if (bAnyOutOfLayout || bAnyGap || bAnySurface)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailMixedContent);
return EVoxelTileClass::Mixed;
if (bAnyOutOfLayout) { INC_DWORD_STAT(STAT_VoxelForgeCaveMixOutOfLayout); }
if (bAnyGap) { INC_DWORD_STAT(STAT_VoxelForgeCaveMixGap); }
if (bAnySurface) { INC_DWORD_STAT(STAT_VoxelForgeCaveMixSurfaceWorld); }
}
const FIntVector RepCC(0, 0, CaveRepChunkZ);
const ECaveGeneratorType CaveType = StrateManager->GetGeneratorTypeForChunk(RepCC);
//---------------------------------------------------------------------
// ⚠️ LA GARDE QUI COMPTE : LES PARAMS DOIVENT ÊTRE LES MÊMES SUR TOUTE LA TUILE
// ⚠️ LA GARDE QUI COMPTE : LES PARAMS DOIVENT ÊTRE LES MÊMES SUR TOUTE LA SOUS-BOÎTE CAVE
//---------------------------------------------------------------------
// `GetGenerationParams` et ses homologues BLENDENT les params dans les bandes de transition :
// `Alpha` dépend du chunk Z pour `Gradient`, et du chunk XY EN PLUS pour `Interleaved`. Deux
// chunks d'une même tuile peuvent donc porter des params différents — c'est le constat de
// chunks d'une même sous-boîte peuvent donc porter des params différents — c'est le constat de
// `AUDIT §C2`, confirmé par lecture le 2026-07-28 — et UNE pile ne peut pas représenter DEUX
// champs. On construit donc les params pour CHAQUE coordonnée de chunk que la boîte touche et
// on exige qu'ils soient identiques bit à bit.
@@ -2896,7 +3008,12 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
// `Mixed` de trop. On se trompe du côté du CPU, jamais du côté du trou.
const int32 CX0 = FloorDivC(MinX, CHUNK_SIZE), CX1 = FloorDivC(MaxX, CHUNK_SIZE);
const int32 CY0 = FloorDivC(MinY, CHUNK_SIZE), CY1 = FloorDivC(MaxY, CHUNK_SIZE);
const int32 CZ0 = FloorDivC(MinZ, CHUNK_SIZE), CZ1 = FloorDivC(MaxZ, CHUNK_SIZE);
// IMPORTANT : les gardes restent complètes, mais seulement sur les chunks où le mesher
// échantillonne réellement CETTE strate cave. Inclure gap/surface/hors-layout ici ferait
// échouer la garde d'archétype avant de pouvoir plier leurs hypothèses indépendantes.
// The guards stay exhaustive over the cave samples. Non-cave chunks are intentionally not
// represented by this stack; their hypotheses were folded separately in the Z pass.
const int32 CZ0 = FloorDivC(CaveMinZ, CHUNK_SIZE), CZ1 = FloorDivC(CaveMaxZ, CHUNK_SIZE);
// Une tuile très étalée (Step élevé) toucherait trop de chunks pour que cette vérification
// reste bon marché. Au-delà, `Mixed` — on renonce au gain, jamais à la sûreté.
@@ -2929,7 +3046,12 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
// déclenché la tentative : un seul chunk hors pile invaliderait le verdict.
if (!StrateManager->UsesOperatorStackForChunk(CC))
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNotOpStack);
// Le passage Z précédent a déjà accepté l'unique slot cave. Avec le layout actuel
// (prédicat indépendant de X/Y), ce recheck est redondant ; un hit nomme donc
// précisément cette garde tardive au lieu d'être agrégé aux opt-ins désactivés.
// The prior Z pass already accepted the sole cave slot. With the current X/Y-
// independent predicate this recheck is redundant, so attribute it separately.
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNotOpStackRecheck);
return EVoxelTileClass::Mixed;
}
@@ -2994,7 +3116,7 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
}
default:
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
return EVoxelTileClass::Mixed; // SurfaceWorld ne peut pas arriver ici (bAnyNonCave)
return EVoxelTileClass::Mixed; // SurfaceWorld ne peut pas être le type du slot cave
}
bFirst = false;
@@ -3030,9 +3152,9 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
}
TileStack.PrepareChunk(OpCtx);
const FBox TileBox(FVector((float)MinX, (float)MinY, (float)MinZ),
FVector((float)MaxX, (float)MaxY, (float)MaxZ));
const EVoxelTileClass StackVerdict = TileStack.ClassifyBox(TileBox, OpCtx);
const FBox CaveBox(FVector((float)MinX, (float)MinY, (float)CaveMinZ),
FVector((float)MaxX, (float)MaxY, (float)CaveMaxZ));
const EVoxelTileClass StackVerdict = TileStack.ClassifyBox(CaveBox, OpCtx);
if (StackVerdict == EVoxelTileClass::Mixed)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailStackVerdict);
@@ -3042,6 +3164,17 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
if (StackVerdict == EVoxelTileClass::AllSolid) { bCanAir = false; }
else { bCanSolid = false; }
// Le verdict cave se plie avec gap=solide, hors-layout=air, seals surface=solide. Si les
// deux hypothèses sont mortes ici, les catégories se contredisent : ce n'est PAS un échec
// de borne de la pile ni une disturbance.
// Fold the cave verdict with gap=solid, out-of-layout=air, and solid surface seals. If both
// hypotheses die here, the categories conflict; this is not a stack-bound/disturbance bail.
if (!bCanSolid && !bCanAir)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailFoldConflict);
return EVoxelTileClass::Mixed;
}
//---------------------------------------------------------------------
// ⚠️ LES DISTURBANCES NE SONT PAS DANS LA PILE (`OPSTACK-DECOMPOSITION §10.2`) :
// `GetDensityAt` les applique APRÈS, sur la densité déjà négatée. Un verdict qui les
@@ -3052,20 +3185,11 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
if (D.ChasmDensity > 0.0f) { bCanSolid = false; }
if (D.BridgeDensity > 0.0f || D.RidgeDensity > 0.0f) { bCanAir = false; }
if (bCanSolid == bCanAir)
if (!bCanSolid && !bCanAir)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailDisturbance);
return EVoxelTileClass::Mixed;
}
if (bCanSolid)
{
INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackSolid);
}
else
{
INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackAir);
}
return bCanSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir;
}
// ── Balayage des colonnes XY sur le treillis exact du mesher (marge incluse). Une colonne
@@ -3124,6 +3248,15 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
// Ici exactement UNE hypothèse doit survivre (chaque point testé en tue une ; les tuiles
// sans point intérieur ont tué AllAir via gap/seal). Égalité = prudence → Mixed.
if (bCanSolid == bCanAir) return EVoxelTileClass::Mixed;
if (bAnyCave)
{
// Compte seulement les verdicts FINAUX qui sautent réellement une tuile. Une pile peut avoir
// prouvé sa sous-boîte cave puis perdre l'hypothèse sur une colonne SurfaceWorld adjacente.
// Count only final verdicts that actually skip a tile; a later surface column may still
// invalidate the hypothesis proved for the cave sub-box.
if (bCanSolid) { INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackSolid); }
else { INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackAir); }
}
return bCanSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir;
}
+9 -2
View File
@@ -10,10 +10,17 @@ DEFINE_STAT(STAT_VoxelForgeTilesSkippedAllAir);
DEFINE_STAT(STAT_VoxelForgeTilesMeshed);
DEFINE_STAT(STAT_VoxelForgeTilesOpStackSolid);
DEFINE_STAT(STAT_VoxelForgeTilesOpStackAir);
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStack);
DEFINE_STAT(STAT_VoxelForgeCaveBailMixedContent);
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackSoleSlot);
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackBoundaryTile);
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackNoLayout);
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackRecheck);
DEFINE_STAT(STAT_VoxelForgeCaveMixOutOfLayout);
DEFINE_STAT(STAT_VoxelForgeCaveMixGap);
DEFINE_STAT(STAT_VoxelForgeCaveMixSurfaceWorld);
DEFINE_STAT(STAT_VoxelForgeCaveBailTwoCaveSlots);
DEFINE_STAT(STAT_VoxelForgeCaveBailParams);
DEFINE_STAT(STAT_VoxelForgeCaveBailStackVerdict);
DEFINE_STAT(STAT_VoxelForgeCaveBailFoldConflict);
DEFINE_STAT(STAT_VoxelForgeCaveBailDisturbance);
DEFINE_STAT(STAT_VoxelForgeCaveBailNoStack);
DEFINE_STAT(STAT_VoxelForgeColumnMemoHit);
@@ -2,6 +2,7 @@
// Runtime strate layout generation and queries.
#include "VoxelStrateManager.h"
#include "CoreGlobals.h" // GIsAutomationTesting — the opt-in diagnostic stays quiet under tests
#include "VoxelSettings.h"
#include "VoxelTypes.h" // For CHUNK_SIZE, VOXEL_SIZE, WorldToChunkCoord
#include "VoxelCaveMorphology.h" // For VoxelSDF and VoxelHash
@@ -129,6 +130,71 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
Slot.HeightInChunks);
}
// Diagnostic de configuration, une seule fois par construction de layout. SurfaceWorld est
// volontairement exclu : son chemin T1.d exact-lattice ne dépend pas de ce drapeau.
// Configuration diagnostic once per layout build. SurfaceWorld is deliberately excluded:
// its exact-lattice T1.d path does not depend on this flag.
int32 NumCaveSlots = 0;
int32 NumOperatorStackDisabledCaves = 0;
for (const FStrateSlot& Slot : StrateLayout)
{
if (!Slot.Definition || Slot.Definition->GeneratorType == ECaveGeneratorType::SurfaceWorld)
{
continue;
}
++NumCaveSlots;
if (!Slot.Definition->bUseOperatorStack)
{
++NumOperatorStackDisabledCaves;
}
}
// ⚠️ WARNING EN ÉDITEUR/JEU, JAMAIS EN TEST. Les tests `Determinism.*` construisent
// DÉLIBÉRÉMENT un monde non opt-in — c'est leur oracle de comparaison — et le framework
// d'automatisation compte un Warning comme un échec. Un diagnostic ne doit pas casser la suite
// qu'il est censé éclairer. Le message reste écrit UNE fois : seule la verbosité change.
// Warning in editor/game where it is actionable, never in tests: the Determinism.* tests build
// a non-opted-in world ON PURPOSE as their comparison oracle, and the automation framework
// treats a Warning as a failure. One message, two verbosities.
const bool bQuietDiagnostic = GIsAutomationTesting;
if (NumOperatorStackDisabledCaves > 0)
{
const FString Summary = FString::Printf(
TEXT("[StrateManager] Operator-stack opt-in: %d/%d cave layout slots have Use Operator Stack disabled. These slots cannot use operator-stack ClassifyBox/T1.d; enable the asset setting on the listed definitions if that is intended."),
NumOperatorStackDisabledCaves, NumCaveSlots);
if (bQuietDiagnostic) { UE_LOG(LogTemp, Verbose, TEXT("%s"), *Summary); }
else { UE_LOG(LogTemp, Warning, TEXT("%s"), *Summary); }
}
else
{
UE_LOG(LogTemp, Log,
TEXT("[StrateManager] Operator-stack opt-in: all %d cave layout slots have Use Operator Stack enabled."),
NumCaveSlots);
}
for (const FStrateSlot& Slot : StrateLayout)
{
if (!Slot.Definition
|| Slot.Definition->GeneratorType == ECaveGeneratorType::SurfaceWorld
|| Slot.Definition->bUseOperatorStack)
{
continue;
}
const FString Line = FString::Printf(
TEXT("[StrateManager] cave slot=%d name='%s' Z chunks=[%d to %d] bUseOperatorStack=false"),
Slot.StrateIndex,
*Slot.Definition->StrateName.ToString(),
Slot.TopChunkZ,
Slot.BottomChunkZ);
if (bQuietDiagnostic) { UE_LOG(LogTemp, Verbose, TEXT("%s"), *Line); }
else { UE_LOG(LogTemp, Warning, TEXT("%s"), *Line); }
}
CachedSeed = WorldSeed;
bOpenSurfaceEntry = Settings->bOpenSurfaceEntry;
OriginSpineRadius = Settings->OriginSpineRadius;
+12 -6
View File
@@ -68,6 +68,8 @@ class VOXELFORGE_API UVoxelGenerator : public UObject
GENERATED_BODY()
public:
UVoxelGenerator();
//=========================================================================
// SEED (source unique: Settings->Seed)
//=========================================================================
@@ -311,16 +313,20 @@ public:
* (OriginVoxels, Step, CellsPerAxis) = les MÊMES arguments que GenerateMesh ; le verdict
* porte sur le treillis exact que le mesher échantillonnerait (marge ±1 incluse).
*
* v1 : ne prouve que les chunks GAP (bedrock) et les strates SurfaceWorld colonnes
* terrain/plafond évaluées par le MÊME ComputeSurfaceColumn que le chemin densité (donc
* bit-identiques), bandes de seal solides, gardes spine/passages/disturbances/diff.
* Tout autre archétype (intérieur de caves) Mixed. Worker-safe (lecture seule +
* caches thread_local partagés avec GetDensityAt un verdict Mixed laisse les colonnes
* chaudes pour la génération qui suit).
* GAP (bedrock), hors-layout (air constant) et SurfaceWorld plient leurs hypothèses par Z ;
* SurfaceWorld évalue ses colonnes terrain/plafond avec le MÊME ComputeSurfaceColumn que le
* chemin densité. Un unique slot cave opt-in peut ajouter le verdict conservatif de sa pile,
* sous gardes d'archétype et de params bit-identiques. Spine/passages/disturbances/diff restent
* des gardes conservatrices. Worker-safe (lecture seule + caches thread_local partagés avec
* GetDensityAt un verdict Mixed laisse les colonnes chaudes pour la génération qui suit).
*/
EVoxelTileClass ClassifyTile(const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis) const;
private:
/** Identité process-unique du propriétaire des caches `CP_*` thread_local.
* Process-unique owner identity for the `CP_*` thread-local cache key. */
uint64 DensityCacheOwnerId = 0;
/** Pick the biome (index into Ctx.Biomes) for a Voronoi site, by its climate. */
int32 ClassifyBiomeAtSite(float SiteX, float SiteY, const FBiomeContext& Ctx, uint32 SiteHash) const;
+9 -2
View File
@@ -14,10 +14,17 @@ DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Skipped All Air"), STAT_VoxelForge
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Meshed"), STAT_VoxelForgeTilesMeshed, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Operator Stack Solid"), STAT_VoxelForgeTilesOpStackSolid, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Operator Stack Air"), STAT_VoxelForgeTilesOpStackAir, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack"), STAT_VoxelForgeCaveBailNotOpStack, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Mixed Content"), STAT_VoxelForgeCaveBailMixedContent, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack Sole Slot"), STAT_VoxelForgeCaveBailNotOpStackSoleSlot, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack Boundary Tile"), STAT_VoxelForgeCaveBailNotOpStackBoundaryTile, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack No Layout"), STAT_VoxelForgeCaveBailNotOpStackNoLayout, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack Recheck"), STAT_VoxelForgeCaveBailNotOpStackRecheck, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Mix Out Of Layout"), STAT_VoxelForgeCaveMixOutOfLayout, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Mix Gap"), STAT_VoxelForgeCaveMixGap, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Mix Surface World"), STAT_VoxelForgeCaveMixSurfaceWorld, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Two Cave Slots"), STAT_VoxelForgeCaveBailTwoCaveSlots, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Params"), STAT_VoxelForgeCaveBailParams, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Stack Verdict"), STAT_VoxelForgeCaveBailStackVerdict, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Fold Conflict"), STAT_VoxelForgeCaveBailFoldConflict, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Disturbance"), STAT_VoxelForgeCaveBailDisturbance, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail No Stack"), STAT_VoxelForgeCaveBailNoStack, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Column Memo Hits"), STAT_VoxelForgeColumnMemoHit, STATGROUP_VoxelForge, VOXELFORGE_API);