383ec86c37d20bb3a7ff828edb190e18ece448c0
55
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
383ec86c37 |
perf(apply): stutter is NOT the game thread -- and the flat floor cannot be relaxed
Two things the owner reported from playing |
||
|
|
412a7ad219 |
perf(game): the path he actually walks -- meshed p95 4.91s -> 0.41s, RSS 19.5GB -> 1.7GB
The owner: "I want real performance gain for the ingame path... not in deletion, but
in optimization and tricks." Three rounds of ours had improved the commandlet export
he never runs. This one is the streaming path.
MEMORY -- it was retention, not the new lattice
19.5 GB was worker FChunkSDFCache arrays keeping capacity when keys were replaced
(946 MB across 15 workers in diagnostics), shared graph caches with no byte or
lifetime budget, and batch commandlets holding transient UObject worlds until exit.
BlockSkip is bounded to 8^3 bytes per tile; interval scratch is worker-local and
capped. Fixes: release storage on key replacement, LRU with a 256 MiB resident
budget, limit in-flight graph builds, drop oversized entries, CollectGarbage between
batch cases.
peak RSS 19.5 GB -> 1.643 GB (their runs) / 1.71 GB (mine, 3x128^3) -91%
a 3-case stress batch showed no accumulation
WHY SOME EMPTY TILES COST 5.6s AND OTHERS 3us -- a 1.9 MILLION x spread
Fast ones returned straight from the layout/edge proof (verdict AllAir/AllSolid,
refine=0, mesh=0). Slow ones returned Mixed, so the caller ran the FULL mesher grid
and only discovered emptiness by brute force -- logs show empty=1 after ~5 s of mesh
work. The interval machinery now reaches a verdict before a mesh job is enqueued.
IN GAME, 841 requests at streaming radius:
count p50 p95 max
empty 394 6.087 ms 12.113 ms 25.049 ms
meshed 447 114.748 ms 410.314 ms 844.043 ms
LOD0 empty 221 7.126 ms 11.901 ms 25.049 ms
LOD0 meshed 122 355.794 ms 600.945 ms 844.043 ms
against
|
||
|
|
d38d4cabef |
perf(gen): interval propagation -- blocks can finally be PROVEN empty. 1.55s -> 1.21s
Task 4 ran 11 hours and was killed by system memory pressure before it could file a report. The work survived, builds clean (0 errors), and is verified faster with byte-identical output. This commit is my verification, not its summary -- it never got to write one. THE STRUCTURAL FIX |
||
|
|
0e9df9e5a7 |
perf(gen): the game path is NOTHING like the export path -- and empty tiles cost 5.6s
Overnight task 3. The headline is not a speedup: it is that we have been measuring
the wrong thing all along.
⭐ IN-GAME MEASUREMENT, FINALLY (headless -game -nullrhi, TunnelNetwork PIE map)
46,078 profiled tile calls
46,025 EMPTY (99.885%)
53 actually meshed
meshed p50 0.6085 s p95 4.9075 s max 5.0199 s mean 1.3515 s
LOD1-3 empty medians ~2-3 us
LOD4 empty OUTLIERS up to 5.618 s
The pawn fell through successive strata during the run, reproducing the owner's
"I can outrun the generation and fall" exactly.
Two facts to sit with:
1. **Almost nothing is meshed. 99.9% of tile requests produce nothing** -- and some
of those empty tiles take FIVE AND A HALF SECONDS. A tile that generates no
geometry costing seconds is the bug, and it is classification cost, not meshing.
2. The export path we have optimised for three rounds does 64 tiles in ~1.5 s
total. The game does 46,078 calls with multi-second tile latency. **They share
the density function and nothing else about their cost profile.**
⇒ Every optimisation so far was aimed at the path the owner does not play.
WHY NO BLOCK CAN BE SKIPPED -- answered, and it is structural
Per-op bound-vs-brute-force table produced (op_bounds.json). Most ops bound fine
once given proven suprema, and those were tightened:
FBM envelope 1.5, cellular 6.0, SdfConvertOp exact 2*|BaseDensity| = 16,
Lerp radius envelope max(Min,Max), plus state-aware room/column/floor-bias/worm/
terrace/cliff/arch/dome/pinch/roughness bounds. Structural ops now return exact
zero outside their geometric bands.
But OriginSpineOp, PassageCarveOp and the active seals **cannot** take a finite
generic density bound: they Lerp/Min/Max against the CURRENT density, so with
arbitrary incoming density their supremum is mathematically unbounded. This is not
a missing EffectOverBox, it is the operator semantics.
⇒ Hierarchical evaluation requires propagating a density INTERVAL through the
stack, or changing those operators. That is the real prerequisite, now named.
Early-out with tightened bounds still skips 0 of 4,096 blocks and costs 96.6%
(3.305 -> 6.497 s). Left opt-in and DISABLED. ClassifyTile's TerrainZ/overhang
guard untouched.
SPATIAL INDEX REMOVED (ee89c4d's, which never earned its keep)
wall clock 3.1407 s on -> 3.1894 s off = 1.5%, below the 17% floor
cache bytes 557,626,564 -> 246,049,920 = -297 MiB
candidates 19.5M -> 81.6M scanned (4.17x), evaluated +25.6%
Memory the owner can feel beats counters he cannot. Per-primitive broadphase stays.
VERIFIED END-TO-END on a correctly built binary: 1.515 / 1.554 / 1.569 s, geometry
hash 98eaad61..d3c4f unchanged from
|
||
|
|
e63f016835 |
tools(profile): the instrument lied by 11.5x -- now it checks itself, and batching kills startup cost
Overnight task 1 of the owner's mandate: "better diagnostic, profiler and tools, so we can batch test way more quicker." WHY THIS CAME BEFORE ANY MORE OPTIMISATION |
||
|
|
ee89c4d8c2 |
perf(gen): 5x fewer candidates, 16x cheaper floor -- and 0% end-to-end. Read the last section.
Owner, after playing
|
||
|
|
0576e800c4 |
perf(gen): the landing work was per-voxel -- 342s to 2.6s, same world
The owner played |
||
|
|
64f5d61e08 |
fix(crash): EndPlay tore the world down under live workers -- the cache made it lethal
PIE crashed with EXCEPTION_ACCESS_VIOLATION reading 0xffffffffffffffff:
FRoomGraphSource::Eval() VoxelDensityOpStack.cpp:3056 (Manager->GetLayoutVersion())
UVoxelGenerator::GetDensityAt() VoxelGenerator.cpp:1103 (ActiveOpStack->EvalMC)
... AVoxelWorld::LoadTile lambda, on a task-graph worker
Two lifetime bugs, combined:
1. EndPlay gave up after a 3-second deadline and destroyed UObjects while workers
were still inside GenerateTileResult. The crash log records 4, 3 and 7 tasks
still running during teardown. ARCHITECTURE has always said EndPlay blocks on
ActiveTaskCount -> 0; it did not.
2. e08b49a's worker-local cache keeps 128 built op stacks alive per thread, and
those stacks hold a raw UVoxelStrateManager*. Before the cache, a stack was
rebuilt at every chunk crossing and never outlived its owner for long. After
it, a freed manager stayed reachable and got dereferenced.
The cache did not create the teardown race. It made an existing one lethal.
Fixes:
- EndPlay now waits unconditionally for ActiveTaskCount == 0. The 3s deadline is
demoted to a log line: a slow drain is reported, teardown is never torn out
from under a reader.
- UVoxelStrateManager carries a process-unique CacheLifetimeId, folded into the
cache key. Deliberately not a UObject pointer: allocator address reuse must not
let a stale worker-local stack look current across PIE sessions. PassagesVersion
restarts per layout and cannot make that distinction.
- Cached passage/room manager references are weak pointers, fail-closed.
- Verified FStrateGenerationParams is copied BY VALUE into tunnel operators, and
stack moves preserve owned operator allocations, so no operator references a
previous cache entry's parameters.
TunnelNetwork mesh 342.332 s -> 16.199 s (21.1x)
per tile, 64 tiles 5.349 s -> 0.253 s
NOT verified, and it matters here:
- No PIE measurement. The owner restricted this session to analysis and fixes, so
the numbers above are the commandlet's direct-mesher path, NOT the streaming
path the game uses. They are not game timings and are not presented as such.
- The 128-slot cache may churn at distance: an L0 tile crosses at most 27 exact
chunks, but L2/L3/L4 tiles touch 216 / 1,000 / 6,859 chunk keys. Whether that
helps or hurts in game is unmeasured.
- EndPlay waiting forever converts a crash into a hang if a task never observes
bShuttingDown. Workers do check it, but this trade is deliberate and worth
watching.
Named, not changed: the straight "roads" the owner still sees are inter-strate
passages. FStratePassageConfig defaults Connections=1 and Style=Straight
(VoxelStrateTypes.h:1781); GeneratePassages maps Straight -> SlopedTunnel and
builds a two-leg switchback that FPassageCarveOp carves
(VoxelStrateManager.cpp:819).
|
||
|
|
d97373c5a8 |
feat(gen): Jahni's playtest corrections -- rooms flattened, roads deleted
From walking a TunnelNetwork preset at (0,0). Four corrections, and two of
them were the same mistake.
2a. The (0,0) landing room is sized to host a FUTURE straight vertical
shaft from the surface, with flooring around the origin itself. The
room is the landing for a shaft the player digs later.
2b. The inter-strate descent no longer starts at the spine -- placed by
deterministic hash within a radius around (0,0). The spine room is for
the surface shaft; the descent is elsewhere.
2c. ⛔ DELETED the landing->hub connector roads from
|
||
|
|
55bf807f60 |
feat(descent): landing ROOMS and a walkable tunnel -- per Jahni's correction
Jahni corrected the design: "the connection between level is a tiny tunnel
connecting both strate, not vertical, it needs to be walkable" and "'the
spine' shouldn't be carved except optionally from the top first strate,
then it's used to have a landing room".
COMPOSER-NOTES §6.8 had said the spine "provides a clean landing space, not
a continuous open shaft" -- and the implementation carved a vertical
cylinder anyway. A vertical cylinder has no walkable floor except at its
bottom, so a carve guaranteeing open AIR guaranteed nothing STANDABLE.
That one omission is behind five of my dead hypotheses this week.
Now:
origin spine a finite rounded ROOM per strate, no vertical bore.
radius 14 -> 8 m wide, 7.5 m tall
descent a walkable tunnel, single-file by design: 3 m mouth,
2.5 m waist, gradient capped at a NAMED 15 deg
exotic styles VerticalShaft / SpiralDescent / CascadingDrops /
CrackCrevice preserved, no longer the default
⚠️ THE GEOMETRY BITES, and it is worth Jahni's eye: at 15 deg a 32 m drop
needs 119.4 m of horizontal run -- about 59.7 m per switchback leg. That is
a long walk between strates. It is the honest consequence of "walkable",
not a tuning artifact, and the alternative is a steeper number.
⭐ AND THE ROOT CAUSE GOT NAMED A THIRD TIME, by Codex mid-run: "the round
tube was hollow air but had no authored support strip, so 'open' still did
not mean 'standable'." Tunnels now carry a floor band, and the root join
terminates in the finite origin room rather than an annular shaft hub.
Measured, commandlet sweep, 8 archetypes x 8 seeds, 64/64 valid:
origin column has player-fit cells 17/64 -> 63/64
origin column reachable 0/64 -> 7/64
arrival landing probes 315/320 fit AND in the arrival component
tunnel audit 5,916/5,916 floor and capsule samples pass; zero gradient,
floor, capsule, geometry or seal failures
narrow-gap events 0 box verdicts 14 checked, 0 violations
⚠️ arrival->departure is 6/64. The landings and tunnels are now provably
walkable; what remains is the INTRA-STRATE vertical connectivity problem
found in
|
||
|
|
413c15de24 |
feat(passages): §6.5 landings -- you arrive somewhere you can stand
The last unbuilt Tier 1 item. A passage was a bore through rock with no floor inside the walkable angle, so its mouth was not a standing position and neither was anything near it -- the departure mouth measured a FOUR-CELL player-fit component. You arrived and there was nowhere to stand. Both ends of all 7 passages now open into a landing, sized from the body: capsule diameter 2.72 vox floor >= 12 vox (3 m) to stand and turn capsule height 7.04 vox room >= 12 vox tall, stock mouth gives 18 (4.5 m) 3-voxel floor slab, tube tangent to it -- no lip to climb out of connector 5-vox radius: 2.5 m clear width, 3 m clear height ramps over 44 deg get a deterministic level dog-leg ⭐ THE MOUTHS ARE NO LONGER SPECKS: archetype components largest arrival-departure Maze 309 -> 340 176 -> 3210 43-81 -> 3210-3210 TunnelNetwork 467 -> 227 1292 -> 3841 2-1 -> 3841-269 Underwater 86 -> 117 35 -> 3838 3-11 -> 3838-267 FlatPlain 45 -> 11 259 -> 2130 1-1 -> 268-273 CrystalChamber 197 -> 90 24 -> 286 1-1 -> 286-274 FloatingIslands 2 -> 1 3 -> 72 1-1 -> 72-72 Law 1/8 -> 2/7 connected (1 refused on MaxCells), and critically ZERO connections now run through a four-cell pocket. Every landing floor was verified against the FINAL density, after all posts: 14/14 pass the 44 deg slope bracket, worst room gradient .059, worst connector .522. 14/14 deterministic root joins. 0 seal failures. 0 box-verdict violations across 82 proved + 174 mixed, 109,142 voxels. Op-stack bit-identical, order-independence green over 10 slots / 31 passages / 3 pool orders. Cache 0.665 us rebuild, 0.646 us hot. Post order: origin spine -> vertical seal -> passage tube + landing floor -> XY edge seal. Landings clamp 4 voxels inside the vertical seal; the XY seal stays last and wins at world edges. ⚠️ ARCHITECTURAL NOTE, not a detail: landings join a deterministic annular ROOT HUB at (0,0), built identically for every endpoint -- no nearest- component search, no passage ordering. That is what makes the join guaranteed rather than likely, and it extends the (0,0) spine concept from a landmark into a structural network. It also means routes tend to pass through the centre, which is a gameplay consequence worth Jahni's eye. ⚠️ AND IT DOES NOT CLOSE THE LAW. Codex is explicit: the landings guarantee membership in the new root network, NOT in the archetype's pre-existing fit component. The whole-strate Maze check still shows a 71.9-voxel gap between 1,235 and 2,938-cell components. So the native corridor networks remain disconnected from the root network in places. Real, separate, next. 52 build-and-measure cycles, ~9 hours, finding real geometry bugs on the way (a landing floor overlapping the ramp cells and hiding a floor discontinuity; the disturbance pass refilling connector headroom). Build clean, 32/32 success, 0 failed. Showcase regenerated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K8V9my1PL68pmQb2gutZZV |
||
|
|
2eaceb5984 |
feat(maze): a spanning tree -- it is a maze now, not a grid
Jahni: "mostly a cube full of straight pathway along the edges... basically
just a repeating pattern of tunnel connecting to each other, definitely
unwanted". And the measurement: the arrival mouth in a 30-cell pocket while
the network held 1,217. SAME CAUSE -- edge selection was independent hash
gating per +X/+Y/+Z edge, i.e. a random subgraph of the cubic lattice.
Every junction identical, and connectivity left to chance.
Fixed the way VerticalShafts was fixed in
|
||
|
|
faa62d419e |
feat(runtime): the composed world can BE the game world
Jahni: "can you not implement those settings? as in, have them be live in
game?" This is that. The composer stops being a lab tool.
THE ARCHITECTURAL LINE, which is the whole design (COMPOSER-NOTES §3.1):
COMPOSITION stays editor-only, EVALUATION becomes runtime. Rolling,
measuring, preview, selection, promotion, the corpus -- none of it reaches
a player's machine. What crosses into the runtime is exactly two things:
reading a season manifest, and materialising a recipe into an op stack.
UVoxelSeasonAsset a cookable UPrimaryDataAsset holding the manifest,
so a season ships in a packaged game. The JSON
stays the reviewable, git-diffable artefact.
UVoxelSettings::Season optional. Unset => behaviour is exactly as today.
GetEffective*() season seed / spine radius / world radius / season
number override the authored values when assigned.
The test deliberately sets DIFFERENT authored
values, so "the season is authoritative" is proved
rather than coincidental.
⭐ MULTIPLAYER DETERMINISM, PROVED: two independent runtime managers built
the same season and compared 393,216 density samples BIT-IDENTICALLY. This
is the property the whole multiplayer model rests on -- replicate seed +
layout + diff, never geometry.
⭐ AND A STALE SEASON IS DETECTABLE, NOT SILENT. Initialize() now returns
bool and an assigned-but-invalid season NEVER falls back to the authored
pool. Falling back would hand a client a world that looks fine and is not
the season -- silent divergence, the worst failure mode in a shared world.
GetCurrentSeasonContentHash() is exposed BlueprintPure to compare at join.
HasComposerRecipeOverride() is gone. It force-classified every custom recipe
as Mixed, which was correct while box verdicts were only proved for
hand-written stacks -- but
|
||
|
|
dd38832bb6 |
feat(composer): lateral REGIONS -- built, measured, and gated OFF
Jahni's own idea: "archetype could even mix within the same strate at some
point, tunnel network leading to a big chamber". An archetype becomes a
spatial REGION inside a strate, not a label on it.
Built by extending the mechanism that already exists rather than inventing
one: the stack blends VERTICALLY between strates, so regions blend the same
way LATERALLY. Deterministic jittered Voronoi sites on a 256-voxel lattice,
1-3 regions per strate from (seed, strateIndex), 24-voxel symmetric blend
band, per-chunk lookup cache. Parameters are never blended -- a shaft's
ShaftSpacing means nothing to a slab -- only the resulting DENSITY is.
Structural posts stay global and unconditional, so an invented region
cannot switch off the primordial law.
single-region path BIT-IDENTICAL to legacy, 256 probes, native + recipe
cross-boundary boxes 20 of 40, 199,800 voxels brute-forced, 0 VIOLATIONS
(every seam-spanning box correctly returned Mixed)
blend-band cost 663 ns vs 338 ns per voxel, over 16.0% of voxels
~1.15x field-weighted
survival 16/16 valid, non-vacuous, largest share >= .50
⛔ AND IT IS GATED OFF, because the seam breaks the one law that matters:
arrival -> departure across a region boundary passes 9/16 seeds, against
11/16 same-region controls in the same harness. Density blending alone does
not guarantee a player can cross a seam. A strate that mixes archetypes and
strands you at the boundary is strictly worse than one that does not mix.
VF_LateralRegionsAreShippable() returns false and the test ASSERTS THE GATE
IS SHUT while the rate is imperfect -- so flipping it without fixing
connectivity turns the suite red by design. The rate is a warning, not an
assertion: it is the measured state of an off-by-default feature, not a
regression in a live world, and a permanently red suite stops being read,
which is how real failures get missed.
Everything that ships is still hard-guarded: single-region bit-identity,
zero cross-boundary box violations, and every shipping-path law test
untouched.
The fix is an explicit CROSS-SEAM CORRIDOR CONTRACT -- a design decision,
recorded rather than made unilaterally. Codex diagnosed this correctly and
declined to tune the threshold, which was the right call.
Build clean, 29 succeeded / 0 failed, run and verified by me.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
|
||
|
|
859a9b25c1 |
feat(composer): the structure roll -- 64 distinct stacks nobody wrote
Tier 4b. Until now the composer varied PARAMETERS within an archetype:
differently-sized caves. This rolls STRUCTURE, which is what makes a strate
a different KIND of place.
Per COMPOSER-NOTES §3.2: roll root polarity (rock+carve or void+fill) ->
a shape source legal for that polarity -> the conversion FOLLOWS from
polarity rather than being rolled -> k modifiers drawn from what the
CHANNEL DECLARATIONS say is legal, not from a hand-written list ->
structural posts appended automatically and not omittable.
64 distinct recipes of 64 (perfect structural diversity)
invalid recipes emitted: 0 (the roller cannot produce an
order the DAG validator rejects)
survival 46/64 (71.9%) non-vacuous 62, largest-share 60,
law pass 48
⭐ AND THEY ARE REAL PLACES NOBODY DESIGNED. Root polarity as a one-bit
identity lever, exactly as §3.2 predicted:
VF|r=vd/T|s=rg/T|x=fl/T TunnelNetwork's ROOM GRAPH rooted in void with
a FILL -- rooms become solid blobs hanging in
open space. air .978, feature scale 64.
VF|r=vd/M|s=lt/M|x=fl/M Maze's lattice corridors filled instead of
carved. air .938, feature scale 16.
RC|r=rk/V|s=sh/V|x=cv/V shafts carved from rock with a Maze modifier
set they never ship with.
Same parts, different worlds. That is the whole thesis of the op stack,
finally exercised.
⭐⭐ AND THE VERDICTS HOLD ON STACKS NOBODY VERIFIED:
489 proved (213 AllSolid, 276 AllAir), 2,071 mixed
650,859 voxels brute-forced
0 VIOLATIONS
This is what
|
||
|
|
8e346a3054 |
feat(opstack): EffectOverBox correct in ISOLATION -- the blocker is closed
COMPOSER-NOTES §6.1, titled THE BLOCKER, gated everything after it. A source writing only Sdf did not touch density, so it answered the box query ON BEHALF OF the converter that followed it -- fine when a human wrote and verified the pair, unsafe the moment the composer assembles a combination nobody checked. And the failure is the silent one: a tile wrongly proved uniform means no geometry, no collision, no error, and a player falls through the floor. FVoxelBoxHypotheses now carries FVoxelBoxSdfInterval. SDF writers publish their own [Min, Max]; replacement writers replace it, accumulating writers MinWith it; converters and detail ops consume the prior interval through a state-aware EffectOverBox. Each op answers only for itself and the fold composes them. Unknown, invalid, non-finite or unimplemented bounds force Mixed -- when in doubt the answer is Mixed, because a missed skip costs milliseconds and a false uniform costs a player. Bounds are SUPREMA of what Eval can produce, never the param named "max": FBM 1.5 (not 1.0), max(Min,Max) radius envelopes, warp sqrt(2), SmoothMin slack. [FLT_MAX, FLT_MAX] is the exact no-surface identity. ⭐ THE PROOF IS THE NOVEL STACK. Built a combination no shipping builder produces: ConstantVoid -> LatticeCorridorSource -> SdfRoughness -> SdfFill brute-forced 332,750 voxels across 256 boxes: 250 proved AllAir, 0 VIOLATIONS. If isolation is real an unverified stack must still be sound, and it is. That test -- not the refactor -- is the deliverable. Every archetype re-proved by brute force, 0 violations everywhere: Maze 112/256 · CrystalChamber 38/60 · Underwater 33/40 · FlatPlain 32/60 VerticalShafts 28/60 · FloatingIslands 15/60 · TunnelNetwork prod 9/40 TunnelNetwork dense 0/40 (correctly -- it really is all mixed) COST, reported not hidden: isolation gives up some skipping. TunnelNetwork production 12->9, VerticalShafts 32->28. Answering for yourself alone is strictly weaker than answering for a verified pair. The classifier stays materially useful and the trade buys safe composition, which is the point. Eval is untouched; equivalence tests bit-identical; no per-voxel box query. SurfaceWorld keeps its separate exact height/lattice classifier. Also: ⛔ WITHDREW the "corrective ops must be fused" claim from §3.2. FFloorBiasMod does NOT undo FCaveRoughnessMod -- it adds an independent NormZ^2 x FloorBias term to the ACCUMULATED density and never reads TotalRough. Not a correction, no fuse, no "corrects" dependency. The claim came from reading a name and a comment; one grep would have shown the ten Density-writing ops in between before the paragraph was written. Build clean, 22 succeeded / 0 failed, verified in VoxelM.log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU |
||
|
|
7b982ad5ae |
feat(opstack): every op declares its channels -- ordering becomes derivable
The composer must assemble stacks nobody wrote by hand. Today the legal order of operators lives in a human's head and in the hand-written builders. COMPOSER-NOTES §3.2: an op's legal position is fixed by which channel it READS and which it WRITES, so dependencies form a DAG and any topological sort of that DAG is a legal stack. EVoxelOpChannel names the two fields FVoxelOpSample actually carries (Density, Sdf) -- read from the struct, not assumed. All 29 concrete density ops declare reads, writes and additive/transformative, filled in by reading each Eval rather than trusting its name. The declarations are PURE VIRTUAL so a new op cannot silently omit them; the composer's safety depends on every op answering. A declaration nobody checks is a comment, so ValidateChannelOrder exists and VoxelForge.OpStack.ChannelDAG runs all eight shipping stacks through it. All pass: TunnelNetwork/Underwater 20 ops, Maze 8, VerticalShafts 9, FloatingIslands 8, FlatPlain/CrystalChamber 6, SurfaceWorld 6. Metadata is snapshotted in FVoxelOpStack::Add, so no declaration virtual is ever called per voxel -- ARCHITECTURE §8.10 respected. ⚠️ THE FUSE WAS DELIBERATELY NOT APPLIED, and the reason is the finding I asked for instead. §3.2 assumed FFloorBiasMod sits next to FCaveRoughnessMod. It does not: TEN ops separate them (Terrace, LayerLine, Ribbing, Overhang, Cliff, Scallop, Arch, RoomColumn, Dome, Pinch), and all ten read and write Density. Moving FloorBias would reorder the live density accumulation, and IEEE-754 addition is not bitwise commutative -- so the note's claim that fusing "leaves the field bit-identical" is false as written. That leaves a real open question for the composer, recorded not resolved: channel rules alone say any Density->Density order is legal, but they cannot express "this op CORRECTS that one". Either the fuse proceeds and accepts a changed field (legitimate -- peer determinism never required bit-identity across builds), or corrective intent needs its own declaration. Settling it needs FFloorBiasMod's actual math read, which is the next question I put to Codex. Build clean, 22 succeeded / 0 failed, verified in VoxelM.log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU |
||
|
|
4f11ce81a4 |
fix(shafts): the primordial law holds -- 0 trusted negatives across 16 seeds
Closes the VerticalShafts bug. Two remaining failures were investigated
and neither was a broken world.
PART A -- my measurement window was binding, not the world.
The window was a square centred on (0,0) with a radius, while mouths sit
up to ~200 voxels out in arbitrary directions, so a box holding both was
mostly empty space paid for at full price. Seed 6 at step 1 needed
16,515,072 cells and blew the cap; fitted to the two mouths it needs
6,553,344 and measures fine.
seed 6 step 2 NOT_CONNECTED -> CONNECTED at doubled margin
seed 6 step 1 NOT_CONNECTED -> CONNECTED at doubled margin
seed 14 step 2 CONNECTED
seed 14 step 1 CONNECTED
A fitted box can cut off a route that detours outside it, so every
negative is re-run at doubled margin. 5 of 16 were binding negatives and
all 5 flipped. TRUSTED NEGATIVES: 0. There is no evidence of a single
unescapable strate in 16 seeds.
PART B -- the drainage tree has no orphans, and a third cause existed.
0 / 10,372 unreachable shaft paths (0% orphan rate)
16 strict local minima, all caught by the direct-spine fallback
neighbour fallback fired 0 times; emergency fallback 0
91.8% of non-largest components are smaller than one shaft volume --
they are roughness bubbles, not geometry
So a mouth could land in a noise bubble that is open but unreachable --
worse than useless, exactly the trap the law forbids.
VF_SuggestLandingPoint now returns the selected shaft AXIS, which is on
the drainage tree by construction.
⚠️ Codex found a third cause neither of us listed: tree capsules could
cross ledge bands, so a guaranteed connector could be blocked by a ledge.
Connector Z is now drawn from a deterministic ledge-free interval,
mirrored in the op stack.
⚠️ And an 8th error in my spec: "connect to any neighbour shaft" as a
fallback can create CYCLES -- a loop of shafts that drains nowhere and is
disconnected from the spine. The fallback must preserve strictly
decreasing origin-distance. Implemented that way.
per-archetype law 6/6 · box-verdict violations 0 · stack equivalence
bit-identical over 20,000 samples · sweep 45.3 -> 20.4 s
cache 7.970 us rebuild, 0.073 us hot call (225-cell rolls on rebuild only)
Build clean, 21 succeeded / 0 failed, verified by me in VoxelM.log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
|
||
|
|
9d2324879d |
feat(world): the XY edge seal -- the fourth structural invariant
VF_ApplyBoundarySeal was purely VERTICAL: it sealed each strate's ceiling
and floor and there was no horizontal equivalent, because until the depths
were bounded the world had no sides. The GDD now says "bounded, but
gigantic", so a player who walked far enough reached unterminated
generation running off into nothing.
Built as a FORCING op, appended automatically and not author-omittable, so
an invented strate cannot forget its own edge. Because a forcing op proves
AllSolid in its band, the outer shell also becomes free to skip in tile
classification -- correctness and a perf win from one change.
order spine -> vertical seal -> passage carving -> XY EDGE SEAL
-> disturbances -> diff
A near-rim passage may pierce a vertical seal; the XY seal runs after and
wins. A passage must never punch a hole through the world's edge.
ClassifyBox/ForcedMarginOverBox proved via the closest AABB radius with a
one-voxel margin: 8 boxes proved, 1 tile proved, 600 voxels brute-forced,
0 violations. FXYEdgeSealOp is IsXYPure. Max rim discontinuity .187 against
a 1.0 threshold -- a ramp, not a cylinder wall.
⚠️ WorldRadiusVoxels DEFAULTS TO 0, and that is deliberate. Codex flagged
that DA_Settings and BP_VoxelSettings predate the property and so do not
serialize it -- they inherit the C++ default silently. A default of 8192
would have walled in every existing world on next editor open with nothing
in the asset to explain it. 0 is a bitwise no-op (45 interior comparisons
against the unbounded path prove it). The GDD's intended ~8192 is a value
to set in the asset, deliberately, once. Documented at the declaration.
Codex found four spec errors, three of them mine: VF_ApplyBoundarySeal
lives in VoxelDensityPrimitives.h not VoxelCaveMorphology; the asset
default trap above; and my "density increases through the band" wording
contradicts the MC sign convention (solid is more NEGATIVE) -- the test
measures increasing solidness.
Build clean, 21 succeeded / 0 failed, run and verified by me.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
|
||
|
|
7a20eeed0d |
feat(shafts): a drainage tree -- the field goes from 22% to 97% connected
The plateau was the tell. Roughness changed the component COUNT (it
manufactures noise pockets) but the giant component sat at ~0.22 in every
variant, and widening connectors past the proven roughness bound barely
moved it. That is a random graph below its percolation threshold: 0.6
sites, 0.35 bonds, and a 1.6-cell pair cutoff that drops many neighbour
pairs once jitter spreads them 1.7 (orthogonal) to 2.4 (diagonal) cells
apart.
So the lever was topology. Every shaft now emits a deterministic connector
to the nearest shaft STRICTLY closer to the origin, so chains drain inward
and terminate at the spine. The probabilistic connectors stay as texture
and loops -- the tree guarantees you can get out, the noise keeps it from
reading as a diagram.
Deliberately NOT tuned: raising CrossConnectChance/ShaftDensity/the cutoff
toward the percolation threshold buys a world connected at one seed and
shattered at the next, which is exactly the 1-in-16 behaviour we had. A
phase transition is not a knob.
seed 1337 components 30 -> 17, largest share .220 -> .975
16 seeds spine is the largest component in 16/16 (was 8/16)
largest share .58 - .9999
Seam safety, because a parent computed from a 3x3 window would differ
between chunks and the connector would exist in one and not the other:
collect over 7x7, emit only for the inner 3x3, whose 5x5 candidate windows
are then fully contained. 154 seam probes per path, 0 mismatches.
Box verdicts re-checked against the new air: 27 proved, 33 mixed,
0 violations. Stack mirror bit-identical over 20,000 samples.
Perf: 1.634 us forced rebuild, 0.100 us hot call -- the 49 rolls happen
only on cache rebuild, never per voxel.
⚠️ ACCEPTANCE STILL FAILS: arrival->departure 4/16 (was 1/16). Reported,
not tuned. With 97% of the air in one component, two mouths that both land
IN the field would essentially always connect -- so the remaining failure
is placement, not topology. Next.
Build clean, 20 succeeded / 0 warnings / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bp7FxoQcSoiuhxjuLr6aBM
|
||
|
|
58e61cdefa |
feat(shafts): the spine joins its neighbours -- necessary, not sufficient
Jahni chose: make the (0,0) column join the shafts it passes, so VerticalShafts behaves like the other seven archetypes. Built, and it works as far as it goes -- the spine is now the LARGEST air component in 13 of 16 seeds (share up to 0.81, was 0.096 and never largest). ⚠️ IT DOES NOT MEET THE ACCEPTANCE CRITERION. arrival->departure is still 1/16. Reported as a failure rather than dressed up, because the criterion was the measured law, not "the code was written". The test now prints ACCEPTANCE arrival->departure: FAIL (1/16 seeds pass) so it cannot be quietly forgotten. Why it is insufficient, and it is structural: the spine only joins its own 3x3 cell neighbourhood, while passage mouths land 60-200 voxels from (0,0). The departure network is unreachable from the spine in all 16 seeds. Joining the hub to its neighbours does not help if the rest of the graph never reaches the hub. Part A diagnosis (seed 1337, margin 0, step 2, radius 192): baseline 30 components, largest .220 roughness 0 17 components, largest .226 connector radius 8 29 components, largest .229 H1 is real but partial: roughness fragments the space (13 of the 30 components are noise pockets), yet widening connectors past the proven roughness bound barely moves it. So roughness is NOT what severs the network -- the largest component sits at ~0.22 in every variant, which is the signature of a graph below its percolation threshold. The lever is topology, not radius. H2 (the hardcoded 1.6 pair cutoff) went untested. Implementation notes: the spine is added ONLY as a connector endpoint, not as a carving shaft -- VF_ApplyOriginSpine remains sole owner -- and is excluded from ledge placement. Spine connectors use radius 6.625, above the proven roughness supremum of 5.625 (sup|FBM| = 1.5, not 1.0). SpineRadius is threaded through BuildVerticalShaftStack, not hardcoded. Op-stack mirror bit-identical over 20,000 samples, 0 box-verdict violations. LayoutOrderIndependence green. Build clean, 19 succeeded / 0 warnings / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bp7FxoQcSoiuhxjuLr6aBM |
||
|
|
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> |
||
|
|
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. |
||
|
|
3ad2720d0a |
perf(opstack): direct-indexed column box (measured 14.7% miss); + T1.d bail attribution
BUILD RESULTS FIRST: 14/14 tests green, 0 violations. VerticalShafts box verdicts went 0 -> 30 of 60 (zero was the number for the project's whole life). TunnelNetwork production held at 11 proved / 14641 voxels, byte-identical -- that line was the isolated diagnostic for |
||
|
|
21f62c4a70 |
docs(opstack): two comments still described a half-finished port that is finished
Both said the refactor was mid-flight and both contradicted the code around them. 1. BuildTunnelNetworkStack: "stage A of three, the twelve detail modifiers and the per-room override are not ported yet, which is why UsesOperatorStackForChunk returns false for TunnelNetwork." All three stages are done, the twelve modifiers are added a dozen lines below the comment, and that function returns true. 2. FIslandBlobSource: "two of the three frame users are not ported yet; revisit when TunnelNetwork brings the second real use." TunnelNetwork is ported, and BuildTunnelNetworkStack already records the verdict: CaveWarp wraps exactly one operator and VerticalScale is a pure scalar function, so ZERO frames out of three candidates. The question was answered; only this comment still asked it, and it would have sent the next reader off to build frame infrastructure that was explicitly decided against. A comment that contradicts the code beneath it is the "read the code, not the comment" trap in reverse. This file already carries the forward version of that lesson, at the cliff modifier whose comment promises a gradient it never samples. Comments only -- no code changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
eaa44bf0c0 |
fix(opstack): two cell-sweep pads assumed MinRadius <= MaxRadius; a third already didn't
Second defect from the full 28-op EffectOverBox audit. Three ops roll a radius as Lerp(MinRadius, MaxRadius, hash01), which lands in [min(A,B), max(A,B)] -- Lerp does not require A <= B. Their EffectOverBox sweeps lattice cells padded by the largest radius a cell could hold, so a pad below the true maximum means the cells are never examined and the op reports Identity for a box its own Eval will fill or carve: no geometry, no collision. FGridColumnMod ~1086 Max(MaxRadius, 0) exposed FShaftFieldSource ~1436 Max(ShaftMaxRadius, ConnectorRadius) exposed FIslandBlobSource ~1803 Max(IslandMinRadius, IslandMaxRadius) already correct The third one is the argument: the concern was met and guarded once in this same file, and the other two shipped without it. Fixed with FMath::Max3, a spelling already used here (~2481) and in VoxelCaveMorphology.cpp. Shipped defaults are ordered correctly (2/5, 2/7, 5/11), so this is a NO-OP at defaults -- that is its acceptance signal, and any moved number means the diff did more than intended. It needs a mis-ordered asset value, which nothing prevents: ClampMin is a per-property floor and UE cannot express "<= that other property". ColumnMinRadius is also settable per-room via UVoxelTerrainOpDefinition. Deliberately NOT fixed by normalising the params: swapping the Lerp endpoints maps the same hash to a different radius, changing geometry and breaking the eight equivalence tests. Only the bound becomes conservative; Eval is byte- identical -- verified, no Eval/Roll/GetCells line in the diff. Also audited and found sound (recorded so they are not re-chased): FPassageCarveOp and the passage bounding sphere, FOriginSpineOp, FBoundarySealOp, FShaftLedgeMod, FCaveArchMod, FDomeMod. Not built -- Jahni builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7dbdf51b44 |
fix(opstack): three ExtraReach box verdicts used sup|FBM| = 1.0; the proved bound is 1.5
CORRECTNESS on a box verdict, not perf. This file derives |Perlin3D| <= 1.5
rigorously, exposes it as PerlinAbsBound, uses it for the tunnel warp, and says
outright that the header's "~[-1,1]" is an observation and that a box verdict
resting on one is a hole. Three ExtraReach formulas in the same file assumed
sup|FBM| <= 1.0 -- and VoxelNoise::FBM normalises (Total / MaxValue), so
sup|FBM| = sup|Perlin3D| exactly. The bound was wrong by 1.5x.
At shipped defaults, soundness needed B <= 1.27 (VerticalShafts, Rough 3.0) and
B <= 1.40 (Maze, Rough 2.0); both are violated at B = 1.5. Break-even roughness
is 1.6. FloatingIslands survives only because its SDFBlendRadius is 5 -- sound
by parameter luck, not construction.
Nothing in the running game was affected (no strate has bUseOperatorStack
ticked) and the empirical Perlin sup ~1.0-1.1 is why nothing surfaced. But the
tile scans SAMPLE, and for shafts they sampled a source that proved zero tiles
until
|
||
|
|
eb317d9933 |
feat(stats): stat VoxelForge -- make tile skipping and the column memo observable
The plugin had ZERO stat counters, so every claim this refactor makes was harness-only: "skipped correctly" and "skipped nothing" render identically. CODEX-TASK-001 and -002, executed by Codex (gpt-5.6-luna xhigh), reviewed against the source. Eight DWORD counters in a new stat group: GenerateTileResult -- TilesClassified / SkippedAllSolid / SkippedAllAir / Meshed ClassifyTile bAnyCave exit -- TilesOpStackSolid / TilesOpStackAir FSurfaceColumnSource::GetColumn -- ColumnMemoHit / ColumnMemoMiss The op-stack counters are separate from the lumped skip counters on purpose: ClassifyTile also proves AllSolid on its hand-written bedrock-gap path with no strate opted in, so the lumped number cannot show a before/after. The op-stack pair is zero BY CONSTRUCTION until a strate ticks the flag. GetColumn is instrumented and deliberately NOT fixed -- the instrument and the fix in one build would make each other unreadable. Verified against UE_5.7 Stats.h rather than assumed (first stats use in this plugin, no in-repo precedent): all four macro arities, and INC_DWORD_STAT -> FThreadStats::AddMessage, so the worker-thread safety both sites need holds by mechanism. Not built -- Jahni builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e002bd4e2e |
feat(opstack): VerticalShafts' connector branch never tested a connector -- test the real capsules
Jahni's call: bank T1.d at 11/40 and take VerticalShafts rather than squeeze the Perlin sup. Right call -- a clearly-scoped defect with no hole-risk maths, on an archetype that was getting nothing. The defect is stated in its own comment. FShaftFieldSource::EffectOverBox ended with a "connectors" branch that never looks at a connector: it returns CarveOnly because a shaft EXISTS within Spacing*1.6 + Pad. At ShaftSpacing 55 and ShaftDensity 0.6 that box spans ~4x4 cells and ~10 shafts, so the condition is true essentially everywhere -- exactly the reported 0 proved of 60. Conservative, never wrong, completely sterile; the same shape as the worm's unconditional CarveOnly one archetype over. It now rebuilds the connectors the way GetCells does and tests the real capsule. Two things had to be right and both were read in the source rather than assumed: - The enumeration is a superset. Eval reads connectors from the 3x3 of ITS query's cell, so any pair visible from a point in the box has both shafts in the 3x3 of some cell the box touches, i.e. in [box cells] +- 1 -- the range swept here. Pairs no query ever sees may be produced: extra CarveOnly, never a hole. - The pair order matches, so the hash matches. VoxelHash::Pair is fed in insertion order and GetCells inserts over (dy, dx), row-major; this sweeps (cy, cx), and row-major order restricted to a sub-grid preserves the relative order of two cells. So the same pair gets the same hash WITHOUT assuming Pair() is symmetric -- which was never verified and now need not be. The capsule test splits the axes because a connector is HORIZONTAL at height Zc: Z is exact, XY is point-to-segment from the box centre minus the XY half diagonal. Tighter than the 3-D half-diagonal the tunnel version had to settle for. Also the same sampler trap, caught before the build this time: tile XY was drawn from +-48 voxels against ShaftSpacing 55 -- under one period of the pattern, identical in kind to the +-32-vs-80 bug that cost the tunnel test three runs. Widened to +-440, and the report prints its own extent in units of ShaftSpacing. The safety net was already there and is untouched: this test brute-forces the full lattice of every proved tile, both hypotheses, and AddErrors on the first violation. If the superset or hash-order argument is wrong, the assertion fails rather than a player falling through the floor. Unbuilt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b2938d38f1 |
fix(opstack): the warp bound was the dominant term all along -- 2.0 -> 1.5, and MEASURE it
Jahni asked whether I was in a loop. I was, and re-reading the original found it
in one line.
PerlinAbsBound was set to 2.0 in the first commit of the spatial EffectOverBox
and never revisited. The dilation is CaveWarpStrength * VOXEL_NOISE_SCALE *
PerlinAbsBound, and CaveWarpStrength is 8.0 by default:
8 * 1.25 * 2.0 = 20 voxels, applied +/- on every axis
tile 10 voxels -> query box 50 per side -> 125x the tile volume
So the tunnel test I called "an order of magnitude tighter" actually needed
DistToAxis >= 78 against a cull rejecting at ~107. 27% tighter, not 10x -- and
the run said so: tunnels reaching went 78.0 -> 58.5, a 25% cut. The instrument
was right and I credited the tunnels.
Four rounds -- worm, columns, sampler, tunnel disjunction -- each individually
correct, every one of them tightening around a term nobody had measured. I kept
instrumenting what I had just changed and never instrumented what I had assumed.
What re-reading showed: BuildChunkCache is called everywhere with
Expansion = CaveWarpStrength + 2.0f, which is only correct if |Perlin3D| <= 0.8.
The whole plugin has always bet on 0.8. I picked 2.0 -- 2.5x more conservative
than the assumption the cache's own correctness already rests on.
The corrected bound is derived, not guessed. GradDot returns +-u +-v with u and
v two DISTINCT components (checked on all four hash branches). Splitting the
eight corners by i gives, per axis, sum w*|dx| = (1-su)*fx + su*(1-fx) <= 0.5
(max at fx = 0.5). Hence |Perlin3D| <= S_x + S_y + S_z <= 1.5, with no case
analysis on the hashes. Dilation 20 -> 15.
And the instrument that should have existed from the start: EffectOverBox now
also runs every room/tunnel test with the warp dilation set to ZERO and reports
both, so Hit* - Hit*NoWarp is exactly the blocking caused by my box rather than
by geometry. The report says, in the output, that if that gap dominates the next
move is the warp bound and not the primitives.
Separately: the diagnostics had turned into narrative, printing hardcoded numbers
from previous runs next to live ones ("32 of 34 tiles vs 21 for rooms" while the
live figures said 21 of 28). Unreadable, and I wrote all of it. Diagnostics now
report THIS run; the history stays in OPSTACK-PROGRESS.
Unbuilt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9733179723 |
feat(opstack): tunnels get a threshold test -- T1.d is measured at 6 of 40 tiles, 0 violations
THE PRIZE LANDED. At production defaults: 6 of 40 tiles proved AllSolid, 7986 voxels brute-forced, 0 violations. 15% of tiles skip GenerateMesh entirely, one BuildChunkCache traded against 30000+ density evaluations each, and not one verdict is asserted -- every voxel was re-evaluated and came back on the claimed side. The dense fixture still reports 0, exactly as the arithmetic said it must, so this is a measurement of production rather than of a widened test. The breakdown finally isolated the tunnels: of 34 unproved tiles, 32 were blocked by a tunnel and 21 by a room, so >=13 were tunnel-only. That is what justified the deferred disjunction, and why it was deferred rather than guessed. A primitive no longer matters if it misses its cull OR its own SDF stays >= T+K over the box. Each branch wins on a different class, by calculation: for rooms the cull (Rmax+3K) is tighter than the threshold (Rmax+T+K), so rooms keep the cull alone; for tunnels the cull is a capsule BOUNDING SPHERE, radius ~107 for a 200-long tube of radius 7, against a true segment distance. Order of magnitude. The bound is exact, not cautious. TaperedCapsule was read, not assumed -- Dist(P, ClosestOnSegment) - Lerp(Ra,Rb,t) -- so SDF >= dist(P,segment) - max(Ra,Rb); and dist(box,segment) >= dist(centre,segment) - half-diagonal by triangle inequality. VF_DistPointSegment is written locally rather than taken from FMath: five lines, and "I think that function does that" is not good enough under a correctness bound. Identity CHANGED MEANING, from "Sdf stays FLT_MAX" to "Sdf >= T" with T = max(3*SDFBlendRadius, WormNetworkRange). Sound only because all three consumers of the SDF channel were read one by one: FSdfConvertOp's Blend is MakeSdfCarve(P.SDFBlendRadius, ...) => K; the twelve modifiers gate at 3K; FWormFieldSource at WormNetworkRange. Plus the one that could have bitten -- FCaveTerraceMod re-probes the SDF at Z+-1, OUTSIDE the box, but its gate is line 13 and the probes are lines 28-29, so a gate false everywhere never emits one. ANY NEW CONSUMER OF THE Sdf CHANNEL MUST HAVE A THRESHOLD <= T OR JOIN THAT MAX, or it gets tiles with no geometry and no collision. Written at the site. Why -K suffices for any N: SmoothMin's penalty is exactly zero once |A-B| >= K, so the running minimum saturates at K below the smallest term and cannot descend further. Sdf >= min_i(SDF_i) - K for ANY number of primitives, not - N*K/6 -- without which the slack would scale with the ~88 tunnels and be worthless. Unbuilt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8043a613c3 |
perf(opstack): drop the redundant column test, and measure WHICH primitive class blocks the box
The worm fix worked -- attribution now reads "AllSolid killed by: RoomGraphSource x40", with WormFieldSource gone from the list. The blocker moved one operator upstream. And my own warning is now the thing to distrust. It said "if it names RoomGraphSource, the tiles genuinely straddle cave", which is a hypothesis wearing the costume of a conclusion -- the same mistake as the previous warning, one level down. RoomGraphSource has four primitive classes behind it whose bounds differ enormously in quality. No third guess. PROVED, not guessed: the columns test is removed. It treated columns as infinite cylinders in Z (the cache gives them no vertical bound), so a box hundreds of voxels below the owning room answered Both over a shared XY circle. It was REDUNDANT rather than conservative: columns are read by exactly one operator, FRoomColumnMod, whose Eval opens with VF_NearCaveSurface(InOut.Sdf, ...). In a box no room/tunnel/pit/chimney reaches, Sdf stays FLT_MAX everywhere, so no column can execute wherever it sits in XY. Removing it tightens the verdict without touching correctness. THE INSTRUMENT: EffectOverBox no longer early-outs on the first hit, it counts all four classes -- stopping at the first gives the right verdict and no information, which is exactly why "RoomGraphSource x40" was unactionable. Free at the scale that matters: BuildChunkCache has just run and dwarfs a walk over ~100 structs, and the verdict is memoised so the walk happens once per box. GetLastRoomBoxDiagnostic reads back what the operator computed rather than letting the test re-derive the criterion. The test could replay it -- and that second definition would drift from the real one and lie on the day it was believed. Same reason VF_BuildOpStackForChunk exists. The hypothesis it exists to kill or confirm: a tunnel is culled per voxel by its BOUNDING SPHERE, an enormous over-estimate for a long thin capsule, while a room's cull sphere is a fair fit. Tunnels >> rooms would mean the box test is losing to capsule bounding spheres rather than to real cave. NOT done deliberately: tightening tunnels to a true capsule test is not free correctness -- the per-voxel cull IS the bounding sphere, so a capsule test would be tighter than the cull and would break the stated criterion. Making it sound needs "no primitive can bring Sdf below max(Blend, SDFBlendRadius*3, WormNetworkRange)", which needs a bound on how far SmoothMin of N primitives dips below min. Real 0.2 design work, and doing it blind before knowing whether tunnels are even the problem is the C10 mistake verbatim. Unbuilt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f537648867 |
fix(opstack): the worm inherits the room source's box verdict -- the actual blocker
First build of the spatial EffectOverBox came back green with 0 tiles proved of 40. The warning written for exactly that case fired, and then was not good enough: it offered two candidate causes and BOTH were wrong. The real one was a third operator neither candidate mentioned. FWormFieldSource answered CarveOnly unconditionally with a provably correct amplitude bound of WormStrength. But BaseDensity = 8 and WormStrength = 10 are the DEFAULTS -- the field's own comment requires the worm to exceed BaseDensity or it could never carve air. So SolidMargin = 8 - 10 < 0 on every tile of every strate with worms on, before the fold reached anything the room source proved. A numerically correct bound that is structurally always fatal. The fix was already written three lines up in the worm's own Eval: NetworkMask is 0 when CaveSDF >= WormNetworkRange, which FLT_MAX always satisfies. The worm IS spatially bounded -- by the room source's bound, exactly like the twelve detail modifiers -- so where the source proves Identity it does not execute at all. Thirteen inheritors instead of twelve. Verified FVoxelOpSample::Sdf really does initialise to FLT_MAX rather than assuming it; the inheritance inverts into a hole otherwise. Deliberately NOT VF_NoCaveOverBox: that helper answers "identity" for a null Rooms, correct for the twelve modifiers and wrong for an op a future assembly could place behind a different SDF writer. No room source means we do not know, which must cost CPU rather than a hole. And the instrument, because the guess is the thing that cost a build: a diagnostic that lists candidate causes without measuring them is still a guess wearing rigour. FVoxelOpStack::ClassifyBoxAttributed reports the index of the first op that kills each hypothesis -- same loop, same early-out, verdict identical to ClassifyBox, because a diagnostic that takes a different path than the thing it explains sends you hunting in the wrong place. IVoxelDensityOp::DebugName gives them names and touches no cache key, so it cannot change the world. Check 4 now always prints "AllSolid killed by: <op> xN", and the zero-proved warning says to read it instead of re-deriving. Unbuilt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
03ddcde334 |
feat(opstack): FRoomGraphSource::EffectOverBox answers spatially -- and AUDIT C2 is fixed
The chain no longer dies at the room source. The criterion is the per-voxel cull
lifted from point to box, which can fail for exactly one reason: Eval starts at
MinSDF = FLT_MAX and only lowers it through a primitive that survives its own
cull, so if no cached primitive survives its cull anywhere in the box, Sdf stays
FLT_MAX across the whole box.
FSdfConvertOp already returned Identity ("la source a repondu pour la paire") and
the twelve detail modifiers already inherited through VF_NoCaveOverBox. One
function learned to answer and fourteen operators became provable -- what the C1
wiring was built for.
Three deliberate choices, all erring toward CPU rather than toward a hole:
- |Perlin3D| <= 2, derived from GradDot + the convex hull of a trilinear lerp,
instead of the header's observed "~[-1,1]". A verdict resting on an observation
is the hole this file spends its life avoiding.
- the op pool is passed to BuildChunkCache, not nullptr: the bake reads OpParams
to place pits and chimneys, so nullptr would under-bound the cache and could
return Identity over a real pit.
- the search box is wider than Eval's, giving a superset of primitives.
The verdict is memoised per box (all twelve modifiers ask the same question), and
the cache is a SECOND per-worker cache so classification cannot disturb a live
generation's hot cache.
AUDIT C2, confirmed 2026-07-28, is fixed on the switch path in the same breath:
GetDensityWithParams now takes required ParamsFingerprint + LayoutVersion. The
alternative this audit section used to recommend -- add chunk Z to the key -- is
insufficient (Interleaved makes Alpha depend on chunk XY too) and destructive
(chunk XY is deliberately absent so gradient probes don't thrash the box, ARCH
8.10). The CRC is taken once per chunk where the params memo already lives, so
the per-voxel cost is two integer compares. The three test call sites pass it
too, so the oracle stops sharing the defect it tests.
Check 4 of the tunnel test no longer asserts "0 proved" -- that assertion would
now forbid the gain. It brute-forces every proved tile voxel by voxel instead and
reports the count, because a false verdict leaves no geometry and no collision
behind it.
The second debt (per-room ops can raise a modifier's amplitude above the strate
params a box bound reads) turned out to be DORMANT, not live: where the source
proves Identity the modifiers' gate never opens, and where it answers Both it
supplies no MaxCarveOverBox so nothing is provable anyway. It goes live the day
the source gains one. Written at the site.
Unbuilt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
239c037f35 |
feat(opstack): the twelve detail modifiers inherit the room source's box verdict
Follow-on to the numeric fold, and the reason that commit does not yet pay off on TunnelNetwork. Separate commit because it is a separate mechanism with a separate revert story: the fold change was about AMPLITUDES, this one is about IDENTITY. THE OBSERVATION Every one of the twelve detail modifiers is gated on bNearCaveSurface, i.e. on Sdf < BlendRadius*3, and Sdf only becomes finite if the room graph wrote something. So wherever the source proves that no room and no tunnel reaches the box, Sdf stays FLT_MAX across the whole box and all twelve are IDENTITY -- not merely bounded, not merely CarveOnly-with-a-small-number. Identity. Each of them already knew this per voxel (it is their first `if`) and still declared Both / FillOnly / CarveOnly per box, which killed deep bedrock's AllSolid hypothesis exactly as hard as an operator that genuinely acts there. Twelve over-cautious declarations, one cause: they were not asking the source they depend on, although since C1 they already hold a pointer to it. VF_NoCaveOverBox(Rooms, Box, Ctx) short-circuits each EffectOverBox to Identity when the source itself answers Identity. Strictly conservative: it never returns Identity on its own authority, only where the source already did. A null pointer also means Identity, and that is correct rather than convenient -- with no room source in the stack, Sdf is FLT_MAX everywhere and the gate never opens. FCaveRoughnessMod gains the pointer purely for this. It is named RoomsForBox and documented as NOT for Eval, because that op deliberately reads STRATE params rather than LocalParams() and mixing the two up is the exact mistake C1's note exists to prevent. ⚠️ WHAT THIS BUYS TODAY: almost nothing, and that is worth stating rather than implying. FRoomGraphSource::EffectOverBox still answers Identity only when RoomDensity <= 0. The short-circuit becomes the deep-bedrock switch on the day the source answers SPATIALLY -- its room and tunnel bounds are already in the SDF cache; what it costs is building that cache for the queried box, which only pays once ClassifyTile actually consumes ClassifyBox. The wiring is put in now so that day touches ONE place instead of thirteen. No test change: this can only turn Both/FillOnly/CarveOnly into Identity where the source already returned Identity, so no existing verdict can move. The TunnelNetwork test still asserts 0 proved verdicts over 40 tiles, and it should still hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
353d504169 |
feat(opstack): the box fold carries NUMBERS -- amplitude bounds alongside EVoxelOpEffect (DECOMPOSITION 0.2)
Its own commit, nothing else in it, because it changes the contract all thirteen tests rest on. VoxelForge.OpStack.BoxVerdictFold is extended in the same commit, as required. THE PROBLEM, restated because the fix only makes sense against it Direction alone can never recover a FIELDED carve. A noise-threshold carve (TunnelNetwork's worms, wall roughness) has no spatial bound: it answers CarveOnly on every box of every strate that enables it, and that answer is TRUE. So it kills AllSolid everywhere and the archetype skips zero tiles. No refinement of EffectOverBox can fix that. But the AMPLITUDE is bounded, and for the worm it is trivial: the block only runs below the threshold, so t = 1 - WormValue/WormThreshold is in [0,1] and NetworkMask is in [0,1], hence the carve is at most WormStrength. "The rock is solid by more than the sum of every remaining carve" is therefore provable. THE CONTRACT - IVoxelDensityOp gains MaxCarveOverBox / MaxFillOverBox (density units, FLT_MAX = unknown) and ForcedMarginOverBox (how far the density is guaranteed from zero, 0 = unknown). - FVoxelBoxHypotheses gains SolidMargin / AirMargin. A forcing op sets one; each carve subtracts its amplitude; the hypothesis dies when the margin is no longer strictly positive. - VF_ForceHypotheses and VF_FoldEffect take the new values as DEFAULTED parameters. BACKWARDS COMPATIBILITY IS THE POINT, and it is structural rather than promised: the defaults are FLT_MAX and 0, so an op that overrides nothing subtracts FLT_MAX from a margin of 0 and kills the hypothesis exactly as the purely directional fold did. Not one existing verdict moves. The float arithmetic is deliberately unguarded: 0 - FLT_MAX is -FLT_MAX, -FLT_MAX - FLT_MAX saturates to -inf, -inf > 0 is false, and no NaN is reachable because both terms share a sign. BOUNDS DECLARED (each proved from the code, not estimated -- over-estimating costs CPU, under-estimating is a hole) - FConstantFieldSource::ForcedMarginOverBox = |Value|. This is the missing FIRST TERM: without a source that states how solid the rock is, there is nothing for a bounded carve to be subtracted from, and every bound would still kill the hypothesis. - FWormFieldSource: MaxCarve = WormStrength (the bound that had been written and unused since stage A), MaxFill = 0. - FCaveRoughnessMod: 1.4 * SurfaceRoughness * VOXEL_NOISE_SCALE both ways. - FLayerLineMod (LayerLineDepth), FRibbingMod (RibbingDepth), FScallopMod (ScallopStrength), FCaveOverhangMod (SCALE * Depth * Strength). WHAT THIS DOES *NOT* DO YET, said plainly rather than implied TunnelNetwork still proves ZERO tiles. The chain dies at FRoomGraphSource, which answers Both with unknown amplitude, before any of the bounded ops are reached. Making it answer spatially means building the SDF cache for the queried box, which only pays once ClassifyTile actually consumes ClassifyBox -- so it belongs with that work, not here. The one case that changes today is a tunnel strate with RoomDensity <= 0: the room source returns Identity and the bounded worm + roughness can now leave AllSolid standing. Seven ops keep the FLT_MAX default (terrace, cliff, arch, column, dome, pinch, floor bias). Their amplitudes depend on room-relative data, and bounding them would change no verdict while the room source is unbounded. Writing bounds nobody can consume is how a bound goes stale unnoticed. ONE PRE-EXISTING DEBT MADE SHARPER, noted at every site: these bounds are computed from STRATE params, and a per-room terrain op can write a LARGER amplitude (ApplyTo overwrites even where the strate had 0). So a bound can be too small -- the dangerous direction. Same root as the too-optimistic EffectOverBox flagged in C1, same fix, and it MUST land before ClassifyTile consumes ClassifyBox. TEST: eight new blocks in BoxVerdictFold. The first two are the ones to read -- they assert that the defaults reproduce the old fold exactly, and that "unknown" is not "zero". Also asserted: carve amplitudes accumulate; equality loses the tile (the > is strict on purpose, since zero counts as air at the mesher); a bounded Both no longer kills a margin it cannot cross; and nothing bounded can resurrect an unprovable box. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9591088d34 |
feat(opstack C1): per-room op override -- eleven detail ops read LocalParams() from the room source
STAGE C group 1. No operator added: nineteen ops before, nineteen after. What changed is what ELEVEN of them READ. THE MECHANISM, AND WHY §2's "NO CLEAN HOME" PROBLEM DISSOLVED FRoomGraphSource gains LocalParams(): the strate params with the nearest room's hash-rolled UVoxelTerrainOpDefinition applied on top, exactly as the original's `FStrateGenerationParams LocalTerrainParams = Params; ... NR.RoomOp->ApplyTo(...)` shadow does. Every detail op that read the shadowed copy now reads that. OPSTACK-DECOMPOSITION §2 called this the piece with no clean home and proposed giving each modifier an "only inside room N" predicate. The difficulty came entirely from assuming each modifier must OWN its params. One op owns the shared state, the rest read it -- and that pattern was already established here twice (FOverhangShelfMod <- FSurfaceColumnSource, FShaftLedgeMod <- FShaftFieldSource). No scoping predicate was invented. Same shape as the pit/chimney resolution. ELEVEN, NOT TWELVE, AND THAT IS READ FROM THE CODE Surface roughness (4b) does NOT read the per-room copy: the original's `const FStrateGenerationParams& Params = LocalTerrainParams;` is declared INSIDE the `if (bNearCaveSurface)` block, which begins after step 4b. Ported that way and flagged in the op so it does not get "uniformised" later. MEMOISED PER VOXEL, AND WHY THAT IS FIDELITY RATHER THAN OPTIMISATION The original builds the copy ONCE per voxel inside the gate. Eleven ops calling LocalParams() would build it eleven times. So FState carries a valid-flag that FRoomGraphSource::Eval clears at the top of every voxel (before any early-out, so nothing can read the previous voxel's room), and the first modifier that asks pays for it. Deep rock pays nothing, exactly as before. The ~74-field copy itself is TRANSCRIBED AS-IS. It is a real per-voxel cost; recorded in OPSTACK-PROGRESS as a perf item, not "improved" here. ONE THING THIS EXPOSES, WRITTEN DOWN BEFORE IT BITES EffectOverBox still answers from the STRATE params, because a box spans many rooms and a per-voxel copy has no meaning there. Since ApplyTo writes the op's value even where the strate's was 0, a room op can ENABLE a modifier the strate had off -- so a box verdict can now be too optimistic on a strate that has a terrain-op pool. Harmless today (nothing consumes ClassifyBox in production) and it MUST be fixed before ClassifyTile does. Noted at FLayerLineMod::EffectOverBox and in OPSTACK-PROGRESS. TEST (same commit) -- this is the acceptance stage B could not make - The pool gains a Terrace op with values (3.0 / 0.95 / 1.4) deliberately far from the strate's (6.0 / 0.6 / 0.5). A stack that ignored the override now CANNOT be bit-identical. - New check 3c asks the structure, not the density, because there is still no params probe for a pool (the pool is not in the SDF cache key): how many baked rooms carry a Terrace op, and how many samples fall inside one of those rooms' influence radius. Both must be non-zero, or check 1's green means only that two paths agree where the override never applies -- which is what stage B already proved. Fourth application of the pit lesson. IF THIS GROUP IS WRONG: check 1 fails with diffs clustered inside a subset of rooms (those that drew Terrace) and terrace-shaped Z banding, while check 3c still reports non-zero coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b063d437c1 |
feat(opstack B4): port Columns (4d), Domes (4g), Pinch (4h), FloorBias + Column op in the test pool
STAGE B group 4 of 5 -- and with it, ALL TWELVE detail modifiers are ported. 19 ops:
ConstantRock -> RoomGraph -> SdfCarve -> Roughness -> Terrace -> LayerLines -> Ribbing
-> Overhang -> Cliff -> Scallop -> Arch -> RoomColumn -> Dome -> Pinch -> FloorBias
-> Worms -> [structural x3]
Still NOT wired: UsesOperatorStackForChunk returns false for TunnelNetwork. What is missing is C1,
the per-room op override -- which adds NO operator, it changes what eleven of them READ.
TRANSCRIBED
- FRoomColumnMod (4d): walks SDFCache.Columns. Not FGridColumnMod -- that one rolls cylinders on a
world grid for FlatPlain/CrystalChamber; this one iterates a per-room bake.
- FDomeMod (4g): upward half-ellipsoid, up to 2 per room, capped at 0.85 * room radius.
- FPinchMod (4h): perimeter-anchored ellipsoid with the SideFactor term that leaves the passage
axis clear.
- FFloorBiasMod: quadratic fill below room centre, only in definite air. LAST in the chain and not
interchangeable -- it exists to undo what roughness (4b) did to floors. That is why the operator
order in BuildTunnelNetworkStack is the original's order line for line.
THE TRAP IN THIS GROUP, WHICH IS THE PIT LESSON A THIRD TIME
Columns have NO strate parameter. The per-voxel loop has no `if (ColumnDensity > 0)`, and the bake
reads OpParams (a FRESH struct with only the room's op applied), so FStrateGenerationParams::
ColumnDensity is read by nothing. Consequences, all three of which shaped this commit:
1. The `ColumnDensity = 0` line in the old DisableStageBModifiers was inert. It was never what
kept columns off; the absence of a Column op in the pool was.
2. No FeatureProbes entry can cover columns -- there is no param to switch off. And a second
stack built on a pool without the Column op would SHARE the thread_local SDF cache (the pool
is not in its key) and return identical densities, i.e. it would lie. Exactly the trap that
was documented for pits.
3. So the stage-A guard that ERRORED on TotalColumns > 0 is inverted in this same commit: it now
REQUIRES TotalColumns > 0. Asking the bake what it baked is the only sound check here.
DisableStageBModifiers is deleted rather than left empty: its emptiness is the measure of stage B
being complete, and an empty function still being called is how a step gets forgotten.
TEST (same commit): op count 15 -> 19; MakeShaftOpPool renamed MakeRoomOpPool and given a third
entry (Column, Probability 1.0 on all three so every room draws one of the three evenly); three
more param probes (dome/pinch/floor bias); the column guard inverted.
⚠️ THE POOL MUST STAY Pit/Chimney/Column UNTIL C1. Eleven of the twelve modifiers read the per-room
param copy in the original and the strate params here; those agree only while no room carries a
DETAIL-type op, and ApplyTo(Pit/Chimney/Column) writes only pit/chimney/column fields. A Terrace
entry would break the equivalence -- which is precisely the test C1 adds, and the only possible
proof that the override works.
IF THIS GROUP IS WRONG: dome/pinch/arch diffs land inside open cave near room centres and
perimeters; a column error shows up as a ring of diffs at fixed XY through the whole room height.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
6ec60099d6 |
feat(opstack B3): port cave Overhang, Cliff, Scallop, Arch (STEP 4c)
STAGE B group 3 of 5. The stack is now 15 ops: ConstantRock -> RoomGraph -> SdfCarve -> Roughness -> Terrace -> LayerLines -> Ribbing -> Overhang -> Cliff -> Scallop -> Arch -> Worms -> [structural x3] Still NOT wired: UsesOperatorStackForChunk returns false for TunnelNetwork. TRANSCRIBED - FCaveOverhangMod: fBM with Z frequency at 0.15x of XY, positive lobe only (rock extends INTO the cave, never away), quadratic fade. Note this is the THIRD unrelated thing called "overhang" in the plugin -- FOverhangShelfMod (SurfaceWorld) borrows the uphill terrain height and shares nothing with it but the name. - FScallopMod: cellular noise, positive lobe carved out. Second consumer of VoxelNoise::Cellular3D, which is the second reason B1 shared that body instead of copying it. - FCaveArchMod: the first ROOM-RELATIVE modifier -- it needs the room's hash, centre and radii, so it is what makes FRoomGraphSource::GetNearestRoomIdx() necessary. Its gate is NOT the shared one: CaveSDF < SDFBlendRadius (inside the cave), not < SDFBlendRadius*3. PORTED AS-IS THOUGH THE ORIGINAL'S OWN COMMENT DISAGREES WITH ITS CODE FCaveCliffMod's comment promises "sample density at Z+1 and Z-1, compute vertical gradient". The code samples nothing: it draws a Perlin whose Z frequency is 3x its XY frequency and names the result VertGrad. It is a gradient-shaped PROXY, decorrelated from the wall's actual slope; the multiply by CaveSDF still gives it the right SIGN either side of the surface, which is why it produces steeper faces at all. Fixing it would change the world, and saying nothing would leave it to be "fixed" later by someone reading the comment instead of the code. Ported, documented in the op, recorded in OPSTACK-PROGRESS. ONE DELIBERATE ADDITION FCaveArchMod bounds-checks NearestRoomIdx with IsValidIndex before indexing; the original tests only >= 0. The index comes from EvaluateSDFCached so it is valid by construction, which is exactly why this can never change output -- only prevent a crash if that invariant ever breaks. Same class of decision as the params fingerprint in the cache key: err on cost, never on result. Overhang is FillOnly, Scallop is CarveOnly, Arch is FillOnly; only Cliff needs Both. Four of the eight modifiers ported so far keep a usable direction for the fold, which matters for the DECOMPOSITION 0.2 work later. TEST (same commit): op count 11 -> 15, four params groups move into EnableTunnelFeatures, four more per-operator coverage probes. ArchDensity is set to 0.9 rather than its 0.1 default and the reason is written down: at 0.1 a room draws ~0.3 arches and the probe would report zero with nothing wrong. Test coverage settings are not production values. IF THIS GROUP IS WRONG: diffs concentrate inside open cave (arch) or in a band |SDF| < range (the other three), and the roughness/terrace probes stay green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ab1a996cc5 |
feat(opstack B2): port Terrace, LayerLines, Ribbing (STEP 4c) + hoist the room source's per-worker state
STAGE B group 2 of 5. The stack is now 11 ops: ConstantRock -> RoomGraph -> SdfCarve -> CaveRoughness -> Terrace -> LayerLines -> Ribbing -> Worms -> [structural x3] Still NOT wired: UsesOperatorStackForChunk returns false for TunnelNetwork. STRUCTURAL CHANGE THIS GROUP FORCED FRoomGraphSource's thread_local SDF cache moved out of Eval into a `static FState& State()` accessor, because terracing re-queries the cache at Z+-1 and (from B4 on) columns walk it and the room-relative ops need NearestRoomIdx. Kept `static`, i.e. SHARED BETWEEN INSTANCES, on purpose: that is what the original does, and the test's stale-cache check (check 3) rests on two stacks sharing this cache while the params CRC in the key keeps them from serving each other's rooms. Making it a member would have turned that check green for the wrong reason. This is the established pattern in this file, not a new one: FOverhangShelfMod reads FSurfaceColumnSource, FShaftLedgeMod reads FShaftFieldSource, both via a non-owning pointer handed over at build time. Terrace now reads FRoomGraphSource the same way. TRANSCRIBED - FCaveTerraceMod: the Z-only SDF gradient orientation gate, the optional noise displacement of Z before the staircase, the Edge = lerp(0.45, 0.02, Hardness) stair profile, the quadratic fade. - FLayerLineMod: sine along Z, half-wave rectified, CUBED, subtracted. - FRibbingMod: same sine shifted by PI/2, rectified, SQUARED, added. THINGS THAT LOOK WRONG AND WERE PORTED AS-IS - Terrace's two gradient probes use UNWARPED X/Y and RAW Z, while the field they probe was evaluated at WARPED coords and effective Z, and they exclude pits/chimneys. So the probe does not sample exactly the field whose slope it measures. Real, in the original, load-bearing for the look; noted in OPSTACK-PROGRESS rather than fixed. - Terrace/LayerLines/Ribbing use raw WorldZ while roughness uses EffectiveZ. Deliberate in the original (geology stays horizontal whatever the strate's vertical scale). - `&& CaveSDF < FLT_MAX` is redundant under the bNearCaveSurface gate; kept. - NearestRoom is now reset to -1 unconditionally, which the original does not do (its thread_local keeps the previous voxel's value when RoomDensity <= 0). Unobservable there because no consumer runs; here the consumers are separate objects, and a stale value crossing an operator boundary is not something you find later. Ribbing and LayerLines are FillOnly / CarveOnly rather than Both -- two detail modifiers that keep a usable direction for the fold, which is more than the roughness or the terrace can say. TEST (same commit): op count 8 -> 11; the three params move from DisableStageBModifiers into EnableTunnelFeatures with spacings deliberately small against the sample window; three MORE coverage probes, one per operator rather than one per group -- a single "B2" probe would stay green while two of the three were wrong. IF THIS GROUP IS WRONG: diffs cluster where |SDF| < spacing*1.5 near horizontal surfaces (terrace) or in thin Z bands (lines/ribs). If instead the whole equivalence collapses everywhere, suspect the FState hoist, not the three new ops. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6e29cbfe4c |
feat(opstack B1): port TunnelNetwork surface roughness (STEP 4b) + share the cellular noise
STAGE B of TunnelNetwork, group 1 of 5. The stack is now
ConstantRock -> RoomGraph -> SdfCarve -> CaveRoughness(4b) -> Worms -> [structural x3]
8 ops (was 7). Still NOT wired: UsesOperatorStackForChunk returns false for TunnelNetwork.
TRANSCRIBED
- FCaveRoughnessMod (VoxelDensityOpStack.cpp): STEP 4b literally -- two octave sets
(main + fine x3 with the +2000/+2500/+3000 offsets), the optional domain warp, the four-way
noise switch, the min(TotalRough, 0) anti-fill clamp inside definite air, and the quadratic
fade by distance from surface. Reads EffectiveZ, not WorldZ.
- VoxelNoise::Cellular3D moved verbatim from VoxelGenerator.cpp's `static CellularNoise3D` into
VoxelCaveMorphology.h; the generator keeps a one-line forwarder, exactly as FractalNoise3D and
RidgedNoise3D already do since T2.a. It lands in the cave header rather than VoxelNoise.h
because it needs VoxelHash, and VoxelNoise.h must not depend on the cave header. Forking a pure
function is how AUDIT C1 happened.
- HRidged3D added next to HFractal3D (same FVector round-trip, deliberately).
TWO THINGS THAT LOOKED WRONG AND WERE PORTED AS-IS
- The domain warp computes ONE offset and adds it to BOTH noise positions, so the main and fine
octave sets are warped identically. Two independent warps would be tidier and a different world.
- Roughness reads the STRATE params, NOT the per-room override. The original's
`const FStrateGenerationParams& Params = LocalTerrainParams;` shadow is declared INSIDE the
`if (bNearCaveSurface)` block that begins after step 4b. So eleven of the twelve detail
modifiers read the room copy and this one does not -- flagged in the op so C1 does not
"uniformise" it. (The handoff's "all 13 modifiers read the shadowed copy" is off on both counts:
it is twelve modifiers, and one of them is outside the shadow.)
STAGE B5 DECIDED HERE, WITH REASONS (VF_NearCaveSurface)
The gate is a repeated early-out per op, NOT a scoping container: the stack is a flat list that
ClassifyBox folds op by op, a container would have to re-implement VF_FoldOp and would hide its
children from the fold, and an op that only exists inside a container is not a Phase-3 asset.
Cost stated rather than hidden: the original tests once and skips twelve, the stack tests twelve
times. Measured before optimised -- that is the C10 lesson.
TEST (same commit)
- Op count 7 -> 8.
- SurfaceRoughness/DomainWarp moved from DisableStageBModifiers into EnableTunnelFeatures.
- NEW check 1b, group coverage: rebuild the stack with the group OFF and count moved samples.
Zero is an ERROR, not a warning -- a ported group that never executes is exactly how a bad
transcription survives a whole green run (the PitDensity lesson). This diff-of-stacks is sound
here although it lied for the op pool, because these are params and the params CRC IS in the
SDF cache key.
- NEW check 1c: all 4 noise types x {warp off, warp on} compared to the original over 1500 points.
The main equivalence only ever takes the FBM branch; three of four cases were untested.
IF THIS GROUP IS WRONG, what breaks first: the main equivalence reports diffs concentrated in
open cave and within |SDF| < SurfaceRoughness*2; if instead only check 1c fails, the fault is
inside one branch of the noise switch and nothing else is implicated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ef5bda3d8a |
feat: TunnelNetwork stage A — the SDF spine, wrapping BuildChunkCache
The last archetype is ~1080 lines with 13 detail modifiers, a two-region cache and a per-room op override. Porting it whole before anything can be verified is ~600 unverified lines on top of ~200 — the pattern this refactor has dodged six times. So: three stages. Stage A = vertical scale, base rock, cave warp, room graph (+ pits and chimneys), carve, worms, structural post. 6 ops. It is verifiable NOW because every detail modifier is amplitude-gated and FStrateGenerationParams already defaults them all to zero — zeroing SurfaceRoughness sends the ORIGINAL down exactly the path stage A ported. TunnelNetwork stays OFF in UsesOperatorStackForChunk until stage C. The decision that matters: FRoomGraphSource CALLS BuildChunkCache and EvaluateSDFCached rather than transcribing them. That is where §8.4's two-region window-invariance discipline lives; a transcription would fork it, and the fork would be "validated" by a test comparing it to the original. Only the ~60 lines of glue are transcribed. FRAME ops are retired. All three candidates are now ported and none needed one: CaveWarp's scope is exactly one operator (pits/chimneys read unwarped coords), VerticalScale is a one-line pure function, and the island warp was already local. Not missing infrastructure — one idea seen three times from a distance. Also: check 3 was going to compare two interleaved param sets against the original, which would have FAILED — the original's SDF cache key has no params, so it serves B the rooms it built for A. Comparing there measures its bug, not the port. Rewritten against each stack evaluated alone. The same reasoning suggests a live production staleness across Gradient transitions; filed in AUDIT §C2 as SUSPECTED with the check that would confirm it, since it rests on a premise I have not verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
96e75abe57 |
feat: port FloatingIslands — the stack that runs backwards
6 of 8 archetypes ported. This one starts from VOID and FILLS where the other four start from ROCK and CARVE, which is what it was worth doing: neither end of the pile needed a new operator, only the opposite sign. FConstantRockSource -> FConstantFieldSource(+/-Base) AllSolid <-> AllAir FSdfCarveOp -> FSdfConvertOp(Sign = +/-1) carve <-> fill FSdfRoughnessMod 4th archetype, unchanged Only the island blob source is new. Multiplying by +/-1 is exact in IEEE-754, so the three already-green ports are bit-for-bit untouched. ClassifyBox can return AllAir for the first time in the plugin, and an island strate is by construction mostly empty — the test counts AllSolid and AllAir separately so an aggregate cannot hide whether that fired. Two bounds that would have been holes if assumed rather than derived: the island bound is one-sided (a hairline thread of matter hangs below each island down its axis, so only the TOP may reject), and the domain warp displaces X and Y independently, so the pad needs WarpAmp*sqrt(2). Also: AUDIT C1 was NOT closed. The 2026-07-27 sweep matched `SeedF * K` and this archetype's warp spells it `(float)S * K`, so one site survived — at seed 2e9 the warp flattens and every island snaps back to a perfect circle. Fixed in both paths in one pass so the equivalence test stays a valid oracle. Expect island silhouettes to change at large seeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d0a9ce3018 |
fix: the column key omitted the params — my perf fix broke the overhang
VerticalShaftEquivalence is bit-identical (966 samples inside a shaft), so operator
reuse across archetypes is measured now, not intended.
But SurfaceHeightEquivalence failed: 69/20000 overhang samples differ, 1 crossing the
isosurface. Cause is the ColumnKey from
|
||
|
|
3acb3fbc6b |
feat: port VerticalShafts — three ops reused from Maze unchanged
The port that tests the thesis rather than the fidelity. Previous ports asked whether the decomposition reproduces the original; this one asks whether operators actually get reused across archetypes, which is section 2.5's claim and the only reason to do this refactor instead of tidying the switch. ConstantRock, SdfRoughness and SdfCarve are Maze's, reused without a line changed. In the switch, GetMazeDensity and GetVerticalShaftDensity are two ~100-line functions with nothing visibly in common; as operators they are the same three ops with a different source and different tuning (freq 0.1 vs 0.12, window rough+4 vs R+rough+2). New: FShaftFieldSource (infinite cylinders + hash-gated connectors into the SDF channel) and FShaftLedgeMod (banded shelves on the +X/+Y half so the shaft stays climbable). Deviation from section 6, stated: it suggested splitting the source so the XY-pure cylinder half could get an exact box verdict. Kept as one op because the connectors derive from the same 3x3 roll and the ledge mod needs the shaft list anyway, so splitting means rolling twice or sharing a cache between two ops. Forfeited: the exact verdict on the cylinder half. Kept: a conservative EffectOverBox testing circles and connector reach. FShaftLedgeMod gates on the POST-roughness Sdf as the stack left it; re-deriving it would use the pre-roughness value and shift every ledge. Reading the channel rather than recomputing is what the two-channel sample is for. Compile fix: FCells was declared below the functions returning it. Member bodies are deferred, return types are not. Ported: Maze, FlatPlain, CrystalChamber, SurfaceWorld (biomes included), VerticalShafts — 5 of 8. UNVERIFIED: not compiled past the FCells fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f3faa3b5c2 |
perf: the column memo threw itself away every chunk
Jahni measured what I had only flagged: generation is slower on the op-stack path. Two compounding causes. The memo was keyed on InstanceId, which changes on every stack rebuild — every chunk. GSurfColCache, the cache this path replaced, is keyed on (XY box, StrateKey, Seed, LayoutVersion) with no ChunkZ, deliberately shared down the whole vertical strate stack. So a 4-chunk strate recomputed every column four times, including the cliff's four extra structural samples per column. And the table held 256 entries where a chunk is CHUNK_SIZE^2 = 1024 columns, so it thrashed against itself within a single tile before any cross-chunk question arose. PrepareChunk now derives a shared ColumnKey from (StrateBottomWorldZ, LayoutVersion, Seed) — the same identity GSurfColCache uses — and the table is 4096 entries (~150 KB/worker, in line with GSurfColCache's 6 x 59 KB). The memo is thread_local so it already survived rebuilds; only the key was discarding the contents. Sharing across chunk Z is sound because heights are XY-pure by type and the biome field is documented Z-independent — the same justification GSurfColCache rests on. ColumnKey starts at InstanceId rather than 0: slots initialise to Key = 0, so a zero key would falsely hit the pristine slot at (0,0). Without PrepareChunk you get per-instance caching, which is less sharing but still correct. This may not close the gap entirely and I am not claiming it does. Virtual dispatch and the hashed lookup vs a direct-indexed box both remain; they are smaller than a 4x column recompute, but "smaller" is a guess until measured. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c277931a08 |
feat: SurfaceWorld complete — biome blending wired, guard removed
The two biome checks added last commit printed nothing on success, so a passing run was indistinguishable from a block that never executed — the exact flaw I flagged twice this session and then wrote myself. Both now report their coverage. Step 2c closes SurfaceWorld: - FSurfaceColumnSource takes per-biome params and an OWNED IVoxelBiomeField. Empty params leaves the original path bit-for-bit unchanged. - The field is owned by the stack rather than borrowed: the adapter points at GetDensityAt's thread_local biome context and cache, and the stack is itself thread_local rebuilt in the same refetch block, so all three live and die together. Structural ownership beats a convention the next reader has to infer. - The overhang amp blends across biomes — Lerp(Amp(PD), Amp(PN), W) with slope and threshold from the dominant only, as ComputeSurfaceColumn does. Interpolating the slope would be meaningless; it measures the terrain rather than configuring it. - FGeneratorBiomeField lives in VoxelGenerator.cpp, on the side that knows the generator. The op sees a capability, never an owner — which is what lets it become an asset in Phase 3. - The no-biome guard is removed from UsesOperatorStackForChunk. Also: the two constructors now delegate to one body with one id counter. The first draft had two competing counters, one tagged with a high bit to avoid collision, which is a smell rather than a design. 5 of 8 archetypes ported: Maze, FlatPlain, CrystalChamber, SurfaceWorld. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1a3f6b6a72 |
fix: same stray-brace error as 7cd2bed — and label the boundary so it stops recurring
I anchored on the FACTORIES banner again, which sits after the anonymous namespace's
closing brace, so FBiomeBlendHeightSource landed at file scope and the `}` added with
it closed nothing. Identical to
|
||
|
|
cd4cf216f5 |
fix: AUDIT C1 — bounded, site-salted seed offsets across all 85 noise sites
The op stack had already inherited this three times and every remaining port would copy it again, so fixing it now is cheaper than after. The audit's documented fix was wrong: bounding SeedF while keeping the * 97.7f multiplier still reaches 1.6e6, where the ULP is 0.19 — 9.5x the per-voxel step. Less spectacular, still broken, ticket closed. VoxelHash::SeedOffset(Seed, SiteKey) inverts the roles: the multiplier no longer decorrelates by amplifying, it IDENTIFIES the site, and the hash decorrelates. Output is in final units, bounded to [0, 16383], so the ULP is 10% of a voxel step. Site-salted, so two seeds must collide at all ~50 sites rather than sharing one global bucket. Safe to apply without compiling because the transformation is a pure regex and the literal stays visible at the call site, so each line remains eye-checkable against the original. Applied to all three files in one pass so the archetype switch and the ported ops changed identically — had they not, the three equivalence tests would say so. 62 + 7 + 16 sites, none left, plus two bare `+ SeedF` worm sites by hand. New test VoxelForge.Determinism.LargeSeedSurvives (seeds up to 2e9) because the equivalence tests are structurally blind to this: they compare the stack against the switch, both read the same faulty expression, so at a large seed both collapse identically — bit-identical, green, and both flat. An oracle that shares the bug cannot see it. This test asserts a property instead of a comparison. EXPECT EVERY WORLD TO LOOK DIFFERENT: this re-rolls every noise offset in the plugin. Intended, and covered by OPSTACK-PLAN 2.6.1. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f7ed9407bf |
feat: wire SurfaceWorld (biome-less) + fix a column-memo perf trap
All six checks green, 9399 samples inside the overhang window. The single-entry column memo was correct only if the caller walks a Z column before changing XY, which the mesher does not promise. Iterating X first within a Z slice would miss on every voxel and re-run the whole height stack per voxel, cliff resamples included — an order of magnitude on the plugin's most expensive archetype. The tests could not have caught it: they sample random XY, where a one-entry memo and a 256-entry one behave identically. Only reading the access pattern finds this. Replaced with a direct-mapped 256-entry thread_local table hashed on the XY bit patterns, full key compared on hit, so a collision costs a recompute and never returns the wrong column. Wiring: UsesOperatorStackForChunk returns true for SurfaceWorld only when the strate has no biomes. The original blends heights toward the neighbouring biome across the border band; the stack evaluates one param set, so a biome strate would get a hard seam at every border rather than a subtle shift. The guard sits beside the archetype list so "can this strate take the stack?" stays one question in one place, and GetDensityAt keeps a defensive CP_BiomeCtx check that falls back if the two disagree. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |