diff --git a/AUDIT-2026-07.md b/AUDIT-2026-07.md index 5202e54..5bf0cfd 100644 --- a/AUDIT-2026-07.md +++ b/AUDIT-2026-07.md @@ -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, 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 -measured it. +Raised as a suspicion while porting TunnelNetwork, then checked rather than believed. **It holds**, +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 -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. +The chain, each link read in the code rather than inferred: -If that is right, a worker descending a transition band gets **no rebuild** and evaluates the lower -chunk against the upper chunk's room layout. Same family as `§C2` above and as the overhang -regression of 2026-07-27; the difference is that this one needs no live edit to trigger. +1. **`GetGenerationParams` really does vary within one strate.** Its Gradient branch computes + `Alpha = 1 − DistFromBottom / EffectiveBlend` where `DistFromBottom = ChunkCoord.Z − + 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 -Gradient-transitioned strate and compare the room-placement fields. If they differ, the cache is -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. +⇒ A worker that generates `(X, Y, Z1)` and then `(X, Y, Z2)` in the same strate and the same XY box +evaluates the second chunk against **the room list baked from the first chunk's params**. + +**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` fingerprint of the params (plus `LayoutVersion`) into its key, so differing params force a rebuild.