docs(audit C2): the within-strate stale-room suspicion is CONFIRMED by reading -- and it is the default config

No code changed. AUDIT-2026-07.md only: §C2's "SUSPECTED, NOT PROVEN" sub-item becomes "CONFIRMED BY
READING", with the four links of the chain and where each one is in the source.

THE CHAIN, checked instead of believed
1. GetGenerationParams' Gradient arms compute Alpha = 1 - DistFromBottom / EffectiveBlend, where
   DistFromBottom = ChunkCoord.Z - Slot.BottomChunkZ. Alpha is therefore a function of chunk Z
   WITHIN the slot -- so two chunk Zs in the same strate really do get different params. The top
   boundary mirrors it.
2. They differ in exactly the fields that place rooms: Lerp expands VF_STRATE_PARAM_FIELDS, which
   lists RoomSpacing, RoomDensity, MinRoomRadius, MaxRoomRadius, RoomHeightRatio, RoomShapeVariety,
   SDFBlendRadius, CaveWarpStrength and the tunnel fields -- every input BuildChunkCache reads.
3. It is the DEFAULT: TransitionType = Gradient, TransitionBlendChunks = 2. Two chunk-Z layers with
   distinct params at each end of every strate, out of the box.
4. StrateIndex cannot save it: the memo resolves the SLOT index from the band centre, identical for
   every chunk Z in the slot, and the cache key's XY box does not change as a worker walks down a
   column. No rebuild happens.

So a worker that builds (X,Y,Z1) then (X,Y,Z2) in one strate evaluates the second chunk against the
room list baked from the first chunk's params.

WHY IT MATTERS MORE THAN A SEAM
The result depends on which chunk that worker happened to build first. That is a window-invariance
break (ARCHITECTURE 8.4), not a cosmetic one, and in multiplayer two peers can generate different
geometry for the same chunk from the same seed -- a direct 2.6.1 violation on the ORIGINAL path.

WHY NOTHING CAUGHT IT
The test fixture sets TransitionType = Hard on every strate deliberately, so that "which archetype
owns this chunk" stays unambiguous. That switches the blend off entirely. The one configuration the
tests never build is the default one.

NOT FIXED HERE, deliberately: this is the switch path, and the fix (fold a params CRC + LayoutVersion
into the SDF cache key, exactly as FRoomGraphSource already does) is a behaviour change to live
generation that Jahni should land with a build in front of him. The operator stack does not inherit
the bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 03:48:01 +02:00
parent 10dbe64b06
commit 64d0e11821
+41 -14
View File
@@ -182,24 +182,51 @@ Seed) **are** seed-guarded, and the strate-index memo **is** version-guarded (`S
`ChangeSeed` mostly survives; **live-edit is where this bites**. Symptom: "I tweaked the strate asset, `ChangeSeed` mostly survives; **live-edit is where this bites**. Symptom: "I tweaked the strate asset,
regenerated, and one patch kept the old shape." regenerated, and one patch kept the old shape."
#### ⚠️ SUSPECTED, NOT PROVEN, 2026-07-28 — the SDF cache may serve stale rooms *within* a strate #### ✅ CONFIRMED BY READING, 2026-07-28 — the SDF cache DOES serve stale rooms *within* a strate
Found while porting TunnelNetwork, and stated as a suspicion on purpose: I have reasoned it, not Raised as a suspicion while porting TunnelNetwork, then checked rather than believed. **It holds**,
measured it. on default settings, with no live edit and no unusual asset configuration.
`GetDensityWithParams`' SDF cache key is `(XY search box, StrateIndex, Seed)`. It contains **no The chain, each link read in the code rather than inferred:
params and no chunk Z**. Meanwhile `GetGenerationParams` *blends* params between neighbouring strates
across a Gradient transition — so two chunks at different Z **inside the same strate** can carry
different `RoomSpacing`/`RoomDensity`/… while sharing an XY box, a strate index and a seed.
If that is right, a worker descending a transition band gets **no rebuild** and evaluates the lower 1. **`GetGenerationParams` really does vary within one strate.** Its Gradient branch computes
chunk against the upper chunk's room layout. Same family as `§C2` above and as the overhang `Alpha = 1 DistFromBottom / EffectiveBlend` where `DistFromBottom = ChunkCoord.Z
regression of 2026-07-27; the difference is that this one needs no live edit to trigger. Slot.BottomChunkZ`, then returns `FStrateGenerationParams::Lerp(BaseParams, BelowParams, Alpha)`.
`Alpha` is a function of **chunk Z inside the slot**, so two chunk Zs in the *same* slot get
genuinely different params. The top boundary mirrors it.
*(`VoxelStrateManager.cpp`, `GetGenerationParams`, the `case EVoxelStrateTransition::Gradient:`
arms — both of them.)*
2. **It varies in exactly the fields that place rooms.** `Lerp` expands the
`VF_STRATE_PARAM_FIELDS` X-macro, which lists `RoomSpacing`, `RoomDensity`, `MinRoomRadius`,
`MaxRoomRadius`, `RoomHeightRatio`, `RoomShapeVariety`, `SDFBlendRadius`, `CaveWarpStrength`,
the tunnel fields — every input `BuildChunkCache` reads.
3. **And it is the DEFAULT.** `UVoxelStrateDefinition::TransitionType = Gradient` and
`TransitionBlendChunks = 2`. So at each end of every strate there are two chunk-Z layers with
distinct params (`Alpha` 1.0 and 0.5), out of the box.
4. **`StrateIndex` cannot save it.** The memo resolves the *slot* index from the band centre, so it
is identical for every chunk Z within the slot. The cache key is `(XY box, StrateIndex, Seed)`
and the XY box does not change when the worker moves down a column. **No rebuild happens.**
**What would confirm it:** call `GetGenerationParams` for two adjacent chunk Zs inside a ⇒ A worker that generates `(X, Y, Z1)` and then `(X, Y, Z2)` in the same strate and the same XY box
Gradient-transitioned strate and compare the room-placement fields. If they differ, the cache is evaluates the second chunk against **the room list baked from the first chunk's params**.
being reused across a real param change. **Do that before acting** — the whole thing rests on
"Gradient blending actually varies within a strate", which I have not verified. **Two consequences, and the second is the serious one:**
- a visible seam / wrong room sizes in the transition band, and
- **the result depends on which chunk that worker happened to build first.** That is a window-
invariance break (`ARCHITECTURE §8.4`) rather than a cosmetic bug, and in multiplayer it means two
peers can generate different geometry for the same chunk from the same seed — a direct
`OPSTACK-PLAN §2.6.1` violation on the *original* path.
**Why nothing caught it:** the test fixture sets `TransitionType = Hard` on every strate on purpose
(so "which archetype owns this chunk" stays unambiguous), which switches the blend off entirely.
The one configuration the tests never build is the default one.
**Fix (unimplemented, deliberately — this is the `switch` path, not the port):** fold the params into
the SDF cache key exactly as `FRoomGraphSource` already does — `FCrc::MemCrc32` over the params
struct, plus `LayoutVersion`. `FStrateGenerationParams` is pure POD, so a memory CRC cannot produce a
false *match*; at worst padding causes a needless rebuild. Err on CPU, never on a wrong room.
Alternatively add chunk Z to the key, which is coarser (it rebuilds on every Z step even outside a
blend band) but needs no CRC.
The operator-stack port does **not** inherit this: `FRoomGraphSource` folds a `FCrc::MemCrc32` The operator-stack port does **not** inherit this: `FRoomGraphSource` folds a `FCrc::MemCrc32`
fingerprint of the params (plus `LayoutVersion`) into its key, so differing params force a rebuild. fingerprint of the params (plus `LayoutVersion`) into its key, so differing params force a rebuild.